From b21ed4d99fe68636df7aed724ec83e613b8beef9 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 31 Aug 2016 19:10:11 +0200 Subject: [PATCH] Fix #163 -- Form to change orders (#191) --- src/pretix/base/services/orders.py | 168 +++++++++++++++- src/pretix/base/settings.py | 12 ++ src/pretix/control/forms/event.py | 6 + src/pretix/control/forms/orders.py | 55 +++++- src/pretix/control/forms/vouchers.py | 1 - src/pretix/control/logdisplay.py | 44 ++++- .../templates/pretixcontrol/event/mail.html | 1 + .../templates/pretixcontrol/order/change.html | 105 ++++++++++ .../templates/pretixcontrol/order/index.html | 8 + src/pretix/control/urls.py | 2 + src/pretix/control/views/orders.py | 92 ++++++++- src/static/pretixcontrol/scss/main.scss | 6 + src/tests/base/test_orders.py | 183 +++++++++++++++++- src/tests/control/test_orders.py | 82 ++++++++ src/tests/control/test_permissions.py | 2 + 15 files changed, 751 insertions(+), 16 deletions(-) create mode 100644 src/pretix/control/templates/pretixcontrol/order/change.html 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" %}
{% trans "SMTP settings" %} diff --git a/src/pretix/control/templates/pretixcontrol/order/change.html b/src/pretix/control/templates/pretixcontrol/order/change.html new file mode 100644 index 0000000000..4093d31250 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/order/change.html @@ -0,0 +1,105 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %} + {% blocktrans trimmed with code=order.code %} + Change order: {{ code }} + {% endblocktrans %} +{% endblock %} +{% block content %} +

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

+

+ {% blocktrans trimmed %} + You can use this tool to change the ordered products or to partially cancel the order. Please keep + in mind that changing an order can have several implications, e.g. the payment method fee might change or + additional questions can be added to the order that need to be answered by the user. + {% endblocktrans %} +

+

+ {% blocktrans trimmed %} + The user will receive a notification about the change but in the case of new required questions, the user + will not be forced to answer them. You cannot use this form to add something to the order, please create + a second order instead. + {% endblocktrans %} +

+

+ {% blocktrans trimmed %} + If an invoice is attached to the order, a cancellation will be created together with a new invoice. + {% endblocktrans %} +

+
+ {% blocktrans trimmed %} + Please use this tool carefully. Changes you make here are not reversible. In most cases it is easier to + cancel the order completely and create a new one. + {% endblocktrans %} +
+
+ {% csrf_token %} + {% for position in positions %} +
+
+

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

+
+
+
+ {% bootstrap_form_errors position.form %} + {% if position.custom_error %} +
+ {{ position.custom_error }} +
+ {% endif %} +
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ {% endfor %} +
+ + {% trans "Cancel" %} + + +
+
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 7b0b8bc37d..73b31dda6e 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -148,6 +148,14 @@
+
+ {% if order.status == "n" and request.eventperm.can_change_orders %} + + + {% trans "Change products" %} + + {% endif %} +

{% trans "Ordered items" %}

diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 0de92b9668..f2faa60b04 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -83,6 +83,8 @@ urlpatterns = [ name='event.order.extend'), url(r'^orders/(?P[0-9A-Z]+)/comment$', orders.OrderComment.as_view(), name='event.order.comment'), + url(r'^orders/(?P[0-9A-Z]+)/change$', orders.OrderChange.as_view(), + name='event.order.change'), url(r'^orders/(?P[0-9A-Z]+)/$', orders.OrderDetail.as_view(), name='event.order'), url(r'^orders/(?P[0-9A-Z]+)/download/(?P[^/]+)$', orders.OrderDownload.as_view(), name='event.order.download'), diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 29fa07bc16..fc8aa0c0d9 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -13,7 +13,8 @@ from django.views.generic import DetailView, ListView, TemplateView, View from pretix.base.i18n import language from pretix.base.models import ( - CachedFile, CachedTicket, EventLock, Invoice, Item, Order, Quota, + CachedFile, CachedTicket, EventLock, Invoice, Item, ItemVariation, Order, + Quota, ) from pretix.base.services import tickets from pretix.base.services.export import export @@ -22,13 +23,17 @@ from pretix.base.services.invoices import ( regenerate_invoice, ) from pretix.base.services.mail import SendMailException, mail -from pretix.base.services.orders import cancel_order, mark_order_paid +from pretix.base.services.orders import ( + OrderChangeManager, OrderError, cancel_order, mark_order_paid, +) from pretix.base.services.stats import order_overview from pretix.base.signals import ( register_data_exporters, register_payment_providers, register_ticket_outputs, ) -from pretix.control.forms.orders import CommentForm, ExporterForm, ExtendForm +from pretix.control.forms.orders import ( + CommentForm, ExporterForm, ExtendForm, OrderPositionChangeForm, +) from pretix.control.permissions import EventPermissionRequiredMixin from pretix.multidomain.urlreverse import build_absolute_uri @@ -77,6 +82,12 @@ class OrderView(EventPermissionRequiredMixin, DetailView): code=self.kwargs['code'].upper() ) + def _redirect_back(self): + return redirect('control:event.order', + event=self.request.event.slug, + organizer=self.request.event.organizer.slug, + code=self.order.code) + @cached_property def order(self): return self.get_object() @@ -441,12 +452,6 @@ class OrderExtend(OrderView): else: return self.get(*args, **kwargs) - def _redirect_back(self): - return redirect('control:event.order', - event=self.request.event.slug, - organizer=self.request.event.organizer.slug, - code=self.order.code) - def get(self, *args, **kwargs): if self.order.status != Order.STATUS_PENDING: messages.error(self.request, _('This action is only allowed for pending orders.')) @@ -462,6 +467,75 @@ class OrderExtend(OrderView): data=self.request.POST if self.request.method == "POST" else None) +class OrderChange(OrderView): + permission = 'can_change_orders' + template_name = 'pretixcontrol/order/change.html' + + def dispatch(self, request, *args, **kwargs): + if self.order.status != Order.STATUS_PENDING: + messages.error(self.request, _('This action is only allowed for pending orders.')) + return self._redirect_back() + return super().dispatch(request, *args, **kwargs) + + @cached_property + def positions(self): + positions = list(self.order.positions.all()) + for p in positions: + p.form = OrderPositionChangeForm(prefix='op-{}'.format(p.pk), instance=p, + data=self.request.POST if self.request.method == "POST" else None) + return positions + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['positions'] = self.positions + return ctx + + def post(self, *args, **kwargs): + ocm = OrderChangeManager(self.order, self.request.user) + form_valid = True + for p in self.positions: + if not p.form.is_valid(): + print(p.pk, 'Form invalid') + form_valid = False + break + + try: + if p.form.cleaned_data['operation'] == 'product': + 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 = Item.objects.get(pk=itemid, event=self.request.event) + if varid: + variation = ItemVariation.objects.get(pk=varid, item=item) + else: + variation = None + ocm.change_item(p, item, variation) + elif p.form.cleaned_data['operation'] == 'price': + ocm.change_price(p, p.form.cleaned_data['price']) + elif p.form.cleaned_data['operation'] == 'cancel': + ocm.cancel(p) + + except OrderError as e: + p.custom_error = str(e) + form_valid = False + break + + if not form_valid: + messages.error(self.request, _('An error occured. Please see the details below.')) + else: + try: + ocm.commit() + except OrderError as e: + messages.error(self.request, str(e)) + else: + messages.success(self.request, _('The order has been changed and the user has been notified.')) + return self._redirect_back() + + return self.get(*args, **kwargs) + + class OverView(EventPermissionRequiredMixin, TemplateView): template_name = 'pretixcontrol/orders/overview.html' permission = 'can_view_orders' diff --git a/src/static/pretixcontrol/scss/main.scss b/src/static/pretixcontrol/scss/main.scss index 71d955e806..57814a793b 100644 --- a/src/static/pretixcontrol/scss/main.scss +++ b/src/static/pretixcontrol/scss/main.scss @@ -123,3 +123,9 @@ h1 .btn-sm { .progress-bar-#{$i} { width: 1% * $i; } } +.form-order-change .radio { + display: block; +} +.form-order-change .form-group { + margin: 0; +} diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 5c12f64106..f406a966ff 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -1,11 +1,20 @@ from datetime import timedelta +from decimal import Decimal import pytest +from django.db import transaction +from django.test import TestCase from django.utils.timezone import now -from pretix.base.models import Event, Order, Organizer +from pretix.base.decimal import round_decimal +from pretix.base.models import ( + Event, Item, Order, OrderPosition, Organizer, Quota, +) from pretix.base.payment import FreeOrderProvider -from pretix.base.services.orders import _create_order, expire_orders +from pretix.base.services import invoices +from pretix.base.services.orders import ( + OrderChangeManager, OrderError, _create_order, expire_orders, +) @pytest.fixture @@ -85,3 +94,173 @@ def test_expiring_auto_disabled(event): assert o1.status == Order.STATUS_PENDING o2 = Order.objects.get(id=o2.id) assert o2.status == Order.STATUS_PENDING + + +class OrderChangeManagerTests(TestCase): + def setUp(self): + super().setUp() + o = Organizer.objects.create(name='Dummy', slug='dummy') + self.event = Event.objects.create(organizer=o, name='Dummy', slug='dummy', date_from=now(), plugins='pretix.plugins.banktransfer') + self.order = Order.objects.create( + code='FOO', event=self.event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + timedelta(days=10), + total=Decimal('46.00'), payment_provider='banktransfer' + ) + self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', tax_rate=Decimal('7.00'), + default_price=Decimal('23.00'), admission=True) + self.shirt = Item.objects.create(event=self.event, name='T-Shirt', tax_rate=Decimal('19.00'), + default_price=Decimal('12.00')) + self.op1 = OrderPosition.objects.create( + order=self.order, item=self.ticket, variation=None, + price=Decimal("23.00"), attendee_name="Peter" + ) + self.op2 = OrderPosition.objects.create( + order=self.order, item=self.ticket, variation=None, + price=Decimal("23.00"), attendee_name="Dieter" + ) + self.ocm = OrderChangeManager(self.order, None) + + def test_change_item_success(self): + self.ocm.change_item(self.op1, self.shirt, None) + self.ocm.commit() + self.op1.refresh_from_db() + self.order.refresh_from_db() + assert self.op1.item == self.shirt + assert self.op1.price == self.shirt.default_price + assert self.op1.tax_rate == self.shirt.tax_rate + assert round_decimal(self.op1.price * (1 - 100 / (100 + self.op1.tax_rate))) == self.op1.tax_value + assert self.order.total == self.op1.price + self.op2.price + + def test_change_price_success(self): + self.ocm.change_price(self.op1, Decimal('24.00')) + self.ocm.commit() + self.op1.refresh_from_db() + self.order.refresh_from_db() + assert self.op1.item == self.ticket + assert self.op1.price == Decimal('24.00') + assert round_decimal(self.op1.price * (1 - 100 / (100 + self.op1.tax_rate))) == self.op1.tax_value + assert self.order.total == self.op1.price + self.op2.price + + def test_cancel_success(self): + self.ocm.cancel(self.op1) + self.ocm.commit() + self.order.refresh_from_db() + assert self.order.positions.count() == 1 + assert self.order.total == self.op2.price + + def test_free_to_paid(self): + self.op1.price = Decimal('0.00') + self.op1.save() + self.op2.delete() + self.order.total = Decimal('0.00') + self.order.save() + self.ocm.change_price(self.op1, Decimal('24.00')) + with self.assertRaises(OrderError): + self.ocm.commit() + self.op1.refresh_from_db() + assert self.op1.price == Decimal('0.00') + + def test_cancel_all_in_order(self): + self.ocm.cancel(self.op1) + self.ocm.cancel(self.op2) + with self.assertRaises(OrderError): + self.ocm.commit() + assert self.order.positions.count() == 2 + + def test_empty(self): + self.ocm.commit() + + def test_quota_full(self): + q = self.event.quotas.create(name='Test', size=0) + q.items.add(self.shirt) + self.ocm.change_item(self.op1, self.shirt, None) + with self.assertRaises(OrderError): + self.ocm.commit() + self.op1.refresh_from_db() + assert self.op1.item == self.ticket + + def test_quota_full_but_in_same(self): + q = self.event.quotas.create(name='Test', size=0) + q.items.add(self.shirt) + q.items.add(self.ticket) + self.ocm.change_item(self.op1, self.shirt, None) + self.ocm.commit() + self.op1.refresh_from_db() + assert self.op1.item == self.shirt + + def test_multiple_quotas_shared_full(self): + q1 = self.event.quotas.create(name='Test', size=0) + q2 = self.event.quotas.create(name='Test', size=2) + q1.items.add(self.shirt) + q1.items.add(self.ticket) + q2.items.add(self.shirt) + self.ocm.change_item(self.op1, self.shirt, None) + self.ocm.commit() + self.op1.refresh_from_db() + assert self.op1.item == self.shirt + + def test_multiple_quotas_unshared_full(self): + q1 = self.event.quotas.create(name='Test', size=2) + q2 = self.event.quotas.create(name='Test', size=0) + q1.items.add(self.shirt) + q1.items.add(self.ticket) + q2.items.add(self.shirt) + self.ocm.change_item(self.op1, self.shirt, None) + with self.assertRaises(OrderError): + self.ocm.commit() + self.op1.refresh_from_db() + assert self.op1.item == self.ticket + + def test_multiple_items_success(self): + q1 = self.event.quotas.create(name='Test', size=2) + q1.items.add(self.shirt) + self.ocm.change_item(self.op1, self.shirt, None) + self.ocm.change_item(self.op2, self.shirt, None) + self.ocm.commit() + self.op1.refresh_from_db() + self.op2.refresh_from_db() + assert self.op1.item == self.shirt + assert self.op2.item == self.shirt + + def test_multiple_items_quotas_partially_full(self): + q1 = self.event.quotas.create(name='Test', size=1) + q1.items.add(self.shirt) + self.ocm.change_item(self.op1, self.shirt, None) + self.ocm.change_item(self.op2, self.shirt, None) + with self.assertRaises(OrderError): + self.ocm.commit() + self.op1.refresh_from_db() + self.op2.refresh_from_db() + assert self.op1.item == self.ticket + assert self.op2.item == self.ticket + + def test_payment_fee_calculation(self): + self.event.settings.set('tax_rate_default', Decimal('19.00')) + prov = self.ocm._get_payment_provider() + prov.settings.set('_fee_abs', Decimal('0.30')) + self.ocm.change_price(self.op1, Decimal('24.00')) + self.ocm.commit() + self.order.refresh_from_db() + assert self.order.total == Decimal('47.30') + assert self.order.payment_fee == prov.calculate_fee(self.order.total) + assert self.order.payment_fee_tax_rate == Decimal('19.00') + assert round_decimal(self.order.payment_fee * (1 - 100 / (100 + self.order.payment_fee_tax_rate))) == self.order.payment_fee_tax_value + + def test_require_pending(self): + self.order.status = Order.STATUS_PAID + self.order.save() + self.ocm.change_item(self.op1, self.shirt, None) + with self.assertRaises(OrderError): + self.ocm.commit() + self.op1.refresh_from_db() + assert self.op1.item == self.ticket + + def test_change_price_to_free_marked_as_paid(self): + self.ocm.change_price(self.op1, Decimal('0.00')) + self.ocm.change_price(self.op2, Decimal('0.00')) + self.ocm.commit() + self.order.refresh_from_db() + assert self.order.total == 0 + assert self.order.status == Order.STATUS_PAID + assert self.order.payment_provider == 'free' diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index 20eee5a692..90f25d1e65 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -3,6 +3,7 @@ from decimal import Decimal import pytest from django.utils.timezone import now +from tests.base import SoupTest from pretix.base.models import ( CachedTicket, Event, EventPermission, Item, Order, OrderPosition, @@ -362,3 +363,84 @@ def test_order_go_not_found(client, env): client.login(email='dummy@dummy.dummy', password='dummy') response = client.get('/control/event/dummy/dummy/orders/go?code=BAR') assert response['Location'].endswith('/control/event/dummy/dummy/orders/') + + +class OrderChangeTests(SoupTest): + def setUp(self): + super().setUp() + o = Organizer.objects.create(name='Dummy', slug='dummy') + self.event = Event.objects.create(organizer=o, name='Dummy', slug='dummy', date_from=now(), + plugins='pretix.plugins.banktransfer') + self.order = Order.objects.create( + code='FOO', event=self.event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + timedelta(days=10), + total=Decimal('46.00'), payment_provider='banktransfer' + ) + self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', tax_rate=Decimal('7.00'), + default_price=Decimal('23.00'), admission=True) + self.shirt = Item.objects.create(event=self.event, name='T-Shirt', tax_rate=Decimal('19.00'), + default_price=Decimal('12.00')) + self.op1 = OrderPosition.objects.create( + order=self.order, item=self.ticket, variation=None, + price=Decimal("23.00"), attendee_name="Peter" + ) + self.op2 = OrderPosition.objects.create( + order=self.order, item=self.ticket, variation=None, + price=Decimal("23.00"), attendee_name="Dieter" + ) + user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + EventPermission.objects.create( + event=self.event, + user=user, + can_view_orders=True, + can_change_orders=True + ) + self.client.login(email='dummy@dummy.dummy', password='dummy') + + def test_change_item_success(self): + self.client.post('/control/event/{}/{}/orders/{}/change'.format( + self.event.organizer.slug, self.event.slug, self.order.code + ), { + 'op-{}-operation'.format(self.op1.pk): 'product', + 'op-{}-itemvar'.format(self.op1.pk): str(self.shirt.pk), + 'op-{}-operation'.format(self.op2.pk): '', + 'op-{}-itemvar'.format(self.op2.pk): str(self.ticket.pk), + }) + self.op1.refresh_from_db() + self.order.refresh_from_db() + assert self.op1.item == self.shirt + assert self.op1.price == self.shirt.default_price + assert self.op1.tax_rate == self.shirt.tax_rate + assert self.order.total == self.op1.price + self.op2.price + + def test_change_price_success(self): + self.client.post('/control/event/{}/{}/orders/{}/change'.format( + self.event.organizer.slug, self.event.slug, self.order.code + ), { + 'op-{}-operation'.format(self.op1.pk): 'price', + 'op-{}-itemvar'.format(self.op1.pk): str(self.ticket.pk), + 'op-{}-price'.format(self.op1.pk): '24.00', + 'op-{}-operation'.format(self.op2.pk): '', + 'op-{}-itemvar'.format(self.op2.pk): str(self.ticket.pk), + }) + self.op1.refresh_from_db() + self.order.refresh_from_db() + assert self.op1.item == self.ticket + assert self.op1.price == Decimal('24.00') + assert self.order.total == self.op1.price + self.op2.price + + def test_cancel_success(self): + self.client.post('/control/event/{}/{}/orders/{}/change'.format( + self.event.organizer.slug, self.event.slug, self.order.code + ), { + 'op-{}-operation'.format(self.op1.pk): 'cancel', + 'op-{}-itemvar'.format(self.op1.pk): str(self.ticket.pk), + 'op-{}-price'.format(self.op1.pk): str(self.op1.price), + 'op-{}-operation'.format(self.op2.pk): '', + 'op-{}-itemvar'.format(self.op2.pk): str(self.ticket.pk), + 'op-{}-price'.format(self.op2.pk): str(self.op2.price), + }) + self.order.refresh_from_db() + assert self.order.positions.count() == 1 + assert self.order.total == self.op2.price diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index c99aae2cba..6a4294d3f5 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -64,6 +64,7 @@ event_urls = [ "orders/ABC/resend", "orders/ABC/invoice", "orders/ABC/extend", + "orders/ABC/change", "orders/ABC/download/pdf", "orders/ABC/", "orders/", @@ -147,6 +148,7 @@ event_permission_urls = [ ("can_change_orders", "orders/FOO/transition", 405), ("can_change_orders", "orders/FOO/resend", 405), ("can_change_orders", "orders/FOO/invoice", 405), + ("can_change_orders", "orders/FOO/change", 200), ("can_change_vouchers", "vouchers/add", 200), ("can_change_vouchers", "vouchers/bulk_add", 200), ("can_change_vouchers", "vouchers/", 200),