From e7b9c496206ef99f46fa61a8e117cbbef58b6f37 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 20 Jul 2020 16:36:24 +0200 Subject: [PATCH] Allow customers to change to a different product variation (#1719) --- src/pretix/api/serializers/event.py | 3 + src/pretix/base/email.py | 45 ++++ src/pretix/base/models/items.py | 4 +- src/pretix/base/models/orders.py | 45 +++- src/pretix/base/services/orders.py | 9 +- src/pretix/base/services/quotas.py | 2 +- src/pretix/base/settings.py | 40 ++++ src/pretix/base/templatetags/money.py | 2 +- src/pretix/control/forms/event.py | 3 + .../templates/pretixcontrol/event/cancel.html | 11 + src/pretix/presale/forms/order.py | 96 ++++++++ .../templates/pretixpresale/event/order.html | 174 +++++++------- .../pretixpresale/event/order_change.html | 80 +++++++ src/pretix/presale/urls.py | 3 + src/pretix/presale/views/order.py | 137 ++++++++++- src/tests/base/test_models.py | 215 ++++++++++++++---- src/tests/presale/test_orders.py | 215 +++++++++++++++++- 17 files changed, 952 insertions(+), 132 deletions(-) create mode 100644 src/pretix/presale/forms/order.py create mode 100644 src/pretix/presale/templates/pretixpresale/event/order_change.html diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 0809cfe24c..34afa9ef5d 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -623,6 +623,9 @@ class EventSettingsSerializer(serializers.Serializer): 'cancel_allow_user_paid_adjust_fees_explanation', 'cancel_allow_user_paid_refund_as_giftcard', 'cancel_allow_user_paid_require_approval', + 'change_allow_user_variation', + 'change_allow_user_until', + 'change_allow_user_price', ] def __init__(self, *args, **kwargs): diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py index 9f3dd0605d..46578d97c1 100644 --- a/src/pretix/base/email.py +++ b/src/pretix/base/email.py @@ -315,6 +315,51 @@ def base_placeholders(sender, **kwargs): } ), ), + SimpleFunctionalMailTextPlaceholder( + 'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri( + event, + 'presale:event.order.modify', kwargs={ + 'order': order.code, + 'secret': order.secret, + } + ), lambda event: build_absolute_uri( + event, + 'presale:event.order.modify', kwargs={ + 'order': 'F8VVL', + 'secret': '6zzjnumtsx136ddy', + } + ), + ), + SimpleFunctionalMailTextPlaceholder( + 'url_products_change', ['order', 'event'], lambda order, event: build_absolute_uri( + event, + 'presale:event.order.change', kwargs={ + 'order': order.code, + 'secret': order.secret, + } + ), lambda event: build_absolute_uri( + event, + 'presale:event.order.change', kwargs={ + 'order': 'F8VVL', + 'secret': '6zzjnumtsx136ddy', + } + ), + ), + SimpleFunctionalMailTextPlaceholder( + 'url_cancel', ['order', 'event'], lambda order, event: build_absolute_uri( + event, + 'presale:event.order.cancel', kwargs={ + 'order': order.code, + 'secret': order.secret, + } + ), lambda event: build_absolute_uri( + event, + 'presale:event.order.cancel', kwargs={ + 'order': 'F8VVL', + 'secret': '6zzjnumtsx136ddy', + } + ), + ), SimpleFunctionalMailTextPlaceholder( 'url', ['event', 'position'], lambda event, position: build_absolute_uri( event, diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index bbb67525a5..603dc652d7 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -378,9 +378,9 @@ class Item(LoggedModel): 'but only for fixed bundles!') ) allow_cancel = models.BooleanField( - verbose_name=_('Allow product to be canceled'), + verbose_name=_('Allow product to be canceled or changed'), default=True, - help_text=_('If this is checked, the usual cancellation settings of this event apply. If this is unchecked, ' + help_text=_('If this is checked, the usual cancellation and order change settings of this event apply. If this is unchecked, ' 'orders containing this product can not be canceled by users but only by you.') ) min_per_order = models.IntegerField( diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index b0faf10435..4b62a0c9dc 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -434,6 +434,19 @@ class Order(LockModel, LoggedModel): self.status in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED) and self.count_positions ) + @cached_property + def user_change_deadline(self): + until = self.event.settings.get('change_allow_user_until', as_type=RelativeDateWrapper) + if until: + if self.event.has_subevents: + terms = [ + until.datetime(se) + for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True)) + ] + return min(terms) if terms else None + else: + return until.datetime(self.event) + @cached_property def user_cancel_deadline(self): if self.status == Order.STATUS_PAID and self.total != Decimal('0.00'): @@ -466,6 +479,36 @@ class Order(LockModel, LoggedModel): fee += self.event.settings.cancel_allow_user_paid_keep return round_decimal(fee, self.event.currency) + @property + @scopes_disabled() + def user_change_allowed(self) -> bool: + """ + Returns whether or not this order can be canceled by the user. + """ + from .checkin import Checkin + + if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID) or not self.count_positions: + return False + + if self.cancellation_requests.exists(): + return False + positions = list( + self.positions.all().annotate( + has_variations=Exists(ItemVariation.objects.filter(item_id=OuterRef('item_id'))), + has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'))) + ).select_related('item').prefetch_related('issued_gift_cards') + ) + cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions]) + if not cancelable or not positions: + return False + for op in positions: + if op.issued_gift_cards.all(): + return False + if self.user_change_deadline and now() > self.user_change_deadline: + return False + + return self.event.settings.change_allow_user_variation and any([op.has_variations for op in positions]) + @property @scopes_disabled() def user_cancel_allowed(self) -> bool: @@ -474,7 +517,7 @@ class Order(LockModel, LoggedModel): """ from .checkin import Checkin - if self.cancellation_requests.exists(): + if self.cancellation_requests.exists() or not self.cancel_allowed(): return False positions = list( self.positions.all().annotate( diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index ecdd0cd4f1..a895dd0a88 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1131,7 +1131,7 @@ def notify_user_changed_order(order, user=None, auth=None, invoices=[]): try: order.send_mail( email_subject, email_template, email_context, - 'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices, + 'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices, attach_tickets=True, ) except SendMailException: logger.exception('Order changed email could not be sent') @@ -1869,9 +1869,10 @@ class OrderChangeManager: def _reissue_invoice(self): i = self.order.invoices.filter(is_cancellation=False).last() - if self.reissue_invoice and i and self._invoice_dirty: - self._invoices.append(generate_cancellation(i)) - if invoice_qualified(self.order): + if self.reissue_invoice and self._invoice_dirty: + if i: + self._invoices.append(generate_cancellation(i)) + if (i or self.event.settings.invoice_generate == 'True') and invoice_qualified(self.order): self._invoices.append(generate_invoice(self.order)) def _check_complete_cancel(self): diff --git a/src/pretix/base/services/quotas.py b/src/pretix/base/services/quotas.py index 22f99c5eed..7d6a97ef00 100644 --- a/src/pretix/base/services/quotas.py +++ b/src/pretix/base/services/quotas.py @@ -89,7 +89,7 @@ class QuotaAvailability: def compute(self, now_dt=None): now_dt = now_dt or now() - quotas = list(self._queue) + quotas = list(set(self._queue)) quotas_original = list(self._queue) self._queue.clear() if not quotas: diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index fe535662bf..e176198a06 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -925,6 +925,46 @@ DEFAULTS = { "multiple event dates, the earliest date will be used."), ) }, + 'change_allow_user_variation': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Customers can change the variation of the products they purchased"), + ) + }, + 'change_allow_user_price': { + 'default': 'gt', + 'type': str, + 'form_class': forms.ChoiceField, + 'serializer_class': serializers.ChoiceField, + 'serializer_kwargs': dict( + choices=( + ('gt', _('Only allow changes if the resulting price is higher or equal than the previous price.')), + ('eq', _('Only allow changes if the resulting price is equal to the previous price.')), + ('any', _('Allow changes regardless of price, even if this results in a refund.')), + ) + ), + 'form_kwargs': dict( + label=_("Requirement for changed prices"), + choices=( + ('gt', _('Only allow changes if the resulting price is higher or equal than the previous price.')), + ('eq', _('Only allow changes if the resulting price is equal to the previous price.')), + ('any', _('Allow changes regardless of price, even if this results in a refund.')), + ), + widget=forms.RadioSelect, + ), + }, + 'change_allow_user_until': { + 'default': None, + 'type': RelativeDateWrapper, + 'form_class': RelativeDateTimeField, + 'serializer_class': SerializerRelativeDateTimeField, + 'form_kwargs': dict( + label=_("Do not allow changes after"), + ) + }, 'cancel_allow_user': { 'default': 'True', 'type': bool, diff --git a/src/pretix/base/templatetags/money.py b/src/pretix/base/templatetags/money.py index 75e5adac33..a06d3a1634 100644 --- a/src/pretix/base/templatetags/money.py +++ b/src/pretix/base/templatetags/money.py @@ -37,7 +37,7 @@ def money_filter(value: Decimal, arg='', hide_currency=False): arg, floatformat(value, 2) ) - return format_currency(value, arg, locale=translation.get_language()) + return format_currency(value, arg, locale=translation.get_language()[:2]) except: return '{} {}'.format( arg, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 9c1c12f923..d0e6750852 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -575,6 +575,9 @@ class CancelSettingsForm(SettingsForm): 'cancel_allow_user_paid_adjust_fees_explanation', 'cancel_allow_user_paid_refund_as_giftcard', 'cancel_allow_user_paid_require_approval', + 'change_allow_user_variation', + 'change_allow_user_price', + 'change_allow_user_until', ] def __init__(self, *args, **kwargs): diff --git a/src/pretix/control/templates/pretixcontrol/event/cancel.html b/src/pretix/control/templates/pretixcontrol/event/cancel.html index d2cdd9ae14..5ef9f4bc7d 100644 --- a/src/pretix/control/templates/pretixcontrol/event/cancel.html +++ b/src/pretix/control/templates/pretixcontrol/event/cancel.html @@ -38,6 +38,17 @@ {% endif %} +
+ {% trans "Order changes" %} +
+ {% blocktrans trimmed %} + Allowing users to change their order is a feature under development. Therefore, currently only specific changes (such as changing the variation of a product) are possible. More options might be added later. + {% endblocktrans %} +
+ {% bootstrap_field form.change_allow_user_variation layout="control" %} + {% bootstrap_field form.change_allow_user_price layout="control" %} + {% bootstrap_field form.change_allow_user_until layout="control" %} +
- {% if order.cancel_allowed and order.user_cancel_allowed %} + {% if order.user_change_allowed or order.user_cancel_allowed %}

- {% trans "Cancellation" context "action" %} + {% trans "Change or cancel your order" context "action" %}

-
- {% if order.status == "p" and order.total != 0 %} - {% if order.user_cancel_fee >= order.total %} +
    + {% if order.user_change_allowed %} +
  • - {% if request.event.settings.cancel_allow_user_paid_require_approval %} - {% blocktrans trimmed %} - You can request to cancel this order, but you will not receive a refund. - {% endblocktrans %} - {% else %} - {% blocktrans trimmed %} - You can cancel this order, but you will not receive a refund. - {% endblocktrans %} - {% endif %} - {% trans "This will invalidate all tickets in this order." %} + {% blocktrans trimmed %} + If you want to make changes to the products you bought, you can click on the button to change your order. + {% endblocktrans %}

    - {% elif order.user_cancel_fee %} -

    - {% if request.event.settings.cancel_allow_user_paid_require_approval %} - {% blocktrans trimmed with fee=order.user_cancel_fee|money:request.event.currency %} - You can request to cancel this order. If your request is approved, a cancellation - fee of {{ fee }} will be kept and you will receive a refund of - the remainder. - {% endblocktrans %} - {% else %} - {% blocktrans trimmed with fee=order.user_cancel_fee|money:request.event.currency %} - You can cancel this order. In this case, a cancellation fee of {{ fee }} - will be kept and you will receive a refund of the remainder. - {% endblocktrans %} - {% endif %} - {% if request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "force" %} - {% trans "The refund will be issued in form of a gift card that you can use for further purchases." %} - {% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "option" %} - {% trans "The refund can be issued to your original payment method or as a gift card." %} - {% else %} - {% trans "The refund will be issued to your original payment method." %} - {% endif %} - {% trans "This will invalidate all tickets in this order." %} -

    - {% else %} -

    - {% if request.event.settings.cancel_allow_user_paid_require_approval %} - {% blocktrans trimmed %} - You can request to cancel this order. If your request is approved, you get a full - refund. - {% endblocktrans %} - {% else %} - {% blocktrans trimmed %} - You can cancel this order and receive a full refund. - {% endblocktrans %} - {% endif %} - {% if request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "force" %} - {% trans "The refund will be issued in form of a gift card that you can use for further purchases." %} - {% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "option" %} - {% trans "The refund can be issued to your original payment method or as a gift card." %} - {% else %} - {% trans "The refund will be issued to your original payment method." %} - {% endif %} - {% trans "This will invalidate all tickets in this order." %} -

    - {% endif %} - - - {% trans "Cancel order" %} - - {% else %} -

    - {% blocktrans trimmed %} - You can cancel this order using the following button. - {% endblocktrans %} - {% trans "This will invalidate all tickets in this order." %} -

    - - - {% trans "Cancel order" %} - + + + {% trans "Change order" %} + +
  • {% endif %} -
+ {% if order.user_cancel_allowed %} +
  • + {% if order.status == "p" and order.total != 0 %} + {% if order.user_cancel_fee >= order.total %} +

    + {% if request.event.settings.cancel_allow_user_paid_require_approval %} + {% blocktrans trimmed %} + You can request to cancel this order, but you will not receive a refund. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed %} + You can cancel this order, but you will not receive a refund. + {% endblocktrans %} + {% endif %} + {% trans "This will invalidate all tickets in this order." %} +

    + {% elif order.user_cancel_fee %} +

    + {% if request.event.settings.cancel_allow_user_paid_require_approval %} + {% blocktrans trimmed with fee=order.user_cancel_fee|money:request.event.currency %} + You can request to cancel this order. If your request is approved, a cancellation + fee of {{ fee }} will be kept and you will receive a refund of + the remainder. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed with fee=order.user_cancel_fee|money:request.event.currency %} + You can cancel this order. In this case, a cancellation fee of {{ fee }} + will be kept and you will receive a refund of the remainder. + {% endblocktrans %} + {% endif %} + {% if request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "force" %} + {% trans "The refund will be issued in form of a gift card that you can use for further purchases." %} + {% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "option" %} + {% trans "The refund can be issued to your original payment method or as a gift card." %} + {% else %} + {% trans "The refund will be issued to your original payment method." %} + {% endif %} + {% trans "This will invalidate all tickets in this order." %} +

    + {% else %} +

    + {% if request.event.settings.cancel_allow_user_paid_require_approval %} + {% blocktrans trimmed %} + You can request to cancel this order. If your request is approved, you get a full + refund. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed %} + You can cancel this order and receive a full refund. + {% endblocktrans %} + {% endif %} + {% if request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "force" %} + {% trans "The refund will be issued in form of a gift card that you can use for further purchases." %} + {% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "option" %} + {% trans "The refund can be issued to your original payment method or as a gift card." %} + {% else %} + {% trans "The refund will be issued to your original payment method." %} + {% endif %} + {% trans "This will invalidate all tickets in this order." %} +

    + {% endif %} + + + {% trans "Cancel order" %} + + {% else %} +

    + {% blocktrans trimmed %} + You can cancel this order using the following button. + {% endblocktrans %} + {% trans "This will invalidate all tickets in this order." %} +

    + + + {% trans "Cancel order" %} + + {% endif %} +
  • + {% endif %} +
    {% endif %} {% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/event/order_change.html b/src/pretix/presale/templates/pretixpresale/event/order_change.html new file mode 100644 index 0000000000..0734b144c1 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/order_change.html @@ -0,0 +1,80 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load rich_text %} +{% block title %}{% trans "Modify order" %}{% endblock %} +{% block content %} +

    + {% blocktrans trimmed with code=order.code %} + Change order: {{ code }} + {% endblocktrans %} +

    +
    + {% csrf_token %} + {% for position, positions in formgroups.items %} +
    +
    +

    + {{ position.item }} + {% if position.variation %} + – {{ position.variation }} + {% endif %} +

    +
    +
    +
    + {% if position.subevent %} +
    + +
    +
      + {{ pos.subevent.name }} · {{ pos.subevent.get_date_range_display }} + {% if pos.event.settings.show_times %} + + {{ pos.subevent.date_from|date:"TIME_FORMAT" }} + {% endif %} +
    +
    +
    + {% endif %} + + {% for p in positions %} + {% if p.pk != position.pk %} + {# Add-Ons #} + + {{ p.item.name }}{% if p.variation %} – {{ p.variation.value }}{% endif %} + {% endif %} + {% if p.attendee_name %} +
    + +
    + {{ p.attendee_name }} +
    +
    + {% endif %} + {% bootstrap_form p.form layout="checkout" %} + {% endfor %} +
    +
    +
    + {% endfor %} + +
    + +
    + +
    +
    +
    +
    +{% endblock %} diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index 8ba461afa2..dfb00db5df 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -64,6 +64,9 @@ event_patterns = [ url(r'^order/(?P[^/]+)/(?P[A-Za-z0-9]+)/invoice$', pretix.presale.views.order.OrderInvoiceCreate.as_view(), name='event.order.geninvoice'), + url(r'^order/(?P[^/]+)/(?P[A-Za-z0-9]+)/change$', + pretix.presale.views.order.OrderChange.as_view(), + name='event.order.change'), url(r'^order/(?P[^/]+)/(?P[A-Za-z0-9]+)/cancel$', pretix.presale.views.order.OrderCancel.as_view(), name='event.order.cancel'), diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 3f00ffe953..4e8fdc5016 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -2,6 +2,7 @@ import inspect import mimetypes import os import re +from collections import OrderedDict from decimal import Decimal from django import forms @@ -25,7 +26,8 @@ from pretix.base.models import ( CachedTicket, GiftCard, Invoice, Order, OrderPosition, Quota, ) from pretix.base.models.orders import ( - CachedCombinedTicket, OrderFee, OrderPayment, OrderRefund, QuestionAnswer, + CachedCombinedTicket, InvoiceAddress, OrderFee, OrderPayment, OrderRefund, + QuestionAnswer, ) from pretix.base.payment import PaymentException from pretix.base.services.invoices import ( @@ -34,17 +36,20 @@ from pretix.base.services.invoices import ( ) from pretix.base.services.mail import SendMailException from pretix.base.services.orders import ( - OrderError, cancel_order, change_payment_provider, + OrderChangeManager, OrderError, cancel_order, change_payment_provider, ) +from pretix.base.services.pricing import get_price from pretix.base.services.tickets import generate, invalidate_cache from pretix.base.signals import ( allow_ticket_download, order_modified, register_ticket_outputs, ) +from pretix.base.templatetags.money import money_filter from pretix.base.views.mixins import OrderQuestionsViewMixin from pretix.base.views.tasks import AsyncAction from pretix.helpers.safedownload import check_token from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse from pretix.presale.forms.checkout import InvoiceAddressForm, QuestionsForm +from pretix.presale.forms.order import OrderPositionChangeForm from pretix.presale.views import ( CartMixin, EventViewMixin, iframe_entry_view_wrapper, ) @@ -1006,3 +1011,131 @@ class InvoiceDownload(EventViewMixin, OrderDetailMixin, View): resp['Content-Disposition'] = 'inline; filename="{}.pdf"'.format(invoice.number) resp._csp_ignore = True # Some browser's PDF readers do not work with CSP return resp + + +@method_decorator(xframe_options_exempt, 'dispatch') +class OrderChange(EventViewMixin, OrderDetailMixin, TemplateView): + template_name = "pretixpresale/event/order_change.html" + + def dispatch(self, request, *args, **kwargs): + self.request = request + self.kwargs = kwargs + if not self.order: + raise Http404(_('Unknown order code or not authorized to access this order.')) + if not self.order.user_change_allowed: + messages.error(request, _('You cannot change this order.')) + return redirect(self.get_order_url()) + return super().dispatch(request, *args, **kwargs) + + @cached_property + def formdict(self): + storage = OrderedDict() + for pos in self.positions: + if pos.addon_to_id: + if pos.addon_to not in storage: + storage[pos.addon_to] = [] + storage[pos.addon_to].append(pos) + else: + if pos not in storage: + storage[pos] = [] + storage[pos].append(pos) + return storage + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['order'] = self.order + ctx['positions'] = self.positions + ctx['formgroups'] = self.formdict + return ctx + + @cached_property + def positions(self): + positions = list( + self.order.positions.select_related('item', 'item__tax_rule').prefetch_related( + 'item__quotas', 'item__variations', 'item__variations__quotas' + ) + ) + try: + ia = self.order.invoice_address + except InvoiceAddress.DoesNotExist: + ia = None + for p in positions: + p.form = OrderPositionChangeForm(prefix='op-{}'.format(p.pk), instance=p, + invoice_address=ia, event=self.request.event, + data=self.request.POST if self.request.method == "POST" else None) + return positions + + def _process_change(self, ocm): + try: + ia = self.order.invoice_address + except InvoiceAddress.DoesNotExist: + ia = None + for p in self.positions: + if not p.form.is_valid(): + return False + + try: + change_item = None + if p.form.cleaned_data['itemvar']: + if '-' in p.form.cleaned_data['itemvar']: + itemid, varid = p.form.cleaned_data['itemvar'].split('-') + else: + itemid, varid = p.form.cleaned_data['itemvar'], None + + item = self.request.event.items.get(pk=itemid) + if varid: + variation = item.variations.get(pk=varid) + else: + variation = None + if item != p.item or variation != p.variation: + change_item = (item, variation) + + if change_item is not None: + ocm.change_item(p, *change_item) + new_price = get_price(change_item[0], change_item[1], voucher=p.voucher, subevent=p.subevent, + invoice_address=ia) + + if new_price.gross != p.price or new_price.rate != p.tax_rate: + ocm.change_price(p, new_price.gross) + + if change_item[0].tax_rule != p.tax_rule or new_price.rate != p.tax_rate: + ocm.change_tax_rule(p, change_item[0].tax_rule) + + except OrderError as e: + p.custom_error = str(e) + return False + return True + + def post(self, *args, **kwargs): + was_paid = self.order.status == Order.STATUS_PAID + ocm = OrderChangeManager( + self.order, + user=self.request.user, + notify=True, + reissue_invoice=True, + ) + form_valid = self._process_change(ocm) + + if not form_valid: + messages.error(self.request, _('An error occurred. Please see the details below.')) + else: + try: + ocm.commit(check_quotas=True) + except OrderError as e: + messages.error(self.request, str(e)) + else: + + if self.order.status != Order.STATUS_PAID and was_paid: + messages.success(self.request, _('The order has been changed. You can now proceed by paying the open amount of {amount}.').format( + amount=money_filter(self.order.pending_sum, self.request.event.currency) + )) + return redirect(eventreverse(self.request.event, 'presale:event.order.pay.change', kwargs={ + 'order': self.order.code, + 'secret': self.order.secret + })) + else: + messages.success(self.request, _('The order has been changed.')) + + return redirect(self.get_order_url()) + + return self.get(*args, **kwargs) diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index 24f9fb6308..004609756b 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -1319,47 +1319,6 @@ class OrderTestCase(BaseQuotaTestCase): assert self.order.user_cancel_deadline < now() assert not self.order.user_cancel_allowed - @classscope(attr='o') - def test_user_cancel_absolute_deadline_paid_no_subevents(self): - self.order.status = Order.STATUS_PAID - self.order.save() - self.event.settings.cancel_allow_user_paid = True - assert self.order.user_cancel_deadline is None - self.event.settings.set('cancel_allow_user_paid_until', RelativeDateWrapper( - now() + timedelta(days=1) - )) - self.order = Order.objects.get(pk=self.order.pk) - assert self.order.user_cancel_allowed - assert self.order.user_cancel_deadline > now() - self.event.settings.set('cancel_allow_user_paid_until', RelativeDateWrapper( - now() - timedelta(days=1) - )) - self.order = Order.objects.get(pk=self.order.pk) - assert self.order.user_cancel_deadline < now() - assert not self.order.user_cancel_allowed - - @classscope(attr='o') - def test_user_cancel_relative_deadline_paid_no_subevents(self): - self.order.status = Order.STATUS_PAID - self.order.save() - self.event.date_from = now() + timedelta(days=3) - self.event.save() - self.event.settings.cancel_allow_user_paid = True - - assert self.order.user_cancel_deadline is None - self.event.settings.set('cancel_allow_user_paid_until', RelativeDateWrapper( - RelativeDate(days_before=2, time=datetime.time(14, 0, 0), base_date_name='date_from') - )) - self.order = Order.objects.get(pk=self.order.pk) - assert self.order.user_cancel_deadline > now() - assert self.order.user_cancel_allowed - self.event.settings.set('cancel_allow_user_paid_until', RelativeDateWrapper( - RelativeDate(days_before=4, time=datetime.time(14, 0, 0), base_date_name='date_from') - )) - self.order = Order.objects.get(pk=self.order.pk) - assert self.order.user_cancel_deadline < now() - assert not self.order.user_cancel_allowed - @classscope(attr='o') def test_user_cancel_relative_deadline_to_subevents(self): self.event.date_from = now() + timedelta(days=3) @@ -1595,6 +1554,180 @@ class OrderTestCase(BaseQuotaTestCase): p2: Decimal('10.00'), } + @classscope(attr='o') + def test_can_change_order(self): + item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, + admission=True, allow_cancel=True) + v = item1.variations.create(value="V") + OrderPosition.objects.create(order=self.order, item=item1, + variation=v, price=23) + assert not self.order.user_change_allowed + self.event.settings.change_allow_user_variation = True + assert self.order.user_change_allowed + + @classscope(attr='o') + def test_can_change_order_with_giftcard(self): + item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, + admission=True, allow_cancel=True, issue_giftcard=True) + v = item1.variations.create(value="V") + p = OrderPosition.objects.create(order=self.order, item=item1, + variation=v, price=23) + self.event.settings.change_allow_user_variation = True + self.event.organizer.issued_gift_cards.create( + currency="EUR", issued_in=p + ) + assert not self.order.user_change_allowed + + @classscope(attr='o') + def test_can_change_checked_in(self): + v = self.item1.variations.create(value="V") + self.order.positions.update(variation=v) + self.order.status = Order.STATUS_PAID + self.order.save() + self.event.settings.change_allow_user_variation = True + assert self.order.user_change_allowed + Checkin.objects.create( + position=self.order.positions.first(), + list=CheckinList.objects.create(event=self.event, name='Default') + ) + assert not self.order.user_change_allowed + + @classscope(attr='o') + def test_can_change_order_multiple(self): + item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, + admission=True, allow_cancel=True) + v = item1.variations.create(value="V") + item2 = Item.objects.create(event=self.event, name="Ticket", default_price=23, + admission=True, allow_cancel=True) + v2 = item2.variations.create(value="V") + OrderPosition.objects.create(order=self.order, item=item1, + variation=v, price=23) + OrderPosition.objects.create(order=self.order, item=item2, + variation=v2, price=23) + self.event.settings.change_allow_user_variation = True + assert self.order.user_change_allowed + + @classscope(attr='o') + def test_can_not_change_order(self): + item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, + admission=True, allow_cancel=False) + v = item1.variations.create(value="V") + OrderPosition.objects.create(order=self.order, item=item1, + variation=v, price=23) + self.event.settings.change_allow_user_variation = True + assert self.order.user_change_allowed is False + + @classscope(attr='o') + def test_require_any_variation(self): + item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, + admission=True, allow_cancel=True) + OrderPosition.objects.create(order=self.order, item=item1, + variation=None, price=23) + self.event.settings.change_allow_user_variation = True + assert self.order.user_change_allowed is False + item2 = Item.objects.create(event=self.event, name="Ticket", default_price=23, + admission=True, allow_cancel=True) + v2 = item2.variations.create(value="V") + OrderPosition.objects.create(order=self.order, item=item2, + variation=v2, price=23) + assert self.order.user_change_allowed is True + + @classscope(attr='o') + def test_can_not_change_order_multiple(self): + item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, + admission=True, allow_cancel=False) + item2 = Item.objects.create(event=self.event, name="Ticket", default_price=23, + admission=True, allow_cancel=True) + v = item1.variations.create(value="V") + v2 = item2.variations.create(value="V") + OrderPosition.objects.create(order=self.order, item=item1, + variation=v, price=23) + OrderPosition.objects.create(order=self.order, item=item2, + variation=v2, price=23) + self.event.settings.change_allow_user_variation = True + assert self.order.user_change_allowed is False + + @classscope(attr='o') + def test_can_not_change_order_multiple_mixed(self): + item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, + admission=True, allow_cancel=False) + item2 = Item.objects.create(event=self.event, name="Ticket", default_price=23, + admission=True, allow_cancel=True) + v = item1.variations.create(value="V") + v2 = item2.variations.create(value="V") + OrderPosition.objects.create(order=self.order, item=item1, + variation=v, price=23) + OrderPosition.objects.create(order=self.order, item=item2, + variation=v2, price=23) + self.event.settings.change_allow_user_variation = True + assert self.order.user_change_allowed is False + + @classscope(attr='o') + def test_user_change_absolute_deadline_unpaid_no_subevents(self): + v = self.item1.variations.create(value="V") + self.order.positions.update(variation=v) + self.event.settings.change_allow_user_variation = True + assert self.order.user_change_deadline is None + self.event.settings.set('change_allow_user_until', RelativeDateWrapper( + now() + timedelta(days=1) + )) + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_change_deadline > now() + assert self.order.user_change_allowed + self.event.settings.set('change_allow_user_until', RelativeDateWrapper( + now() - timedelta(days=1) + )) + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_change_deadline < now() + assert not self.order.user_change_allowed + + @classscope(attr='o') + def test_user_change_relative_deadline_unpaid_no_subevents(self): + v = self.item1.variations.create(value="V") + self.order.positions.update(variation=v) + self.event.settings.change_allow_user_variation = True + self.event.date_from = now() + timedelta(days=3) + self.event.save() + + assert self.order.user_change_deadline is None + self.event.settings.set('change_allow_user_until', RelativeDateWrapper( + RelativeDate(days_before=2, time=datetime.time(14, 0, 0), base_date_name='date_from') + )) + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_change_deadline > now() + assert self.order.user_change_allowed + self.event.settings.set('change_allow_user_until', RelativeDateWrapper( + RelativeDate(days_before=4, time=datetime.time(14, 0, 0), base_date_name='date_from') + )) + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_change_deadline < now() + assert not self.order.user_change_allowed + + @classscope(attr='o') + def test_user_change_relative_deadline_to_subevents(self): + v = self.item1.variations.create(value="V") + self.order.positions.update(variation=v) + self.event.settings.change_allow_user_variation = True + self.event.date_from = now() + timedelta(days=3) + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="SE1", date_from=now() + timedelta(days=10)) + se2 = self.event.subevents.create(name="SE2", date_from=now() + timedelta(days=1)) + self.op1.subevent = se1 + self.op1.save() + self.op2.subevent = se2 + self.op2.save() + + self.event.settings.set('change_allow_user_until', RelativeDateWrapper( + RelativeDate(days_before=2, time=datetime.time(14, 0, 0), base_date_name='date_from') + )) + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_change_deadline < now() + self.op2.subevent = se1 + self.op2.save() + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_change_deadline > now() + class ItemCategoryTest(TestCase): """ diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index 07a9df9304..475d12b767 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -37,9 +37,9 @@ class BaseOrdersTest(TestCase): self.shirt = Item.objects.create(event=self.event, name='T-Shirt', category=self.category, default_price=12) self.quota_shirts.items.add(self.shirt) self.shirt_red = ItemVariation.objects.create(item=self.shirt, default_price=14, value="Red") - var2 = ItemVariation.objects.create(item=self.shirt, value="Blue") + self.shirt_blue = ItemVariation.objects.create(item=self.shirt, value="Blue") self.quota_shirts.variations.add(self.shirt_red) - self.quota_shirts.variations.add(var2) + self.quota_shirts.variations.add(self.shirt_blue) self.quota_tickets = Quota.objects.create(event=self.event, name='Tickets', size=5) self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', category=self.category, default_price=23, @@ -1290,3 +1290,214 @@ class OrdersTest(BaseOrdersTest): self.order.secret, a.pk, match.group(1)) ) assert response.status_code == 404 + + def test_change_not_allowed(self): + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 302 + + def test_change_variation_paid(self): + self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_user_price = 'any' + + with scopes_disabled(): + shirt_pos = OrderPosition.objects.create( + order=self.order, + item=self.shirt, + variation=self.shirt_red, + price=Decimal("14"), + ) + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_blue.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, follow=True) + self.assertRedirects(response, + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret), + target_status_code=200) + shirt_pos.refresh_from_db() + assert shirt_pos.variation == self.shirt_blue + assert shirt_pos.price == Decimal('12.00') + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_PENDING + assert self.order.total == Decimal('35.00') + + def test_change_variation_require_higher_price(self): + self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_user_price = 'gt' + + with scopes_disabled(): + shirt_pos = OrderPosition.objects.create( + order=self.order, + item=self.shirt, + variation=self.shirt_red, + price=Decimal("14"), + ) + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_blue.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, follow=True) + assert response.status_code == 200 + assert 'alert-danger' in response.rendered_content + + shirt_pos.variation = self.shirt_blue + shirt_pos.price = Decimal('12.00') + shirt_pos.save() + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, + follow=True + ) + self.assertRedirects(response, + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret), + target_status_code=200) + shirt_pos.refresh_from_db() + assert shirt_pos.variation == self.shirt_red + assert shirt_pos.price == Decimal('14.00') + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_PENDING + assert self.order.total == Decimal('37.00') + + def test_change_variation_require_equal_price(self): + self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_user_price = 'eq' + + with scopes_disabled(): + shirt_pos = OrderPosition.objects.create( + order=self.order, + item=self.shirt, + variation=self.shirt_blue, + price=Decimal("12"), + ) + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, follow=True) + assert response.status_code == 200 + assert 'alert-danger' in response.rendered_content + + def test_change_variation_require_same_product(self): + self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_user_price = 'any' + + with scopes_disabled(): + shirt_pos = OrderPosition.objects.create( + order=self.order, + item=self.shirt, + variation=self.shirt_blue, + price=Decimal("12"), + ) + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + f'op-{shirt_pos.pk}-itemvar': f'{self.ticket.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, follow=True) + assert response.status_code == 200 + assert 'alert-danger' in response.rendered_content + + def test_change_variation_require_quota(self): + self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_user_price = 'any' + + with scopes_disabled(): + q = self.event.quotas.create(name="s2", size=0) + q.items.add(self.shirt) + q.variations.add(self.shirt_red) + + with scopes_disabled(): + shirt_pos = OrderPosition.objects.create( + order=self.order, + item=self.shirt, + variation=self.shirt_blue, + price=Decimal("12"), + ) + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, follow=True) + assert response.status_code == 200 + assert 'alert-danger' in response.rendered_content + + q.variations.add(self.shirt_blue) + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, follow=True) + self.assertRedirects(response, + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret), + target_status_code=200) + shirt_pos.refresh_from_db() + assert shirt_pos.variation == self.shirt_red + assert shirt_pos.price == Decimal('14.00') + + def test_change_paid_to_pending(self): + self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_user_price = 'any' + self.order.status = Order.STATUS_PAID + self.order.save() + + with scopes_disabled(): + self.order.payments.create(provider="manual", amount=Decimal('35.00'), state=OrderPayment.PAYMENT_STATE_CONFIRMED) + shirt_pos = OrderPosition.objects.create( + order=self.order, + item=self.shirt, + variation=self.shirt_blue, + price=Decimal("12"), + ) + + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, + follow=True + ) + self.assertRedirects(response, + '/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret), + target_status_code=200) + assert 'The order has been changed. You can now proceed by paying the open amount of €2.00.' in response.rendered_content + shirt_pos.refresh_from_db() + assert shirt_pos.variation == self.shirt_red + assert shirt_pos.price == Decimal('14.00') + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_PENDING + assert self.order.pending_sum == Decimal('2.00')