diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 60c6b1633c..a1b2fff2c5 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1,3 +1,4 @@ +import copy import json import logging from collections import Counter, namedtuple @@ -23,7 +24,10 @@ from pretix.base.models import ( User, Voucher, ) from pretix.base.models.event import SubEvent -from pretix.base.models.orders import CachedTicket, InvoiceAddress, OrderFee +from pretix.base.models.orders import ( + CachedTicket, InvoiceAddress, OrderFee, generate_position_secret, + generate_secret, +) from pretix.base.models.organizer import TeamAPIToken from pretix.base.models.tax import TaxedPrice from pretix.base.payment import BasePaymentProvider @@ -657,10 +661,12 @@ class OrderChangeManager: PriceOperation = namedtuple('PriceOperation', ('position', 'price')) CancelOperation = namedtuple('CancelOperation', ('position',)) AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent')) + SplitOperation = namedtuple('SplitOperation', ('position',)) def __init__(self, order: Order, user, notify=True): self.order = order self.user = user + self.split_order = None self._totaldiff = 0 self._quotadiff = Counter() self._operations = [] @@ -782,6 +788,12 @@ class OrderChangeManager: self._quotadiff.update(new_quotas) self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent)) + def split(self, position: OrderPosition): + if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'): + self._invoice_dirty = True + + self._operations.append(self.SplitOperation(position)) + def _check_quotas(self): for quota, diff in self._quotadiff.items(): if diff <= 0: @@ -801,12 +813,26 @@ class OrderChangeManager: def _check_paid_to_free(self): if self.order.total == 0: try: - mark_order_paid(self.order, 'free', send_mail=False, count_waitinglist=False) + mark_order_paid( + self.order, 'free', send_mail=False, count_waitinglist=False, + user=self.user + ) + except Quota.QuotaExceededException: + raise OrderError(self.error_messages['paid_to_free_exceeded']) + + if self.split_order and self.split_order.total == 0: + try: + mark_order_paid( + self.split_order, 'free', send_mail=False, count_waitinglist=False, + user=self.user + ) except Quota.QuotaExceededException: raise OrderError(self.error_messages['paid_to_free_exceeded']) def _perform_operations(self): nextposid = self.order.positions.aggregate(m=Max('positionid'))['m'] + 1 + split_positions = [] + for op in self._operations: if isinstance(op, self.ItemOperation): self.order.log_action('pretix.event.order.changed.item', user=self.user, data={ @@ -891,23 +917,89 @@ class OrderChangeManager: 'positionid': pos.positionid, 'subevent': op.subevent.pk if op.subevent else None, }) + elif isinstance(op, self.SplitOperation): + split_positions.append(op.position) - def _recalculate_total_and_payment_fee(self): - payment_fee = Decimal('0.00') - if self.order.total != 0: - prov = self._get_payment_provider() - if prov: - payment_fee = prov.calculate_fee(self.order.total) + if split_positions: + self.split_order = self._create_split_order(split_positions) - if payment_fee: - fee = self.order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0] + def _create_split_order(self, split_positions): + split_order = Order.objects.get(pk=self.order.pk) + split_order.pk = None + split_order.code = None + split_order.datetime = now() + split_order.secret = generate_secret() + split_order.save() + split_order.log_action('pretix.event.order.changed.split_from', user=self.user, data={ + 'original_order': self.order.code + }) + + for op in split_positions: + self.order.log_action('pretix.event.order.changed.split', user=self.user, data={ + 'position': op.pk, + 'positionid': op.positionid, + 'old_item': op.item.pk, + 'old_variation': op.variation.pk if op.variation else None, + 'old_price': op.price, + 'new_order': split_order.code, + }) + op.order = split_order + op.secret = generate_position_secret() + op.save() + + try: + ia = copy.copy(self.order.invoice_address) + ia.pk = None + ia.order = split_order + ia.save() + except InvoiceAddress.DoesNotExist: + pass + + split_order.total = sum([p.price for p in split_positions]) + if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID: + payment_fee = self._get_payment_provider().calculate_fee(split_order.total) + fee = split_order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0] fee.value = payment_fee fee._calculate_tax() - fee.save() - else: - self.order.fees.filter(fee_type=OrderFee.FEE_TYPE_PAYMENT).delete() + if payment_fee != 0: + fee.save() + elif fee.pk: + fee.delete() + split_order.total += fee.value - self.order.total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()]) + for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT): + new_fee = copy.copy(fee) + new_fee.pk = None + new_fee.order = split_order + split_order.total += new_fee.value + new_fee.save() + + split_order.save() + + if split_order.total != Decimal('0.00') and self.order.invoices.filter(is_cancellation=False).last(): + generate_invoice(split_order) + return split_order + + def _recalculate_total_and_payment_fee(self): + self.order.total = sum([p.price for p in self.order.positions.all()]) + + if self.order.status != Order.STATUS_PAID: + # Do not change payment fees of paid orders + payment_fee = Decimal('0.00') + if self.order.total != 0: + prov = self._get_payment_provider() + if prov: + payment_fee = prov.calculate_fee(self.order.total) + + if payment_fee: + fee = self.order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0] + fee.value = payment_fee + fee._calculate_tax() + fee.save() + else: + self.order.fees.filter(fee_type=OrderFee.FEE_TYPE_PAYMENT).delete() + + self.order.total += sum([f.value for f in self.order.fees.all()]) self.order.save() def _reissue_invoice(self): @@ -917,7 +1009,7 @@ class OrderChangeManager: generate_invoice(self.order) def _check_complete_cancel(self): - cancels = len([o for o in self._operations if isinstance(o, self.CancelOperation)]) + cancels = len([o for o in self._operations if isinstance(o, (self.CancelOperation, self.SplitOperation))]) if cancels == self.order.positions.count(): raise OrderError(self.error_messages['complete_cancel']) @@ -928,27 +1020,27 @@ class OrderChangeManager: except InvoiceAddress.DoesNotExist: return None - def _notify_user(self): - with language(self.order.locale): + def _notify_user(self, order): + with language(order.locale): try: - invoice_name = self.order.invoice_address.name - invoice_company = self.order.invoice_address.company + invoice_name = order.invoice_address.name + invoice_company = order.invoice_address.company except InvoiceAddress.DoesNotExist: invoice_name = "" invoice_company = "" - email_template = self.order.event.settings.mail_text_order_changed + email_template = order.event.settings.mail_text_order_changed email_context = { - 'event': self.order.event.name, + 'event': order.event.name, 'url': build_absolute_uri(self.order.event, 'presale:event.order', kwargs={ - 'order': self.order.code, - 'secret': self.order.secret + 'order': order.code, + 'secret': order.secret }), 'invoice_name': invoice_name, 'invoice_company': invoice_company, } - email_subject = _('Your order has been changed: %(code)s') % {'code': self.order.code} + email_subject = _('Your order has been changed: %(code)s') % {'code': order.code} try: - self.order.send_mail( + order.send_mail( email_subject, email_template, email_context, 'pretix.event.order.email.order_changed', self.user ) @@ -973,7 +1065,9 @@ class OrderChangeManager: self._clear_tickets_cache() self._check_paid_to_free() if self.notify: - self._notify_user() + self._notify_user(self.order) + if self.split_order: + self._notify_user(self.split_order) def _clear_tickets_cache(self): CachedTicket.objects.filter(order_position__order=self.order).delete() diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 0d6ed5abde..da654ea3d9 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -138,7 +138,9 @@ order_placed = EventPluginSignal( ) """ This signal is sent out every time an order is placed. The order object is given -as the first argument. +as the first argument. This signal is *not* sent out if an order is created through +splitting an existing order, so you can not expect to see all orders by listening +to this signal. As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ @@ -148,7 +150,8 @@ order_paid = EventPluginSignal( ) """ This signal is sent out every time an order is paid. The order object is given -as the first argument. +as the first argument. This signal is *not* sent out if an order is marked as paid +because it an already-paid order has been splitted. As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index ea1dd2f4f1..e70c83d2cc 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -187,7 +187,8 @@ class OrderPositionChangeForm(forms.Form): ('product', 'Change product'), ('price', 'Change price'), ('subevent', 'Change event date'), - ('cancel', 'Remove product') + ('cancel', 'Remove product'), + ('split', 'Split into new order'), ) ) diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 3d87cd7bac..1919aa3cfb 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -85,6 +85,21 @@ def _display_order_changed(event: Event, logentry: LogEntry): price=formats.localize(Decimal(data['price'])), currency=event.currency ) + elif logentry.action_type == 'pretix.event.order.changed.split': + old_item = str(event.items.get(pk=data['old_item'])) + if data['old_variation']: + old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation'])) + return text + ' ' + _('Position #{posid} ({old_item}, {old_price} {currency}) split into new order: {order}').format( + old_item=old_item, + posid=data.get('positionid', '?'), + order=data['new_order'], + old_price=formats.localize(Decimal(data['old_price'])), + currency=event.currency + ) + elif logentry.action_type == 'pretix.event.order.changed.split_from': + return _('This order has been created by splitting the order {order}').format( + order=data['original_order'], + ) @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 7b097e4555..2fac57c8f2 100644 --- a/src/pretix/control/templates/pretixcontrol/order/change.html +++ b/src/pretix/control/templates/pretixcontrol/order/change.html @@ -36,6 +36,12 @@ If an invoice is attached to the order, a cancellation will be created together with a new invoice. {% endblocktrans %}

+

+ {% blocktrans trimmed %} + If you chose "split into new order" for multiple positions, they will be all split in one second order + together, not multiple orders. + {% endblocktrans %} +

{% blocktrans trimmed %} Please use this tool carefully. Changes you make here are not reversible. Also, if you change an order @@ -118,6 +124,13 @@ {% endif %}
+
+ +