Add proper permission handling for exports

This commit is contained in:
Raphael Michel
2026-01-29 10:07:14 +01:00
parent fa99ef9946
commit a7fe43ec01
12 changed files with 327 additions and 221 deletions

View File

@@ -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',

View File

@@ -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):

View File

@@ -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')

View File

@@ -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(

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,

View File

@@ -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'

View File

@@ -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):

View File

@@ -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),

View File

@@ -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]

View File

@@ -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),