diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index b17f08913..636de683c 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -37,7 +37,7 @@ Frontend -------- .. automodule:: pretix.presale.signals - :members: html_head, html_footer, footer_link, global_footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, contact_form_fields_overrides, question_form_fields_overrides, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header, seatingframe_html_head + :members: html_head, html_footer, footer_link, global_footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, contact_form_fields_overrides, question_form_fields_overrides, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header, seatingframe_html_head, filter_subevents .. automodule:: pretix.presale.signals diff --git a/src/pretix/presale/signals.py b/src/pretix/presale/signals.py index f3f9cc8d7..75d48506a 100644 --- a/src/pretix/presale/signals.py +++ b/src/pretix/presale/signals.py @@ -415,3 +415,19 @@ consent state. Receivers should return a list of ``pretix.presale.cookies.Cookie As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ + +filter_subevents = GlobalSignal() +""" +Arguments: ``subevents``, ``sales_channel`` + +This signal allows you to filter which subevents are publicly available. Receivers are passed a +list of subevents that are about to be shown to the user and are expected to return a list of the +same format, with all subevents removed that should not be available for sale. + +``sales_channels`` is a ``SalesChannel`` instance. + +This is not an event-plugin signal as this will also be called on the organizer level when showing +a list of subevents across events. Expect that the subevents in the input are mixed from different +events. However, receivers will only receive subevents of events that the plugin is active for and +can only filter out these. +""" diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 27045ac9d..ef7a9ac75 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -85,7 +85,8 @@ from pretix.presale.ical import get_public_ical from pretix.presale.signals import item_description, seatingframe_html_head from pretix.presale.views.organizer import ( EventListMixin, add_subevents_for_days, days_for_template, - filter_qs_by_attr, has_before_after, weeks_for_template, + filter_qs_by_attr, filter_subevents_with_plugins, has_before_after, + weeks_for_template, ) from . import ( @@ -546,6 +547,12 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): self.subevent = request.event.subevents.using(settings.DATABASE_REPLICA).filter(pk=kwargs['subevent'], active=True).first() if not self.subevent: raise Http404() + + # Prevent direct access to subevents that are hidden by a plugin + subevents = filter_subevents_with_plugins([self.subevent], self.request.sales_channel) + if self.subevent not in subevents: + raise Http404() + return super().get(request, *args, **kwargs) else: return super().get(request, *args, **kwargs) @@ -703,9 +710,14 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): ).using(settings.DATABASE_REPLICA), self.request ), - limit_before, after, ebd, set(), self.request.event, - self.kwargs.get('cart_namespace'), - voucher, + before=limit_before, + after=after, + ebd=ebd, + timezones=set(), + event=self.request.event, + cart_namespace=self.kwargs.get('cart_namespace'), + voucher=voucher, + sales_channel=self.request.sales_channel, ) # Hide names of subevents in event series where it is always the same. No need to show the name of the museum thousands of times @@ -762,9 +774,14 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): ).using(settings.DATABASE_REPLICA), self.request ), - limit_before, after, ebd, set(), self.request.event, - self.kwargs.get('cart_namespace'), - voucher, + before=limit_before, + after=after, + ebd=ebd, + timezones=set(), + event=self.request.event, + cart_namespace=self.kwargs.get('cart_namespace'), + voucher=voucher, + sales_channel=self.request.sales_channel, ) # Hide names of subevents in event series where it is always the same. No need to show the name of the museum thousands of times @@ -803,7 +820,7 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): future_only=self.request.event.settings.event_calendar_future_only ) else: - context['subevent_list'] = self.request.event.subevents_sorted( + subevents = self.request.event.subevents_sorted( filter_qs_by_attr( self.request.event.subevents_annotated( self.request.sales_channel, @@ -812,12 +829,14 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): self.request ) ) + subevents = filter_subevents_with_plugins(list(subevents), self.request.sales_channel) + context['subevent_list'] = subevents if self.request.event.settings.event_list_available_only and not voucher: context['subevent_list'] = [ - se for se in context['subevent_list'] + se for se in subevents if not se.presale_has_ended and (se.best_availability_state is None or se.best_availability_state >= Quota.AVAILABILITY_RESERVED) ] - context['visible_events'] = len(context['subevent_list']) > 0 + context['visible_events'] = len(subevents) > 0 return context diff --git a/src/pretix/presale/views/organizer.py b/src/pretix/presale/views/organizer.py index 47ff568f0..519708c8b 100644 --- a/src/pretix/presale/views/organizer.py +++ b/src/pretix/presale/views/organizer.py @@ -49,6 +49,7 @@ from django.db.models import ( Case, Exists, F, Max, Min, OuterRef, Prefetch, Q, Value, When, ) from django.db.models.functions import Coalesce, Greatest +from django.dispatch.dispatcher import NO_RECEIVERS from django.http import Http404, HttpResponse, QueryDict from django.templatetags.static import static from django.utils.decorators import method_decorator @@ -76,6 +77,7 @@ from pretix.helpers.thumb import get_thumbnail from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse from pretix.presale.forms.organizer import EventListFilterForm from pretix.presale.ical import get_public_ical +from pretix.presale.signals import filter_subevents from pretix.presale.views import OrganizerViewMixin @@ -561,16 +563,56 @@ def add_events_for_days(request, baseqs, before, after, ebd, timezones): }) -def add_subevents_for_days(qs, before, after, ebd, timezones, event=None, cart_namespace=None, voucher=None): +def filter_subevents_with_plugins(subevents, sales_channel=None): + # Special-case of GlobalSignal.send_chained() that only sends subevents that have the plugin enabled + # and then mixes the results back together. + from pretix.base.signals import ( + _populate_app_cache, app_cache, get_defining_app, is_app_active, + ) + + if not filter_subevents.receivers or filter_subevents.sender_receivers_cache.get(None) is NO_RECEIVERS: + return subevents + + if not app_cache: + _populate_app_cache() + + for receiver in filter_subevents._live_receivers(None): + app = get_defining_app(receiver) + event_state = {} + + def app_active(event): + if event.pk not in event_state: + event_state[event.pk] = is_app_active(event, app, allow_legacy_plugins=True) + return event_state[event.pk] + + subevents_passed_to_receiver = [ + s for s in subevents if app_active(s.event) + ] + response = receiver( + signal=filter_subevents, + sender=None, + subevents=subevents_passed_to_receiver, + sales_channel=sales_channel, + ) + subevents = [ + s for s in subevents if s in response or s not in subevents_passed_to_receiver + ] + + return subevents + + +def add_subevents_for_days(qs, before, after, ebd, timezones, sales_channel, event=None, cart_namespace=None, + voucher=None): qs = qs.filter(active=True, is_public=True).filter( Q(Q(date_to__gte=before) & Q(date_from__lte=after)) | Q(Q(date_to__isnull=True) & Q(date_from__gte=before) & Q(date_from__lte=after)) ).order_by( 'date_from' ) + subevents = filter_subevents_with_plugins(list(qs), sales_channel) quotas_to_compute = [] - for se in qs: + for se in subevents: if se.presale_is_running: quotas_to_compute += se.active_quotas for q in se.active_quotas: @@ -588,7 +630,7 @@ def add_subevents_for_days(qs, before, after, ebd, timezones, event=None, cart_n qa.compute(allow_cache=True) qcache.update(qa.results) - for se in qs: + for se in subevents: if qcache: se._quota_cache = qcache if event is not None: # save database lookup later @@ -763,24 +805,31 @@ class CalendarView(OrganizerViewMixin, EventListMixin, TemplateView): ).filter( Q(all_sales_channels=True) | Q(limit_sales_channels=self.request.sales_channel), ), before, after, ebd, timezones) - add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter( - Q(event__all_sales_channels=True) | - Q(event__limit_sales_channels=self.request.sales_channel), - event__organizer=self.request.organizer, - event__is_public=True, - event__live=True, - ).prefetch_related( - Prefetch( - 'event', - queryset=Event.objects.prefetch_related( - '_settings_objects', - Prefetch( - 'organizer', - queryset=Organizer.objects.prefetch_related('_settings_objects') + add_subevents_for_days( + filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter( + Q(event__all_sales_channels=True) | + Q(event__limit_sales_channels=self.request.sales_channel), + event__organizer=self.request.organizer, + event__is_public=True, + event__live=True, + ).prefetch_related( + Prefetch( + 'event', + queryset=Event.objects.prefetch_related( + '_settings_objects', + Prefetch( + 'organizer', + queryset=Organizer.objects.prefetch_related('_settings_objects') + ) ) ) - ) - ), self.request.sales_channel), self.request).using(settings.DATABASE_REPLICA), before, after, ebd, timezones) + ), self.request.sales_channel), self.request).using(settings.DATABASE_REPLICA), + before=before, + after=after, + ebd=ebd, + timezones=timezones, + sales_channel=self.request.sales_channel, + ) self._multiple_timezones = len(timezones) > 1 return ebd @@ -860,24 +909,31 @@ class WeekCalendarView(OrganizerViewMixin, EventListMixin, TemplateView): ).filter( Q(all_sales_channels=True) | Q(limit_sales_channels=self.request.sales_channel), ), before, after, ebd, timezones) - add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter( - Q(event__all_sales_channels=True) | - Q(event__limit_sales_channels=self.request.sales_channel), - event__organizer=self.request.organizer, - event__is_public=True, - event__live=True, - ).prefetch_related( - Prefetch( - 'event', - queryset=Event.objects.prefetch_related( - '_settings_objects', - Prefetch( - 'organizer', - queryset=Organizer.objects.prefetch_related('_settings_objects') + add_subevents_for_days( + filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter( + Q(event__all_sales_channels=True) | + Q(event__limit_sales_channels=self.request.sales_channel), + event__organizer=self.request.organizer, + event__is_public=True, + event__live=True, + ).prefetch_related( + Prefetch( + 'event', + queryset=Event.objects.prefetch_related( + '_settings_objects', + Prefetch( + 'organizer', + queryset=Organizer.objects.prefetch_related('_settings_objects') + ) ) ) - ) - ), self.request.sales_channel), self.request).using(settings.DATABASE_REPLICA), before, after, ebd, timezones) + ), self.request.sales_channel), self.request).using(settings.DATABASE_REPLICA), + before=before, + after=after, + ebd=ebd, + timezones=timezones, + sales_channel=self.request.sales_channel, + ) self._multiple_timezones = len(timezones) > 1 return ebd @@ -1211,24 +1267,31 @@ class DayCalendarView(OrganizerViewMixin, EventListMixin, TemplateView): ).filter( Q(all_sales_channels=True) | Q(limit_sales_channels=self.request.sales_channel), ), before, after, ebd, timezones) - add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter( - Q(event__all_sales_channels=True) | - Q(event__limit_sales_channels=self.request.sales_channel), - event__organizer=self.request.organizer, - event__is_public=True, - event__live=True, - ).prefetch_related( - Prefetch( - 'event', - queryset=Event.objects.prefetch_related( - '_settings_objects', - Prefetch( - 'organizer', - queryset=Organizer.objects.prefetch_related('_settings_objects') + add_subevents_for_days( + filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter( + Q(event__all_sales_channels=True) | + Q(event__limit_sales_channels=self.request.sales_channel), + event__organizer=self.request.organizer, + event__is_public=True, + event__live=True, + ).prefetch_related( + Prefetch( + 'event', + queryset=Event.objects.prefetch_related( + '_settings_objects', + Prefetch( + 'organizer', + queryset=Organizer.objects.prefetch_related('_settings_objects') + ) ) ) - ) - ), self.request.sales_channel), self.request).using(settings.DATABASE_REPLICA), before, after, ebd, timezones) + ), self.request.sales_channel), self.request).using(settings.DATABASE_REPLICA), + before=before, + after=after, + ebd=ebd, + timezones=timezones, + sales_channel=self.request.sales_channel, + ) self._multiple_timezones = len(timezones) > 1 return ebd diff --git a/src/pretix/presale/views/widget.py b/src/pretix/presale/views/widget.py index ccef1e386..c612accf9 100644 --- a/src/pretix/presale/views/widget.py +++ b/src/pretix/presale/views/widget.py @@ -73,7 +73,8 @@ from pretix.presale.views.event import ( ) from pretix.presale.views.organizer import ( EventListMixin, add_events_for_days, add_subevents_for_days, - days_for_template, filter_qs_by_attr, weeks_for_template, + days_for_template, filter_qs_by_attr, filter_subevents_with_plugins, + weeks_for_template, ) logger = logging.getLogger(__name__) @@ -403,6 +404,14 @@ class WidgetAPIProductList(EventListMixin, View): return self.response({ 'error': gettext('The selected date does not exist in this event series.') }) + + # Prevent direct access to subevents that are hidden by a plugin + subevents = filter_subevents_with_plugins([self.subevent], request.sales_channel) + if self.subevent not in subevents: + return self.response({ + 'error': gettext('The selected date is not available.') + }) + else: return self._get_event_list(request, **kwargs) else: @@ -568,8 +577,9 @@ class WidgetAPIProductList(EventListMixin, View): Q(event__limit_sales_channels=self.request.sales_channel), ), self.request ), - limit_before, after, ebd, set(), self.request.event, - kwargs.get('cart_namespace') + before=limit_before, after=after, ebd=ebd, timezones=set(), event=self.request.event, + cart_namespace=kwargs.get('cart_namespace'), + sales_channel=self.request.sales_channel, ) else: timezones = set() @@ -580,17 +590,27 @@ class WidgetAPIProductList(EventListMixin, View): Q(all_sales_channels=True) | Q(limit_sales_channels=self.request.sales_channel), ), self.request ), - limit_before, after, ebd, timezones + before=limit_before, + after=after, + ebd=ebd, + timezones=timezones, + ) + add_subevents_for_days( + filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter( + Q(event__all_sales_channels=True) | + Q(event__limit_sales_channels__identifier=self.request.sales_channel.identifier), + event__organizer=self.request.organizer, + event__is_public=True, + event__live=True, + ).prefetch_related( + 'event___settings_objects', 'event__organizer___settings_objects' + ), self.request.sales_channel), self.request), + before=limit_before, + after=after, + ebd=ebd, + timezones=timezones, + sales_channel=self.request.sales_channel, ) - add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter( - Q(event__all_sales_channels=True) | - Q(event__limit_sales_channels__identifier=self.request.sales_channel.identifier), - event__organizer=self.request.organizer, - event__is_public=True, - event__live=True, - ).prefetch_related( - 'event___settings_objects', 'event__organizer___settings_objects' - ), self.request.sales_channel), self.request), limit_before, after, ebd, timezones) data['weeks'] = weeks_for_template(ebd, self.year, self.month) for w in data['weeks']: @@ -624,8 +644,9 @@ class WidgetAPIProductList(EventListMixin, View): if hasattr(self.request, 'event'): add_subevents_for_days( filter_qs_by_attr(self.request.event.subevents_annotated(self.request.sales_channel), self.request), - limit_before, after, ebd, set(), self.request.event, - kwargs.get('cart_namespace') + before=limit_before, after=after, ebd=ebd, timezones=set(), event=self.request.event, + cart_namespace=kwargs.get('cart_namespace'), + sales_channel=self.request.sales_channel, ) else: timezones = set() @@ -634,13 +655,20 @@ class WidgetAPIProductList(EventListMixin, View): filter_qs_by_attr(Event.annotated(self.request.organizer.events, self.request.sales_channel), self.request), limit_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, - ).prefetch_related( - 'event___settings_objects', 'event__organizer___settings_objects' - ), self.request.sales_channel), self.request), limit_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, + ).prefetch_related( + 'event___settings_objects', 'event__organizer___settings_objects' + ), self.request.sales_channel), self.request), + before=limit_before, + after=after, + ebd=ebd, + timezones=timezones, + sales_channel=self.request.sales_channel, + ) data['days'] = days_for_template(ebd, week) for d in data['days']: