From 71a4664d1fc9c2bb944b21764696d535429e5d52 Mon Sep 17 00:00:00 2001
From: Raphael Michel
Date: Mon, 30 Oct 2017 23:15:53 +0100
Subject: [PATCH] Fix #339 -- Allow to split orders (#341)
* Fix #339 -- Allow to split orders
* Add tests for split orders
* Add notificatiosn to both users
* Improve logdisplay
---
src/pretix/base/services/orders.py | 146 +++++++--
src/pretix/base/signals.py | 7 +-
src/pretix/control/forms/orders.py | 3 +-
src/pretix/control/logdisplay.py | 15 +
.../templates/pretixcontrol/order/change.html | 13 +
src/pretix/control/views/orders.py | 2 +
src/tests/base/test_orders.py | 304 ++++++++++++++++++
7 files changed, 461 insertions(+), 29 deletions(-)
diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py
index 60c6b1633..a1b2fff2c 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 0d6ed5abd..da654ea3d 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 ea1dd2f4f..e70c83d2c 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 3d87cd7ba..1919aa3cf 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 7b097e455..2fac57c8f 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 %}