diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index 98984c3076..18686e8c27 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -20,7 +20,7 @@ Order events There are multiple signals that will be sent out in the ordering cycle: .. automodule:: pretix.base.signals - :members: validate_cart, order_fee_calculation, order_paid, order_placed, order_fee_type_name, allow_ticket_download + :members: validate_cart, order_fee_calculation, order_paid, order_placed, order_canceled, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download Frontend -------- diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index ea9e6ad834..5182b6c297 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -42,7 +42,9 @@ from pretix.base.services.orders import ( extend_order, mark_order_expired, mark_order_refunded, ) from pretix.base.services.tickets import generate -from pretix.base.signals import order_placed, register_ticket_outputs +from pretix.base.signals import ( + order_modified, order_placed, register_ticket_outputs, +) class OrderFilter(FilterSet): @@ -451,61 +453,64 @@ class OrderViewSet(viewsets.ModelViewSet): ) return super().update(request, *args, **kwargs) - @transaction.atomic def perform_update(self, serializer): - if 'comment' in self.request.data and serializer.instance.comment != self.request.data.get('comment'): - serializer.instance.log_action( - 'pretix.event.order.comment', - user=self.request.user, - auth=self.request.auth, - data={ - 'new_comment': self.request.data.get('comment') - } - ) + with transaction.atomic(): + if 'comment' in self.request.data and serializer.instance.comment != self.request.data.get('comment'): + serializer.instance.log_action( + 'pretix.event.order.comment', + user=self.request.user, + auth=self.request.auth, + data={ + 'new_comment': self.request.data.get('comment') + } + ) - if 'checkin_attention' in self.request.data and serializer.instance.checkin_attention != self.request.data.get('checkin_attention'): - serializer.instance.log_action( - 'pretix.event.order.checkin_attention', - user=self.request.user, - auth=self.request.auth, - data={ - 'new_value': self.request.data.get('checkin_attention') - } - ) + if 'checkin_attention' in self.request.data and serializer.instance.checkin_attention != self.request.data.get('checkin_attention'): + serializer.instance.log_action( + 'pretix.event.order.checkin_attention', + user=self.request.user, + auth=self.request.auth, + data={ + 'new_value': self.request.data.get('checkin_attention') + } + ) - if 'email' in self.request.data and serializer.instance.email != self.request.data.get('email'): - serializer.instance.log_action( - 'pretix.event.order.contact.changed', - user=self.request.user, - auth=self.request.auth, - data={ - 'old_email': serializer.instance.email, - 'new_email': self.request.data.get('email'), - } - ) + if 'email' in self.request.data and serializer.instance.email != self.request.data.get('email'): + serializer.instance.log_action( + 'pretix.event.order.contact.changed', + user=self.request.user, + auth=self.request.auth, + data={ + 'old_email': serializer.instance.email, + 'new_email': self.request.data.get('email'), + } + ) - if 'locale' in self.request.data and serializer.instance.locale != self.request.data.get('locale'): - serializer.instance.log_action( - 'pretix.event.order.locale.changed', - user=self.request.user, - auth=self.request.auth, - data={ - 'old_locale': serializer.instance.locale, - 'new_locale': self.request.data.get('locale'), - } - ) + if 'locale' in self.request.data and serializer.instance.locale != self.request.data.get('locale'): + serializer.instance.log_action( + 'pretix.event.order.locale.changed', + user=self.request.user, + auth=self.request.auth, + data={ + 'old_locale': serializer.instance.locale, + 'new_locale': self.request.data.get('locale'), + } + ) + + if 'invoice_address' in self.request.data: + serializer.instance.log_action( + 'pretix.event.order.modified', + user=self.request.user, + auth=self.request.auth, + data={ + 'invoice_data': self.request.data.get('invoice_address'), + } + ) + + serializer.save() if 'invoice_address' in self.request.data: - serializer.instance.log_action( - 'pretix.event.order.modified', - user=self.request.user, - auth=self.request.auth, - data={ - 'invoice_data': self.request.data.get('invoice_address'), - } - ) - - serializer.save() + order_modified.send(sender=serializer.instance.event, order=serializer.instance) def perform_create(self, serializer): serializer.save() diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 74fed0a920..722683f193 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -42,7 +42,9 @@ from pretix.base.services.mail import SendMailException from pretix.base.services.pricing import get_price from pretix.base.services.tasks import ProfiledTask from pretix.base.signals import ( - allow_ticket_download, order_fee_calculation, order_placed, periodic_task, + allow_ticket_download, order_approved, order_canceled, order_changed, + order_denied, order_expired, order_fee_calculation, order_placed, + periodic_task, ) from pretix.celery_app import app from pretix.helpers.models import modelcopy @@ -134,55 +136,58 @@ def mark_order_refunded(order, user=None, auth=None, api_token=None): ) -@transaction.atomic def mark_order_expired(order, user=None, auth=None): """ Mark this order as expired. This sets the payment status and returns the order object. :param order: The order to change :param user: The user that performed the change """ - if isinstance(order, int): - order = Order.objects.get(pk=order) - if isinstance(user, int): - user = User.objects.get(pk=user) - with order.event.lock(): - order.status = Order.STATUS_EXPIRED - order.save(update_fields=['status']) + with transaction.atomic(): + if isinstance(order, int): + order = Order.objects.get(pk=order) + if isinstance(user, int): + user = User.objects.get(pk=user) + with order.event.lock(): + order.status = Order.STATUS_EXPIRED + order.save(update_fields=['status']) - order.log_action('pretix.event.order.expired', user=user, auth=auth) - i = order.invoices.filter(is_cancellation=False).last() - if i: - generate_cancellation(i) + order.log_action('pretix.event.order.expired', user=user, auth=auth) + i = order.invoices.filter(is_cancellation=False).last() + if i: + generate_cancellation(i) + order_expired.send(order.event, order=order) return order -@transaction.atomic def approve_order(order, user=None, send_mail: bool=True, auth=None): """ Mark this order as approved :param order: The order to change :param user: The user that performed the change """ - if not order.require_approval or not order.status == Order.STATUS_PENDING: - raise OrderError(_('This order is not pending approval.')) + with transaction.atomic(): + if not order.require_approval or not order.status == Order.STATUS_PENDING: + raise OrderError(_('This order is not pending approval.')) - order.require_approval = False - order.set_expires(now(), order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()])) - order.save(update_fields=['require_approval', 'expires']) + order.require_approval = False + order.set_expires(now(), order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()])) + order.save(update_fields=['require_approval', 'expires']) - order.log_action('pretix.event.order.approved', user=user, auth=auth) - if order.total == Decimal('0.00'): - p = order.payments.create( - state=OrderPayment.PAYMENT_STATE_CREATED, - provider='free', - amount=0, - fee=None - ) - try: - p.confirm(send_mail=False, count_waitinglist=False, user=user, auth=auth) - except Quota.QuotaExceededException: - raise OrderError(error_messages['unavailable']) + order.log_action('pretix.event.order.approved', user=user, auth=auth) + if order.total == Decimal('0.00'): + p = order.payments.create( + state=OrderPayment.PAYMENT_STATE_CREATED, + provider='free', + amount=0, + fee=None + ) + try: + p.confirm(send_mail=False, count_waitinglist=False, user=user, auth=auth) + except Quota.QuotaExceededException: + raise OrderError(error_messages['unavailable']) + + order_approved.send(order.event, order=order) invoice = order.invoices.last() # Might be generated by plugin already if order.event.settings.get('invoice_generate') == 'True' and invoice_qualified(order): @@ -234,30 +239,32 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None): return order.pk -@transaction.atomic def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None): """ Mark this order as canceled :param order: The order to change :param user: The user that performed the change """ - if not order.require_approval or not order.status == Order.STATUS_PENDING: - raise OrderError(_('This order is not pending approval.')) + with transaction.atomic(): + if not order.require_approval or not order.status == Order.STATUS_PENDING: + raise OrderError(_('This order is not pending approval.')) - with order.event.lock(): - order.status = Order.STATUS_CANCELED - order.save(update_fields=['status']) + with order.event.lock(): + order.status = Order.STATUS_CANCELED + order.save(update_fields=['status']) - order.log_action('pretix.event.order.denied', user=user, auth=auth, data={ - 'comment': comment - }) - i = order.invoices.filter(is_cancellation=False).last() - if i: - generate_cancellation(i) + order.log_action('pretix.event.order.denied', user=user, auth=auth, data={ + 'comment': comment + }) + i = order.invoices.filter(is_cancellation=False).last() + if i: + generate_cancellation(i) - for position in order.positions.all(): - if position.voucher: - Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) + for position in order.positions.all(): + if position.voucher: + Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) + + order_denied.send(order.event, order=order) if send_mail: try: @@ -294,7 +301,6 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None): return order.pk -@transaction.atomic def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None, cancellation_fee=None): """ @@ -302,85 +308,87 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device :param order: The order to change :param user: The user that performed the change """ - if isinstance(order, int): - order = Order.objects.get(pk=order) - if isinstance(user, int): - user = User.objects.get(pk=user) - if isinstance(api_token, int): - api_token = TeamAPIToken.objects.get(pk=api_token) - if isinstance(device, int): - device = Device.objects.get(pk=device) - if isinstance(oauth_application, int): - oauth_application = OAuthApplication.objects.get(pk=oauth_application) - if isinstance(cancellation_fee, str): - cancellation_fee = Decimal(cancellation_fee) + with transaction.atomic(): + if isinstance(order, int): + order = Order.objects.get(pk=order) + if isinstance(user, int): + user = User.objects.get(pk=user) + if isinstance(api_token, int): + api_token = TeamAPIToken.objects.get(pk=api_token) + if isinstance(device, int): + device = Device.objects.get(pk=device) + if isinstance(oauth_application, int): + oauth_application = OAuthApplication.objects.get(pk=oauth_application) + if isinstance(cancellation_fee, str): + cancellation_fee = Decimal(cancellation_fee) - if not order.cancel_allowed(): - raise OrderError(_('You cannot cancel this order.')) - i = order.invoices.filter(is_cancellation=False).last() - if i: - generate_cancellation(i) + if not order.cancel_allowed(): + raise OrderError(_('You cannot cancel this order.')) + i = order.invoices.filter(is_cancellation=False).last() + if i: + generate_cancellation(i) + + if cancellation_fee: + with order.event.lock(): + for position in order.positions.all(): + if position.voucher: + Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) + position.canceled = True + position.save(update_fields=['canceled']) + for fee in order.fees.all(): + fee.canceled = True + fee.save(update_fields=['canceled']) + + f = OrderFee( + fee_type=OrderFee.FEE_TYPE_CANCELLATION, + value=cancellation_fee, + tax_rule=order.event.settings.tax_rate_default, + order=order, + ) + f._calculate_tax() + f.save() + + if order.payment_refund_sum < cancellation_fee: + raise OrderError(_('The cancellation fee cannot be higher than the payment credit of this order.')) + order.status = Order.STATUS_PAID + order.total = f.value + order.save(update_fields=['status', 'total']) + + if i: + generate_invoice(order) + else: + with order.event.lock(): + order.status = Order.STATUS_CANCELED + order.save(update_fields=['status']) - if cancellation_fee: - with order.event.lock(): for position in order.positions.all(): if position.voucher: Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) - position.canceled = True - position.save(update_fields=['canceled']) - for fee in order.fees.all(): - fee.canceled = True - fee.save(update_fields=['canceled']) - f = OrderFee( - fee_type=OrderFee.FEE_TYPE_CANCELLATION, - value=cancellation_fee, - tax_rule=order.event.settings.tax_rate_default, - order=order, - ) - f._calculate_tax() - f.save() + order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device, + data={'cancellation_fee': cancellation_fee}) - if order.payment_refund_sum < cancellation_fee: - raise OrderError(_('The cancellation fee cannot be higher than the payment credit of this order.')) - order.status = Order.STATUS_PAID - order.total = f.value - order.save(update_fields=['status', 'total']) - - if i: - generate_invoice(order) - else: - with order.event.lock(): - order.status = Order.STATUS_CANCELED - order.save(update_fields=['status']) - - for position in order.positions.all(): - if position.voucher: - Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) - - order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device, - data={'cancellation_fee': cancellation_fee}) - - if send_mail: - email_template = order.event.settings.mail_text_order_canceled - email_context = { - 'event': order.event.name, - 'code': order.code, - 'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={ - 'order': order.code, - 'secret': order.secret - }) - } - with language(order.locale): - email_subject = _('Order canceled: %(code)s') % {'code': order.code} - try: - order.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.order_canceled', user - ) - except SendMailException: - logger.exception('Order canceled email could not be sent') + if send_mail: + email_template = order.event.settings.mail_text_order_canceled + email_context = { + 'event': order.event.name, + 'code': order.code, + 'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={ + 'order': order.code, + 'secret': order.secret + }) + } + with language(order.locale): + email_subject = _('Order canceled: %(code)s') % {'code': order.code} + try: + order.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.order_canceled', user + ) + except SendMailException: + logger.exception('Order canceled email could not be sent') + order_canceled.send(order.event, order=order) return order.pk @@ -1377,6 +1385,8 @@ class OrderChangeManager: if self.split_order: self._notify_user(self.split_order) + order_changed.send(self.order.event, order=self.order) + def _clear_tickets_cache(self): CachedTicket.objects.filter(order_position__order=self.order).delete() CachedCombinedTicket.objects.filter(order=self.order).delete() diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 3a581f379e..54c3cd6778 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -275,6 +275,66 @@ because an already-paid order has been split. As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ +order_canceled = EventPluginSignal( + providing_args=["order"] +) +""" +This signal is sent out every time an order is canceled. The order object is given +as the first argument. + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +""" + +order_expired = EventPluginSignal( + providing_args=["order"] +) +""" +This signal is sent out every time an order is marked as expired. The order object is given +as the first argument. + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +""" + +order_modified = EventPluginSignal( + providing_args=["order"] +) +""" +This signal is sent out every time an order's information is modified. The order object is given +as the first argument. + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +""" + +order_changed = EventPluginSignal( + providing_args=["order"] +) +""" +This signal is sent out every time an order's content is changed. The order object is given +as the first argument. + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +""" + +order_approved = EventPluginSignal( + providing_args=["order"] +) +""" +This signal is sent out every time an order is being approved. The order object is given +as the first argument. + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +""" + +order_denied = EventPluginSignal( + providing_args=["order"] +) +""" +This signal is sent out every time an order is being denied. The order object is given +as the first argument. + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +""" + logentry_display = EventPluginSignal( providing_args=["logentry"] ) diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 5800f5ad84..f5d781203e 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -57,7 +57,7 @@ from pretix.base.services.orders import ( from pretix.base.services.stats import order_overview from pretix.base.services.tickets import generate from pretix.base.signals import ( - register_data_exporters, register_ticket_outputs, + order_modified, register_data_exporters, register_ticket_outputs, ) from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.rich_text import markdown_compile_email @@ -1321,6 +1321,8 @@ class OrderModifyInformation(OrderQuestionsViewMixin, OrderView): CachedTicket.objects.filter(order_position__order=self.order).delete() CachedCombinedTicket.objects.filter(order=self.order).delete() + + order_modified.send(sender=self.request.event, order=self.order) return redirect(self.get_order_url()) diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 1abe627dff..71278e1f3b 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -30,7 +30,9 @@ from pretix.base.services.invoices import ( from pretix.base.services.mail import SendMailException from pretix.base.services.orders import cancel_order, change_payment_provider from pretix.base.services.tickets import generate -from pretix.base.signals import allow_ticket_download, register_ticket_outputs +from pretix.base.signals import ( + allow_ticket_download, order_modified, register_ticket_outputs, +) from pretix.base.views.mixins import OrderQuestionsViewMixin from pretix.base.views.tasks import AsyncAction from pretix.helpers.safedownload import check_token @@ -540,6 +542,7 @@ class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, Tem for k in f.changed_data } for f in self.forms] }) + order_modified.send(sender=self.request.event, order=self.order) if self.invoice_form.has_changed(): success_message = ('Your invoice address has been updated. Please contact us if you need us ' 'to regenerate your invoice.')