diff --git a/doc/development/api/exporter.rst b/doc/development/api/exporter.rst index c45c76d08..2228aaa0c 100644 --- a/doc/development/api/exporter.rst +++ b/doc/development/api/exporter.rst @@ -29,6 +29,22 @@ that we'll provide in this plugin:: from .exporter import MyExporter return MyExporter +Some exporters might also prove to be useful, when provided on an organizer-level. In order to declare your +exporter as capable of providing exports spanning multiple events, your plugin should listen for this signal +and return the subclass of ``pretix.base.exporter.BaseExporter`` that we'll provide in this plugin:: + + from django.dispatch import receiver + + from pretix.base.signals import register_multievent_data_exporters + + + @receiver(register_multievent_data_exporters, dispatch_uid="multieventexporter_myexporter") + def register_multievent_data_exporter(sender, **kwargs): + from .exporter import MyExporter + return MyExporter + +If your exporter supports both event-level and multi-event level exports, you will need to listen for both +signals. The exporter class ------------------ diff --git a/src/pretix/base/exporter.py b/src/pretix/base/exporter.py index ee8ffc7c2..dfe131857 100644 --- a/src/pretix/base/exporter.py +++ b/src/pretix/base/exporter.py @@ -6,11 +6,14 @@ from typing import Tuple from defusedcsv import csv from django import forms +from django.db.models import QuerySet from django.utils.formats import localize from django.utils.translation import gettext, gettext_lazy as _ from openpyxl import Workbook from openpyxl.cell.cell import KNOWN_TYPES +from pretix.base.models import Event + class BaseExporter: """ @@ -19,6 +22,12 @@ class BaseExporter: def __init__(self, event): self.event = event + self.is_multievent = isinstance(event, QuerySet) + if isinstance(event, QuerySet): + self.events = event + self.event = None + else: + self.events = Event.objects.filter(pk=event.pk) def __str__(self): return self.identifier diff --git a/src/pretix/base/exporters/invoices.py b/src/pretix/base/exporters/invoices.py index 201981e78..d6a41d7bb 100644 --- a/src/pretix/base/exporters/invoices.py +++ b/src/pretix/base/exporters/invoices.py @@ -9,11 +9,14 @@ from django.db.models import Exists, OuterRef, Q from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ -from pretix.base.models import OrderPayment +from pretix.base.models import Invoice, OrderPayment +from ...control.forms.filter import get_all_payment_providers from ..exporter import BaseExporter from ..services.invoices import invoice_pdf_task -from ..signals import register_data_exporters +from ..signals import ( + register_data_exporters, register_multievent_data_exporters, +) class InvoiceExporter(BaseExporter): @@ -21,7 +24,7 @@ class InvoiceExporter(BaseExporter): verbose_name = _('All invoices') def render(self, form_data: dict, output_file=None): - qs = self.event.invoices.filter(shredded=False) + qs = Invoice.objects.filter(event__in=self.events, shredded=False) if form_data.get('payment_provider'): qs = qs.annotate( @@ -68,11 +71,16 @@ class InvoiceExporter(BaseExporter): if not any: return None + if self.is_multievent: + filename = '{}_invoices.zip'.format(self.events.first().organizer.slug) + else: + filename = '{}_invoices'.format(self.event.slug) + if output_file: - return '{}_invoices.zip'.format(self.event.slug), 'application/zip', None + return filename, 'application/zip', None else: with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf: - return '{}_invoices.zip'.format(self.event.slug), 'application/zip', zipf.read() + return filename, 'application/zip', zipf.read() @property def export_form_fields(self): @@ -99,7 +107,7 @@ class InvoiceExporter(BaseExporter): label=_('Payment provider'), choices=[ ('', _('All payment providers')), - ] + [ + ] + get_all_payment_providers() if self.is_multievent else [ (k, v.verbose_name) for k, v in self.event.get_payment_providers().items() ], required=False, @@ -115,3 +123,8 @@ class InvoiceExporter(BaseExporter): @receiver(register_data_exporters, dispatch_uid="exporter_invoices") def register_invoice_export(sender, **kwargs): return InvoiceExporter + + +@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_invoices") +def register_multievent_invoice_export(sender, **kwargs): + return InvoiceExporter diff --git a/src/pretix/base/exporters/mail.py b/src/pretix/base/exporters/mail.py index cb6b84fd9..912821bd2 100644 --- a/src/pretix/base/exporters/mail.py +++ b/src/pretix/base/exporters/mail.py @@ -8,7 +8,9 @@ from pretix.base.models import OrderPosition from ..exporter import BaseExporter from ..models import Order -from ..signals import register_data_exporters +from ..signals import ( + register_data_exporters, register_multievent_data_exporters, +) class MailExporter(BaseExporter): @@ -16,14 +18,18 @@ class MailExporter(BaseExporter): verbose_name = _('Email addresses (text file)') def render(self, form_data: dict): - qs = self.event.orders.filter(status__in=form_data['status']) + qs = Order.objects.filter(event__in=self.events, status__in=form_data['status']).prefetch_related('event') addrs = qs.values('email') pos = OrderPosition.objects.filter( - order__event=self.event, order__status__in=form_data['status'] + order__event__in=self.events, order__status__in=form_data['status'] ).values('attendee_email') data = "\r\n".join(set(a['email'] for a in addrs if a['email']) | set(a['attendee_email'] for a in pos if a['attendee_email'])) - return '{}_pretixemails.txt'.format(self.event.slug), 'text/plain', data.encode("utf-8") + + if self.is_multievent: + return '{}_pretixemails.txt'.format(self.events.first().organizer.slug), 'text/plain', data.encode("utf-8") + else: + return '{}_pretixemails.txt'.format(self.event.slug), 'text/plain', data.encode("utf-8") @property def export_form_fields(self): @@ -44,3 +50,8 @@ class MailExporter(BaseExporter): @receiver(register_data_exporters, dispatch_uid="exporter_mail") def register_mail_export(sender, **kwargs): return MailExporter + + +@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_mail") +def register_multievent_mail_export(sender, **kwargs): + return MailExporter diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index 11a6e0837..bffcbee85 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -11,14 +11,18 @@ from django.utils.formats import date_format from django.utils.translation import gettext as _, gettext_lazy, pgettext from pretix.base.models import ( - GiftCard, InvoiceAddress, InvoiceLine, Order, OrderPosition, Question, + GiftCard, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition, + Question, ) from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund from pretix.base.services.quotas import QuotaAvailability from pretix.base.settings import PERSON_NAME_SCHEMES +from ...control.forms.filter import get_all_payment_providers from ..exporter import ListExporter, MultiSheetListExporter -from ..signals import register_data_exporters +from ..signals import ( + register_data_exporters, register_multievent_data_exporters, +) class OrderListExporter(MultiSheetListExporter): @@ -50,13 +54,13 @@ class OrderListExporter(MultiSheetListExporter): tax_rates = set( a for a in OrderFee.objects.filter( - order__event=self.event + order__event__in=self.events ).values_list('tax_rate', flat=True).distinct().order_by() ) tax_rates |= set( a for a in OrderPosition.objects.filter( - order__event=self.event + order__event__in=self.events ).values_list('tax_rate', flat=True).distinct().order_by() ) tax_rates = sorted(tax_rates) @@ -71,8 +75,6 @@ class OrderListExporter(MultiSheetListExporter): return self.iterate_fees(form_data) def iterate_orders(self, form_data: dict): - tz = pytz.timezone(self.event.settings.timezone) - p_date = OrderPayment.objects.filter( order=OuterRef('pk'), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), @@ -86,20 +88,20 @@ class OrderListExporter(MultiSheetListExporter): s = OrderPosition.objects.filter( order=OuterRef('pk') ).order_by().values('order').annotate(k=Count('id')).values('k') - qs = self.event.orders.annotate( + qs = Order.objects.filter(event__in=self.events).annotate( payment_date=Subquery(p_date, output_field=DateTimeField()), pcnt=Subquery(s, output_field=IntegerField()) - ).select_related('invoice_address').prefetch_related('invoices') + ).select_related('invoice_address').prefetch_related('invoices').prefetch_related('event') if form_data['paid_only']: qs = qs.filter(status=Order.STATUS_PAID) tax_rates = self._get_all_tax_rates(qs) headers = [ - _('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'), + _('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'), _('Company'), _('Name'), ] - name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] - if len(name_scheme['fields']) > 1: + name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None + if name_scheme and len(name_scheme['fields']) > 1: for k, label, w in name_scheme['fields']: headers.append(label) headers += [ @@ -140,7 +142,10 @@ class OrderListExporter(MultiSheetListExporter): } for order in qs.order_by('datetime'): + tz = pytz.timezone(order.event.settings.timezone) + row = [ + order.event.slug, order.code, order.total, order.get_status_display(), @@ -152,7 +157,7 @@ class OrderListExporter(MultiSheetListExporter): order.invoice_address.company, order.invoice_address.name, ] - if len(name_scheme['fields']) > 1: + if name_scheme and len(name_scheme['fields']) > 1: for k, label, w in name_scheme['fields']: row.append( order.invoice_address.name_parts.get(k, '') @@ -167,7 +172,7 @@ class OrderListExporter(MultiSheetListExporter): order.invoice_address.vat_id, ] except InvoiceAddress.DoesNotExist: - row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0)) + row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0)) row += [ order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '', @@ -197,15 +202,14 @@ class OrderListExporter(MultiSheetListExporter): yield row def iterate_fees(self, form_data: dict): - tz = pytz.timezone(self.event.settings.timezone) - qs = OrderFee.objects.filter( - order__event=self.event, + order__event__in=self.events, ).select_related('order', 'order__invoice_address', 'tax_rule') if form_data['paid_only']: qs = qs.filter(order__status=Order.STATUS_PAID) headers = [ + _('Event slug'), _('Order code'), _('Status'), _('Email'), @@ -219,8 +223,8 @@ class OrderListExporter(MultiSheetListExporter): _('Company'), _('Invoice address name'), ] - name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] - if len(name_scheme['fields']) > 1: + name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None + if name_scheme and len(name_scheme['fields']) > 1: for k, label, w in name_scheme['fields']: headers.append(_('Invoice address name') + ': ' + str(label)) headers += [ @@ -231,7 +235,9 @@ class OrderListExporter(MultiSheetListExporter): for op in qs.order_by('order__datetime'): order = op.order + tz = pytz.timezone(order.event.settings.timezone) row = [ + order.event.slug, order.code, order.get_status_display(), order.email, @@ -248,7 +254,7 @@ class OrderListExporter(MultiSheetListExporter): order.invoice_address.company, order.invoice_address.name, ] - if len(name_scheme['fields']) > 1: + if name_scheme and len(name_scheme['fields']) > 1: for k, label, w in name_scheme['fields']: row.append( order.invoice_address.name_parts.get(k, '') @@ -263,14 +269,12 @@ class OrderListExporter(MultiSheetListExporter): order.invoice_address.vat_id, ] except InvoiceAddress.DoesNotExist: - row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0)) + row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0)) yield row def iterate_positions(self, form_data: dict): - tz = pytz.timezone(self.event.settings.timezone) - qs = OrderPosition.objects.filter( - order__event=self.event, + order__event__in=self.events, ).select_related( 'order', 'order__invoice_address', 'item', 'variation', 'voucher', 'tax_rule' @@ -281,13 +285,14 @@ class OrderListExporter(MultiSheetListExporter): qs = qs.filter(order__status=Order.STATUS_PAID) headers = [ + _('Event slug'), _('Order code'), _('Position ID'), _('Status'), _('Email'), _('Order date'), ] - if self.event.has_subevents: + if self.events.filter(has_subevents=True).exists(): headers.append(pgettext('subevent', 'Date')) headers.append(_('Start date')) headers.append(_('End date')) @@ -300,8 +305,8 @@ class OrderListExporter(MultiSheetListExporter): _('Tax value'), _('Attendee name'), ] - name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] - if len(name_scheme['fields']) > 1: + name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None + if name_scheme and len(name_scheme['fields']) > 1: for k, label, w in name_scheme['fields']: headers.append(_('Attendee name') + ': ' + str(label)) headers += [ @@ -315,7 +320,8 @@ class OrderListExporter(MultiSheetListExporter): _('Voucher'), _('Pseudonymization ID'), ] - questions = list(self.event.questions.all()) + + questions = list(Question.objects.filter(event__in=self.events)) options = {} for q in questions: if q.type == Question.TYPE_CHOICE_MULTIPLE: @@ -329,7 +335,7 @@ class OrderListExporter(MultiSheetListExporter): _('Company'), _('Invoice address name'), ] - if len(name_scheme['fields']) > 1: + if name_scheme and len(name_scheme['fields']) > 1: for k, label, w in name_scheme['fields']: headers.append(_('Invoice address name') + ': ' + str(label)) headers += [ @@ -343,18 +349,20 @@ class OrderListExporter(MultiSheetListExporter): for op in qs.order_by('order__datetime', 'positionid'): order = op.order + tz = pytz.timezone(order.event.settings.timezone) row = [ + order.event.slug, order.code, op.positionid, order.get_status_display(), order.email, order.datetime.astimezone(tz).strftime('%Y-%m-%d'), ] - if self.event.has_subevents: + if order.event.has_subevents: row.append(op.subevent.name) - row.append(op.subevent.date_from.astimezone(self.event.timezone).strftime('%Y-%m-%d %H:%M:%S')) + row.append(op.subevent.date_from.astimezone(order.event.timezone).strftime('%Y-%m-%d %H:%M:%S')) if op.subevent.date_to: - row.append(op.subevent.date_to.astimezone(self.event.timezone).strftime('%Y-%m-%d %H:%M:%S')) + row.append(op.subevent.date_to.astimezone(order.event.timezone).strftime('%Y-%m-%d %H:%M:%S')) else: row.append('') row += [ @@ -366,7 +374,7 @@ class OrderListExporter(MultiSheetListExporter): op.tax_value, op.attendee_name, ] - if len(name_scheme['fields']) > 1: + if name_scheme and len(name_scheme['fields']) > 1: for k, label, w in name_scheme['fields']: row.append( op.attendee_name_parts.get(k, '') @@ -404,7 +412,7 @@ class OrderListExporter(MultiSheetListExporter): order.invoice_address.company, order.invoice_address.name, ] - if len(name_scheme['fields']) > 1: + if name_scheme and len(name_scheme['fields']) > 1: for k, label, w in name_scheme['fields']: row.append( order.invoice_address.name_parts.get(k, '') @@ -419,7 +427,7 @@ class OrderListExporter(MultiSheetListExporter): order.invoice_address.vat_id, ] except InvoiceAddress.DoesNotExist: - row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0)) + row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0)) row += [ order.sales_channel, order.locale @@ -427,7 +435,10 @@ class OrderListExporter(MultiSheetListExporter): yield row def get_filename(self): - return '{}_orders'.format(self.event.slug) + if self.is_multievent: + return '{}_orders'.format(self.events.first().organizer.slug) + else: + return '{}_orders'.format(self.event.slug) class PaymentListExporter(ListExporter): @@ -459,31 +470,27 @@ class PaymentListExporter(ListExporter): ) def iterate_list(self, form_data): - tz = pytz.timezone(self.event.settings.timezone) - - provider_names = { - k: v.verbose_name - for k, v in self.event.get_payment_providers().items() - } + provider_names = dict(get_all_payment_providers()) payments = OrderPayment.objects.filter( - order__event=self.event, + order__event__in=self.events, state__in=form_data.get('payment_states', []) ).order_by('created') refunds = OrderRefund.objects.filter( - order__event=self.event, + order__event__in=self.events, state__in=form_data.get('refund_states', []) ).order_by('created') objs = sorted(list(payments) + list(refunds), key=lambda o: o.created) headers = [ - _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'), + _('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'), _('Status code'), _('Amount'), _('Payment method') ] yield headers for obj in objs: + tz = pytz.timezone(obj.order.event.settings.timezone) if isinstance(obj, OrderPayment) and obj.payment_date: d2 = obj.payment_date.astimezone(tz).date().strftime('%Y-%m-%d') elif isinstance(obj, OrderRefund) and obj.execution_date: @@ -491,6 +498,7 @@ class PaymentListExporter(ListExporter): else: d2 = '' row = [ + obj.order.event.slug, obj.order.code, obj.full_id, obj.created.astimezone(tz).date().strftime('%Y-%m-%d'), @@ -503,7 +511,10 @@ class PaymentListExporter(ListExporter): yield row def get_filename(self): - return '{}_payments'.format(self.event.slug) + if self.is_multievent: + return '{}_payments'.format(self.events.first().organizer.slug) + else: + return '{}_payments'.format(self.event.slug) class QuotaListExporter(ListExporter): @@ -585,7 +596,7 @@ class InvoiceDataExporter(MultiSheetListExporter): _('Total value (without taxes)'), _('Payment matching IDs'), ] - qs = self.event.invoices.order_by('full_invoice_no').select_related( + qs = Invoice.objects.filter(event__in=self.events).order_by('full_invoice_no').select_related( 'order', 'refers' ).prefetch_related('order__payments').annotate( total_gross=Subquery( @@ -682,7 +693,7 @@ class InvoiceDataExporter(MultiSheetListExporter): _('Invoice recipient:') + ' ' + _('Internal reference'), ] qs = InvoiceLine.objects.filter( - invoice__event=self.event + invoice__event__in=self.events ).order_by('invoice__full_invoice_no', 'position').select_related( 'invoice', 'invoice__order', 'invoice__refers' ) @@ -723,7 +734,10 @@ class InvoiceDataExporter(MultiSheetListExporter): ] def get_filename(self): - return '{}_invoices'.format(self.event.slug) + if self.is_multievent: + return '{}_invoices'.format(self.events.first().organizer.slug) + else: + return '{}_invoices'.format(self.event.slug) class GiftcardRedemptionListExporter(ListExporter): @@ -731,27 +745,27 @@ class GiftcardRedemptionListExporter(ListExporter): verbose_name = gettext_lazy('Gift card redemptions') def iterate_list(self, form_data): - tz = pytz.timezone(self.event.settings.timezone) - payments = OrderPayment.objects.filter( - order__event=self.event, + order__event__in=self.events, provider='giftcard' ).order_by('created') refunds = OrderRefund.objects.filter( - order__event=self.event, + order__event__in=self.events, provider='giftcard' ).order_by('created') objs = sorted(list(payments) + list(refunds), key=lambda o: (o.order.code, o.created)) headers = [ - _('Order'), _('Payment ID'), _('Date'), _('Gift card code'), _('Amount'), _('Issuer') + _('Event slug'), _('Order'), _('Payment ID'), _('Date'), _('Gift card code'), _('Amount'), _('Issuer') ] yield headers for obj in objs: + tz = pytz.timezone(obj.order.event.settings.timezone) gc = GiftCard.objects.get(pk=obj.info_data.get('gift_card')) row = [ + obj.order.event.slug, obj.order.code, obj.full_id, obj.created.astimezone(tz).date().strftime('%Y-%m-%d'), @@ -762,7 +776,10 @@ class GiftcardRedemptionListExporter(ListExporter): yield row def get_filename(self): - return '{}_giftcardredemptions'.format(self.event.slug) + if self.is_multievent: + return '{}_giftcardredemptions'.format(self.events.first().organizer.slug) + else: + return '{}_giftcardredemptions'.format(self.event.slug) @receiver(register_data_exporters, dispatch_uid="exporter_orderlist") @@ -770,11 +787,21 @@ def register_orderlist_exporter(sender, **kwargs): return OrderListExporter +@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_orderlist") +def register_multievent_orderlist_exporter(sender, **kwargs): + return OrderListExporter + + @receiver(register_data_exporters, dispatch_uid="exporter_paymentlist") def register_paymentlist_exporter(sender, **kwargs): return PaymentListExporter +@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_paymentlist") +def register_multievent_paymentlist_exporter(sender, **kwargs): + return PaymentListExporter + + @receiver(register_data_exporters, dispatch_uid="exporter_quotalist") def register_quotalist_exporter(sender, **kwargs): return QuotaListExporter @@ -785,6 +812,16 @@ def register_invoicedata_exporter(sender, **kwargs): return InvoiceDataExporter +@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_invoicedata") +def register_multievent_invoicedatae_xporter(sender, **kwargs): + return InvoiceDataExporter + + @receiver(register_data_exporters, dispatch_uid="exporter_giftcardredemptionlist") def register_giftcardredemptionlist_exporter(sender, **kwargs): return GiftcardRedemptionListExporter + + +@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardredemptionlist") +def register_multievent_i_giftcardredemptionlist_exporter(sender, **kwargs): + return GiftcardRedemptionListExporter diff --git a/src/pretix/base/services/export.py b/src/pretix/base/services/export.py index 47caaba5f..60f72bce0 100644 --- a/src/pretix/base/services/export.py +++ b/src/pretix/base/services/export.py @@ -5,9 +5,15 @@ from django.utils.timezone import override from django.utils.translation import gettext from pretix.base.i18n import LazyLocaleException, language -from pretix.base.models import CachedFile, Event, cachedfile_name -from pretix.base.services.tasks import ProfiledEventTask -from pretix.base.signals import register_data_exporters +from pretix.base.models import ( + CachedFile, Event, Organizer, User, cachedfile_name, +) +from pretix.base.services.tasks import ( + ProfiledEventTask, ProfiledOrganizerUserTask, +) +from pretix.base.signals import ( + register_data_exporters, register_multievent_data_exporters, +) from pretix.celery_app import app @@ -32,3 +38,26 @@ def export(event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) file.file.save(cachedfile_name(file, file.filename), ContentFile(data)) file.save() return file.pk + + +@app.task(base=ProfiledOrganizerUserTask, throws=(ExportError,)) +def multiexport(organizer: Organizer, user: User, fileid: str, provider: str, form_data: Dict[str, Any]) -> None: + file = CachedFile.objects.get(id=fileid) + with language(user.locale), override(user.timezone): + allowed_events = user.get_events_with_permission('can_view_orders') + + events = allowed_events.filter(pk__in=form_data.get('events')) + responses = register_multievent_data_exporters.send(organizer) + + for receiver, response in responses: + ex = response(events) + if ex.identifier == provider: + 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 + file.file.save(cachedfile_name(file, file.filename), ContentFile(data)) + file.save() + return file.pk diff --git a/src/pretix/base/services/tasks.py b/src/pretix/base/services/tasks.py index 08efc47f7..ee5751cf2 100644 --- a/src/pretix/base/services/tasks.py +++ b/src/pretix/base/services/tasks.py @@ -19,7 +19,7 @@ from django_scopes import scope, scopes_disabled from pretix.base.metrics import ( pretix_task_duration_seconds, pretix_task_runs_total, ) -from pretix.base.models import Event +from pretix.base.models import Event, Organizer, User from pretix.celery_app import app @@ -88,10 +88,30 @@ class EventTask(app.Task): return ret +class OrganizerUserTask(app.Task): + def __call__(self, *args, **kwargs): + organizer_id = kwargs['organizer'] + with scopes_disabled(): + organizer = Organizer.objects.get(pk=organizer_id) + kwargs['organizer'] = organizer + + user_id = kwargs['user'] + user = User.objects.get(pk=user_id) + kwargs['user'] = user + + with scope(organizer=organizer): + ret = super().__call__(*args, **kwargs) + return ret + + class ProfiledEventTask(ProfiledTask, EventTask): pass +class ProfiledOrganizerUserTask(ProfiledTask, OrganizerUserTask): + pass + + class TransactionAwareTask(ProfiledTask): """ Task class which is aware of django db transactions and only executes tasks diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index cdae79e8a..858789b27 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -268,6 +268,16 @@ subclass of pretix.base.exporter.BaseExporter As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ +register_multievent_data_exporters = django.dispatch.Signal( + providing_args=["event"] +) +""" +This signal is sent out to get all known data exporters, which support exporting data for +multiple events. Receivers should return a subclass of pretix.base.exporter.BaseExporter + +The ``sender`` keyword argument will contain an organizer. +""" + validate_order = EventPluginSignal( providing_args=["payment_provider", "positions", "email", "locale", "invoice_address", "meta_info"] diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index 0675c68e9..aae10a117 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -411,6 +411,14 @@ def get_organizer_navigation(request): 'active': url.url_name == 'organizer', 'icon': 'calendar', }, + { + 'label': _('Export'), + 'url': reverse('control:organizer.export', kwargs={ + 'organizer': request.organizer.slug, + }), + 'active': 'organizer.export' in url.url_name, + 'icon': 'download', + }, ] if 'can_change_organizer_settings' in request.orgapermset: nav.append({ diff --git a/src/pretix/control/templates/pretixcontrol/organizers/export.html b/src/pretix/control/templates/pretixcontrol/organizers/export.html new file mode 100644 index 000000000..f02715dc6 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/export.html @@ -0,0 +1,37 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load order_overview %} +{% block title %}{% trans "Data export" %}{% endblock %} +{% block content %} +

+ {% trans "Data export" %} + {% if "identifier" in request.GET %} + {% trans "Show all" %} + {% endif %} +

+ {% for e in exporters %} +
+ +

+ {{ e.verbose_name }} + +

+
+
+
+
+ {% csrf_token %} + + {% bootstrap_form e.form layout='control' %} + +
+
+
+
+ {% endfor %} +{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index fed5edde7..fd6b03356 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -106,6 +106,8 @@ urlpatterns = [ url(r'^organizer/(?P[^/]+)/team/(?P[^/]+)/delete$', organizer.TeamDeleteView.as_view(), name='organizer.team.delete'), url(r'^organizer/(?P[^/]+)/slugrng', main.SlugRNG.as_view(), name='events.add.slugrng'), + url(r'^organizer/(?P[^/]+)/export/$', organizer.ExportView.as_view(), name='organizer.export'), + url(r'^organizer/(?P[^/]+)/export/do$', organizer.ExportDoView.as_view(), name='organizer.export.do'), url(r'^nav/typeahead/$', typeahead.nav_context_list, name='nav.typeahead'), url(r'^events/$', main.EventList.as_view(), name='events'), url(r'^events/add$', main.EventWizard.as_view(), name='events.add'), diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index ab87a583b..9cc17bafd 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -1,4 +1,5 @@ import json +from datetime import timedelta from decimal import Decimal from django import forms @@ -14,25 +15,32 @@ from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils.functional import cached_property +from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django.views import View from django.views.generic import ( - CreateView, DeleteView, DetailView, FormView, ListView, UpdateView, + CreateView, DeleteView, DetailView, FormView, ListView, TemplateView, + UpdateView, ) from pretix.api.models import WebHook from pretix.base.auth import get_auth_backends from pretix.base.models import ( - Device, GiftCard, OrderPayment, Organizer, Team, TeamInvite, User, + CachedFile, Device, GiftCard, OrderPayment, Organizer, Team, TeamInvite, + User, ) from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue from pretix.base.models.giftcards import gen_giftcard_secret from pretix.base.models.organizer import TeamAPIToken from pretix.base.payment import PaymentException +from pretix.base.services.export import multiexport from pretix.base.services.mail import SendMailException, mail +from pretix.base.signals import register_multievent_data_exporters +from pretix.base.views.tasks import AsyncAction from pretix.control.forms.filter import ( EventFilterForm, GiftCardFilterForm, OrganizerFilterForm, ) +from pretix.control.forms.orders import ExporterForm from pretix.control.forms.organizer import ( DeviceForm, EventMetaPropertyForm, GiftCardCreateForm, GiftCardUpdateForm, OrganizerDeleteForm, OrganizerForm, OrganizerSettingsForm, @@ -1127,3 +1135,97 @@ class GiftCardUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi 'giftcard': self.object.pk } )) + + +class ExportMixin: + @cached_property + def exporters(self): + exporters = [] + events = self.request.user.get_events_with_permission('can_view_orders') + responses = register_multievent_data_exporters.send(self.request.organizer) + for ex in sorted([response(events) for r, response in responses], key=lambda ex: str(ex.verbose_name)): + if self.request.GET.get("identifier") and ex.identifier != self.request.GET.get("identifier"): + continue + + # Use form parse cycle to generate useful defaults + test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier) + test_form.fields = ex.export_form_fields + test_form.is_valid() + initial = { + k: v for k, v in test_form.cleaned_data.items() if ex.identifier + "-" + k in self.request.GET + } + + ex.form = ExporterForm( + data=(self.request.POST if self.request.method == 'POST' else None), + prefix=ex.identifier, + 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 + )), + ]) + exporters.append(ex) + return exporters + + +class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, View): + known_errortypes = ['ExportError'] + task = multiexport + + def get_success_message(self, value): + return None + + def get_success_url(self, value): + return reverse('cachedfile.download', kwargs={'id': str(value)}) + + def get_error_url(self): + return reverse('control:organizer.export', kwargs={ + 'organizer': self.request.organizer.slug + }) + + @cached_property + def exporter(self): + for ex in self.exporters: + if ex.identifier == self.request.POST.get("exporter"): + return ex + + def post(self, request, *args, **kwargs): + if not self.exporter: + messages.error(self.request, _('The selected exporter was not found.')) + return redirect('control:organizer.export', kwargs={ + 'organizer': self.request.organizer.slug + }) + + if not self.exporter.form.is_valid(): + messages.error(self.request, _('There was a problem processing your input. See below for error details.')) + return self.get(request, *args, **kwargs) + + cf = CachedFile() + cf.date = now() + cf.expires = now() + timedelta(days=3) + cf.save() + return self.do( + organizer=self.request.organizer.id, + user=self.request.user.id, + fileid=str(cf.id), + provider=self.exporter.identifier, + form_data=self.exporter.form.cleaned_data + ) + + +class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, TemplateView): + template_name = 'pretixcontrol/organizers/export.html' + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['exporters'] = self.exporters + return ctx diff --git a/src/pretix/plugins/ticketoutputpdf/exporters.py b/src/pretix/plugins/ticketoutputpdf/exporters.py index f0a6124bc..c3611917a 100644 --- a/src/pretix/plugins/ticketoutputpdf/exporters.py +++ b/src/pretix/plugins/ticketoutputpdf/exporters.py @@ -11,7 +11,7 @@ from PyPDF2.merger import PdfFileMerger from pretix.base.exporter import BaseExporter from pretix.base.i18n import language -from pretix.base.models import Order, OrderPosition +from pretix.base.models import Event, Order, OrderPosition from pretix.base.settings import PERSON_NAME_SCHEMES from .ticketoutput import PdfTicketOutput @@ -24,7 +24,7 @@ class AllTicketsPDF(BaseExporter): @property def export_form_fields(self): - name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] + name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None d = OrderedDict( [ ('include_pending', @@ -41,7 +41,7 @@ class AllTicketsPDF(BaseExporter): ] + ([ ('name:{}'.format(k), _('Attendee name: {part}').format(part=label)) for k, label, w in name_scheme['fields'] - ] if settings.JSON_FIELD_AVAILABLE and len(name_scheme['fields']) > 1 else []), + ] if settings.JSON_FIELD_AVAILABLE and name_scheme and len(name_scheme['fields']) > 1 else []), )), ] ) @@ -49,10 +49,8 @@ class AllTicketsPDF(BaseExporter): def render(self, form_data): merger = PdfFileMerger() - - o = PdfTicketOutput(self.event) qs = OrderPosition.objects.filter( - order__event=self.event + order__event__in=self.events ).prefetch_related( 'answers', 'answers__question' ).select_related('order', 'item', 'variation', 'addon_to') @@ -76,10 +74,14 @@ class AllTicketsPDF(BaseExporter): 'resolved_name_part' ) + o = PdfTicketOutput(Event.objects.none()) for op in qs: if not op.generate_ticket: continue + if op.order.event != o.event: + o = PdfTicketOutput(op.event) + with language(op.order.locale): layout = o.layout_map.get( (op.item_id, op.order.sales_channel), @@ -95,4 +97,8 @@ class AllTicketsPDF(BaseExporter): merger.write(outbuffer) merger.close() outbuffer.seek(0) - return '{}_tickets.pdf'.format(self.event.slug), 'application/pdf', outbuffer.read() + + if self.is_multievent: + return '{}_tickets.pdf'.format(self.events.first().organizer.slug), 'application/pdf', outbuffer.read() + else: + return '{}_tickets.pdf'.format(self.event.slug), 'application/pdf', outbuffer.read() diff --git a/src/pretix/plugins/ticketoutputpdf/signals.py b/src/pretix/plugins/ticketoutputpdf/signals.py index 0dd51494d..a532649e9 100644 --- a/src/pretix/plugins/ticketoutputpdf/signals.py +++ b/src/pretix/plugins/ticketoutputpdf/signals.py @@ -10,7 +10,7 @@ from pretix.base.channels import get_all_sales_channels from pretix.base.signals import ( # NOQA: legacy import EventPluginSignal, event_copy_data, item_copy_data, layout_text_variables, logentry_display, logentry_object_link, register_data_exporters, - register_ticket_outputs, + register_multievent_data_exporters, register_ticket_outputs, ) from pretix.control.signals import item_forms from pretix.plugins.ticketoutputpdf.forms import TicketLayoutItemForm @@ -34,6 +34,12 @@ def register_data(sender, **kwargs): return AllTicketsPDF +@receiver(register_multievent_data_exporters, dispatch_uid="dataexport_multievent_pdf") +def register_multievent_data(sender, **kwargs): + from .exporters import AllTicketsPDF + return AllTicketsPDF + + @receiver(item_forms, dispatch_uid="pretix_ticketoutputpdf_item_forms") def control_item_forms(sender, request, item, **kwargs): forms = []