From 34efc2d953fbda66de426891ad2d85126b9fbee2 Mon Sep 17 00:00:00 2001 From: Philipp Kern Date: Tue, 11 Feb 2025 23:56:55 +0100 Subject: [PATCH] Add relevant_orderlist exporter (including bugfixes) This Exporter has the most useful information in the first rows of the exported document. Specifically the product and the custom questions. Other fields are also resorted somewhat to place very useless columns at the end of the table. See code for details :) - register relevant_orderlist as separate data_exporter - sort it with the other order data exporters. --- src/pretix/base/exporters/__init__.py | 1 + src/pretix/base/exporters/orderlist.py | 2 +- .../base/exporters/relevant_orderlist.py | 1199 +++++++++++++++++ src/pretix/locale/de/LC_MESSAGES/django.po | 16 + 4 files changed, 1217 insertions(+), 1 deletion(-) create mode 100644 src/pretix/base/exporters/relevant_orderlist.py diff --git a/src/pretix/base/exporters/__init__.py b/src/pretix/base/exporters/__init__.py index 420741db68..49c8b6437f 100644 --- a/src/pretix/base/exporters/__init__.py +++ b/src/pretix/base/exporters/__init__.py @@ -28,5 +28,6 @@ from .items import * # noqa from .json import * # noqa from .mail import * # noqa from .orderlist import * # noqa +from .relevant_orderlist import * # noqa from .reusablemedia import * # noqa from .waitinglist import * # noqa diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index 887115772d..610074f54a 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -88,7 +88,7 @@ class OrderListExporter(MultiSheetListExporter): description = gettext_lazy('Download a spreadsheet of all orders. The spreadsheet will include three sheets, one ' 'with a line for every order, one with a line for every order position, and one with ' 'a line for every additional fee charged in an order.') - featured = True + featured = False @cached_property def providers(self): diff --git a/src/pretix/base/exporters/relevant_orderlist.py b/src/pretix/base/exporters/relevant_orderlist.py new file mode 100644 index 0000000000..a0e2355f71 --- /dev/null +++ b/src/pretix/base/exporters/relevant_orderlist.py @@ -0,0 +1,1199 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# + +# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of +# the Apache License 2.0 can be obtained at . +# +# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A +# full history of changes and contributors is available at . +# +# This file contains Apache-licensed contributions copyrighted by: Benjamin Hättasch, Tobias Kunze +# +# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under the License. + +from collections import OrderedDict, defaultdict +from decimal import Decimal +from zoneinfo import ZoneInfo + +from django import forms +from django.conf import settings +from django.db.models import ( + Case, + CharField, + Count, + DateTimeField, + F, + IntegerField, + Max, + Min, + OuterRef, + Q, + Subquery, + Sum, + When, +) +from django.db.models.functions import Coalesce +from django.dispatch import receiver +from django.utils.formats import date_format +from django.utils.functional import cached_property +from django.utils.timezone import get_current_timezone, now +from django.utils.translation import ( + gettext as _, + gettext_lazy, + pgettext, + pgettext_lazy, +) +from openpyxl.cell import WriteOnlyCell +from openpyxl.comments import Comment +from openpyxl.styles import Font, PatternFill + +from pretix.base.models import ( + Checkin, + GiftCard, + GiftCardTransaction, + Invoice, + InvoiceAddress, + Order, + OrderPosition, + Question, +) +from pretix.base.models.orders import ( + OrderFee, + OrderPayment, + OrderRefund, + Transaction, +) +from pretix.base.services.quotas import QuotaAvailability +from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized + +from ...control.forms.filter import get_all_payment_providers +from ...helpers import GroupConcat +from ...helpers.iter import chunked_iterable +from ...helpers.safe_openpyxl import remove_invalid_excel_chars +from ..exporter import ( + ListExporter, + MultiSheetListExporter, + OrganizerLevelExportMixin, +) +from ..forms.widgets import SplitDateTimePickerWidget +from ..signals import ( + register_data_exporters, + register_multievent_data_exporters, +) +from ..timeframes import ( + DateFrameField, + resolve_timeframe_to_datetime_start_inclusive_end_exclusive, +) + + +class RelevantOrderListExporter(MultiSheetListExporter): + identifier = "relevantorderlist" + verbose_name = gettext_lazy("Order data (sorted by relevance)") + category = pgettext_lazy("export_category", "Order data") + description = gettext_lazy( + "Download a spreadsheet of all orders. The spreadsheet will include three sheets, one " + "with a line for every order, one with a line for every order position, and one with " + "a line for every additional fee charged in an order. The most relevant data is in the " + "first columns of the tables." + ) + featured = True + + @cached_property + def providers(self): + return dict(get_all_payment_providers()) + + @property + def sheets(self): + return ( + ("orders", _("Orders")), + ("positions", _("Order positions")), + ("fees", _("Order fees")), + ) + + @property + def additional_form_fields(self): + d = [ + ( + "paid_only", + forms.BooleanField( + label=_("Only paid orders"), initial=False, required=False + ), + ), + ( + "include_payment_amounts", + forms.BooleanField( + label=_("Include payment amounts"), initial=True, required=False + ), + ), + ( + "group_multiple_choice", + forms.BooleanField( + label=_("Show multiple choice answers grouped in one column"), + initial=False, + required=False, + ), + ), + ( + "date_range", + DateFrameField( + label=_("Date range"), + include_future_frames=False, + required=False, + help_text=_("Only include orders created within this date range."), + ), + ), + ( + "event_date_range", + DateFrameField( + label=_("Event date"), + include_future_frames=True, + required=False, + help_text=_( + "Only include orders including at least one ticket for a date in this range. " + "Will also include other dates in case of mixed orders!" + ), + ), + ), + ] + d = OrderedDict(d) + if not self.is_multievent and not self.event.has_subevents: + del d["event_date_range"] + return d + + def _get_all_payment_methods(self, qs): + pps = dict(get_all_payment_providers()) + return sorted( + [ + (pp, pps[pp]) + for pp in set( + OrderPayment.objects.exclude(provider="free") + .filter(order__event__in=self.events) + .values_list("provider", flat=True) + .distinct() + ) + ], + key=lambda pp: pp[0], + ) + + def _get_all_tax_rates(self, qs): + tax_rates = set( + a + for a in OrderFee.objects.filter(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__in=self.events) + .values_list("tax_rate", flat=True) + .distinct() + .order_by() + ) + tax_rates = sorted(tax_rates) + return tax_rates + + def iterate_sheet(self, form_data, sheet): + if sheet == "orders": + return self.iterate_orders(form_data) + elif sheet == "positions": + return self.iterate_positions(form_data) + elif sheet == "fees": + return self.iterate_fees(form_data) + + @cached_property + def event_object_cache(self): + return {e.pk: e for e in self.events} + + def _date_filter(self, qs, form_data, rel): + annotations = {} + filters = {} + + if form_data.get("date_range"): + dt_start, dt_end = ( + resolve_timeframe_to_datetime_start_inclusive_end_exclusive( + now(), form_data["date_range"], self.timezone + ) + ) + if dt_start: + filters[f"{rel}datetime__gte"] = dt_start + if dt_end: + filters[f"{rel}datetime__lt"] = dt_end + + if form_data.get("event_date_range"): + dt_start, dt_end = ( + resolve_timeframe_to_datetime_start_inclusive_end_exclusive( + now(), form_data["event_date_range"], self.timezone + ) + ) + if dt_start: + annotations["event_date_max"] = Case( + When( + **{f"{rel}event__has_subevents": True}, + then=Max(f"{rel}all_positions__subevent__date_from"), + ), + default=F(f"{rel}event__date_from"), + ) + filters["event_date_max__gte"] = dt_start + if dt_end: + annotations["event_date_min"] = Case( + When( + **{f"{rel}event__has_subevents": True}, + then=Min(f"{rel}all_positions__subevent__date_from"), + ), + default=F(f"{rel}event__date_from"), + ) + filters["event_date_min__lt"] = dt_end + + if filters: + return qs.annotate(**annotations).filter(**filters) + return qs + + def orders_qs(self, form_data): + p_date = ( + OrderPayment.objects.filter( + order=OuterRef("pk"), + state__in=( + OrderPayment.PAYMENT_STATE_CONFIRMED, + OrderPayment.PAYMENT_STATE_REFUNDED, + ), + payment_date__isnull=False, + ) + .values("order") + .annotate(m=Max("payment_date")) + .values("m") + .order_by() + ) + p_providers = ( + OrderPayment.objects.filter( + order=OuterRef("pk"), + state__in=( + OrderPayment.PAYMENT_STATE_CONFIRMED, + OrderPayment.PAYMENT_STATE_REFUNDED, + OrderPayment.PAYMENT_STATE_PENDING, + OrderPayment.PAYMENT_STATE_CREATED, + ), + ) + .values("order") + .annotate(m=GroupConcat("provider", delimiter=",")) + .values("m") + .order_by() + ) + i_numbers = ( + Invoice.objects.filter( + order=OuterRef("pk"), + ) + .values("order") + .annotate(m=GroupConcat("full_invoice_no", delimiter=", ")) + .values("m") + .order_by() + ) + + s = ( + OrderPosition.objects.filter(order=OuterRef("pk")) + .order_by() + .values("order") + .annotate(k=Count("id")) + .values("k") + ) + qs = ( + Order.objects.filter(event__in=self.events) + .annotate( + payment_date=Subquery(p_date, output_field=DateTimeField()), + payment_providers=Subquery(p_providers, output_field=CharField()), + invoice_numbers=Subquery(i_numbers, output_field=CharField()), + pcnt=Subquery(s, output_field=IntegerField()), + ) + .select_related("invoice_address", "customer") + ) + + qs = self._date_filter(qs, form_data, rel="") + + if form_data["paid_only"]: + qs = qs.filter(status=Order.STATUS_PAID) + return qs + + # Bestellungen (hier geht es hauptsächlich um die Beträge und ob sie bezahlt sind) + # Wichtig: Bestellnummer ("Order code"), Gesamtbetrag ("Order total"), Status ("Status"), + # E-Mail ("Email"), Name ("Name") + aufgeteilt, + # Bezahlt mit {Zahlungsmethode...} ("Payed by {method}") + # Semi-Wichtig: Datum der letzten Zahlung ("Date of last payment"), + # Zahlungsmethoden ("Payment providers") + # Unwichtig: alles andere + def iterate_orders(self, form_data: dict): + qs = self.orders_qs(form_data) + tax_rates = self._get_all_tax_rates(qs) + + # HEADERS START (Must be reordered the same way the Fields get reordered) + headers = [ + _("Order code"), # Wichtig + _("Order total"), # Wichtig + _("Status"), # Wichtig + _("Email"), # Wichtig + _("Name"), # Wichtig + ] + name_scheme = ( # für Wichtig + PERSON_NAME_SCHEMES[self.event.settings.name_scheme] + if not self.is_multievent + else None + ) + if name_scheme and len(name_scheme["fields"]) > 1: # Wichtig + for k, label, w in name_scheme["fields"]: + headers.append(label) + if form_data.get("include_payment_amounts"): # Wichtig + payment_methods = self._get_all_payment_methods(qs) + for id, vn in payment_methods: + headers.append(_("Paid by {method}").format(method=vn)) + + headers += [ + _("Date of last payment"), # Semi-Wichtig + _("Payment providers"), # Semi-Wichtig + _("Event slug"), + _("Event name"), + _("Phone number"), + _("Order date"), + _("Order time"), + _("Company"), + ] + headers += [ + _("Address"), + _("ZIP code"), + _("City"), + _("Country"), + pgettext("address", "State"), + _("Custom address field"), + _("VAT ID"), + _("Fees"), + _("Order locale"), + ] + + for tr in tax_rates: + headers += [ + _("Gross at {rate} % tax").format(rate=tr), + _("Net at {rate} % tax").format(rate=tr), + _("Tax value at {rate} % tax").format(rate=tr), + ] + + headers.append(_("Invoice numbers")) + headers.append(_("Sales channel")) + headers.append(_("Requires special attention")) + headers.append(_("Check-in text")) + headers.append(_("Comment")) + headers.append(_("Follow-up date")) + headers.append(_("Positions")) + headers.append(_("Email address verified")) + headers.append(_("External customer ID")) + + # get meta_data labels from first cached event + headers += next(iter(self.event_object_cache.values())).meta_data.keys() + yield headers + # HEADERS END + + full_fee_sum_cache = { + o["order__id"]: o["grosssum"] + for o in OrderFee.objects.values("tax_rate", "order__id") + .order_by() + .annotate(grosssum=Sum("value")) + } + fee_sum_cache = { + (o["order__id"], o["tax_rate"]): o + for o in OrderFee.objects.values("tax_rate", "order__id") + .order_by() + .annotate(taxsum=Sum("tax_value"), grosssum=Sum("value")) + } + if form_data.get("include_payment_amounts"): + payment_sum_cache = { + (o["order__id"], o["provider"]): o["grosssum"] + for o in OrderPayment.objects.values("provider", "order__id") + .order_by() + .filter( + state__in=[ + OrderPayment.PAYMENT_STATE_CONFIRMED, + OrderPayment.PAYMENT_STATE_REFUNDED, + ] + ) + .annotate(grosssum=Sum("amount")) + } + refund_sum_cache = { + (o["order__id"], o["provider"]): o["grosssum"] + for o in OrderRefund.objects.values("provider", "order__id") + .order_by() + .filter( + state__in=[ + OrderRefund.REFUND_STATE_DONE, + OrderRefund.REFUND_STATE_TRANSIT, + ] + ) + .annotate(grosssum=Sum("amount")) + } + sum_cache = { + (o["order__id"], o["tax_rate"]): o + for o in OrderPosition.objects.values("tax_rate", "order__id") + .order_by() + .annotate(taxsum=Sum("tax_value"), grosssum=Sum("price")) + } + + yield self.ProgressSetTotal(total=qs.count()) + for order in qs.order_by("datetime").iterator(): + tz = ZoneInfo(self.event_object_cache[order.event_id].settings.timezone) + + # ROW(S) START (Must be reordered the same way the Fields get reordered) + row = [ + order.code, # Wichtig ("Order code") + order.total, # Wichtig ("Order total") + order.get_extended_status_display(), # Wichtig ("Status") + order.email, # Wichtig ("Email") + ] + try: # Wichtig: ("Name") + aufgegeilt + row.append(order.invoice_address.name) + if name_scheme and len(name_scheme["fields"]) > 1: + for k, label, w in name_scheme["fields"]: + row.append( + get_name_parts_localized( + order.invoice_address.name_parts, k + ) + ) + except InvoiceAddress.DoesNotExist: + row += [""] * ( + 1 + + ( + len(name_scheme["fields"]) + if name_scheme and len(name_scheme["fields"]) > 1 + else 0 + ) + ) + + if form_data.get( + "include_payment_amounts" + ): # Wichtig ("Payed by {method}") + payment_methods = self._get_all_payment_methods(qs) + for id, vn in payment_methods: + row.append( + payment_sum_cache.get((order.id, id), Decimal("0.00")) + - refund_sum_cache.get((order.id, id), Decimal("0.00")) + ) + + row.append( # Semi-Wichtig ("Date of last payment") + order.payment_date.astimezone(tz).strftime("%Y-%m-%d") + if order.payment_date + else "" + ) + row.append( # Semi-Wichtig ("Payment providers") + ", ".join( + [ + str(self.providers.get(p, p)) + for p in sorted(set((order.payment_providers or "").split(","))) + if p and p != "free" + ] + ) + ) + + row += [ # unwichtig + self.event_object_cache[order.event_id].slug, + str(self.event_object_cache[order.event_id].name), + str(order.phone) if order.phone else "", + order.datetime.astimezone(tz).strftime("%Y-%m-%d"), + order.datetime.astimezone(tz).strftime("%H:%M:%S"), + ] + try: + row.append(order.invoice_address.company) + row += [ + order.invoice_address.street, + order.invoice_address.zipcode, + order.invoice_address.city, + ( + order.invoice_address.country + if order.invoice_address.country + else order.invoice_address.country_old + ), + order.invoice_address.state, + order.invoice_address.custom_field, + order.invoice_address.vat_id, + ] + except InvoiceAddress.DoesNotExist: + row += [""] * (8) + + row += [ + full_fee_sum_cache.get(order.id) or Decimal("0.00"), + order.locale, + ] + + for tr in tax_rates: + taxrate_values = sum_cache.get( + (order.id, tr), + {"grosssum": Decimal("0.00"), "taxsum": Decimal("0.00")}, + ) + fee_taxrate_values = fee_sum_cache.get( + (order.id, tr), + {"grosssum": Decimal("0.00"), "taxsum": Decimal("0.00")}, + ) + + row += [ + taxrate_values["grosssum"] + fee_taxrate_values["grosssum"], + ( + taxrate_values["grosssum"] + - taxrate_values["taxsum"] + + fee_taxrate_values["grosssum"] + - fee_taxrate_values["taxsum"] + ), + taxrate_values["taxsum"] + fee_taxrate_values["taxsum"], + ] + + row.append(order.invoice_numbers) + row.append(order.sales_channel) + row.append(_("Yes") if order.checkin_attention else _("No")) + row.append(order.checkin_text or "") + row.append(order.comment or "") + row.append( + order.custom_followup_at.strftime("%Y-%m-%d") + if order.custom_followup_at + else "" + ) + row.append(order.pcnt) + row.append(_("Yes") if order.email_known_to_work else _("No")) + row.append( + str(order.customer.external_identifier) + if order.customer and order.customer.external_identifier + else "" + ) + + row += self.event_object_cache[order.event_id].meta_data.values() + yield row + # ROW(S) END + + def fees_qs(self, form_data): + p_providers = ( + OrderPayment.objects.filter( + order=OuterRef("order"), + state__in=( + OrderPayment.PAYMENT_STATE_CONFIRMED, + OrderPayment.PAYMENT_STATE_REFUNDED, + OrderPayment.PAYMENT_STATE_PENDING, + OrderPayment.PAYMENT_STATE_CREATED, + ), + ) + .values("order") + .annotate(m=GroupConcat("provider", delimiter=",")) + .values("m") + .order_by() + ) + qs = ( + OrderFee.all.filter( + order__event__in=self.events, + ) + .annotate( + payment_providers=Subquery(p_providers, output_field=CharField()), + ) + .select_related( + "order", "order__invoice_address", "order__customer", "tax_rule" + ) + ) + if form_data["paid_only"]: + qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False) + + qs = self._date_filter(qs, form_data, rel="order__") + return qs + + # Gebühren, hier sind alle Felder irrelevant + def iterate_fees(self, form_data: dict): + qs = self.fees_qs(form_data) + + headers = [ + _("Event slug"), + _("Event name"), + _("Order code"), + _("Status"), + _("Email"), + _("Phone number"), + _("Order date"), + _("Order time"), + _("Fee type"), + _("Description"), + _("Price"), + _("Tax rate"), + _("Tax rule"), + _("Tax value"), + _("Company"), + _("Invoice address name"), + ] + 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 += [ + _("Address"), + _("ZIP code"), + _("City"), + _("Country"), + pgettext("address", "State"), + _("VAT ID"), + ] + + headers.append(_("External customer ID")) + headers.append(_("Payment providers")) + + # get meta_data labels from first cached event + headers += next(iter(self.event_object_cache.values())).meta_data.keys() + yield headers + + yield self.ProgressSetTotal(total=qs.count()) + for op in qs.order_by("order__datetime").iterator(): + order = op.order + tz = ZoneInfo(order.event.settings.timezone) + row = [ + self.event_object_cache[order.event_id].slug, + str(self.event_object_cache[order.event_id].name), + order.code, + _("canceled") if op.canceled else order.get_extended_status_display(), + order.email, + str(order.phone) if order.phone else "", + order.datetime.astimezone(tz).strftime("%Y-%m-%d"), + order.datetime.astimezone(tz).strftime("%H:%M:%S"), + op.get_fee_type_display(), + op.description, + op.value, + op.tax_rate, + str(op.tax_rule) if op.tax_rule else "", + op.tax_value, + ] + try: + row += [ + order.invoice_address.company, + order.invoice_address.name, + ] + if name_scheme and len(name_scheme["fields"]) > 1: + for k, label, w in name_scheme["fields"]: + row.append( + get_name_parts_localized( + order.invoice_address.name_parts, k + ) + ) + row += [ + order.invoice_address.street, + order.invoice_address.zipcode, + order.invoice_address.city, + ( + order.invoice_address.country + if order.invoice_address.country + else order.invoice_address.country_old + ), + order.invoice_address.state, + order.invoice_address.vat_id, + ] + except InvoiceAddress.DoesNotExist: + row += [""] * ( + 8 + + ( + len(name_scheme["fields"]) + if name_scheme and len(name_scheme["fields"]) > 1 + else 0 + ) + ) + row.append( + str(order.customer.external_identifier) + if order.customer and order.customer.external_identifier + else "" + ) + row.append( + ", ".join( + [ + str(self.providers.get(p, p)) + for p in sorted(set((op.payment_providers or "").split(","))) + if p and p != "free" + ] + ) + ) + row += self.event_object_cache[order.event_id].meta_data.values() + yield row + + def positions_qs(self, form_data: dict): + qs = OrderPosition.all.filter( + order__event__in=self.events, + ) + if form_data["paid_only"]: + qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False) + + qs = self._date_filter(qs, form_data, rel="order__") + return qs + + # Bestellzeilen: Persönliche Daten, sehr viel Umsortieren ggü orderlist.py, deshalb TODO + def iterate_positions(self, form_data: dict): + base_qs = self.positions_qs(form_data) + + p_providers = ( + OrderPayment.objects.filter( + order=OuterRef("order"), + state__in=( + OrderPayment.PAYMENT_STATE_CONFIRMED, + OrderPayment.PAYMENT_STATE_REFUNDED, + OrderPayment.PAYMENT_STATE_PENDING, + OrderPayment.PAYMENT_STATE_CREATED, + ), + ) + .values("order") + .annotate(m=GroupConcat("provider", delimiter=",")) + .values("m") + .order_by() + ) + qs = ( + base_qs.annotate( + payment_providers=Subquery(p_providers, output_field=CharField()), + checked_in_lists=Subquery( + Checkin.objects.filter( + successful=True, + type=Checkin.TYPE_ENTRY, + position=OuterRef("pk"), + ) + .order_by() + .values("position") + .annotate( + c=GroupConcat( + "list__name", + # These appear not to work properly on SQLite. Well, we don't support SQLite outside testing + # anyways. + ordered="sqlite" + not in settings.DATABASES["default"]["ENGINE"], + distinct="sqlite" + not in settings.DATABASES["default"]["ENGINE"], + delimiter=", ", + ) + ) + .values("c") + ), + ) + .select_related( + "order", + "order__invoice_address", + "order__customer", + "item", + "variation", + "voucher", + "tax_rule", + "addon_to", + ) + .prefetch_related( + "subevent", + "subevent__meta_values", + "answers", + "answers__question", + "answers__options", + ) + ) + + has_subevents = self.events.filter(has_subevents=True).exists() + + # HEADERS START + # WICHTIG: + headers = [ + _("Product"), # wichtig + ] + questions = list(Question.objects.filter(event__in=self.events)) # für wichtig + options = defaultdict(list) # für wichtig + for q in questions: # wichtig + if q.type == Question.TYPE_CHOICE_MULTIPLE: + if form_data["group_multiple_choice"]: + for o in q.options.all(): + options[q.pk].append(o) + headers.append(str(q.question)) + else: + for o in q.options.all(): + headers.append(str(q.question) + " – " + str(o.answer)) + options[q.pk].append(o) + else: + if q.type == Question.TYPE_CHOICE: + for o in q.options.all(): + options[q.pk].append(o) + headers.append(str(q.question)) + + # MITTELWICHTIG: + headers += [ + _("Status"), # mittelwichtig + _("Email"), # mittelwichtig + ] + + # UNWICHTIG: + headers += [ + _("Order code"), # unwichtig + _("Order date"), # unwichtig + _("Order time"), # unwichtig + ] + if has_subevents: # unwichtig, unser Event hat das aber auch nicht + headers.append(pgettext("subevent", "Date")) + headers.append(_("Start date")) + headers.append(_("End date")) + headers += [ + _("Product ID"), # unwichtig + _("Attendee name"), # unwichtig + ] + name_scheme = ( # für unwichtig: Namensschema + PERSON_NAME_SCHEMES[self.event.settings.name_scheme] + if not self.is_multievent + else None + ) + if name_scheme and len(name_scheme["fields"]) > 1: # unwichtig: Namensschema + for k, label, w in name_scheme["fields"]: + headers.append(_("Attendee name") + ": " + str(label)) + headers += [ + _("Attendee email"), # unwichtig + _("Order comment"), # unwichtig + _("Payment providers"), # unwichtig + ] + + # get meta_data labels from first cached event + meta_data_labels = next(iter(self.event_object_cache.values())).meta_data.keys() + if has_subevents: # unwichtig + headers += meta_data_labels + + # SEHR UNWICHTIG: + headers += [ + _("Event slug"), # sehr unwichtig + _("Event name"), # sehr unwichtig + _("Position ID"), # sehr unwichtig + _("Phone number"), # sehr unwichtig + _("Variation"), # sehr unwichtig + _("Variation ID"), # sehr unwichtig + _("Price"), # sehr unwichtig + _("Tax rate"), # sehr unwichtig + _("Tax rule"), # sehr unwichtig + _("Tax value"), # sehr unwichtig + _("Company"), # sehr unwichtig + _("Address"), # sehr unwichtig + _("ZIP code"), # sehr unwichtig + _("City"), # sehr unwichtig + _("Country"), # sehr unwichtig + pgettext("address", "State"), # sehr unwichtig + _("Voucher"), # sehr unwichtig + _("Pseudonymization ID"), # sehr unwichtig + _("Ticket secret"), # sehr unwichtig + _("Seat ID"), # sehr unwichtig + _("Seat name"), # sehr unwichtig + _("Seat zone"), # sehr unwichtig + _("Seat row"), # sehr unwichtig + _("Seat number"), # sehr unwichtig + _("Blocked"), # sehr unwichtig + _("Valid from"), # sehr unwichtig + _("Valid until"), # sehr unwichtig + _("Follow-up date"), # sehr unwichtig + _("Add-on to position ID"), # sehr unwichtig + _("Company"), # sehr unwichtig + _("Invoice address name"), # sehr unwichtig + ] + if name_scheme and len(name_scheme["fields"]) > 1: # sehr unwichtig + for k, label, w in name_scheme["fields"]: + headers.append(_("Invoice address name") + ": " + str(label)) + headers += [ + _("Invoice address street"), # sehr unwichtig + _("Invoice address ZIP code"), # sehr unwichtig + _("Invoice address city"), # sehr unwichtig + _("Invoice address country"), # sehr unwichtig + pgettext("address", "Invoice address state"), # sehr unwichtig + _("VAT ID"), # sehr unwichtig + _("Sales channel"), # sehr unwichtig + _("Order locale"), # sehr unwichtig + _("Email address verified"), # sehr unwichtig + _("External customer ID"), # sehr unwichtig + _("Check-in lists"), # sehr unwichtig + ] + + # UNSORTIERT: + yield headers + # HEADERS END + + all_ids = list( + base_qs.order_by("order__datetime", "positionid").values_list( + "pk", flat=True + ) + ) + yield self.ProgressSetTotal(total=len(all_ids)) + for ids in chunked_iterable(all_ids, 1000): + ops = sorted(qs.filter(id__in=ids), key=lambda k: ids.index(k.pk)) + + for op in ops: + order = op.order + tz = ZoneInfo(self.event_object_cache[order.event_id].settings.timezone) + # ROWS START + # WICHTIG: + row = [ + str(op.item), # wichtig_("Product") + ] + # Eigene Fragen Start + acache = {} # für wichtig (Eigene Fragen) + for a in op.answers.all(): # für wichtig (Eigene Fragen) + # We do not want to localize Date, Time and Datetime question answers, as those can lead + # to difficulties parsing the data (for example 2019-02-01 may become Février, 2019 01 in French). + if a.question.type in ( + Question.TYPE_CHOICE_MULTIPLE, + Question.TYPE_CHOICE, + ): + acache[a.question_id] = set(o.pk for o in a.options.all()) + elif a.question.type in Question.UNLOCALIZED_TYPES: + acache[a.question_id] = a.answer + else: + acache[a.question_id] = str(a) + for q in questions: # wichtig (Eigene Fragen) + if q.type == Question.TYPE_CHOICE_MULTIPLE: + if form_data["group_multiple_choice"]: + row.append( + ", ".join( + str(o.answer) + for o in options[q.pk] + if o.pk in acache.get(q.pk, set()) + ) + ) + else: + for o in options[q.pk]: + row.append( + _("Yes") + if o.pk in acache.get(q.pk, set()) + else _("No") + ) + elif q.type == Question.TYPE_CHOICE: + # Join is only necessary if the question type was modified but also keeps the code simpler here + # as we'd otherwise need some [0] and existance checks + row.append( + ", ".join( + str(o.answer) + for o in options[q.pk] + if o.pk in acache.get(q.pk, set()) + ) + ) + else: + row.append(acache.get(q.pk, "")) + # Eigene Fragen End + + # MITTELWICHTIG: + row += [ + ( # mittelwichtig _("Status") + _("canceled") + if op.canceled + else order.get_extended_status_display() + ), + order.email, # mittelwichtig_("Email" + ] + + # UNWICHTIG: + row += [ + order.code, # unwichtig_("Order code") + order.datetime.astimezone(tz).strftime( + "%Y-%m-%d" + ), # unwichtig _("Order date") + order.datetime.astimezone(tz).strftime( + "%H:%M:%S" + ), # unwichtig _("Order time") + ] + if has_subevents: # unwichtig , unser Event hat das aber auch nicht + if op.subevent: + row.append(op.subevent.name) + row.append( + op.subevent.date_from.astimezone( + self.event_object_cache[order.event_id].timezone + ).strftime("%Y-%m-%d %H:%M:%S") + ) + if op.subevent.date_to: + row.append( + op.subevent.date_to.astimezone( + self.event_object_cache[order.event_id].timezone + ).strftime("%Y-%m-%d %H:%M:%S") + ) + else: + row.append("") + else: + row.append("") + row.append("") + row.append("") + row += [ + str(op.item_id), # unwichtig _("Product ID") + op.attendee_name, # unwichtig _("Attendee name") + ] + if name_scheme and len(name_scheme["fields"]) > 1: # für unwichtig + for k, label, w in name_scheme["fields"]: + row.append(get_name_parts_localized(op.attendee_name_parts, k)) + row += [ + op.attendee_email, # unwichtig _("Attendee email") + ] + row.append(order.comment) # unwichtig_("Order comment") + row.append( # unwichtig_("Payment providers") + ", ".join( + [ + str(self.providers.get(p, p)) + for p in sorted( + set((op.payment_providers or "").split(",")) + ) + if p and p != "free" + ] + ) + ) + + if has_subevents: # unwichtig + if op.subevent: + row += op.subevent.meta_data.values() + else: + row += [""] * len(meta_data_labels) + + # SEHR UNWICHTIG: + row += [ + self.event_object_cache[ # sehr unwichtig_("Event slug") + order.event_id + ].slug, + str( # sehr unwichtig_("Event name") + self.event_object_cache[order.event_id].name + ), + op.positionid, # sehr unwichtig_("Position ID") + ( + str(order.phone) if order.phone else "" + ), # sehr unwichtig_("Phone number") + ( + str(op.variation) if op.variation else "" + ), # sehr unwichtig_("Variation") + ( + str(op.variation_id) if op.variation_id else "" + ), # sehr unwichtig_("Variation ID") + op.price, # sehr unwichtig_("Price") + op.tax_rate, # sehr unwichtig_("Tax rate") + ( + str(op.tax_rule) if op.tax_rule else "" + ), # sehr unwichtig_("Tax rule") + op.tax_value, # sehr unwichtig_("Tax value") + op.company or "", # sehr unwichtig_("Company") + op.street or "", # sehr unwichtig_("Address") + op.zipcode or "", # sehr unwichtig_("ZIP code") + op.city or "", # sehr unwichtig_("City") + op.country if op.country else "", # sehr unwichtig_("Country") + op.state or "", # sehr unwichtig("address", "State") + op.voucher.code if op.voucher else "", # sehr unwichtig_("Voucher") + op.pseudonymization_id, # sehr unwichtig_("Pseudonymization ID") + op.secret, # sehr unwichtig_("Ticket secret") + ] + if op.seat: # sehr unwichtig + row += [ + op.seat.seat_guid, # sehr unwichtig_("Seat ID") + str(op.seat), # sehr unwichtig_("Seat name") + op.seat.zone_name, # sehr unwichtig_("Seat zone") + op.seat.row_name, # sehr unwichtig_("Seat row") + op.seat.seat_number, # sehr unwichtig_("Seat number") + ] + else: + row += ["", "", "", "", ""] # sehr unwichtig + row += [ # sehr unwichtig + _("Yes") if op.blocked else "", # sehr unwichtig_("Blocked") + ( # sehr unwichtig_("Valid from" + date_format( + op.valid_from.astimezone(tz), "SHORT_DATETIME_FORMAT" + ) + if op.valid_from + else "" + ), + ( # sehr unwichtig_("Valid until") + date_format( + op.valid_until.astimezone(tz), "SHORT_DATETIME_FORMAT" + ) + if op.valid_until + else "" + ), + ] + row.append( # sehr unwichtig("Follow-up date") + order.custom_followup_at.strftime("%Y-%m-%d") + if order.custom_followup_at + else "" + ) + row.append( + op.addon_to.positionid if op.addon_to_id else "" + ) # sehr unwichtig_("Add-on to position ID") + try: # sehr unwichtig + row += [ + order.invoice_address.company, # sehr unwichtig_("Company") + order.invoice_address.name, # sehr unwichtig_("Invoice address name") + ] + if ( + name_scheme and len(name_scheme["fields"]) > 1 + ): # sehr unwichtig_("Invoice address name: {fields}") + for k, label, w in name_scheme["fields"]: + row.append( + get_name_parts_localized( + order.invoice_address.name_parts, k + ) + ) + row += [ + order.invoice_address.street, # sehr unwichtig_("Invoice address street") + order.invoice_address.zipcode, # sehr unwichtig_("Invoice address ZIP code") + order.invoice_address.city, # sehr unwichtig_("Invoice address city") + ( # sehr unwichtig_("Invoice address country") + order.invoice_address.country + if order.invoice_address.country + else order.invoice_address.country_old + ), + order.invoice_address.state, # sehr unwichtig("address", "Invoice address state") + order.invoice_address.vat_id, # sehr unwichtig_("VAT ID") + ] + except InvoiceAddress.DoesNotExist: + row += [""] * ( + 8 + + ( + len(name_scheme["fields"]) + if name_scheme and len(name_scheme["fields"]) > 1 + else 0 + ) + ) + row += [ # sehr unwichtig + order.sales_channel, # sehr unwichtig_("Sales channel") + order.locale, # sehr unwichtig_("Order locale") + ( + _("Yes") if order.email_known_to_work else _("No") + ), # sehr unwichtig_("Email address verified") + ( # sehr unwichtig_("External customer ID") + str(order.customer.external_identifier) + if order.customer and order.customer.external_identifier + else "" + ), + ] + row.append( + op.checked_in_lists or "" + ) # sehr unwichtig_("Check-in lists") + + # UNSORTIERT: + + yield row + # ROWS END + + def get_filename(self): + if self.is_multievent: + return "{}_orders".format(self.organizer.slug) + else: + return "{}_orders".format(self.event.slug) + + +@receiver(register_data_exporters, dispatch_uid="exporter_relevant_orderlist") +def register_relevant_orderlist_exporter(sender, **kwargs): + return RelevantOrderListExporter + + +@receiver( + register_multievent_data_exporters, dispatch_uid="multiexporter_relevant_orderlist" +) +def register_multievent_relevant_orderlist_exporter(sender, **kwargs): + return RelevantOrderListExporter diff --git a/src/pretix/locale/de/LC_MESSAGES/django.po b/src/pretix/locale/de/LC_MESSAGES/django.po index eb09c41b86..f394f39708 100644 --- a/src/pretix/locale/de/LC_MESSAGES/django.po +++ b/src/pretix/locale/de/LC_MESSAGES/django.po @@ -1821,6 +1821,10 @@ msgstr "Ohne gültige Mitgliedschaft verstecken" msgid "Order data" msgstr "Bestelldaten" +#: pretix/base/exporters/relevant_orderlist.py:86 +msgid "Order data (sorted by relevance)" +msgstr "Bestelldaten (nach Relevanz sortiert)" + #: pretix/base/exporters/json.py:53 msgid "" "Download a structured JSON representation of all orders. This might be " @@ -1857,6 +1861,18 @@ msgstr "" "Bestellposition und das dritte eine Zeile für jede zusätzlich erhobene " "Gebühr." +#: pretix/base/exporters/relevant_orderlist.py:88 +msgid "" +"Download a spreadsheet of all orders. The spreadsheet will include three " +"sheets, one with a line for every order, one with a line for every order " +"position, and one with a line for every additional fee charged in an " +"order. The most relevant data is in the first columns of the tables." +msgstr "" +"Tabelle (Excel oder CSV) mit allen Bestellungen. Das erste Tabellenblatt " +"enthält eine Zeile für jede Bestellung, das zweite eine Zeile für jede " +"Bestellposition und das dritte eine Zeile für jede zusätzlich erhobene " +"Gebühr. Die relevantesten Daten sind in den ersten Spalten der Tabellen." + #: pretix/base/exporters/orderlist.py:100 pretix/base/models/orders.py:332 #: pretix/control/navigation.py:255 pretix/control/navigation.py:362 #: pretix/control/templates/pretixcontrol/orders/index.html:8