diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py
index bae0c9fcae..eceaf8acb7 100644
--- a/src/pretix/base/services/orders.py
+++ b/src/pretix/base/services/orders.py
@@ -1,6 +1,7 @@
+from collections import Counter, namedtuple
from datetime import datetime, timedelta
from decimal import Decimal
-from typing import List
+from typing import List, Optional
from django.conf import settings
from django.db import transaction
@@ -12,7 +13,8 @@ from pretix.base.i18n import (
LazyDate, LazyLocaleException, LazyNumber, language,
)
from pretix.base.models import (
- CartPosition, Event, EventLock, Order, OrderPosition, Quota, User,
+ CartPosition, Event, EventLock, Item, ItemVariation, Order, OrderPosition,
+ Quota, User,
)
from pretix.base.models.orders import InvoiceAddress
from pretix.base.payment import BasePaymentProvider
@@ -364,6 +366,168 @@ def expire_orders(sender, **kwargs):
o.save()
+class OrderChangeManager:
+ error_messages = {
+ 'free_to_paid': _('You cannot change a free order to a paid order.'),
+ 'product_without_variation': _('You need to select a variation of the product.'),
+ 'quota': _('The quota {name} does not have enough capacity left to perform the operation.'),
+ 'product_invalid': _('The selected product is not active or has no price set.'),
+ 'complete_cancel': _('This operation would leave the order empty. Please cancel the order itself instead.'),
+ 'not_pending': _('Only pending orders can be changed.'),
+ 'paid_to_free_exceeded': _('This operation would make the order free and therefore immediately paid, however '
+ 'no quota is available.'),
+ }
+ ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation', 'price'))
+ PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
+ CancelOperation = namedtuple('CancelOperation', ('position',))
+
+ def __init__(self, order: Order, user):
+ self.order = order
+ self.user = user
+ self._totaldiff = 0
+ self._quotadiff = Counter()
+ self._operations = []
+
+ def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
+ if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
+ raise OrderError(self.error_messages['product_without_variation'])
+ price = item.default_price if variation is None else (
+ variation.default_price if variation.default_price is not None else item.default_price)
+ if not price:
+ raise OrderError(self.error_messages['product_invalid'])
+ self._totaldiff = price - position.price
+ self._quotadiff.update(variation.quotas.all() if variation else item.quotas.all())
+ self._quotadiff.subtract(position.variation.quotas.all() if position.variation else position.item.quotas.all())
+ self._operations.append(self.ItemOperation(position, item, variation, price))
+
+ def change_price(self, position: OrderPosition, price: Decimal):
+ self._totaldiff = price - position.price
+ self._operations.append(self.PriceOperation(position, price))
+
+ def cancel(self, position: OrderPosition):
+ self._totaldiff = -position.price
+ self._quotadiff.subtract(position.variation.quotas.all() if position.variation else position.item.quotas.all())
+ self._operations.append(self.CancelOperation(position))
+
+ def _check_quotas(self):
+ for quota, diff in self._quotadiff.items():
+ if diff <= 0:
+ continue
+ avail = quota.availability()
+ if avail[0] != Quota.AVAILABILITY_OK or avail[1] < diff:
+ raise OrderError(self.error_messages['quota'].format(name=quota.name))
+
+ def _check_free_to_paid(self):
+ if self.order.total == Decimal('0.00') and self._totaldiff > 0:
+ raise OrderError(self.error_messages['free_to_paid'])
+
+ def _check_paid_to_free(self):
+ if self.order.total == 0:
+ try:
+ mark_order_paid(self.order, 'free', send_mail=False)
+ except Quota.QuotaExceededException:
+ raise OrderError(self.error_messages['paid_to_free_exceeded'])
+
+ def _perform_operations(self):
+ for op in self._operations:
+ if isinstance(op, self.ItemOperation):
+ self.order.log_action('pretix.event.order.changed.item', user=self.user, data={
+ 'position': op.position.pk,
+ 'old_item': op.position.item.pk,
+ 'old_variation': op.position.variation.pk if op.position.variation else None,
+ 'new_item': op.item.pk,
+ 'new_variation': op.variation.pk if op.variation else None,
+ 'old_price': op.position.price,
+ 'new_price': op.price
+ })
+ op.position.item = op.item
+ op.position.variation = op.variation
+ op.position.price = op.price
+ op.position._calculate_tax()
+ op.position.save()
+ elif isinstance(op, self.PriceOperation):
+ self.order.log_action('pretix.event.order.changed.price', user=self.user, data={
+ 'position': op.position.pk,
+ 'old_price': op.position.price,
+ 'new_price': op.price
+ })
+ op.position.price = op.price
+ op.position._calculate_tax()
+ op.position.save()
+ elif isinstance(op, self.CancelOperation):
+ self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
+ 'position': op.position.pk,
+ 'old_item': op.position.item.pk,
+ 'old_variation': op.position.variation.pk if op.position.variation else None,
+ 'old_price': op.position.price,
+ })
+ op.position.delete()
+
+ def _recalculate_total_and_payment_fee(self):
+ self.order.total = sum([p.price for p in self.order.positions.all()])
+ if self.order.total == 0:
+ payment_fee = Decimal('0.00')
+ else:
+ payment_fee = self._get_payment_provider().calculate_fee(self.order.total)
+ self.order.payment_fee = payment_fee
+ self.order.total += payment_fee
+ self.order._calculate_tax()
+ self.order.save()
+
+ def _reissue_invoice(self):
+ i = self.order.invoices.filter(is_cancellation=False).last()
+ if i:
+ generate_cancellation(i)
+ generate_invoice(self.order)
+
+ def _check_complete_cancel(self):
+ cancels = len([o for o in self._operations if isinstance(o, self.CancelOperation)])
+ if cancels == self.order.positions.count():
+ raise OrderError(self.error_messages['complete_cancel'])
+
+ def _notify_user(self):
+ with language(self.order.locale):
+ mail(
+ self.order.email, _('Your order has been changed: %(code)s') % {'code': self.order.code},
+ self.order.event.settings.mail_text_order_changed,
+ {
+ 'event': self.order.event.name,
+ 'url': build_absolute_uri(self.order.event, 'presale:event.order', kwargs={
+ 'order': self.order.code,
+ 'secret': self.order.secret
+ }),
+ },
+ self.order.event, locale=self.order.locale
+ )
+
+ def commit(self):
+ if not self._operations:
+ # Do nothing
+ return
+ with transaction.atomic():
+ with self.order.event.lock():
+ if self.order.status != Order.STATUS_PENDING:
+ raise OrderError(self.error_messages['not_pending'])
+ self._check_free_to_paid()
+ self._check_quotas()
+ self._check_complete_cancel()
+ self._perform_operations()
+ self._recalculate_total_and_payment_fee()
+ self._reissue_invoice()
+ self._check_paid_to_free()
+ self._notify_user()
+
+ def _get_payment_provider(self):
+ responses = register_payment_providers.send(self.order.event)
+ pprov = None
+ for rec, response in responses:
+ provider = response(self.order.event)
+ if provider.identifier == self.order.payment_provider:
+ return provider
+ if not pprov:
+ raise OrderError(error_messages['internal'])
+
+
if settings.HAS_CELERY:
from pretix.celery import app
diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py
index 056f373e0b..aedcfc8860 100644
--- a/src/pretix/base/settings.py
+++ b/src/pretix/base/settings.py
@@ -191,6 +191,18 @@ of {total} {currency}. Please complete your payment before {date}.
You can change your order details and view the status of your order at
{url}
+Best regards,
+Your {event} team"""))
+ },
+ 'mail_text_order_changed': {
+ 'type': LazyI18nString,
+ 'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
+
+your order for {event} has been changed.
+
+You can view the status of your order at
+{url}
+
Best regards,
Your {event} team"""))
},
diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py
index 1bac2d0d6a..c7822e01db 100644
--- a/src/pretix/control/forms/event.py
+++ b/src/pretix/control/forms/event.py
@@ -329,6 +329,12 @@ class MailSettingsForm(SettingsForm):
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}")
)
+ mail_text_order_changed = I18nFormField(
+ label=_("Changed order"),
+ required=False,
+ widget=I18nTextarea,
+ help_text=_("Available placeholders: {event}, {url}")
+ )
mail_text_resend_link = I18nFormField(
label=_("Resend link"),
required=False,
diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py
index e6e0077c94..5c468497e9 100644
--- a/src/pretix/control/forms/orders.py
+++ b/src/pretix/control/forms/orders.py
@@ -1,8 +1,10 @@
from django import forms
+from django.core.exceptions import ValidationError
from django.db import models
+from django.utils.translation import ugettext_lazy as _
from pretix.base.forms import I18nModelForm
-from pretix.base.models import Order
+from pretix.base.models import Item, Order
class ExtendForm(I18nModelForm):
@@ -35,3 +37,54 @@ class CommentForm(I18nModelForm):
'class': 'helper-width-100',
}),
}
+
+
+class OrderPositionChangeForm(forms.Form):
+ itemvar = forms.ChoiceField()
+ price = forms.DecimalField(
+ required=False,
+ max_digits=10, decimal_places=2,
+ label=_('New price')
+ )
+ operation = forms.ChoiceField(
+ required=False,
+ widget=forms.RadioSelect,
+ choices=(
+ ('product', 'Change product'),
+ ('price', 'Change price'),
+ ('cancel', 'Remove product')
+ )
+ )
+
+ def __init__(self, *args, **kwargs):
+ instance = kwargs.pop('instance')
+ initial = kwargs.get('initial', {})
+ if instance:
+ try:
+ if instance.variation:
+ initial['itemvar'] = '%d-%d' % (instance.item.pk, instance.variation.pk)
+ elif instance.item:
+ initial['itemvar'] = str(instance.item.pk)
+ except Item.DoesNotExist:
+ pass
+
+ initial['price'] = instance.price
+
+ kwargs['initial'] = initial
+ super().__init__(*args, **kwargs)
+ choices = []
+ for i in instance.order.event.items.prefetch_related('variations').all():
+ pname = 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' % (pname, v.value)))
+ else:
+ choices.append((str(i.pk), pname))
+ self.fields['itemvar'].choices = choices
+
+ def clean(self):
+ if self.cleaned_data.get('operation') == 'price' and not self.cleaned_data.get('price', '') != '':
+ raise ValidationError(_('You need to enter a price if you want to change the product price.'))
diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py
index 8b82eb0358..65d0c3ec85 100644
--- a/src/pretix/control/forms/vouchers.py
+++ b/src/pretix/control/forms/vouchers.py
@@ -3,7 +3,6 @@ import copy
from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Q
-from django.forms import model_to_dict
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py
index 9513929d16..8f2672bd16 100644
--- a/src/pretix/control/logdisplay.py
+++ b/src/pretix/control/logdisplay.py
@@ -1,11 +1,50 @@
+import json
+from decimal import Decimal
+
from django.dispatch import receiver
+from django.utils import formats
from django.utils.translation import ugettext_lazy as _
+from pretix.base.models import Event, ItemVariation, LogEntry
from pretix.base.signals import logentry_display
+def _display_order_changed(event: Event, logentry: LogEntry):
+ data = json.loads(logentry.data)
+
+ text = _('The order has been changed:')
+ if logentry.action_type == 'pretix.event.order.changed.item':
+ old_item = str(event.items.get(pk=data['old_item']))
+ if data['old_variation']:
+ old_item += ' - ' + str(event.itemvariations.get(pk=data['old_variation']))
+ new_item = str(event.items.get(pk=data['new_item']))
+ if data['new_variation']:
+ new_item += ' - ' + str(event.itemvariations.get(pk=data['new_variation']))
+ return text + ' ' + _('{old_item} ({old_price} {currency}) changed to {new_item} ({new_price} {currency}).').format(
+ old_item=old_item, new_item=new_item,
+ old_price=formats.localize(Decimal(data['old_price'])),
+ new_price=formats.localize(Decimal(data['new_price'])),
+ currency=event.currency
+ )
+ elif logentry.action_type == 'pretix.event.order.changed.price':
+ return text + ' ' + _('Price of a position changed from {old_price} {currency} to {new_price} {currency}.').format(
+ old_price=formats.localize(Decimal(data['old_price'])),
+ new_price=formats.localize(Decimal(data['new_price'])),
+ currency=event.currency
+ )
+ elif logentry.action_type == 'pretix.event.order.changed.cancel':
+ 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 + ' ' + _('{old_item} ({old_price} {currency}) removed.').format(
+ old_item=old_item,
+ old_price=formats.localize(Decimal(data['old_price'])),
+ currency=event.currency
+ )
+
+
@receiver(signal=logentry_display, dispatch_uid="pretixcontrol_logentry_display")
-def pretixcontrol_logentry_display(sender, logentry, **kwargs):
+def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
plains = {
'pretix.event.order.modified': _('The order details have been modified.'),
'pretix.event.order.unpaid': _('The order has been marked as unpaid.'),
@@ -23,3 +62,6 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs):
}
if logentry.action_type in plains:
return plains[logentry.action_type]
+
+ if logentry.action_type.startswith('pretix.event.order.changed'):
+ return _display_order_changed(sender, logentry)
diff --git a/src/pretix/control/templates/pretixcontrol/event/mail.html b/src/pretix/control/templates/pretixcontrol/event/mail.html
index 58921d258e..594b8edefb 100644
--- a/src/pretix/control/templates/pretixcontrol/event/mail.html
+++ b/src/pretix/control/templates/pretixcontrol/event/mail.html
@@ -16,6 +16,7 @@
{% bootstrap_field form.mail_text_order_paid layout="horizontal" %}
{% bootstrap_field form.mail_text_order_free layout="horizontal" %}
{% bootstrap_field form.mail_text_resend_link layout="horizontal" %}
+ {% bootstrap_field form.mail_text_order_changed layout="horizontal" %}