diff --git a/src/pretix/base/exporters/__init__.py b/src/pretix/base/exporters/__init__.py index 420741db6..49c8b6437 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/relevant_orderlist.py b/src/pretix/base/exporters/relevant_orderlist.py new file mode 100644 index 000000000..ce4e9f8e9 --- /dev/null +++ b/src/pretix/base/exporters/relevant_orderlist.py @@ -0,0 +1,1197 @@ +# +# 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("Relevant Order data") + category = pgettext_lazy("export_category", "Relevant 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 table." + ) + 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 + 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 + 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") + ] + 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.attendee_name, # unwichtig _("Attendee name") + 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_orderlist") +def register_orderlist_exporter(sender, **kwargs): + return RelevantOrderListExporter + + +@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_orderlist") +def register_multievent_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 eb09c41b8..9757a3ce9 100644 --- a/src/pretix/locale/de/LC_MESSAGES/django.po +++ b/src/pretix/locale/de/LC_MESSAGES/django.po @@ -817,6 +817,11 @@ msgctxt "export_category" msgid "Order data" msgstr "Bestelldaten" +#: pretix/base/exporters/relevant_orderlist.py:87 +msgctxt "export_category" +msgid "Relevant Order data" +msgstr "Relevante Bestelldaten" + #: pretix/base/exporters/answers.py:56 msgid "" "Download a ZIP file including all files that have been uploaded by your " @@ -1821,6 +1826,10 @@ msgstr "Ohne gültige Mitgliedschaft verstecken" msgid "Order data" msgstr "Bestelldaten" +#: pretix/base/exporters/relevant_orderlist.py:86 +msgid "Relevant Order data" +msgstr "Relevante Bestelldaten" + #: pretix/base/exporters/json.py:53 msgid "" "Download a structured JSON representation of all orders. This might be " @@ -1857,6 +1866,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 table." +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 Tabelle." + #: 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