From a7fe43ec01bfc5f52e7389acedc4d62bc6ca8352 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 29 Jan 2026 10:07:14 +0100 Subject: [PATCH] Add proper permission handling for exports --- src/pretix/api/serializers/exporters.py | 5 +- src/pretix/api/views/exporters.py | 112 ++++---- src/pretix/base/exporter.py | 27 +- src/pretix/base/exporters/customers.py | 5 +- src/pretix/base/exporters/orderlist.py | 10 +- src/pretix/base/exporters/reusablemedia.py | 4 + src/pretix/base/services/export.py | 299 ++++++++++++++------- src/pretix/control/views/orders.py | 32 ++- src/pretix/control/views/organizer.py | 43 ++- src/tests/api/test_permissions.py | 1 - src/tests/base/test_export.py | 8 +- src/tests/control/test_permissions.py | 2 - 12 files changed, 327 insertions(+), 221 deletions(-) diff --git a/src/pretix/api/serializers/exporters.py b/src/pretix/api/serializers/exporters.py index b5f99ad466..40ce3a96bb 100644 --- a/src/pretix/api/serializers/exporters.py +++ b/src/pretix/api/serializers/exporters.py @@ -55,11 +55,10 @@ class ExporterSerializer(serializers.Serializer): class JobRunSerializer(serializers.Serializer): def __init__(self, *args, **kwargs): ex = kwargs.pop('exporter') - events = kwargs.pop('events', None) super().__init__(*args, **kwargs) - if events is not None and not isinstance(ex, OrganizerLevelExportMixin): + if ex.is_multievent and not isinstance(ex, OrganizerLevelExportMixin): self.fields["events"] = serializers.SlugRelatedField( - queryset=events, + queryset=ex.events, required=False, allow_empty=False, slug_field='slug', diff --git a/src/pretix/api/views/exporters.py b/src/pretix/api/views/exporters.py index 28d5637636..c39571693e 100644 --- a/src/pretix/api/views/exporters.py +++ b/src/pretix/api/views/exporters.py @@ -38,14 +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, Event, ScheduledEventExport, ScheduledOrganizerExport, + CachedFile, Device, ScheduledEventExport, ScheduledOrganizerExport, TeamAPIToken, ) -from pretix.base.services.export import export, multiexport -from pretix.base.signals import ( - register_data_exporters, register_multievent_data_exporters, +from pretix.base.services.export import ( + export, init_event_exporters, init_organizer_exporters, multiexport, ) from pretix.helpers.http import ChunkBasedFileResponse @@ -111,7 +109,7 @@ class ExportersMixin: @action(detail=True, methods=['POST']) def run(self, *args, **kwargs): instance = self.get_object() - serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs()) + serializer = JobRunSerializer(exporter=instance, data=self.request.data) serializer.is_valid(raise_exception=True) cf = CachedFile(web_download=True) @@ -136,27 +134,34 @@ class ExportersMixin: class EventExportersViewSet(ExportersMixin, viewsets.ViewSet): - permission = 'event.orders:read' - - def get_serializer_kwargs(self): - return {} + permission = None @cached_property def exporters(self): + raw_exporters = list(init_event_exporters( + event=self.request.event, + user=self.request.user if self.request.user and self.request.user.is_authenticated else None, + token=self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None, + device=self.request.auth if isinstance(self.request.auth, Device) else None, + request=self.request, + )) exporters = [] - responses = register_data_exporters.send(self.request.event) - raw_exporters = [response(self.request.event, self.request.organizer) for r, response in responses if response] - raw_exporters = [ - ex for ex in raw_exporters - if ex.available_for_user(self.request.user if self.request.user and self.request.user.is_authenticated else None) - ] for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)): ex._serializer = JobRunSerializer(exporter=ex) exporters.append(ex) return exporters def do_export(self, cf, instance, data): - return export.apply_async(args=(self.request.event.id, str(cf.id), instance.identifier, data)) + return export.apply_async(args=( + self.request.event.id, + ), kwargs={ + 'user': self.request.user.pk if self.request.user and self.request.user.is_authenticated else None, + 'token': self.request.auth.pk if isinstance(self.request.auth, TeamAPIToken) else None, + 'device': self.request.auth.pk if isinstance(self.request.auth, Device) else None, + 'fileid': str(cf.id), + 'provider': instance.identifier, + 'form_data': data, + }) class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet): @@ -164,47 +169,23 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet): @cached_property def exporters(self): + raw_exporters = list(init_organizer_exporters( + organizer=self.request.organizer, + user=self.request.user if self.request.user and self.request.user.is_authenticated else None, + token=self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None, + device=self.request.auth if isinstance(self.request.auth, Device) else None, + request=self.request, + )) exporters = [] - if isinstance(self.request.auth, (Device, TeamAPIToken)): - perm_holder = self.request.auth - else: - perm_holder = self.request.user - events = perm_holder.get_events_with_permission('event.orders:read', request=self.request).filter( - organizer=self.request.organizer - ) - responses = register_multievent_data_exporters.send(self.request.organizer) - raw_exporters = [ - response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else events, self.request.organizer) - for r, response in responses - if response - ] - raw_exporters = [ - ex for ex in raw_exporters - if ( - not isinstance(ex, OrganizerLevelExportMixin) or - perm_holder.has_organizer_permission(self.request.organizer, ex.organizer_required_permission, self.request) - ) and ex.available_for_user(self.request.user if self.request.user and self.request.user.is_authenticated else None) - ] for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)): - ex._serializer = JobRunSerializer(exporter=ex, events=events) + ex._serializer = JobRunSerializer(exporter=ex) exporters.append(ex) return exporters - def get_serializer_kwargs(self): - if isinstance(self.request.auth, (Device, TeamAPIToken)): - perm_holder = self.request.auth - else: - perm_holder = self.request.user - return { - 'events': perm_holder.get_events_with_permission('event.orders:read', request=self.request).filter( - organizer=self.request.organizer - ) - } - def do_export(self, cf, instance, data): return multiexport.apply_async(kwargs={ 'organizer': self.request.organizer.id, - 'user': self.request.user.id if self.request.user.is_authenticated else None, + 'user': self.request.user.id if self.request.user and self.request.user.is_authenticated else None, 'token': self.request.auth.pk if isinstance(self.request.auth, TeamAPIToken) else None, 'device': self.request.auth.pk if isinstance(self.request.auth, Device) else None, 'fileid': str(cf.id), @@ -258,8 +239,13 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet): @cached_property def exporters(self): - responses = register_data_exporters.send(self.request.event) - exporters = [response(self.request.event, self.request.organizer) for r, response in responses if response] + exporters = list(init_event_exporters( + event=self.request.event, + user=self.request.user if self.request.user and self.request.user.is_authenticated else None, + token=self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None, + device=self.request.auth if isinstance(self.request.auth, Device) else None, + request=self.request, + )) return {e.identifier: e for e in exporters} def perform_update(self, serializer): @@ -321,23 +307,15 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet): ctx['exporters'] = self.exporters return ctx - @cached_property - def events(self): - if isinstance(self.request.auth, (TeamAPIToken, Device)): - return self.request.auth.get_events_with_permission('event.orders:read') - elif self.request.user.is_authenticated: - return self.request.user.get_events_with_permission('event.orders:read', self.request).filter( - organizer=self.request.organizer - ) - @cached_property def exporters(self): - responses = register_multievent_data_exporters.send(self.request.organizer) - exporters = [ - response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else self.events, - self.request.organizer) - for r, response in responses if response - ] + exporters = list(init_organizer_exporters( + organizer=self.request.organizer, + user=self.request.user if self.request.user and self.request.user.is_authenticated else None, + token=self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None, + device=self.request.auth if isinstance(self.request.auth, Device) else None, + request=self.request, + )) return {e.identifier: e for e in exporters} def perform_update(self, serializer): diff --git a/src/pretix/base/exporter.py b/src/pretix/base/exporter.py index f23e5bf116..8fcefd1327 100644 --- a/src/pretix/base/exporter.py +++ b/src/pretix/base/exporter.py @@ -73,6 +73,9 @@ class BaseExporter: self.events = Event.objects.filter(pk=event.pk) self.timezone = event.timezone + if hasattr(self, 'organizer_required_permission'): + raise TypeError("Deprecated attribute organizer_required_permission no longer supported.") + def __str__(self): return self.identifier @@ -176,17 +179,29 @@ class BaseExporter: """ return True - -class OrganizerLevelExportMixin: - @property - def organizer_required_permission(self) -> str: + @classmethod + def get_required_event_permission(cls) -> str: """ - The permission level required to use this exporter. Only useful for organizer-level exports, - not for event-level exports. + The permission level required to use this exporter for events. For multi-event-exports, this will be used + to limit the selection of events. Will be ignored if the `OrganizerLevelExportMixin` mixin is used. """ return 'event.orders:read' +class OrganizerLevelExportMixin: + @classmethod + def required_event_permission(cls): + raise TypeError("required_event_permission may not be called on OrganizerLevelExportMixin") + + @classmethod + def get_required_organizer_permission(cls) -> str: + """ + The permission level required to use this exporter. Must be set for organizer-level exports. Set to `None` to + allow everyone with any access to the organizer. + """ + raise NotImplementedError() + + class ListExporter(BaseExporter): ProgressSetTotal = namedtuple('ProgressSetTotal', 'total') diff --git a/src/pretix/base/exporters/customers.py b/src/pretix/base/exporters/customers.py index 0675c2d5e8..b56f27cc1e 100644 --- a/src/pretix/base/exporters/customers.py +++ b/src/pretix/base/exporters/customers.py @@ -47,10 +47,13 @@ from ..signals import register_multievent_data_exporters class CustomerListExporter(OrganizerLevelExportMixin, ListExporter): identifier = 'customerlist' verbose_name = gettext_lazy('Customer accounts') - organizer_required_permission = 'organizer.customers:write' category = pgettext_lazy('export_category', 'Customer accounts') description = gettext_lazy('Download a spreadsheet of all currently registered customer accounts.') + @classmethod + def get_required_organizer_permission(cls) -> str: + return 'organizer.customers:write' + @property def additional_form_fields(self): return OrderedDict( diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index 7d50815326..0e66d5ae94 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -1235,11 +1235,14 @@ class QuotaListExporter(ListExporter): class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter): identifier = 'giftcardtransactionlist' verbose_name = gettext_lazy('Gift card transactions') - organizer_required_permission = 'organizer.giftcards:write' category = pgettext_lazy('export_category', 'Gift cards') description = gettext_lazy('Download a spreadsheet of all gift card transactions.') repeatable_read = False + @classmethod + def get_required_organizer_permission(cls) -> str: + return 'organizer.giftcards:read' + @property def additional_form_fields(self): d = [ @@ -1342,10 +1345,13 @@ class GiftcardRedemptionListExporter(ListExporter): class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter): identifier = 'giftcardlist' verbose_name = gettext_lazy('Gift cards') - organizer_required_permission = 'organizer.giftcards:write' category = pgettext_lazy('export_category', 'Gift cards') description = gettext_lazy('Download a spreadsheet of all gift cards including their current value.') + @classmethod + def get_required_organizer_permission(cls) -> str: + return 'organizer.giftcards:read' + @property def additional_form_fields(self): return OrderedDict( diff --git a/src/pretix/base/exporters/reusablemedia.py b/src/pretix/base/exporters/reusablemedia.py index 83182c2df7..fbc6005908 100644 --- a/src/pretix/base/exporters/reusablemedia.py +++ b/src/pretix/base/exporters/reusablemedia.py @@ -36,6 +36,10 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter): description = _('Download a spread sheet with the data of all reusable medias on your account.') repeatable_read = False + @classmethod + def get_required_organizer_permission(cls) -> str: + return "organizer.reusablemedia:read" + def iterate_list(self, form_data): media = ReusableMedium.objects.filter( organizer=self.organizer, diff --git a/src/pretix/base/services/export.py b/src/pretix/base/services/export.py index 7684d8b515..3d1e5047b6 100644 --- a/src/pretix/base/services/export.py +++ b/src/pretix/base/services/export.py @@ -34,7 +34,7 @@ from django_scopes import scopes_disabled from i18nfield.strings import LazyI18nString from pretix.base.email import get_email_context -from pretix.base.exporter import OrganizerLevelExportMixin +from pretix.base.exporter import BaseExporter, OrganizerLevelExportMixin from pretix.base.i18n import LazyLocaleException, language from pretix.base.models import ( CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken, @@ -64,7 +64,15 @@ class ExportEmptyError(ExportError): @app.task(base=ProfiledEventTask, throws=(ExportError, ExportEmptyError), bind=True) -def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) -> None: +def export(self, event: Event, user: User, device: int, token: int, fileid: str, provider: str, + form_data: Dict[str, Any], staff_session=False) -> None: + if user: + user = User.objects.get(pk=user) + if device: + device = Device.objects.get(pk=device) + if token: + device = TeamAPIToken.objects.get(pk=token) + def set_progress(val): if not self.request.called_directly: self.update_state( @@ -72,30 +80,38 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str, meta={'value': val} ) + ex = init_event_exporter( + identifier=provider, + event=event, + user=user, + token=token, + device=device, + staff_session=staff_session, + progress_callback=set_progress, + ) + if not ex: + raise ExportError( + gettext('Export not found or you do not have sufficient permission to perform this export.') + ) + file = CachedFile.objects.get(id=fileid) with language(event.settings.locale, event.settings.region), override(event.settings.timezone): - responses = register_data_exporters.send(event) - for recv, response in responses: - if not response: - continue - ex = response(event, event.organizer, set_progress) - if ex.identifier == provider: - if ex.repeatable_read: - with repeatable_reads_transaction(): - d = ex.render(form_data) - else: - d = ex.render(form_data) + if ex.repeatable_read: + with repeatable_reads_transaction(): + d = ex.render(form_data) + else: + d = ex.render(form_data) - if d is None: - raise ExportError( - gettext('Your export did not contain any data.') - ) - file.filename, file.type, data = d + if d is None: + raise ExportError( + gettext('Your export did not contain any data.') + ) + file.filename, file.type, data = d - close_old_connections() # This task can run very long, we might need a new DB connection + close_old_connections() # This task can run very long, we might need a new DB connection - f = ContentFile(data) - file.file.save(cachedfile_name(file, file.filename), f) + f = ContentFile(data) + file.file.save(cachedfile_name(file, file.filename), f) return str(file.pk) @@ -105,10 +121,7 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int, if device: device = Device.objects.get(pk=device) if token: - device = TeamAPIToken.objects.get(pk=token) - allowed_events = (device or token or user).get_events_with_permission('event.orders:read') - if user and staff_session: - allowed_events = organizer.events.all() + token = TeamAPIToken.objects.get(pk=token) def set_progress(val): if not self.request.called_directly: @@ -118,12 +131,35 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int, ) file = CachedFile.objects.get(id=fileid) + + 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): + event_qs = event_qs.filter(slug__in=form_data.get('events')) + else: + event_qs = event_qs.filter(pk__in=form_data.get('events')) + + ex = init_organizer_exporter( + identifier=provider, + organizer=organizer, + user=user, + token=token, + device=device, + staff_session=staff_session, + progress_callback=set_progress, + event_qs=event_qs, + ) + if not ex: + raise ExportError( + gettext('Export not found or you do not have sufficient permission to perform this export.') + ) + if user: locale = user.locale timezone = user.timezone region = None # todo: add to user? else: - e = allowed_events.first() + e = ex.events.first() if e: locale = e.settings.locale timezone = e.settings.timezone @@ -133,47 +169,140 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int, timezone = organizer.settings.timezone or settings.TIME_ZONE region = organizer.settings.region with language(locale, region), override(timezone): - if form_data.get('events') is not None and not form_data.get('all_events'): - if isinstance(form_data['events'][0], str): - events = allowed_events.filter(slug__in=form_data.get('events'), organizer=organizer) - else: - events = allowed_events.filter(pk__in=form_data.get('events'), organizer=organizer) + if ex.repeatable_read: + with repeatable_reads_transaction(): + d = ex.render(form_data) else: - events = allowed_events.filter(organizer=organizer) - responses = register_multievent_data_exporters.send(organizer) + d = ex.render(form_data) + if d is None: + raise ExportError( + gettext('Your export did not contain any data.') + ) + file.filename, file.type, data = d - for recv, response in responses: - if not response: - continue - ex = response(events, organizer, set_progress) - if ex.identifier == provider: - if ( - isinstance(ex, OrganizerLevelExportMixin) and - not staff_session and - not (device or token or user).has_organizer_permission(organizer, ex.organizer_required_permission) - ): - raise ExportError( - gettext('You do not have sufficient permission to perform this export.') - ) + close_old_connections() # This task can run very long, we might need a new DB connection - if ex.repeatable_read: - with repeatable_reads_transaction(): - d = ex.render(form_data) - else: - d = ex.render(form_data) - if d is None: - raise ExportError( - gettext('Your export did not contain any data.') - ) - file.filename, file.type, data = d - - close_old_connections() # This task can run very long, we might need a new DB connection - - f = ContentFile(data) - file.file.save(cachedfile_name(file, file.filename), f) + f = ContentFile(data) + file.file.save(cachedfile_name(file, file.filename), f) return str(file.pk) +def init_event_exporter(identifier, **kwargs): + for ex in init_event_exporters(**kwargs): + if ex.identifier == identifier: + return ex + return None + + +def init_event_exporters(event, user=None, token=None, device=None, request=None, progress_callback=None, staff_session=False): + if not user and not token and not device: + raise ValueError("No auth source given.") + perm_holder = device or token or user + + responses = register_data_exporters.send(event) + for r, response in responses: + if not response: + continue + + if issubclass(response, OrganizerLevelExportMixin): + raise TypeError("Cannot user organizer-level exporter on event level") + + permission_name = response.get_required_event_permission() + if not perm_holder.has_event_permission(event.organizer, event, permission_name, request) and not staff_session: + continue + + exporter: BaseExporter = response(event=event, organizer=event.organizer, progress_callback=progress_callback) + + if not exporter.available_for_user(user if user and user.is_authenticated else None): + continue + + yield exporter + + +def init_organizer_exporter(identifier, **kwargs): + for ex in init_organizer_exporters(**kwargs): + if ex.identifier == identifier: + return ex + return None + + +def init_organizer_exporters( + organizer, user=None, token=None, device=None, request=None, progress_callback=None, staff_session=False, event_qs=None +): + if not user and not token and not device: + raise ValueError("No auth source given.") + perm_holder = device or token or user + + _event_list_cache = {} + _has_permission_on_any_team_cache = {} + _team_cache = None + + responses = register_multievent_data_exporters.send(organizer) + for r, response in responses: + if not response: + continue + + if issubclass(response, OrganizerLevelExportMixin): + exporter: BaseExporter = response(event=Event.objects.none(), organizer=organizer, progress_callback=progress_callback) + + try: + if not perm_holder.has_organizer_permission(organizer, response.get_required_organizer_permission(), request) and not staff_session: + continue + except NotImplementedError: + logger.error(f"Not showing export {response} because get_required_organizer_permission() is not implemented.") + continue + + else: + permission_name = response.get_required_event_permission() + + if permission_name not in _event_list_cache: + if staff_session: + events = event_qs.all() + elif event_qs is not None: + events = event_qs.filter( + pk__in=perm_holder.get_events_with_permission( + permission_name, request=request + ).filter( + organizer=organizer + ).values("id") + ) + else: + events = perm_holder.get_events_with_permission( + permission_name, request=request + ).filter( + organizer=organizer + ) + + _event_list_cache[permission_name] = events + + if permission_name not in _has_permission_on_any_team_cache: + # Check if the user has this event permission on any teams they are part of to decide whether to show + # the export at all. + # This is different from _event_list_cache[permission_name].exists() for the case of an organizer with + # zero events in total, or a team with zero events. In these cases, we still want people to be able + # to see waht exports they'll get once they have events. + if user: + if _team_cache is None: + _team_cache = list(user.teams.filter(organizer=organizer)) + _has_permission_on_any_team_cache[permission_name] = staff_session or any( + t.has_event_permission(permission_name) for t in _team_cache + ) + elif token: + _has_permission_on_any_team_cache[permission_name] = token.team.has_event_permission(permission_name) + elif device: + _has_permission_on_any_team_cache[permission_name] = device.has_event_permission(permission_name) + + if not _has_permission_on_any_team_cache[permission_name]: + continue + + exporter: BaseExporter = response(event=_event_list_cache[permission_name], organizer=organizer, progress_callback=progress_callback) + + if not exporter.available_for_user(user if user and user.is_authenticated else None): + continue + + yield exporter + + def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter, config_url, retry_func, has_permission): with language(schedule.locale, context.settings.region), override(schedule.tz): file = CachedFile(web_download=False) @@ -217,7 +346,7 @@ def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter, try: if not exporter: - raise ExportError("Export type not found.") + raise ExportError("Export type not found or permission denied.") if exporter.repeatable_read: with repeatable_reads_transaction(): d = exporter.render(schedule.export_form_data) @@ -291,31 +420,20 @@ def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter, def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> None: schedule = organizer.scheduled_exports.get(pk=schedule) - allowed_events = schedule.owner.get_events_with_permission('event.orders:read') + event_qs = organizer.events.all() if schedule.export_form_data.get('events') is not None and not schedule.export_form_data.get('all_events'): if isinstance(schedule.export_form_data['events'][0], str): - events = allowed_events.filter(slug__in=schedule.export_form_data.get('events'), organizer=organizer) + event_qs = event_qs.filter(slug__in=schedule.export_form_data.get('events')) else: - events = allowed_events.filter(pk__in=schedule.export_form_data.get('events'), organizer=organizer) - else: - events = allowed_events.filter(organizer=organizer) - - responses = register_multievent_data_exporters.send(organizer) - exporter = None - for recv, response in responses: - if not response: - continue - ex = response(events, organizer) - if ex.identifier == schedule.export_identifier: - exporter = ex - break + event_qs = event_qs.filter(pk__in=schedule.export_form_data.get('events')) + exporter = init_organizer_exporter( + identifier=schedule.export_identifier, + organizer=organizer, + user=schedule.owner, + event_qs=event_qs, + ) has_permission = schedule.owner.is_active - if isinstance(exporter, OrganizerLevelExportMixin): - if not schedule.owner.has_organizer_permission(organizer, exporter.organizer_required_permission): - has_permission = False - if exporter and not exporter.available_for_user(schedule.owner): - has_permission = False _run_scheduled_export( schedule, @@ -336,17 +454,12 @@ def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> Non def scheduled_event_export(self, event: Event, schedule: int) -> None: schedule = event.scheduled_exports.get(pk=schedule) - responses = register_data_exporters.send(event) - exporter = None - for recv, response in responses: - if not response: - continue - ex = response(event, event.organizer) - if ex.identifier == schedule.export_identifier: - exporter = ex - break - - has_permission = schedule.owner.is_active and schedule.owner.has_event_permission(event.organizer, event, 'event.orders:read') + exporter = init_event_exporter( + identifier=schedule.export_identifier, + event=event, + user=schedule.owner, + ) + has_permission = schedule.owner.is_active _run_scheduled_export( schedule, diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index de2f60b506..f7a8490726 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -92,7 +92,9 @@ from pretix.base.payment import PaymentException from pretix.base.secrets import assign_ticket_secret from pretix.base.services import tickets from pretix.base.services.cancelevent import cancel_event -from pretix.base.services.export import export, scheduled_event_export +from pretix.base.services.export import ( + export, init_event_exporters, scheduled_event_export, +) from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_pdf, invoice_pdf_task, invoice_qualified, regenerate_invoice, transmit_invoice, @@ -111,9 +113,7 @@ from pretix.base.services.tax import ( VATIDFinalError, VATIDTemporaryError, validate_vat_id, ) from pretix.base.services.tickets import generate -from pretix.base.signals import ( - order_modified, register_data_exporters, register_ticket_outputs, -) +from pretix.base.signals import order_modified, register_ticket_outputs from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.views.mixins import OrderQuestionsViewMixin @@ -2660,12 +2660,7 @@ class OrderGo(EventPermissionRequiredMixin, View): class ExportMixin: @cached_property def exporters(self): - responses = register_data_exporters.send(self.request.event) - raw_exporters = [response(self.request.event, self.request.organizer) for r, response in responses if response] - raw_exporters = [ - ex for ex in raw_exporters - if ex.available_for_user(self.request.user if self.request.user and self.request.user.is_authenticated else None) - ] + raw_exporters = list(init_event_exporters(self.request.event, user=self.request.user, request=self.request)) return sorted( raw_exporters, key=lambda ex: (0 if ex.category else 1, ex.category or "", 0 if ex.featured else 1, str(ex.verbose_name).lower()) @@ -2737,7 +2732,7 @@ class ExportMixin: class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, TemplateView): - permission = 'event.orders:read' + permission = None known_errortypes = ['ExportError', 'ExportEmptyError'] task = export template_name = 'pretixcontrol/orders/export_form.html' @@ -2782,11 +2777,20 @@ class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, Templ cf.date = now() cf.expires = now() + timedelta(hours=24) cf.save() - return self.do(self.request.event.id, str(cf.id), self.exporter.identifier, data) + return self.do( + self.request.event.id, + user=self.request.user.id, + fileid=str(cf.id), + provider=self.exporter.identifier, + device=None, + token=None, + form_data=data, + staff_session=self.request.user.has_active_staff_session(self.request.session.session_key) + ) class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView): - permission = 'event.orders:read' + permission = None paginate_by = 25 context_object_name = 'scheduled' @@ -2906,7 +2910,7 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView): class DeleteScheduledExportView(EventPermissionRequiredMixin, ExportMixin, CompatDeleteView): - permission = 'event.orders:read' + permission = None template_name = 'pretixcontrol/orders/export_delete.html' context_object_name = 'export' diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 79f0603098..3643f3769b 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -104,9 +104,10 @@ from pretix.base.plugins import ( PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, PLUGIN_LEVEL_ORGANIZER, ) -from pretix.base.services.export import multiexport, scheduled_organizer_export +from pretix.base.services.export import ( + init_organizer_exporters, multiexport, scheduled_organizer_export, +) from pretix.base.services.mail import SendMailException, mail, prefix_subject -from pretix.base.signals import register_multievent_data_exporters from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.views.tasks import AsyncAction from pretix.control.forms.exports import ScheduledOrganizerExportForm @@ -1982,7 +1983,7 @@ class ExportMixin: )), ('events', forms.ModelMultipleChoiceField( - queryset=self.events, + queryset=ex.events, widget=forms.CheckboxSelectMultiple( attrs={ 'class': 'scrolling-multiple-choice', @@ -1995,29 +1996,9 @@ class ExportMixin: ]) return ex - @cached_property - def events(self): - return self.request.user.get_events_with_permission('event.orders:read', request=self.request).filter( - organizer=self.request.organizer - ) - @cached_property def exporters(self): - responses = register_multievent_data_exporters.send(self.request.organizer) - raw_exporters = [ - response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else self.events, - self.request.organizer) - for r, response in responses - if response - ] - raw_exporters = [ - ex for ex in raw_exporters - if ( - not isinstance(ex, OrganizerLevelExportMixin) or - self.request.user.has_organizer_permission(self.request.organizer, ex.organizer_required_permission, - self.request) - ) and ex.available_for_user(self.request.user if self.request.user and self.request.user.is_authenticated else None) - ] + raw_exporters = list(init_organizer_exporters(self.request.organizer, user=self.request.user, request=self.request)) return sorted( raw_exporters, key=lambda ex: ( @@ -2208,11 +2189,17 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView): return self.get_scheduled_queryset() def has_permission(self): - if isinstance(self.exporter, OrganizerLevelExportMixin): - if not self.request.user.has_organizer_permission(self.request.organizer, self.exporter.organizer_required_permission): + # Check if permission exists even without staff session + if self.exporter: + if isinstance(self.exporter, OrganizerLevelExportMixin): + if not self.request.user.has_organizer_permission(self.request.organizer, self.exporter.get_required_organizer_permission()): + return False + else: + permission_name = self.exporter.get_required_event_permission() + if not any(t.has_event_permission(permission_name) for t in self.request.user.teams.filter(organizer=self.request.organizer)): + return False + if not self.exporter.available_for_user(self.request.user): return False - if self.exporter and not self.exporter.available_for_user(self.request.user): - return False return True def get_context_data(self, **kwargs): diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index 0b3f7627d4..d25ec2d1ac 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -194,7 +194,6 @@ event_permission_sub_urls = [ ('post', 'event.orders:write', 'cartpositions/', 400), ('delete', 'event.orders:write', 'cartpositions/1/', 404), ('post', 'event.orders:read', 'exporters/invoicedata/run/', 400), - ('get', 'event.orders:read', 'exporters/invoicedata/download/bc3f9884-26ee-425b-8636-80613f84b6fa/3cb49ae6-eda3-4605-814e-099e23777b36/', 404), ('get', None, 'item_meta_properties/', 200), ('get', None, 'item_meta_properties/0/', 404), ('post', 'event.settings.general:write', 'item_meta_properties/', 400), diff --git a/src/tests/base/test_export.py b/src/tests/base/test_export.py index 63e664359d..80c5deb877 100644 --- a/src/tests/base/test_export.py +++ b/src/tests/base/test_export.py @@ -105,7 +105,7 @@ def test_event_fail_invalid_config(event, user): assert s.error_counter == 1 assert len(djmail.outbox) == 1 assert djmail.outbox[0].subject == "Export failed" - assert "Reason: Export type not found." in djmail.outbox[0].body + assert "Reason: Export type not found" in djmail.outbox[0].body assert djmail.outbox[0].to == [user.email] @@ -153,7 +153,7 @@ def test_event_fail_user_no_permission(event, user, team): assert s.error_counter == 1 assert len(djmail.outbox) == 1 assert djmail.outbox[0].subject == "Export failed" - assert "Reason: Permission denied." in djmail.outbox[0].body + assert "Reason: Export type not found or permission denied." in djmail.outbox[0].body assert djmail.outbox[0].to == [user.email] @@ -236,7 +236,7 @@ def test_organizer_fail_invalid_config(event, user): assert s.error_counter == 1 assert len(djmail.outbox) == 1 assert djmail.outbox[0].subject == "Export failed" - assert "Reason: Export type not found." in djmail.outbox[0].body + assert "Reason: Export type not found" in djmail.outbox[0].body assert djmail.outbox[0].to == [user.email] @@ -284,7 +284,7 @@ def test_organizer_fail_user_does_not_have_specific_permission(event, user, team assert s.error_counter == 1 assert len(djmail.outbox) == 1 assert djmail.outbox[0].subject == "Export failed" - assert "Reason: Permission denied." in djmail.outbox[0].body + assert "Reason: Export type not found or permission denied." in djmail.outbox[0].body assert djmail.outbox[0].to == [user.email] diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index 016ff8312d..58002978c7 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -365,8 +365,6 @@ event_permission_urls = [ ("event.subevents:write", "subevents/bulk_action", 302, HTTP_POST), ("event.subevents:write", "subevents/bulk_edit", 404, HTTP_POST), ("event.orders:read", "orders/overview/", 200, HTTP_GET), - ("event.orders:read", "orders/export/", 200, HTTP_GET), - ("event.orders:read", "orders/export/do", 302, HTTP_POST), ("event.orders:read", "orders/", 200, HTTP_GET), ("event.orders:read", "orders/FOO/", 200, HTTP_GET), ("event.orders:write", "orders/FOO/extend", 200, HTTP_GET),