diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 8d97c911e1..3c6e30ad02 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -498,6 +498,10 @@ class OrderPosition(AbstractPosition): verbose_name_plural = _("Order positions") ordering = ("positionid", "id") + @cached_property + def sort_key(self): + return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0 + @classmethod def transform_cart_positions(cls, cp: List, order) -> list: from . import Voucher @@ -529,6 +533,13 @@ class OrderPosition(AbstractPosition): cartpos.delete() return ops + def __str__(self): + if self.variation: + return '#{} – {} – {}'.format( + self.positionid, str(self.item), str(self.variation) + ) + return '#{} – {}'.format(self.positionid, str(self.item)) + def __repr__(self): return '' % ( self.item.id, self.variation.id if self.variation else 0, self.order_id diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index 90bc0e834d..b4bd94b8ea 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -67,7 +67,9 @@ def build_invoice(invoice: Invoice) -> Invoice: invoice.save() invoice.lines.all().delete() - for p in invoice.order.positions.all(): + positions = list(invoice.order.positions.select_related('addon_to', 'item', 'variation')) + positions.sort(key=lambda p: p.sort_key) + for p in positions: desc = str(p.item.name) if p.variation: desc += " - " + str(p.variation.value) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 5bd1c5f084..cc60765d5a 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -9,7 +9,7 @@ import pytz from celery.exceptions import MaxRetriesExceededError from django.conf import settings from django.db import transaction -from django.db.models import F, Q +from django.db.models import F, Max, Q from django.dispatch import receiver from django.utils.formats import date_format from django.utils.timezone import make_aware, now @@ -495,11 +495,14 @@ class OrderChangeManager: 'paid_to_free_exceeded': _('This operation would make the order free and therefore immediately paid, however ' 'no quota is available.'), 'paid_price_change': _('Currently, paid orders can only be changed in a way that does not change the total ' - 'price of the order as partial payments or refunds are not yet supported.') + 'price of the order as partial payments or refunds are not yet supported.'), + 'addon_to_required': _('This is an addon product, please select the base position it should be added to.'), + 'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'), } ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation', 'price')) PriceOperation = namedtuple('PriceOperation', ('position', 'price')) CancelOperation = namedtuple('CancelOperation', ('position',)) + AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to')) def __init__(self, order: Order, user): self.order = order @@ -528,6 +531,21 @@ class OrderChangeManager: self._quotadiff.subtract(position.variation.quotas.all() if position.variation else position.item.quotas.all()) self._operations.append(self.CancelOperation(position)) + def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order): + if price is None: + price = item.default_price if variation is None else variation.price + if price is None: + raise OrderError(self.error_messages['product_invalid']) + if not addon_to and item.category and item.category.is_addon: + raise OrderError(self.error_messages['addon_to_required']) + if addon_to: + if not item.category or item.category_id not in addon_to.item.addons.values_list('addon_category', flat=True): + raise OrderError(self.error_messages['addon_invalid']) + + self._totaldiff = price + self._quotadiff.update(variation.quotas.all() if variation else item.quotas.all()) + self._operations.append(self.AddOperation(item, variation, price, addon_to)) + def _check_quotas(self): for quota, diff in self._quotadiff.items(): if diff <= 0: @@ -552,6 +570,7 @@ class OrderChangeManager: raise OrderError(self.error_messages['paid_to_free_exceeded']) def _perform_operations(self): + nextposid = self.order.positions.aggregate(m=Max('positionid'))['m'] + 1 for op in self._operations: if isinstance(op, self.ItemOperation): self.order.log_action('pretix.event.order.changed.item', user=self.user, data={ @@ -600,6 +619,21 @@ class OrderChangeManager: 'addon_to': None, }) op.position.delete() + elif isinstance(op, self.AddOperation): + pos = OrderPosition.objects.create( + item=op.item, variation=op.variation, addon_to=op.addon_to, + price=op.price, order=self.order, + positionid=nextposid + ) + nextposid += 1 + self.order.log_action('pretix.event.order.changed.add', user=self.user, data={ + 'position': pos.pk, + 'item': op.item.pk, + 'variation': op.variation.pk if op.variation else None, + 'addon_to': op.addon_to.pk if op.addon_to else None, + 'price': op.price, + 'positionid': pos.positionid + }) def _recalculate_total_and_payment_fee(self): self.order.total = sum([p.price for p in self.order.positions.all()]) diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index 0cf22310e0..b465ca4eb4 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -7,7 +7,7 @@ from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from pretix.base.forms import I18nModelForm -from pretix.base.models import Item, Order +from pretix.base.models import Item, ItemAddOn, Order, OrderPosition class ExtendForm(I18nModelForm): @@ -55,6 +55,52 @@ class CommentForm(I18nModelForm): } +class OrderPositionAddForm(forms.Form): + do = forms.BooleanField( + label=_('Add a new product to the order'), + required=False + ) + itemvar = forms.ChoiceField( + label=_('Product') + ) + addon_to = forms.ModelChoiceField( + OrderPosition.objects.none(), + required=False, + label=_('Add-on to'), + ) + price = forms.DecimalField( + required=False, + max_digits=10, decimal_places=2, + label=_('Gross price'), + help_text=_("Keep empty for the product's default price") + ) + + def __init__(self, *args, **kwargs): + order = kwargs.pop('order') + super().__init__(*args, **kwargs) + choices = [] + for i in order.event.items.prefetch_related('variations').all(): + pname = str(i.name) + if not i.is_available(): + pname += ' ({})'.format(_('inactive')) + variations = list(i.variations.all()) + if variations: + for v in variations: + choices.append(('%d-%d' % (i.pk, v.pk), + '%s – %s (%s %s)' % (pname, v.value, localize(v.price), + order.event.currency))) + else: + choices.append((str(i.pk), '%s (%s %s)' % (pname, localize(i.default_price), + order.event.currency))) + self.fields['itemvar'].choices = choices + if ItemAddOn.objects.filter(base_item__event=order.event).exists(): + self.fields['addon_to'].queryset = order.positions.filter(addon_to__isnull=True).select_related( + 'item', 'variation' + ) + else: + del self.fields['addon_to'] + + class OrderPositionChangeForm(forms.Form): itemvar = forms.ChoiceField() price = forms.DecimalField( diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 648f57fa13..8e1a3582b3 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -6,7 +6,7 @@ from django.utils import formats from django.utils.translation import ugettext_lazy as _ from i18nfield.strings import LazyI18nString -from pretix.base.models import Event, ItemVariation, LogEntry +from pretix.base.models import Event, ItemVariation, LogEntry, OrderPosition from pretix.base.signals import logentry_display OVERVIEW_BLACKLIST = [ @@ -51,6 +51,26 @@ def _display_order_changed(event: Event, logentry: LogEntry): old_price=formats.localize(Decimal(data['old_price'])), currency=event.currency ) + elif logentry.action_type == 'pretix.event.order.changed.add': + item = str(event.items.get(pk=data['item'])) + if data['variation']: + item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['variation'])) + if data['addon_to']: + addon_to = OrderPosition.objects.get(order__event=event, pk=data['addon_to']) + return text + ' ' + _('Position #{posid} created: {item} ({price} {currency}) as an add-on to ' + 'position #{addon_to}.').format( + posid=data.get('positionid', '?'), + item=item, addon_to=addon_to.positionid, + price=formats.localize(Decimal(data['price'])), + currency=event.currency + ) + else: + return text + ' ' + _('Position #{posid} created: {item} ({price} {currency}).').format( + posid=data.get('positionid', '?'), + item=item, + price=formats.localize(Decimal(data['price'])), + currency=event.currency + ) @receiver(signal=logentry_display, dispatch_uid="pretixcontrol_logentry_display") diff --git a/src/pretix/control/templates/pretixcontrol/order/change.html b/src/pretix/control/templates/pretixcontrol/order/change.html index 9e288f3912..04cebd28f6 100644 --- a/src/pretix/control/templates/pretixcontrol/order/change.html +++ b/src/pretix/control/templates/pretixcontrol/order/change.html @@ -22,8 +22,7 @@

