From 60cdfe4029366552aa97af04e47e229e703dd7ed Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 5 Oct 2022 09:56:43 +0200 Subject: [PATCH] Allow organizer-level exports with separate permission and no event selection --- doc/development/api/exporter.rst | 8 +++++- src/pretix/api/views/exporters.py | 17 +++++++++-- src/pretix/base/exporter.py | 14 +++++++-- src/pretix/base/exporters/orderlist.py | 10 +++++-- src/pretix/base/services/export.py | 12 ++++++++ src/pretix/control/views/organizer.py | 40 +++++++++++++++++--------- src/tests/api/test_exporters.py | 16 +++++++++++ 7 files changed, 96 insertions(+), 21 deletions(-) diff --git a/doc/development/api/exporter.rst b/doc/development/api/exporter.rst index 3edcbdc158..b81dfa41d8 100644 --- a/doc/development/api/exporter.rst +++ b/doc/development/api/exporter.rst @@ -60,7 +60,13 @@ The exporter class .. py:attribute:: BaseExporter.event The default constructor sets this property to the event we are currently - working for. + working for. This will be ``None`` if the exporter is run for multiple + events. + + .. py:attribute:: BaseExporter.events + + The default constructor sets this property to the list of events to work + on, regardless of whether the exporter is called for one or multiple events. .. autoattribute:: identifier diff --git a/src/pretix/api/views/exporters.py b/src/pretix/api/views/exporters.py index 23f9e9d5ce..3a82163a25 100644 --- a/src/pretix/api/views/exporters.py +++ b/src/pretix/api/views/exporters.py @@ -35,7 +35,8 @@ from rest_framework.reverse import reverse from pretix.api.serializers.exporters import ( ExporterSerializer, JobRunSerializer, ) -from pretix.base.models import CachedFile, Device, TeamAPIToken +from pretix.base.exporter import OrganizerLevelExportMixin +from pretix.base.models import CachedFile, Device, Event, TeamAPIToken from pretix.base.services.export import export, multiexport from pretix.base.signals import ( register_data_exporters, register_multievent_data_exporters, @@ -155,7 +156,19 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet): organizer=self.request.organizer ) responses = register_multievent_data_exporters.send(self.request.organizer) - for ex in sorted([response(events, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)): + 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) + ) + ] + for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)): ex._serializer = JobRunSerializer(exporter=ex, events=events) exporters.append(ex) return exporters diff --git a/src/pretix/base/exporter.py b/src/pretix/base/exporter.py index 082f3adc95..3a138c9fb5 100644 --- a/src/pretix/base/exporter.py +++ b/src/pretix/base/exporter.py @@ -51,7 +51,7 @@ from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for SafeWorkbook, remove_invalid_excel_chars as excel_safe, ) -__ = excel_safe # just so the compatbility import above is "used" and doesn't get removed by linter +__ = excel_safe # just so the compatibility import above is "used" and doesn't get removed by linter class BaseExporter: @@ -80,7 +80,7 @@ class BaseExporter: def verbose_name(self) -> str: """ A human-readable name for this exporter. This should be short but - self-explaining. Good examples include 'JSON' or 'Microsoft Excel'. + self-explaining. Good examples include 'Orders as JSON' or 'Orders as Microsoft Excel'. """ raise NotImplementedError() # NOQA @@ -137,6 +137,16 @@ class BaseExporter: raise NotImplementedError() # NOQA +class OrganizerLevelExportMixin: + @property + def organizer_required_permission(self) -> str: + """ + The permission level required to use this exporter. Only useful for organizer-level exports, + not for event-level exports. + """ + return 'can_view_orders' + + class ListExporter(BaseExporter): ProgressSetTotal = namedtuple('ProgressSetTotal', 'total') diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index dcc95514f9..58a8ea093c 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -60,7 +60,9 @@ from pretix.base.settings import PERSON_NAME_SCHEMES from ...control.forms.filter import get_all_payment_providers from ...helpers import GroupConcat from ...helpers.iter import chunked_iterable -from ..exporter import ListExporter, MultiSheetListExporter +from ..exporter import ( + ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin, +) from ..signals import ( register_data_exporters, register_multievent_data_exporters, ) @@ -884,9 +886,10 @@ class QuotaListExporter(ListExporter): return '{}_quotas'.format(self.event.slug) -class GiftcardTransactionListExporter(ListExporter): +class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter): identifier = 'giftcardtransactionlist' verbose_name = gettext_lazy('Gift card transactions') + organizer_required_permission = 'can_manage_gift_cards' @property def additional_form_fields(self): @@ -998,9 +1001,10 @@ class GiftcardRedemptionListExporter(ListExporter): return '{}_giftcardredemptions'.format(self.event.slug) -class GiftcardListExporter(ListExporter): +class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter): identifier = 'giftcardlist' verbose_name = gettext_lazy('Gift cards') + organizer_required_permission = 'can_manage_gift_cards' @property def additional_form_fields(self): diff --git a/src/pretix/base/services/export.py b/src/pretix/base/services/export.py index 51a9331eb2..9adac010e6 100644 --- a/src/pretix/base/services/export.py +++ b/src/pretix/base/services/export.py @@ -26,6 +26,7 @@ from django.core.files.base import ContentFile from django.utils.timezone import override from django.utils.translation import gettext +from pretix.base.exporter import OrganizerLevelExportMixin from pretix.base.i18n import LazyLocaleException, language from pretix.base.models import ( CachedFile, Device, Event, Organizer, TeamAPIToken, User, cachedfile_name, @@ -119,6 +120,17 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int, 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(self.request.organizer, + ex.organizer_required_permission, + self.request) + ): + raise ExportError( + gettext('You do not have sufficient permission to perform this export.') + ) + d = ex.render(form_data) if d is None: raise ExportError( diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index d665b0949e..24da3891dd 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -65,6 +65,7 @@ from pretix.api.models import WebHook from pretix.api.webhooks import manually_retry_all_calls from pretix.base.auth import get_auth_backends from pretix.base.channels import get_all_sales_channels +from pretix.base.exporter import OrganizerLevelExportMixin from pretix.base.i18n import language from pretix.base.models import ( CachedFile, Customer, Device, Gate, GiftCard, Invoice, LogEntry, @@ -1508,7 +1509,19 @@ class ExportMixin: ) responses = register_multievent_data_exporters.send(self.request.organizer) id = self.request.GET.get("identifier") or self.request.POST.get("exporter") - for ex in sorted([response(events, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)): + 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 + self.request.user.has_organizer_permission(self.request.organizer, ex.organizer_required_permission, self.request) + ) + ] + for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)): if id and ex.identifier != id: continue @@ -1526,18 +1539,19 @@ class ExportMixin: initial=initial ) ex.form.fields = ex.export_form_fields - ex.form.fields.update([ - ('events', - forms.ModelMultipleChoiceField( - queryset=events, - initial=events, - widget=forms.CheckboxSelectMultiple( - attrs={'class': 'scrolling-multiple-choice'} - ), - label=_('Events'), - required=True - )), - ]) + if not isinstance(ex, OrganizerLevelExportMixin): + ex.form.fields.update([ + ('events', + forms.ModelMultipleChoiceField( + queryset=events, + initial=events, + widget=forms.CheckboxSelectMultiple( + attrs={'class': 'scrolling-multiple-choice'} + ), + label=_('Events'), + required=True + )), + ]) exporters.append(ex) return exporters diff --git a/src/tests/api/test_exporters.py b/src/tests/api/test_exporters.py index 17b5f8b97d..43addee623 100644 --- a/src/tests/api/test_exporters.py +++ b/src/tests/api/test_exporters.py @@ -191,3 +191,19 @@ def test_gone_without_celery(token_client, organizer, team, event): cf = CachedFile.objects.create() resp = token_client.get('/api/v1/organizers/{}/events/{}/exporters/orderlist/download/{}/{}/'.format(organizer.slug, event.slug, uuid.uuid4(), cf.id)) assert resp.status_code == 410 + + +@pytest.mark.django_db +def test_org_level_export(token_client, organizer, team, event): + resp = token_client.post('/api/v1/organizers/{}/exporters/giftcardlist/run/'.format(organizer.slug), data={ + '_format': 'xlsx', + }, format='json') + assert resp.status_code == 202 + + team.can_manage_gift_cards = False + team.save() + + resp = token_client.post('/api/v1/organizers/{}/exporters/giftcardlist/run/'.format(organizer.slug), data={ + '_format': 'xlsx', + }, format='json') + assert resp.status_code == 404