From 36f63b1f7bdbe419e509bfa410d627b2811d9eb2 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 25 Feb 2026 16:44:58 +0100 Subject: [PATCH] Clean up logic around exporters --- src/pretix/api/serializers/exporters.py | 34 ++- src/pretix/api/views/exporters.py | 53 +++- src/pretix/base/exporters/orderlist.py | 6 +- src/pretix/base/services/export.py | 2 +- .../pretixcontrol/orders/export.html | 2 +- .../pretixcontrol/orders/export_form.html | 22 +- .../pretixcontrol/organizers/export.html | 2 +- .../pretixcontrol/organizers/export_form.html | 22 +- src/pretix/control/views/orders.py | 48 +++- src/pretix/control/views/organizer.py | 66 ++++- src/tests/api/test_exporters.py | 245 +++++++++++++++++- src/tests/control/test_export.py | 196 ++++++++++++++ src/tests/testdummy/signals.py | 36 +++ 13 files changed, 689 insertions(+), 45 deletions(-) diff --git a/src/pretix/api/serializers/exporters.py b/src/pretix/api/serializers/exporters.py index 40ce3a96bb..1a612b1023 100644 --- a/src/pretix/api/serializers/exporters.py +++ b/src/pretix/api/serializers/exporters.py @@ -27,7 +27,9 @@ from rest_framework.exceptions import ValidationError from pretix.api.serializers.forms import form_field_to_serializer_field from pretix.base.exporter import OrganizerLevelExportMixin -from pretix.base.models import ScheduledEventExport, ScheduledOrganizerExport +from pretix.base.models import ( + Event, ScheduledEventExport, ScheduledOrganizerExport, +) from pretix.base.timeframes import SerializerDateFrameField @@ -54,19 +56,28 @@ class ExporterSerializer(serializers.Serializer): class JobRunSerializer(serializers.Serializer): def __init__(self, *args, **kwargs): - ex = kwargs.pop('exporter') + ex = self.ex = kwargs.pop('exporter') super().__init__(*args, **kwargs) if ex.is_multievent and not isinstance(ex, OrganizerLevelExportMixin): + self.fields["all_events"] = serializers.BooleanField( + required=False, + ) self.fields["events"] = serializers.SlugRelatedField( queryset=ex.events, required=False, - allow_empty=False, + allow_empty=True, slug_field='slug', many=True ) for k, v in ex.export_form_fields.items(): self.fields[k] = form_field_to_serializer_field(v) + def to_representation(self, instance): + # Translate between events as a list of slugs (API) and list of ints (database) + if self.ex.is_multievent and not isinstance(self.ex, OrganizerLevelExportMixin) and "events" in instance and isinstance(instance["events"], list): + instance["events"] = [e.slug for e in self.ex.events.filter(pk__in=instance["events"]).only("slug")] + return instance + def to_internal_value(self, data): if isinstance(data, QueryDict): data = data.copy() @@ -94,6 +105,14 @@ class JobRunSerializer(serializers.Serializer): data[fk] = f'{d_from.isoformat() if d_from else ""}/{d_to.isoformat() if d_to else ""}' data = super().to_internal_value(data) + + # Translate between events as a list of slugs (API) and list of ints (database) + if self.ex.is_multievent and not isinstance(self.ex, OrganizerLevelExportMixin) and "events" in data and isinstance(data["events"], list): + if data["events"] and isinstance(data["events"][0], Event): + data["events"] = [e.pk for e in data["events"]] + elif data["events"] and isinstance(data["events"][0], str): + data["events"] = [e.pk for e in self.ex.events.filter(slug__in=data["events"]).only("pk")] + return data def is_valid(self, raise_exception=False): @@ -130,13 +149,20 @@ class ScheduledExportSerializer(serializers.ModelSerializer): exporter = self.context['exporters'].get(identifier) if exporter: try: - JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"]) + attrs["export_form_data"] = JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"]) except ValidationError as e: raise ValidationError({"export_form_data": e.detail}) else: raise ValidationError({"export_identifier": ["Unknown exporter."]}) return attrs + def to_representation(self, instance): + repr = super().to_representation(instance) + exporter = self.context['exporters'].get(instance.export_identifier) + if exporter: + repr["export_form_data"] = JobRunSerializer(exporter=exporter).to_representation(repr["export_form_data"]) + return repr + def validate_mail_additional_recipients(self, value): d = value.replace(' ', '') if len(d.split(',')) > 25: diff --git a/src/pretix/api/views/exporters.py b/src/pretix/api/views/exporters.py index c39571693e..66b5f10108 100644 --- a/src/pretix/api/views/exporters.py +++ b/src/pretix/api/views/exporters.py @@ -38,10 +38,12 @@ from pretix.api.serializers.exporters import ( ExporterSerializer, JobRunSerializer, ScheduledEventExportSerializer, ScheduledOrganizerExportSerializer, ) +from pretix.base.exporter import OrganizerLevelExportMixin from pretix.base.models import ( CachedFile, Device, ScheduledEventExport, ScheduledOrganizerExport, TeamAPIToken, ) +from pretix.base.models.organizer import TeamQuerySet from pretix.base.services.export import ( export, init_event_exporters, init_organizer_exporters, multiexport, ) @@ -203,7 +205,7 @@ class ScheduledExportersViewSet(viewsets.ModelViewSet): class ScheduledEventExportViewSet(ScheduledExportersViewSet): serializer_class = ScheduledEventExportSerializer queryset = ScheduledEventExport.objects.none() - permission = 'event.orders:read' + permission = None def get_queryset(self): perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user @@ -249,6 +251,18 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet): return {e.identifier: e for e in exporters} def perform_update(self, serializer): + if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner: + # This is to prevent a possible privilege escalation where user A creates a scheduled export and + # user B has settings permission (= they can see the export configuration), but not enough permission + # to run the export themselves. Without this check, user B could modify the export and add themselves + # as a recipient. Thereby, user B would gain access to data they can't have. + exporter = self.exporters.get(serializer.instance.export_identifier) + if not exporter: + raise PermissionDenied("No access to exporter.") + perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user + if not perm_holder.has_event_permission(self.request.organizer, self.request.event, exporter.get_required_event_permission()): + raise PermissionDenied("No permission to edit exports you could not run.") + serializer.save(event=self.request.event) serializer.instance.compute_next_run() serializer.instance.error_counter = 0 @@ -319,6 +333,43 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet): return {e.identifier: e for e in exporters} def perform_update(self, serializer): + if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner: + # This is to prevent a possible privilege escalation where user A creates a scheduled export and + # user B has settings permission (= they can see the export configuration), but not enough permission + # to run the export themselves. Without this check, user B could modify the export and add themselves + # as a recipient. Thereby, user B would gain access to data they can't have. + exporter = self.exporters.get(serializer.instance.export_identifier) + if not exporter: + raise PermissionDenied("No access to exporter.") + perm_holder = (self.request.auth if isinstance(self.request.auth, (Device, TeamAPIToken)) + else self.request.user) + if isinstance(exporter, OrganizerLevelExportMixin): + if not perm_holder.has_organizer_permission( + self.request.organizer, exporter.get_required_organizer_permission(), request=self.request, + ): + raise PermissionDenied("No permission to edit exports you could not run.") + else: + if serializer.instance.export_form_data.get("all_events", False): + if isinstance(self.request.auth, Device): + if not self.request.auth.all_events: + raise PermissionDenied("No permission to edit exports you could not run.") + elif isinstance(self.request.auth, TeamAPIToken): + if not self.request.auth.team.all_events: + raise PermissionDenied("No permission to edit exports you could not run.") + elif self.request.user.is_authenticated: + if not self.request.user.teams.filter( + TeamQuerySet.event_permission_q(exporter.get_required_event_permission()), + all_events=True, + ).exists(): + raise PermissionDenied("No permission to edit exports you could not run.") + else: + events_selected = serializer.instance.export_form_data.get("events", []) + events_permission = set(perm_holder.get_events_with_permission( + exporter.get_required_event_permission(), request=self.request + ).values_list("pk", flat=True)) + if not all(e in events_permission for e in events_selected): + raise PermissionDenied("No permission to edit exports you could not run.") + serializer.save(organizer=self.request.organizer) serializer.instance.compute_next_run() serializer.instance.error_counter = 0 diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index 54b7485e42..fcf32abf7f 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -271,7 +271,7 @@ class OrderListExporter(MultiSheetListExporter): qs = self._date_filter(qs, form_data, rel='') - if form_data['paid_only']: + if form_data.get('paid_only'): qs = qs.filter(status=Order.STATUS_PAID) return qs @@ -457,7 +457,7 @@ class OrderListExporter(MultiSheetListExporter): ).annotate( payment_providers=Subquery(p_providers, output_field=CharField()), ).select_related('order', 'order__invoice_address', 'order__customer', 'tax_rule') - if form_data['paid_only']: + if form_data.get('paid_only'): qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False) if form_data.get('items'): @@ -560,7 +560,7 @@ class OrderListExporter(MultiSheetListExporter): qs = OrderPosition.all.filter( order__event__in=self.events, ) - if form_data['paid_only']: + if form_data.get('paid_only'): qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False) if form_data.get('items'): diff --git a/src/pretix/base/services/export.py b/src/pretix/base/services/export.py index 5cd31fd3ff..2b4be717dd 100644 --- a/src/pretix/base/services/export.py +++ b/src/pretix/base/services/export.py @@ -134,7 +134,7 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int, event_qs = organizer.events.all() if form_data.get('events') is not None and not form_data.get('all_events'): - if isinstance(form_data['events'][0], str): + if form_data['events'] and isinstance(form_data['events'][0], str): # legacy API-created schedules event_qs = event_qs.filter(slug__in=form_data.get('events')) else: event_qs = event_qs.filter(pk__in=form_data.get('events')) diff --git a/src/pretix/control/templates/pretixcontrol/orders/export.html b/src/pretix/control/templates/pretixcontrol/orders/export.html index df8fd791a4..8c45f4fac9 100644 --- a/src/pretix/control/templates/pretixcontrol/orders/export.html +++ b/src/pretix/control/templates/pretixcontrol/orders/export.html @@ -34,7 +34,7 @@ {% if s.export_verbose_name == "?" %} - {% trans "Exporter not found" %} + {% trans "Exporter not found or no permission" %} {% elif s.error_counter >= 5 %} diff --git a/src/pretix/control/templates/pretixcontrol/orders/export_form.html b/src/pretix/control/templates/pretixcontrol/orders/export_form.html index e2ed55aedb..38975624a3 100644 --- a/src/pretix/control/templates/pretixcontrol/orders/export_form.html +++ b/src/pretix/control/templates/pretixcontrol/orders/export_form.html @@ -39,16 +39,18 @@ {% if schedule_form %} {% include "pretixcontrol/orders/fragment_export_schedule_form.html" %} -
- -
+ {% if not no_save %} +
+ +
+ {% endif %} {% else %}
-
+ {% if not no_save %} +
+ +
+ {% endif %} {% else %}