forked from CGM_Public/pretix_original
Order import: Allow to create multiple multi-ticket orders (#5304)
* Order import: Allow to create multiple multi-ticket orders * Update src/pretix/base/modelimport_orders.py * Fix failing test
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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):
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user