diff --git a/src/pretix/base/modelimport.py b/src/pretix/base/modelimport.py index acf25c3735..f700c349ff 100644 --- a/src/pretix/base/modelimport.py +++ b/src/pretix/base/modelimport.py @@ -111,6 +111,13 @@ class ImportColumn: """ return gettext_lazy('Keep empty') + @property + def help_text(self): + """ + Additional description of the column + """ + return None + def __init__(self, event): self.event = event diff --git a/src/pretix/base/modelimport_orders.py b/src/pretix/base/modelimport_orders.py index b273cb0dec..f87a24f2da 100644 --- a/src/pretix/base/modelimport_orders.py +++ b/src/pretix/base/modelimport_orders.py @@ -57,6 +57,7 @@ from pretix.base.signals import order_import_columns class EmailColumn(ImportColumn): identifier = 'email' verbose_name = gettext_lazy('Email address') + order_level = True def clean(self, value, previous_values): if value: @@ -67,9 +68,24 @@ class EmailColumn(ImportColumn): order.email = value +class GroupingColumn(ImportColumn): + identifier = 'grouping' + verbose_name = gettext_lazy('Grouping') + help_text = gettext_lazy( + 'Only applicable when "Import mode" is set to "Group multiple lines together...". Lines with the same grouping ' + 'value will be put in the same order, but MUST be consecutive lines of the input file.' + ) + order_level = True + default_label = "---" + + def assign(self, value, order, position, invoice_address, **kwargs): + pass + + class PhoneColumn(ImportColumn): identifier = 'phone' verbose_name = gettext_lazy('Phone number') + order_level = True def clean(self, value, previous_values): if value: @@ -94,6 +110,10 @@ class SubeventColumn(SubeventColumnMixin, ImportColumn): identifier = 'subevent' verbose_name = pgettext_lazy('subevents', 'Date') default_value = None + help_text = pgettext_lazy( + 'subevents', 'The date can be specified through its full name, full date and time, or internal ID, provided ' + 'only one date in the system matches the input.' + ) def clean(self, value, previous_values): if not value: @@ -108,6 +128,7 @@ class ItemColumn(ImportColumn): identifier = 'item' verbose_name = gettext_lazy('Product') default_value = None + help_text = gettext_lazy('The product can be specified by its internal ID, full name or internal name.') @cached_property def items(self): @@ -137,6 +158,7 @@ class ItemColumn(ImportColumn): class Variation(ImportColumn): identifier = 'variation' verbose_name = gettext_lazy('Product variation') + help_text = gettext_lazy('The variation can be specified by its internal ID or full name.') @cached_property def items(self): @@ -170,6 +192,7 @@ class Variation(ImportColumn): class InvoiceAddressCompany(ImportColumn): identifier = 'invoice_address_company' + order_level = True @property def verbose_name(self): @@ -181,6 +204,8 @@ class InvoiceAddressCompany(ImportColumn): class InvoiceAddressNamePart(ImportColumn): + order_level = True + def __init__(self, event, key, label): self.key = key self.label = label @@ -200,6 +225,7 @@ class InvoiceAddressNamePart(ImportColumn): class InvoiceAddressStreet(ImportColumn): identifier = 'invoice_address_street' + order_level = True @property def verbose_name(self): @@ -211,6 +237,7 @@ class InvoiceAddressStreet(ImportColumn): class InvoiceAddressZip(ImportColumn): identifier = 'invoice_address_zipcode' + order_level = True @property def verbose_name(self): @@ -222,6 +249,7 @@ class InvoiceAddressZip(ImportColumn): class InvoiceAddressCity(ImportColumn): identifier = 'invoice_address_city' + order_level = True @property def verbose_name(self): @@ -234,6 +262,8 @@ class InvoiceAddressCity(ImportColumn): class InvoiceAddressCountry(ImportColumn): identifier = 'invoice_address_country' default_value = None + help_text = gettext_lazy('The country needs to be specified using a two-letter country code.') + order_level = True @property def initial(self): @@ -257,6 +287,8 @@ class InvoiceAddressCountry(ImportColumn): class InvoiceAddressState(ImportColumn): identifier = 'invoice_address_state' + help_text = gettext_lazy('The state can be specified by its short form or full name.') + order_level = True @property def verbose_name(self): @@ -282,6 +314,7 @@ class InvoiceAddressState(ImportColumn): class InvoiceAddressVATID(ImportColumn): identifier = 'invoice_address_vat_id' + order_level = True @property def verbose_name(self): @@ -293,6 +326,7 @@ class InvoiceAddressVATID(ImportColumn): class InvoiceAddressReference(ImportColumn): identifier = 'invoice_address_internal_reference' + order_level = True @property def verbose_name(self): @@ -380,6 +414,7 @@ class AttendeeCity(ImportColumn): class AttendeeCountry(ImportColumn): identifier = 'attendee_country' default_value = None + help_text = gettext_lazy('The country needs to be specified using a two-letter country code.') @property def initial(self): @@ -403,6 +438,7 @@ class AttendeeCountry(ImportColumn): class AttendeeState(ImportColumn): identifier = 'attendee_state' + help_text = gettext_lazy('The state can be specified by its short form or full name.') @property def verbose_name(self): @@ -471,6 +507,7 @@ class Locale(ImportColumn): identifier = 'locale' verbose_name = gettext_lazy('Order locale') default_value = None + order_level = True @property def initial(self): @@ -514,6 +551,7 @@ class ValidUntil(DatetimeColumnMixin, ImportColumn): class Expires(DatetimeColumnMixin, ImportColumn): identifier = 'expires' verbose_name = gettext_lazy('Expiry date') + order_level = True def clean(self, value, previous_values): if not value: @@ -540,6 +578,8 @@ class Saleschannel(ImportColumn): verbose_name = gettext_lazy('Sales channel') default_value = None initial = 'static:web' + help_text = gettext_lazy('The sales channel can be specified by it\'s internal identifier or its full name.') + order_level = True @cached_property def channels(self): @@ -568,6 +608,7 @@ class Saleschannel(ImportColumn): class SeatColumn(ImportColumn): identifier = 'seat' verbose_name = gettext_lazy('Seat ID') + help_text = gettext_lazy('The seat needs to be specified by its internal ID.') def __init__(self, *args): self._cached = set() @@ -599,7 +640,8 @@ class SeatColumn(ImportColumn): class Comment(ImportColumn): identifier = 'comment' - verbose_name = gettext_lazy('Comment') + verbose_name = gettext_lazy('Order comment') + order_level = True def assign(self, value, order, position, invoice_address, **kwargs): order.comment = value or '' @@ -608,6 +650,7 @@ class Comment(ImportColumn): class CheckinAttentionColumn(BooleanColumnMixin, ImportColumn): identifier = 'checkin_attention' verbose_name = gettext_lazy('Requires special attention') + order_level = True def assign(self, value, order, position, invoice_address, **kwargs): order.checkin_attention = value @@ -616,6 +659,7 @@ class CheckinAttentionColumn(BooleanColumnMixin, ImportColumn): class CheckinTextColumn(ImportColumn): identifier = 'checkin_text' verbose_name = gettext_lazy('Check-in text') + order_level = True def assign(self, value, order, position, invoice_address, **kwargs): order.checkin_text = value @@ -696,6 +740,7 @@ class QuestionColumn(ImportColumn): class CustomerColumn(ImportColumn): identifier = 'customer' verbose_name = gettext_lazy('Customer') + order_level = True def clean(self, value, previous_values): if value: @@ -720,6 +765,7 @@ def get_order_import_columns(event): if event.has_subevents: default.append(SubeventColumn(event)) default += [ + GroupingColumn(event), EmailColumn(event), PhoneColumn(event), ItemColumn(event), diff --git a/src/pretix/base/services/modelimport.py b/src/pretix/base/services/modelimport.py index c029e0510b..4c37a6c99a 100644 --- a/src/pretix/base/services/modelimport.py +++ b/src/pretix/base/services/modelimport.py @@ -89,6 +89,9 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user, _('Orders cannot have more than %(max)s positions.') % {'max': django_settings.PRETIX_MAX_ORDER_SIZE} ) + used_groupers = set() + current_grouper = [] + current_order_level_data = {} orders = [] order = None @@ -97,7 +100,28 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user, lock_seats = [] for i, record in enumerate(data): try: - if order is None or settings['orders'] == 'many': + create_new_order = ( + order is None or + settings['orders'] == 'many' or + (settings['orders'] == 'mixed' and record["grouping"] != current_grouper) + ) + + if create_new_order: + if settings['orders'] == 'mixed': + if record["grouping"] in used_groupers: + raise DataImportError( + _('The grouping "%(value)s" occurs on non-consecutive lines (seen again on line %(row)s).') % { + "value": record["grouping"], + "row": i + 1, + } + ) + current_grouper = record["grouping"] + used_groupers.add(current_grouper) + + current_order_level_data = { + c.identifier: record.get(c.identifier) + for c in cols if getattr(c, "order_level", False) + } order = Order( event=event, testmode=settings['testmode'], @@ -108,6 +132,12 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user, order._address.name_parts = {'_scheme': event.settings.name_scheme} orders.append(order) + if settings['orders'] == 'mixed' and len(order._positions) >= django_settings.PRETIX_MAX_ORDER_SIZE: + raise DataImportError( + _('Orders cannot have more than %(max)s positions.') % { + 'max': django_settings.PRETIX_MAX_ORDER_SIZE} + ) + position = OrderPosition(positionid=len(order._positions) + 1) position.attendee_name_parts = {'_scheme': event.settings.name_scheme} position.meta_info = {} @@ -115,13 +145,24 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user, position.assign_pseudonymization_id() for c in cols: - c.assign(record.get(c.identifier), order, position, order._address) + value = record.get(c.identifier) + if getattr(c, "order_level", False) and value != current_order_level_data.get(c.identifier): + raise DataImportError( + _('Inconsistent data in row {row}: Column {col} contains value "{val_line}", but ' + 'for this order, the value has already been set to "{val_order}".').format( + row=i + 1, + col=c.verbose_name, + val_line=value, + val_order=current_order_level_data.get(c.identifier) or "", + ) + ) + c.assign(value, order, position, order._address) if position.seat is not None: lock_seats.append((order.sales_channel, position.seat)) except (ValidationError, ImportError) as e: raise DataImportError( - _('Invalid data in row {row}: {message}').format(row=i, message=str(e)) + _('Invalid data in row {row}: {message}').format(row=i + 1, message=str(e)) ) try: diff --git a/src/pretix/control/forms/modelimport.py b/src/pretix/control/forms/modelimport.py index 565c48f7e4..c96cf29c49 100644 --- a/src/pretix/control/forms/modelimport.py +++ b/src/pretix/control/forms/modelimport.py @@ -20,6 +20,7 @@ # . # from django import forms +from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from pretix.base.modelimport_orders import get_order_import_columns @@ -62,7 +63,8 @@ class ProcessForm(forms.Form): choices=choices, widget=forms.Select( attrs={'data-static': 'true'} - ) + ), + help_text=c.help_text, ) def get_columns(self): @@ -75,14 +77,17 @@ class OrdersProcessForm(ProcessForm): choices=( ('many', _('Create a separate order for each line')), ('one', _('Create one order with one position per line')), - ) + ('mixed', _('Group multiple lines together into the same order based on a grouping column')), + ), + widget=forms.RadioSelect, ) status = forms.ChoiceField( label=_('Order status'), choices=( ('paid', _('Create orders as fully paid')), ('pending', _('Create orders as pending and still require payment')), - ) + ), + widget=forms.RadioSelect, ) testmode = forms.BooleanField( label=_('Create orders as test mode orders'), @@ -99,6 +104,17 @@ class OrdersProcessForm(ProcessForm): def get_columns(self): return get_order_import_columns(self.event) + def clean(self): + data = super().clean() + + grouping = data.get("grouping") and data.get("grouping") != "empty" + if data.get("orders") != "mixed" and grouping: + raise ValidationError({"grouping": [_("A grouping cannot be specified for this import mode.")]}) + if data.get("orders") == "mixed" and not grouping: + raise ValidationError({"grouping": [_("A grouping needs to be specified for this import mode.")]}) + + return data + class VouchersProcessForm(ProcessForm): diff --git a/src/tests/base/test_modelimport_orders.py b/src/tests/base/test_modelimport_orders.py index ca31fc4004..634e40664a 100644 --- a/src/tests/base/test_modelimport_orders.py +++ b/src/tests/base/test_modelimport_orders.py @@ -58,8 +58,8 @@ def user(): return User.objects.create_user('test@localhost', 'test') -def inputfile_factory(multiplier=1): - d = [ +def inputfile_factory(data=None, multiplier=1): + d = data or [ { 'A': 'Dieter', 'B': 'Schneider', @@ -113,7 +113,7 @@ def inputfile_factory(multiplier=1): if multiplier > 1: d = d * multiplier f = StringIO() - w = csv.DictWriter(f, ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N'], dialect=csv.excel) + w = csv.DictWriter(f, d[0].keys(), dialect=csv.excel) w.writeheader() w.writerows(d) f.seek(0) @@ -875,3 +875,88 @@ def test_import_question_valid(user, event, item): assert a6.last().question == q1 # TODO: validate question + + +@pytest.mark.django_db +@scopes_disabled() +def test_import_mixed_order_size(user, event, item): + settings = dict(DEFAULT_SETTINGS) + data = [ + {"G": "GRP1", "EMAIL": "a@example.com", "NAME": "A1"}, + {"G": "GRP1", "EMAIL": "a@example.com", "NAME": "A2"}, + {"G": "GRP1", "EMAIL": "a@example.com", "NAME": "A3"}, + {"G": "GRP2", "EMAIL": "b@example.com", "NAME": "B1"}, + {"G": "GRP3", "EMAIL": "c@example.com", "NAME": "C1"}, + {"G": "GRP3", "EMAIL": "c@example.com", "NAME": "C2"}, + ] + settings['item'] = 'static:{}'.format(item.pk) + settings['grouping'] = 'csv:G' + settings['email'] = 'csv:EMAIL' + settings['attendee_name_full_name'] = 'csv:NAME' + settings['orders'] = 'mixed' + import_orders.apply( + args=(event.pk, inputfile_factory(data).id, settings, 'en', user.pk) + ) + assert event.orders.count() == 3 + + o = event.orders.get(email="a@example.com") + assert o.positions.count() == 3 + assert set(pos.positionid for pos in o.positions.all()) == {1, 2, 3} + assert set(pos.attendee_name for pos in o.positions.all()) == {"A1", "A2", "A3"} + + o = event.orders.get(email="b@example.com") + assert o.positions.count() == 1 + + o = event.orders.get(email="c@example.com") + assert o.positions.count() == 2 + assert set(pos.positionid for pos in o.positions.all()) == {1, 2} + assert set(pos.attendee_name for pos in o.positions.all()) == {"C1", "C2"} + + +@pytest.mark.django_db +@scopes_disabled() +def test_import_mixed_order_size_validate_consecutive(user, event, item): + settings = dict(DEFAULT_SETTINGS) + data = [ + {"G": "GRP1", "EMAIL": "a@example.com", "NAME": "A1"}, + {"G": "GRP1", "EMAIL": "a@example.com", "NAME": "A2"}, + {"G": "GRP1", "EMAIL": "a@example.com", "NAME": "A3"}, + {"G": "GRP2", "EMAIL": "b@example.com", "NAME": "B1"}, + {"G": "GRP3", "EMAIL": "c@example.com", "NAME": "C1"}, + {"G": "GRP1", "EMAIL": "c@example.com", "NAME": "C2"}, + ] + settings['item'] = 'static:{}'.format(item.pk) + settings['grouping'] = 'csv:G' + settings['email'] = 'csv:EMAIL' + settings['attendee_name_full_name'] = 'csv:NAME' + settings['orders'] = 'mixed' + with pytest.raises(DataImportError) as excinfo: + import_orders.apply( + args=(event.pk, inputfile_factory(data).id, settings, 'en', user.pk) + ).get() + assert 'The grouping "GRP1" occurs on non-consecutive lines (seen again on line 6).' in str(excinfo.value) + + +@pytest.mark.django_db +@scopes_disabled() +def test_import_mixed_order_size_consistency(user, event, item): + settings = dict(DEFAULT_SETTINGS) + data = [ + {"G": "GRP1", "EMAIL": "a1@example.com", "NAME": "A1"}, + {"G": "GRP1", "EMAIL": "a2@example.com", "NAME": "A2"}, + {"G": "GRP1", "EMAIL": "a3@example.com", "NAME": "A3"}, + {"G": "GRP2", "EMAIL": "b@example.com", "NAME": "B1"}, + {"G": "GRP3", "EMAIL": "c@example.com", "NAME": "C1"}, + {"G": "GRP3", "EMAIL": "c@example.com", "NAME": "C2"}, + ] + settings['item'] = 'static:{}'.format(item.pk) + settings['grouping'] = 'csv:G' + settings['email'] = 'csv:EMAIL' + settings['attendee_name_full_name'] = 'csv:NAME' + settings['orders'] = 'mixed' + with pytest.raises(DataImportError) as excinfo: + import_orders.apply( + args=(event.pk, inputfile_factory(data).id, settings, 'en', user.pk) + ).get() + assert ('Inconsistent data in row 2: Column Email address contains value "a2@example.com", but for this order, ' + 'the value has already been set to "a1@example.com".') in str(excinfo.value) diff --git a/src/tests/control/test_modelimport.py b/src/tests/control/test_modelimport.py index d24c0b219a..f8726a7f3a 100644 --- a/src/tests/control/test_modelimport.py +++ b/src/tests/control/test_modelimport.py @@ -59,9 +59,10 @@ Anke,Müller,anke@example.net r = client.post('/control/event/dummy/dummy/orders/import/', { 'file': file }, follow=True) + print(r.content) doc = BeautifulSoup(r.content, "lxml") - assert doc.select("select[name=orders]") - assert doc.select("select[name=status]") + assert doc.select("input[name=orders]") + assert doc.select("input[name=status]") assert doc.select("select[name=attendee_email]") assert b"Dieter" in r.content assert b"daniel@example.org" in r.content