{% blocktrans trimmed %} The user will receive a notification about the change but in the case of new required questions, the user - will not be forced to answer them. You cannot use this form to add something to the order, please create - a second order instead. + will not be forced to answer them. {% endblocktrans %}

@@ -31,13 +30,15 @@ If an invoice is attached to the order, a cancellation will be created together with a new invoice. {% endblocktrans %}

-
+
{% blocktrans trimmed %} - Please use this tool carefully. Changes you make here are not reversible. In most cases it is easier to - cancel the order completely and create a new one. + Please use this tool carefully. Changes you make here are not reversible. Also, if you change an order + manually, not all constraints (e.g. on required add-ons) will be checked. Therefore, you might construct + an order that would not be able to exist otherwise. + In most cases it is easier to cancel the order completely and create a new one. {% endblocktrans %} -
-
+
+ {% csrf_token %} {% for position in positions %}
@@ -107,6 +108,29 @@
{% endfor %} +
+
+

+ Add product +

+
+
+
+ {% bootstrap_form_errors add_form %} + {% if add_form.custom_error %} +
+ {{ add_form.custom_error }} +
+ {% endif %} + {% bootstrap_field add_form.do layout='horizontal' %} + {% bootstrap_field add_form.itemvar layout='horizontal' %} + {% bootstrap_field add_form.price layout='horizontal' %} + {% if add_form.addon_to %} + {% bootstrap_field add_form.addon_to layout='horizontal' %} + {% endif %} +
+
+
diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index a7985064e9..95bc3a5e50 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -33,7 +33,7 @@ from pretix.base.signals import ( from pretix.base.views.async import AsyncAction from pretix.control.forms.orders import ( CommentForm, ExporterForm, ExtendForm, OrderContactForm, OrderLocaleForm, - OrderPositionChangeForm, + OrderPositionAddForm, OrderPositionChangeForm, ) from pretix.control.permissions import EventPermissionRequiredMixin from pretix.multidomain.urlreverse import build_absolute_uri @@ -166,7 +166,7 @@ class OrderDetail(OrderView): cartpos = queryset.order_by( 'item', 'variation' ).select_related( - 'item', 'variation' + 'item', 'variation', 'addon_to' ).prefetch_related( 'item__questions', 'answers', 'answers__question', 'checkins' ).order_by('positionid') @@ -181,6 +181,8 @@ class OrderDetail(OrderView): p.cache_answers() positions.append(p) + positions.sort(key=lambda p: p.sort_key) + return { 'positions': positions, 'raw': cartpos, @@ -460,6 +462,11 @@ class OrderChange(OrderView): return self._redirect_back() return super().dispatch(request, *args, **kwargs) + @cached_property + def add_form(self): + return OrderPositionAddForm(prefix='add', order=self.order, + data=self.request.POST if self.request.method == "POST" else None) + @cached_property def positions(self): positions = list(self.order.positions.all()) @@ -471,15 +478,37 @@ class OrderChange(OrderView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['positions'] = self.positions + ctx['add_form'] = self.add_form return ctx - def post(self, *args, **kwargs): - ocm = OrderChangeManager(self.order, self.request.user) - form_valid = True + def _process_add(self, ocm): + if not self.add_form.is_valid(): + return False + else: + if self.add_form.cleaned_data['do']: + if '-' in self.add_form.cleaned_data['itemvar']: + itemid, varid = self.add_form.cleaned_data['itemvar'].split('-') + else: + itemid, varid = self.add_form.cleaned_data['itemvar'], None + + item = Item.objects.get(pk=itemid, event=self.request.event) + if varid: + variation = ItemVariation.objects.get(pk=varid, item=item) + else: + variation = None + try: + ocm.add_position(item, variation, + self.add_form.cleaned_data['price'], + self.add_form.cleaned_data['addon_to']) + except OrderError as e: + self.add_form.custom_error = str(e) + return False + return True + + def _process_change(self, ocm): for p in self.positions: if not p.form.is_valid(): - form_valid = False - break + return False try: if p.form.cleaned_data['operation'] == 'product': @@ -501,8 +530,12 @@ class OrderChange(OrderView): except OrderError as e: p.custom_error = str(e) - form_valid = False - break + return False + return True + + def post(self, *args, **kwargs): + ocm = OrderChangeManager(self.order, self.request.user) + form_valid = self._process_add(ocm) and self._process_change(ocm) if not form_valid: messages.error(self.request, _('An error occured. Please see the details below.')) diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 8bc116056c..02d4ce4b5b 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -146,11 +146,11 @@ class OrderChangeManagerTests(TestCase): default_price=Decimal('12.00')) self.op1 = OrderPosition.objects.create( order=self.order, item=self.ticket, variation=None, - price=Decimal("23.00"), attendee_name="Peter" + price=Decimal("23.00"), attendee_name="Peter", positionid=1 ) self.op2 = OrderPosition.objects.create( order=self.order, item=self.ticket, variation=None, - price=Decimal("23.00"), attendee_name="Dieter" + price=Decimal("23.00"), attendee_name="Dieter", positionid=2 ) self.ocm = OrderChangeManager(self.order, None) @@ -324,3 +324,54 @@ class OrderChangeManagerTests(TestCase): self.order.refresh_from_db() assert self.order.total == 46 assert self.order.status == Order.STATUS_PAID + + def test_add_item_success(self): + self.ocm.add_position(self.shirt, None, None, None) + self.ocm.commit() + self.order.refresh_from_db() + assert self.order.positions.count() == 3 + nop = self.order.positions.last() + assert nop.item == self.shirt + assert nop.price == self.shirt.default_price + assert nop.tax_rate == self.shirt.tax_rate + assert round_decimal(nop.price * (1 - 100 / (100 + self.shirt.tax_rate))) == nop.tax_value + assert self.order.total == self.op1.price + self.op2.price + nop.price + assert nop.positionid == 3 + + def test_add_item_custom_price(self): + self.ocm.add_position(self.shirt, None, Decimal('13.00'), None) + self.ocm.commit() + self.order.refresh_from_db() + assert self.order.positions.count() == 3 + nop = self.order.positions.last() + assert nop.item == self.shirt + assert nop.price == Decimal('13.00') + assert nop.tax_rate == self.shirt.tax_rate + assert round_decimal(nop.price * (1 - 100 / (100 + self.shirt.tax_rate))) == nop.tax_value + assert self.order.total == self.op1.price + self.op2.price + nop.price + + def test_add_item_quota_full(self): + q1 = self.event.quotas.create(name='Test', size=0) + q1.items.add(self.shirt) + self.ocm.add_position(self.shirt, None, None, None) + with self.assertRaises(OrderError): + self.ocm.commit() + assert self.order.positions.count() == 2 + + def test_add_item_addon(self): + self.shirt.category = self.event.categories.create(name='Add-ons', is_addon=True) + self.ticket.addons.create(addon_category=self.shirt.category) + self.ocm.add_position(self.shirt, None, Decimal('13.00'), self.op1) + self.ocm.commit() + self.order.refresh_from_db() + assert self.order.positions.count() == 3 + nop = self.order.positions.last() + assert nop.item == self.shirt + assert nop.addon_to == self.op1 + + def test_add_item_addon_invalid(self): + with self.assertRaises(OrderError): + self.ocm.add_position(self.shirt, None, Decimal('13.00'), self.op1) + self.shirt.category = self.event.categories.create(name='Add-ons', is_addon=True) + with self.assertRaises(OrderError): + self.ocm.add_position(self.shirt, None, Decimal('13.00'), None) diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index 574d461b83..065afb0e7d 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -525,3 +525,21 @@ class OrderChangeTests(SoupTest): self.order.refresh_from_db() assert self.order.positions.count() == 1 assert self.order.total == self.op2.price + + def test_add_item_success(self): + self.client.post('/control/event/{}/{}/orders/{}/change'.format( + self.event.organizer.slug, self.event.slug, self.order.code + ), { + 'op-{}-operation'.format(self.op1.pk): '', + 'op-{}-operation'.format(self.op2.pk): '', + 'op-{}-itemvar'.format(self.op2.pk): str(self.ticket.pk), + 'op-{}-price'.format(self.op2.pk): str(self.op2.price), + 'op-{}-itemvar'.format(self.op1.pk): str(self.ticket.pk), + 'op-{}-price'.format(self.op1.pk): str(self.op1.price), + 'add-itemvar': str(self.shirt.pk), + 'add-do': 'on', + 'add-price': '14.00', + }) + assert self.order.positions.count() == 3 + assert self.order.positions.last().item == self.shirt + assert self.order.positions.last().price == 14