.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+from decimal import Decimal
+
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from pretix.base.channels import get_all_sales_channels
+from pretix.base.forms import I18nModelForm
+from pretix.base.forms.widgets import SplitDateTimePickerWidget
+from pretix.base.models import Discount
+from pretix.control.forms import ItemMultipleChoiceField, SplitDateTimeField
+
+
+class DiscountForm(I18nModelForm):
+ class Meta:
+ model = Discount
+ localized_fields = '__all__'
+ fields = [
+ 'active',
+ 'internal_name',
+ 'sales_channels',
+ 'available_from',
+ 'available_until',
+ 'subevent_mode',
+ 'condition_all_products',
+ 'condition_limit_products',
+ 'condition_min_count',
+ 'condition_min_value',
+ 'condition_apply_to_addons',
+ 'condition_ignore_voucher_discounted',
+ 'benefit_discount_matching_percent',
+ 'benefit_only_apply_to_cheapest_n_matches',
+ ]
+ field_classes = {
+ 'available_from': SplitDateTimeField,
+ 'available_until': SplitDateTimeField,
+ 'condition_limit_products': ItemMultipleChoiceField,
+ }
+ widgets = {
+ 'subevent_mode': forms.RadioSelect,
+ 'available_from': SplitDateTimePickerWidget(),
+ 'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
+ 'condition_limit_products': forms.CheckboxSelectMultiple(attrs={
+ 'data-inverse-dependency': '<[name$=all_products]',
+ 'class': 'scrolling-multiple-choice',
+ }),
+ 'benefit_only_apply_to_cheapest_n_matches': forms.NumberInput(
+ attrs={
+ 'data-display-dependency': '#id_condition_min_count',
+ }
+ )
+ }
+
+ def __init__(self, *args, **kwargs):
+ self.event = kwargs['event']
+ super().__init__(*args, **kwargs)
+
+ self.fields['sales_channels'] = forms.MultipleChoiceField(
+ label=_('Sales channels'),
+ required=True,
+ choices=(
+ (c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
+ if c.discounts_supported
+ ),
+ widget=forms.CheckboxSelectMultiple,
+ )
+ self.fields['condition_limit_products'].queryset = self.event.items.all()
+ self.fields['condition_min_count'].required = False
+ self.fields['condition_min_count'].widget.is_required = False
+ self.fields['condition_min_value'].required = False
+ self.fields['condition_min_value'].widget.is_required = False
+
+ if not self.event.has_subevents:
+ del self.fields['subevent_mode']
+
+ def clean(self):
+ d = super().clean()
+ if d.get('condition_min_value') and d.get('benefit_only_apply_to_cheapest_n_matches'):
+ # field is hidden by JS
+ d['benefit_only_apply_to_cheapest_n_matches'] = None
+ if d.get('subevent_mode') == Discount.SUBEVENT_MODE_DISTINCT and d.get('condition_min_value'):
+ # field is hidden by JS
+ d['condition_min_value'] = Decimal('0.00')
+
+ if d.get('condition_min_count') is None:
+ d['condition_min_count'] = 0
+ if d.get('condition_min_value') is None:
+ d['condition_min_value'] = Decimal('0.00')
+ return d
diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py
index 3d2195354..dd85f10ad 100644
--- a/src/pretix/control/logdisplay.py
+++ b/src/pretix/control/logdisplay.py
@@ -449,6 +449,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.question.added': _('The question has been added.'),
'pretix.event.question.deleted': _('The question has been deleted.'),
'pretix.event.question.changed': _('The question has been changed.'),
+ 'pretix.event.discount.added': _('The discount has been added.'),
+ 'pretix.event.discount.deleted': _('The discount has been deleted.'),
+ 'pretix.event.discount.changed': _('The discount has been changed.'),
'pretix.event.taxrule.added': _('The tax rule has been added.'),
'pretix.event.taxrule.deleted': _('The tax rule has been deleted.'),
'pretix.event.taxrule.changed': _('The tax rule has been changed.'),
diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py
index 370e15c7f..c27c9351a 100644
--- a/src/pretix/control/navigation.py
+++ b/src/pretix/control/navigation.py
@@ -186,6 +186,14 @@ def get_event_navigation(request: HttpRequest):
}),
'active': 'event.items.questions' in url.url_name,
},
+ {
+ 'label': _('Discounts'),
+ 'url': reverse('control:event.items.discounts', kwargs={
+ 'event': request.event.slug,
+ 'organizer': request.event.organizer.slug,
+ }),
+ 'active': 'event.items.discounts' in url.url_name,
+ },
]
})
diff --git a/src/pretix/control/templates/pretixcontrol/items/discount.html b/src/pretix/control/templates/pretixcontrol/items/discount.html
new file mode 100644
index 000000000..24e7ed597
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/items/discount.html
@@ -0,0 +1,74 @@
+{% extends "pretixcontrol/items/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% block title %}{% trans "Automatic discount" %}{% endblock %}
+{% block inside %}
+ {% trans "Automatic discount" %}
+
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/items/discount_delete.html b/src/pretix/control/templates/pretixcontrol/items/discount_delete.html
new file mode 100644
index 000000000..9c7fd4b67
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/items/discount_delete.html
@@ -0,0 +1,41 @@
+{% extends "pretixcontrol/items/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% block title %}{% trans "Delete discount" %}{% endblock %}
+{% block inside %}
+ {% trans "Delete discount" %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/pretix/control/templates/pretixcontrol/items/discounts.html b/src/pretix/control/templates/pretixcontrol/items/discounts.html
new file mode 100644
index 000000000..ddf4907de
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/items/discounts.html
@@ -0,0 +1,147 @@
+{% extends "pretixcontrol/items/base.html" %}
+{% load i18n %}
+{% block title %}{% trans "Automatic discounts" %}{% endblock %}
+{% block inside %}
+ {% trans "Automatic discounts" %}
+
+ {% blocktrans trimmed %}
+ With automatic discounts, you can automatically apply a discount to purchases from your customers based
+ on certain conditions. For example, you can create group discounts like "get 20% off if you buy 3 or more
+ tickets" or "buy 2 tickets, get 1 free".
+ {% endblocktrans %}
+
+
+ {% blocktrans trimmed %}
+ Automatic discounts are available to all customers as long as they are active. If you want to offer special
+ prices only to specific customers, you can use vouchers instead. If you want to offer discounts across
+ multiple purchases ("buy a package of 10 you can turn into individual tickest later"), you can use
+ customer accounts and memberships instead.
+ {% endblocktrans %}
+
+
+ {% blocktrans trimmed %}
+ Discounts are only automatically applied during an initial purchase. They are not applied if an existing
+ order is changed through any of the available options.
+ {% endblocktrans %}
+
+
+ {% blocktrans trimmed %}
+ Every product in the cart can only be affected by one discount. If you have overlapping discounts, the
+ first one in the order of the list below will apply.
+ {% endblocktrans %}
+
+ {% if discounts|length == 0 %}
+
+ {% else %}
+
+ {% trans "Create a new discount" %}
+
+
+
+ {% include "pretixcontrol/pagination.html" %}
+ {% endif %}
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html
index eb0daa0dd..f7cc1d2a5 100644
--- a/src/pretix/control/templates/pretixcontrol/order/index.html
+++ b/src/pretix/control/templates/pretixcontrol/order/index.html
@@ -387,7 +387,7 @@
{% if line.voucher %}
{% trans "Voucher code used:" %}
{{ line.voucher.code }}
@@ -406,6 +406,15 @@
{{ line.used_membership }}
{% endif %}
+ {% if line.discount %}
+
+
+
+
+ {{ line.discount.internal_name }}
+
+
+ {% endif %}
{% if not line.canceled %}
{% if line.generate_ticket %}
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py
index 6363f6862..af75a2768 100644
--- a/src/pretix/control/urls.py
+++ b/src/pretix/control/urls.py
@@ -37,9 +37,9 @@ from django.conf.urls import include, re_path
from django.views.generic.base import RedirectView
from pretix.control.views import (
- auth, checkin, dashboards, event, geo, global_settings, item, main, oauth,
- orderimport, orders, organizer, pdf, search, shredder, subevents,
- typeahead, user, users, vouchers, waitinglist,
+ auth, checkin, dashboards, discounts, event, geo, global_settings, item,
+ main, oauth, orderimport, orders, organizer, pdf, search, shredder,
+ subevents, typeahead, user, users, vouchers, waitinglist,
)
urlpatterns = [
@@ -279,6 +279,16 @@ urlpatterns = [
re_path(r'^quotas/(?P\d+)/delete$', item.QuotaDelete.as_view(),
name='event.items.quotas.delete'),
re_path(r'^quotas/add$', item.QuotaCreate.as_view(), name='event.items.quotas.add'),
+ re_path(r'^discounts/$', discounts.DiscountList.as_view(), name='event.items.discounts'),
+ re_path(r'^discounts/(?P\d+)/delete$', discounts.DiscountDelete.as_view(),
+ name='event.items.discounts.delete'),
+ re_path(r'^discounts/(?P\d+)/up$', discounts.discount_move_up, name='event.items.discounts.up'),
+ re_path(r'^discounts/(?P\d+)/down$', discounts.discount_move_down,
+ name='event.items.discounts.down'),
+ re_path(r'^discounts/reorder$', discounts.reorder_discounts, name='event.items.discounts.reorder'),
+ re_path(r'^discounts/(?P\d+)/$', discounts.DiscountUpdate.as_view(),
+ name='event.items.discounts.edit'),
+ re_path(r'^discounts/add$', discounts.DiscountCreate.as_view(), name='event.items.discounts.add'),
re_path(r'^vouchers/$', vouchers.VoucherList.as_view(), name='event.vouchers'),
re_path(r'^vouchers/tags/$', vouchers.VoucherTags.as_view(), name='event.vouchers.tags'),
re_path(r'^vouchers/rng$', vouchers.VoucherRNG.as_view(), name='event.vouchers.rng'),
diff --git a/src/pretix/control/views/discounts.py b/src/pretix/control/views/discounts.py
new file mode 100644
index 000000000..c0002519e
--- /dev/null
+++ b/src/pretix/control/views/discounts.py
@@ -0,0 +1,269 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+
+import json
+from json.decoder import JSONDecodeError
+
+from django.contrib import messages
+from django.db import transaction
+from django.db.models import Max
+from django.http import (
+ Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
+)
+from django.shortcuts import redirect
+from django.urls import resolve, reverse
+from django.utils.functional import cached_property
+from django.utils.translation import gettext_lazy as _
+from django.views.decorators.http import require_http_methods
+from django.views.generic import ListView
+from django.views.generic.edit import DeleteView
+
+from pretix.base.models import CartPosition, Discount
+from pretix.control.forms.discounts import DiscountForm
+from pretix.control.permissions import (
+ EventPermissionRequiredMixin, event_permission_required,
+)
+from pretix.helpers.models import modelcopy
+
+from ...base.channels import get_all_sales_channels
+from . import CreateView, PaginationMixin, UpdateView
+
+
+class DiscountDelete(EventPermissionRequiredMixin, DeleteView):
+ model = Discount
+ template_name = 'pretixcontrol/items/discount_delete.html'
+ permission = 'can_change_items'
+ context_object_name = 'discount'
+
+ def get_context_data(self, *args, **kwargs) -> dict:
+ context = super().get_context_data(*args, **kwargs)
+ context['possible'] = self.object.allow_delete()
+ return context
+
+ def get_object(self, queryset=None) -> Discount:
+ try:
+ return self.request.event.discounts.get(
+ id=self.kwargs['discount']
+ )
+ except Discount.DoesNotExist:
+ raise Http404(_("The requested discount does not exist."))
+
+ @transaction.atomic
+ def delete(self, request, *args, **kwargs):
+ self.object = self.get_object()
+ success_url = self.get_success_url()
+ if self.object.allow_delete():
+ CartPosition.objects.filter(discount=self.object).update(discount=None)
+ self.object.log_action('pretix.event.discount.deleted', user=self.request.user)
+ self.object.delete()
+ messages.success(request, _('The selected discount has been deleted.'))
+ else:
+ o = self.get_object()
+ o.active = False
+ o.save()
+ o.log_action('pretix.event.discount.changed', user=self.request.user, data={
+ 'active': False
+ })
+ messages.success(request, _('The selected discount has been deactivated.'))
+ return HttpResponseRedirect(success_url)
+
+ def get_success_url(self) -> str:
+ return reverse('control:event.items.discounts', kwargs={
+ 'organizer': self.request.event.organizer.slug,
+ 'event': self.request.event.slug,
+ })
+
+
+class DiscountUpdate(EventPermissionRequiredMixin, UpdateView):
+ model = Discount
+ form_class = DiscountForm
+ template_name = 'pretixcontrol/items/discount.html'
+ permission = 'can_change_items'
+ context_object_name = 'discount'
+
+ def get_object(self, queryset=None) -> Discount:
+ url = resolve(self.request.path_info)
+ try:
+ return self.request.event.discounts.get(
+ id=url.kwargs['discount']
+ )
+ except Discount.DoesNotExist:
+ raise Http404(_("The requested discount does not exist."))
+
+ @transaction.atomic
+ def form_valid(self, form):
+ messages.success(self.request, _('Your changes have been saved.'))
+ if form.has_changed():
+ self.object.log_action(
+ 'pretix.event.discount.changed', user=self.request.user, data={
+ k: form.cleaned_data.get(k) for k in form.changed_data
+ }
+ )
+ return super().form_valid(form)
+
+ def get_success_url(self) -> str:
+ return reverse('control:event.items.discounts', kwargs={
+ 'organizer': self.request.event.organizer.slug,
+ 'event': self.request.event.slug,
+ })
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs['event'] = self.request.event
+ return kwargs
+
+ def form_invalid(self, form):
+ messages.error(self.request, _('We could not save your changes. See below for details.'))
+ return super().form_invalid(form)
+
+
+class DiscountCreate(EventPermissionRequiredMixin, CreateView):
+ model = Discount
+ form_class = DiscountForm
+ template_name = 'pretixcontrol/items/discount.html'
+ permission = 'can_change_items'
+ context_object_name = 'discount'
+
+ def get_success_url(self) -> str:
+ return reverse('control:event.items.discounts', kwargs={
+ 'organizer': self.request.event.organizer.slug,
+ 'event': self.request.event.slug,
+ })
+
+ @cached_property
+ def copy_from(self):
+ if self.request.GET.get("copy_from") and not getattr(self, 'object', None):
+ try:
+ return self.request.event.discounts.get(pk=self.request.GET.get("copy_from"))
+ except Discount.DoesNotExist:
+ pass
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+
+ if self.copy_from:
+ i = modelcopy(self.copy_from)
+ i.pk = None
+ kwargs['instance'] = i
+ else:
+ kwargs['instance'] = Discount(event=self.request.event)
+
+ kwargs['event'] = self.request.event
+ return kwargs
+
+ @transaction.atomic
+ def form_valid(self, form):
+ form.instance.event = self.request.event
+ form.instance.position = (self.request.event.discounts.aggregate(m=Max('position'))['m'] or 0) + 1
+ messages.success(self.request, _('The new discount has been created.'))
+ ret = super().form_valid(form)
+ form.instance.log_action('pretix.event.discount.added', data=dict(form.cleaned_data), user=self.request.user)
+ return ret
+
+ def form_invalid(self, form):
+ messages.error(self.request, _('We could not save your changes. See below for details.'))
+ return super().form_invalid(form)
+
+
+class DiscountList(PaginationMixin, ListView):
+ model = Discount
+ context_object_name = 'discounts'
+ template_name = 'pretixcontrol/items/discounts.html'
+
+ def get_queryset(self):
+ return self.request.event.discounts.prefetch_related('condition_limit_products')
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ ctx['sales_channels'] = get_all_sales_channels()
+ return ctx
+
+
+def discount_move(request, discount, up=True):
+ """
+ This is a helper function to avoid duplicating code in discount_move_up and
+ discount_move_down. It takes a discount and a direction and then tries to bring
+ all discounts for this event in a new order.
+ """
+ try:
+ discount = request.event.discounts.get(
+ id=discount
+ )
+ except Discount.DoesNotExist:
+ raise Http404(_("The requested discount does not exist."))
+ discounts = list(request.event.discounts.order_by("position"))
+
+ index = discounts.index(discount)
+ if index != 0 and up:
+ discounts[index - 1], discounts[index] = discounts[index], discounts[index - 1]
+ elif index != len(discounts) - 1 and not up:
+ discounts[index + 1], discounts[index] = discounts[index], discounts[index + 1]
+
+ for i, d in enumerate(discounts):
+ if d.position != i:
+ d.position = i
+ d.save()
+ messages.success(request, _('The order of discounts has been updated.'))
+
+
+@event_permission_required("can_change_items")
+@require_http_methods(["POST"])
+def discount_move_up(request, organizer, event, discount):
+ discount_move(request, discount, up=True)
+ return redirect('control:event.items.discounts',
+ organizer=request.event.organizer.slug,
+ event=request.event.slug)
+
+
+@event_permission_required("can_change_items")
+@require_http_methods(["POST"])
+def discount_move_down(request, organizer, event, discount):
+ discount_move(request, discount, up=False)
+ return redirect('control:event.items.discounts',
+ organizer=request.event.organizer.slug,
+ event=request.event.slug)
+
+
+@transaction.atomic
+@event_permission_required("can_change_items")
+@require_http_methods(["POST"])
+def reorder_discounts(request, organizer, event):
+ try:
+ ids = json.loads(request.body.decode('utf-8'))['ids']
+ except (JSONDecodeError, KeyError, ValueError):
+ return HttpResponseBadRequest("expected JSON: {ids:[]}")
+
+ input_discounts = list(request.event.discounts.filter(id__in=[i for i in ids if i.isdigit()]))
+
+ if len(input_discounts) != len(ids):
+ raise Http404(_("Some of the provided object ids are invalid."))
+
+ if len(input_discounts) != request.event.discounts.count():
+ raise Http404(_("Not all discounts have been selected."))
+
+ for c in input_discounts:
+ pos = ids.index(str(c.pk))
+ if pos != c.position: # Save unneccessary UPDATE queries
+ c.position = pos
+ c.save(update_fields=['position'])
+
+ return HttpResponse()
diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py
index 31e8dcbef..d677322b3 100644
--- a/src/pretix/control/views/item.py
+++ b/src/pretix/control/views/item.py
@@ -169,7 +169,7 @@ def reorder_items(request, organizer, event):
input_items = list(request.event.items.filter(id__in=[i for i in ids if i.isdigit()]))
if len(input_items) != len(ids):
- raise Http404(_("Some of the provided item ids are invalid."))
+ raise Http404(_("Some of the provided object ids are invalid."))
item_categories = {i.category_id for i in input_items}
if len(item_categories) > 1:
@@ -178,7 +178,7 @@ def reorder_items(request, organizer, event):
# get first and only category
item_category = next(iter(item_categories))
if len(input_items) != request.event.items.filter(category=item_category).count():
- raise Http404(_("Not all items have been selected."))
+ raise Http404(_("Not all objects have been selected."))
for i in input_items:
pos = ids.index(str(i.pk))
@@ -372,10 +372,10 @@ def reorder_categories(request, organizer, event):
input_categories = list(request.event.categories.filter(id__in=[i for i in ids if i.isdigit()]))
if len(input_categories) != len(ids):
- raise Http404(_("Some of the provided category ids are invalid."))
+ raise Http404(_("Some of the provided object ids are invalid."))
if len(input_categories) != request.event.categories.count():
- raise Http404(_("Not all categories have been selected."))
+ raise Http404(_("Not all objects have been selected."))
for c in input_categories:
pos = ids.index(str(c.pk))
@@ -501,10 +501,10 @@ def reorder_questions(request, organizer, event):
input_questions = list(request.event.questions.filter(id__in=custom_question_ids))
if len(input_questions) != len(custom_question_ids):
- raise Http404(_("Some of the provided question ids are invalid."))
+ raise Http404(_("Some of the provided object ids are invalid."))
if len(input_questions) != request.event.questions.count():
- raise Http404(_("Not all questions have been selected."))
+ raise Http404(_("Not all objects have been selected."))
for q in input_questions:
pos = ids.index(str(q.pk))
diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py
index 350a9b2ca..0e4d27b83 100644
--- a/src/pretix/control/views/orders.py
+++ b/src/pretix/control/views/orders.py
@@ -360,7 +360,8 @@ class OrderDetail(OrderView):
cartpos = queryset.order_by(
'item', 'variation'
).select_related(
- 'item', 'variation', 'addon_to', 'tax_rule', 'used_membership', 'used_membership__membership_type'
+ 'item', 'variation', 'addon_to', 'tax_rule', 'used_membership', 'used_membership__membership_type',
+ 'discount',
).prefetch_related(
'item__questions', 'issued_gift_cards',
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')),
diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py
index 784ed4895..71380952c 100644
--- a/src/pretix/presale/checkoutflow.py
+++ b/src/pretix/presale/checkoutflow.py
@@ -55,7 +55,7 @@ from pretix.base.models import Customer, Order
from pretix.base.models.orders import InvoiceAddress, OrderPayment
from pretix.base.models.tax import TaxedPrice, TaxRule
from pretix.base.services.cart import (
- CartError, error_messages, get_fees, set_cart_addons, update_tax_rates,
+ CartError, CartManager, error_messages, get_fees, set_cart_addons,
)
from pretix.base.services.memberships import validate_memberships_in_order
from pretix.base.services.orders import perform_order
@@ -873,11 +873,13 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
self.cart_session['saved_invoice_address'] = saved.pk
try:
- diff = update_tax_rates(
- event=request.event,
+ cm = CartManager(
+ event=self.request.event,
cart_id=get_or_create_cart_id(request),
- invoice_address=addr
+ invoice_address=addr,
+ sales_channel=request.sales_channel.identifier,
)
+ diff = cm.recompute_final_prices_and_taxes()
except TaxRule.SaleNotAllowed:
messages.error(request,
_("Unfortunately, based on the invoice address you entered, we're not able to sell you "
diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html
index d7063ffc2..1d369f130 100644
--- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html
+++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html
@@ -231,6 +231,13 @@
{% else %}
{{ line.price|money:event.currency }}
{% endif %}
+ {% if line.discount and line.line_price_gross != line.price %}
+
+
+
+ {% trans "Discounted" %}
+
+ {% endif %}
{% else %}
@@ -285,6 +292,13 @@
{% else %}
{{ line.price|money:event.currency }}
{% endif %}
+ {% if line.discount and line.line_price_gross != line.price %}
+
+
+
+ {% trans "Discounted" %}
+
+ {% endif %}
{% endif %}
diff --git a/src/pretix/static/pretixcontrol/js/ui/main.js b/src/pretix/static/pretixcontrol/js/ui/main.js
index f61b9f24e..252142579 100644
--- a/src/pretix/static/pretixcontrol/js/ui/main.js
+++ b/src/pretix/static/pretixcontrol/js/ui/main.js
@@ -355,7 +355,7 @@ var form_handlers = function (el) {
var dependent = $(this),
dependency = $($(this).attr("data-display-dependency")),
update = function (ev) {
- var enabled = dependency.toArray().some(function(d) {return (d.type === 'checkbox' || d.type === 'radio') ? d.checked : !!d.value;});
+ var enabled = dependency.toArray().some(function(d) {return (d.type === 'checkbox' || d.type === 'radio') ? d.checked : (!!d.value && !d.value.match(/^0\.?0*$/g))});
if (dependent.is("[data-inverse]")) {
enabled = !enabled;
}
diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss
index f8ad7ec34..f36c921c5 100644
--- a/src/pretix/static/pretixcontrol/scss/_forms.scss
+++ b/src/pretix/static/pretixcontrol/scss/_forms.scss
@@ -849,6 +849,7 @@ details {
text-decoration: line-through;
}
}
+
.select2-container [aria-multiselectable] .select2-results__option span span::before {
content: "";
font-family: FontAwesome;
@@ -860,3 +861,46 @@ details {
.select2-container [aria-multiselectable] .select2-results__option[aria-selected=true] span span::before {
content: ""
}
+
+.form-alternatives {
+ div .control-label {
+ text-align: left;
+ }
+}
+.condition-or {
+ .sepText {
+ width: 75px;
+ background: #FFFFFF;
+ margin: -15px 0 0 -38px;
+ padding: 5px 0;
+ position: absolute;
+ top: 50%;
+ text-align: center;
+ }
+
+ .hr {
+ width:2px;
+ height:64px;
+ background-color: #DDDDDD;
+ position:inherit;
+ top:12px;
+ left:50%;
+ z-index:10;
+ }
+}
+
+@media (max-width: $screen-sm-max) {
+ .condition-or {
+ .hr {
+ width: 100%;
+ height: 2px;
+ left: 0px;
+ top: 4px;
+ margin: 15px 0 15px 0;
+ }
+
+ .sepText {
+ left: 50%;
+ }
+ }
+}
diff --git a/src/pretix/static/pretixpresale/scss/_cart.scss b/src/pretix/static/pretixpresale/scss/_cart.scss
index 8c9e18531..ec7303fcf 100644
--- a/src/pretix/static/pretixpresale/scss/_cart.scss
+++ b/src/pretix/static/pretixpresale/scss/_cart.scss
@@ -20,6 +20,10 @@
display: block;
line-height: 1;
}
+ .price .discounted {
+ font-size: 85%;
+ line-height: 1;
+ }
.dl-indented {
padding-left: 20px;
diff --git a/src/tests/api/test_discounts.py b/src/tests/api/test_discounts.py
new file mode 100644
index 000000000..ccda04184
--- /dev/null
+++ b/src/tests/api/test_discounts.py
@@ -0,0 +1,196 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+
+import pytest
+from django_scopes import scopes_disabled
+
+from pretix.base.models import Discount
+
+
+@pytest.fixture
+def discount(event):
+ return event.discounts.create(
+ internal_name="3 for 2",
+ condition_min_count=3,
+ benefit_discount_matching_percent=100,
+ benefit_only_apply_to_cheapest_n_matches=1,
+ position=1,
+ )
+
+
+TEST_DISCOUNT_RES = {
+ "active": True,
+ "internal_name": "3 for 2",
+ "position": 1,
+ "sales_channels": ["web"],
+ "available_from": None,
+ "available_until": None,
+ "subevent_mode": "mixed",
+ "condition_all_products": True,
+ "condition_limit_products": [],
+ "condition_apply_to_addons": True,
+ "condition_ignore_voucher_discounted": False,
+ "condition_min_count": 3,
+ "condition_min_value": "0.00",
+ "benefit_discount_matching_percent": "100.00",
+ "benefit_only_apply_to_cheapest_n_matches": 1
+}
+
+
+@pytest.mark.django_db
+def test_discount_list(token_client, organizer, event, team, discount):
+ res = dict(TEST_DISCOUNT_RES)
+ res["id"] = discount.pk
+ resp = token_client.get('/api/v1/organizers/{}/events/{}/discounts/'.format(organizer.slug, event.slug))
+ assert resp.status_code == 200
+ assert [res] == resp.data['results']
+ resp = token_client.get('/api/v1/organizers/{}/events/{}/discounts/?active=true'.format(
+ organizer.slug, event.slug))
+ assert resp.status_code == 200
+ assert [res] == resp.data['results']
+ resp = token_client.get('/api/v1/organizers/{}/events/{}/discounts/?active=false'.format(
+ organizer.slug, event.slug))
+ assert resp.status_code == 200
+ assert [] == resp.data['results']
+
+
+@pytest.mark.django_db
+def test_discount_detail(token_client, organizer, event, team, discount):
+ res = dict(TEST_DISCOUNT_RES)
+ res["id"] = discount.pk
+ resp = token_client.get('/api/v1/organizers/{}/events/{}/discounts/{}/'.format(organizer.slug, event.slug,
+ discount.pk))
+ assert resp.status_code == 200
+ assert res == resp.data
+
+
+@pytest.mark.django_db
+def test_discount_create(token_client, organizer, event, team):
+ resp = token_client.post(
+ '/api/v1/organizers/{}/events/{}/discounts/'.format(organizer.slug, event.slug),
+ {
+ "active": True,
+ "internal_name": "3 for 2",
+ "position": 2,
+ "sales_channels": ["web"],
+ "available_from": None,
+ "available_until": None,
+ "subevent_mode": "mixed",
+ "condition_all_products": True,
+ "condition_limit_products": [],
+ "condition_apply_to_addons": True,
+ "condition_ignore_voucher_discounted": False,
+ "condition_min_count": 3,
+ "condition_min_value": "0.00",
+ "benefit_discount_matching_percent": "100.00",
+ "benefit_only_apply_to_cheapest_n_matches": 1
+ },
+ format='json'
+ )
+ assert resp.status_code == 201
+ with scopes_disabled():
+ d = Discount.objects.get(pk=resp.data['id'])
+ assert d.event == event
+ assert d.internal_name == "3 for 2"
+
+
+@pytest.mark.django_db
+def test_discount_update(token_client, organizer, event, team, discount):
+ resp = token_client.patch(
+ '/api/v1/organizers/{}/events/{}/discounts/{}/'.format(organizer.slug, event.slug, discount.pk),
+ {
+ "internal_name": "Foo"
+ },
+ format='json'
+ )
+ assert resp.status_code == 200
+ with scopes_disabled():
+ d = Discount.objects.get(pk=resp.data['id'])
+ assert d.event == event
+ assert d.internal_name == "Foo"
+
+
+@pytest.mark.django_db
+def test_discount_delete(token_client, organizer, event, discount):
+ resp = token_client.delete(
+ '/api/v1/organizers/{}/events/{}/discounts/{}/'.format(organizer.slug, event.slug, discount.pk))
+ assert resp.status_code == 204
+ with scopes_disabled():
+ assert not event.discounts.filter(pk=discount.id).exists()
+
+
+@pytest.mark.django_db
+def test_validate_errors(token_client, organizer, event, team):
+ resp = token_client.post(
+ '/api/v1/organizers/{}/events/{}/discounts/'.format(organizer.slug, event.slug),
+ {
+ "internal_name": "3 for 2",
+ "subevent_mode": "mixed",
+ "condition_min_count": 3,
+ "condition_min_value": "2.00",
+ "benefit_discount_matching_percent": "100.00",
+ "benefit_only_apply_to_cheapest_n_matches": 1
+ },
+ format='json'
+ )
+ assert resp.status_code == 400
+
+ resp = token_client.post(
+ '/api/v1/organizers/{}/events/{}/discounts/'.format(organizer.slug, event.slug),
+ {
+ "internal_name": "3 for 2",
+ "subevent_mode": "mixed",
+ "condition_min_count": 0,
+ "condition_min_value": "0.00",
+ "benefit_discount_matching_percent": "100.00",
+ "benefit_only_apply_to_cheapest_n_matches": 1
+ },
+ format='json'
+ )
+ assert resp.status_code == 400
+
+ resp = token_client.post(
+ '/api/v1/organizers/{}/events/{}/discounts/'.format(organizer.slug, event.slug),
+ {
+ "internal_name": "3 for 2",
+ "subevent_mode": "mixed",
+ "condition_min_count": 0,
+ "condition_min_value": "2.00",
+ "benefit_discount_matching_percent": "100.00",
+ "benefit_only_apply_to_cheapest_n_matches": 1
+ },
+ format='json'
+ )
+ assert resp.status_code == 400
+
+ resp = token_client.post(
+ '/api/v1/organizers/{}/events/{}/discounts/'.format(organizer.slug, event.slug),
+ {
+ "internal_name": "3 for 2",
+ "subevent_mode": "distinct",
+ "condition_min_count": 0,
+ "condition_min_value": "2.00",
+ "benefit_discount_matching_percent": "100.00",
+ },
+ format='json'
+ )
+ assert resp.status_code == 400
diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py
index 7e0d1b1d1..1c4940a47 100644
--- a/src/tests/api/test_permissions.py
+++ b/src/tests/api/test_permissions.py
@@ -81,6 +81,7 @@ event_permission_sub_urls = [
('get', None, 'items/', 200),
('get', None, 'questions/', 200),
('get', None, 'quotas/', 200),
+ ('get', None, 'discounts/', 200),
('post', 'can_change_items', 'items/', 400),
('get', None, 'items/1/', 404),
('put', 'can_change_items', 'items/1/', 404),
@@ -91,6 +92,11 @@ event_permission_sub_urls = [
('put', 'can_change_items', 'categories/1/', 404),
('patch', 'can_change_items', 'categories/1/', 404),
('delete', 'can_change_items', 'categories/1/', 404),
+ ('post', 'can_change_items', 'discounts/', 400),
+ ('get', None, 'discounts/1/', 404),
+ ('put', 'can_change_items', 'discounts/1/', 404),
+ ('patch', 'can_change_items', 'discounts/1/', 404),
+ ('delete', 'can_change_items', 'discounts/1/', 404),
('post', 'can_change_items', 'items/1/variations/', 404),
('get', None, 'items/1/variations/', 404),
('get', None, 'items/1/variations/1/', 404),
diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py
index 25bb7e114..3c2cffc28 100644
--- a/src/tests/base/test_models.py
+++ b/src/tests/base/test_models.py
@@ -924,7 +924,7 @@ class VoucherTestCase(BaseQuotaTestCase):
datetime=now() - timedelta(days=5), expires=now() + timedelta(days=5), total=46,
)
OrderPosition.objects.create(order=order, item=self.item1, voucher=v, price=Decimal('20.00'),
- price_before_voucher=Decimal('23.00'))
+ voucher_budget_use=Decimal('3.00'))
assert v.budget_used() == Decimal('3.00')
order = Order.objects.create(
@@ -932,7 +932,7 @@ class VoucherTestCase(BaseQuotaTestCase):
datetime=now() - timedelta(days=5), expires=now() + timedelta(days=5), total=46,
)
OrderPosition.objects.create(order=order, item=self.item1, voucher=v, price=Decimal('20.00'),
- price_before_voucher=Decimal('23.00'))
+ voucher_budget_use=Decimal('3.00'))
assert v.budget_used() == Decimal('6.00')
diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py
index 3f2661ecd..7437c28a4 100644
--- a/src/tests/base/test_orders.py
+++ b/src/tests/base/test_orders.py
@@ -1070,10 +1070,10 @@ class OrderChangeManagerTests(TestCase):
assert self.order.transactions.count() == 4
@classscope(attr='o')
- def test_change_item_change_price_before_voucher(self):
+ def test_change_item_change_voucher_budget_use(self):
self.op1.voucher = self.event.vouchers.create(item=self.shirt, redeemed=1, price_mode='set', value='5.00')
self.op1.price = Decimal('5.00')
- self.op1.price_before_voucher = Decimal('23.00')
+ self.op1.voucher_budget_use = Decimal('18.00')
self.op1.save()
p = self.op1.price
self.ocm.change_item(self.op1, self.shirt, None)
@@ -1082,13 +1082,13 @@ class OrderChangeManagerTests(TestCase):
self.order.refresh_from_db()
assert self.op1.item == self.shirt
assert self.op1.price == p
- assert self.op1.price_before_voucher == Decimal('12.00')
+ assert self.op1.voucher_budget_use == Decimal('7.00')
@classscope(attr='o')
- def test_change_item_change_price_before_voucher_minimum_value(self):
+ def test_change_item_change_voucher_budget_use_minimum_value(self):
self.op1.voucher = self.event.vouchers.create(item=self.shirt, redeemed=1, price_mode='set', value='20.00')
self.op1.price = Decimal('20.00')
- self.op1.price_before_voucher = Decimal('23.00')
+ self.op1.voucher_budget_use = Decimal('3.00')
self.op1.save()
p = self.op1.price
self.ocm.change_item(self.op1, self.shirt, None)
@@ -1097,7 +1097,7 @@ class OrderChangeManagerTests(TestCase):
self.order.refresh_from_db()
assert self.op1.item == self.shirt
assert self.op1.price == p
- assert self.op1.price_before_voucher == Decimal('20.00')
+ assert self.op1.voucher_budget_use == Decimal('0.00')
@classscope(attr='o')
def test_change_item_success(self):
@@ -3133,7 +3133,7 @@ class OrderReactivateTest(TestCase):
@classscope(attr='o')
def test_reactivate_voucher_budget(self):
self.op1.voucher = self.event.vouchers.create(code="FOO", item=self.ticket, budget=Decimal('0.00'))
- self.op1.price_before_voucher = self.op1.price * 2
+ self.op1.voucher_budget_use = self.op1.price
self.op1.save()
with pytest.raises(OrderError):
reactivate_order(self.order)
diff --git a/src/tests/base/test_pricing_discount.py b/src/tests/base/test_pricing_discount.py
new file mode 100644
index 000000000..6e23812af
--- /dev/null
+++ b/src/tests/base/test_pricing_discount.py
@@ -0,0 +1,1014 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+import copy
+from datetime import timedelta
+from decimal import Decimal
+
+import pytest
+from django.utils.timezone import now
+from django_scopes import scopes_disabled
+
+from pretix.base.models import Discount, Event, Organizer
+from pretix.base.services.pricing import apply_discounts
+
+
+@pytest.fixture
+def event():
+ o = Organizer.objects.create(name='Dummy', slug='dummy')
+ event = Event.objects.create(
+ organizer=o, name='Dummy', slug='dummy',
+ date_from=now()
+ )
+ return event
+
+
+@pytest.fixture
+def item(event):
+ return event.items.create(name='Ticket', default_price=Decimal('23.00'))
+
+
+@pytest.fixture
+def item2(event):
+ return event.items.create(name='Ticket II', default_price=Decimal('50.00'))
+
+
+@pytest.fixture
+def voucher(event):
+ return event.vouchers.create()
+
+
+@pytest.fixture
+def subevent(event):
+ event.has_subevents = True
+ event.save()
+ return event.subevents.create(name='Foobar', date_from=now())
+
+
+mixed_min_count_matching_percent = (
+ Discount(
+ subevent_mode=Discount.SUBEVENT_MODE_MIXED,
+ condition_min_count=3,
+ benefit_discount_matching_percent=20
+ ),
+)
+mixed_min_count_one_free = (
+ Discount(
+ subevent_mode=Discount.SUBEVENT_MODE_MIXED,
+ condition_min_count=3,
+ benefit_discount_matching_percent=100,
+ benefit_only_apply_to_cheapest_n_matches=1,
+ ),
+)
+mixed_min_value_matching_percent = (
+ Discount(
+ subevent_mode=Discount.SUBEVENT_MODE_MIXED,
+ condition_min_value=500,
+ benefit_discount_matching_percent=20
+ ),
+)
+same_min_count_matching_percent = (
+ Discount(
+ subevent_mode=Discount.SUBEVENT_MODE_SAME,
+ condition_min_count=3,
+ benefit_discount_matching_percent=20
+ ),
+)
+same_min_count_one_free = (
+ Discount(
+ subevent_mode=Discount.SUBEVENT_MODE_SAME,
+ condition_min_count=3,
+ benefit_discount_matching_percent=100,
+ benefit_only_apply_to_cheapest_n_matches=1,
+ ),
+)
+same_min_value_matching_percent = (
+ Discount(
+ subevent_mode=Discount.SUBEVENT_MODE_SAME,
+ condition_min_value=500,
+ benefit_discount_matching_percent=20
+ ),
+)
+distinct_min_count_matching_percent = (
+ Discount(
+ subevent_mode=Discount.SUBEVENT_MODE_DISTINCT,
+ condition_min_count=3,
+ benefit_discount_matching_percent=20
+ ),
+)
+distinct_min_count_one_free = (
+ Discount(
+ subevent_mode=Discount.SUBEVENT_MODE_DISTINCT,
+ condition_min_count=3,
+ benefit_discount_matching_percent=100,
+ benefit_only_apply_to_cheapest_n_matches=1,
+ ),
+)
+distinct_min_count_two_free = (
+ Discount(
+ subevent_mode=Discount.SUBEVENT_MODE_DISTINCT,
+ condition_min_count=3,
+ benefit_discount_matching_percent=100,
+ benefit_only_apply_to_cheapest_n_matches=2,
+ ),
+)
+
+
+testcases_single_rule = [
+ # mixed + min_count + matching_percent
+ (
+ mixed_min_count_matching_percent,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ ) * 2,
+ (
+ Decimal('120.00'),
+ Decimal('120.00'),
+ )
+ ),
+ (
+ mixed_min_count_matching_percent,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ ) * 3,
+ (
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ )
+ ),
+ (
+ mixed_min_count_matching_percent,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('120.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ )
+ ),
+
+ # mixed + min_count + matching_percent + apply_to_cheapest
+ (
+ mixed_min_count_one_free,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ ) * 2,
+ (
+ Decimal('120.00'),
+ Decimal('120.00'),
+ )
+ ),
+ (
+ mixed_min_count_one_free,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ ) * 3,
+ (
+ Decimal('0.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ )
+ ),
+ (
+ mixed_min_count_one_free,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ ) * 5,
+ (
+ Decimal('0.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ )
+ ),
+ (
+ mixed_min_count_one_free,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ ) * 6,
+ (
+ Decimal('0.00'),
+ Decimal('0.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ )
+ ),
+ (
+ mixed_min_count_one_free,
+ (
+ (1, 1, Decimal('1.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('2.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('3.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('4.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('5.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('6.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('0.00'),
+ Decimal('0.00'),
+ Decimal('3.00'),
+ Decimal('4.00'),
+ Decimal('5.00'),
+ Decimal('6.00'),
+ )
+ ),
+
+ # mixed + min_value + matching_percent
+ (
+ mixed_min_value_matching_percent,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ ) * 4,
+ (
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ )
+ ),
+ (
+ mixed_min_value_matching_percent,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ ) * 5,
+ (
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ )
+ ),
+ (
+ mixed_min_value_matching_percent,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ ) * 10,
+ (
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ )
+ ),
+
+ # same + min_count + matching_percent
+ (
+ same_min_count_matching_percent,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ )
+ ),
+ (
+ same_min_count_matching_percent,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ )
+ ),
+ (
+ same_min_count_matching_percent,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('120.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('120.00'),
+ )
+ ),
+
+ # same + min_count + matching_percent + apply_to_cheapest
+ (
+ same_min_count_one_free,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('120.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('0.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('0.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ )
+ ),
+ (
+ same_min_count_one_free,
+ (
+ (1, 1, Decimal('1.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('2.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('3.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('4.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('5.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('6.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('7.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('8.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('9.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('0.00'),
+ Decimal('2.00'),
+ Decimal('3.00'),
+ Decimal('0.00'),
+ Decimal('5.00'),
+ Decimal('6.00'),
+ Decimal('7.00'),
+ Decimal('8.00'),
+ Decimal('9.00'),
+ )
+ ),
+
+ # same + min_value + matching_percent
+ (
+ same_min_value_matching_percent,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ )
+ ),
+
+ # distinct + min_count + matching_percent
+ (
+ distinct_min_count_matching_percent,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ )
+ ),
+ (
+ distinct_min_count_matching_percent,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('120.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ )
+ ),
+ (
+ distinct_min_count_matching_percent,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ )
+ ),
+ (
+ distinct_min_count_matching_percent,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 4, Decimal('120.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ )
+ ),
+ (
+ distinct_min_count_matching_percent,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 4, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('96.00'),
+ Decimal('120.00'),
+ )
+ ),
+
+ # distinct + min_count + matching_percent + apply_to_cheapest
+ (
+ distinct_min_count_one_free,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ )
+ ),
+ (
+ distinct_min_count_one_free,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('120.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('0.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ )
+ ),
+ (
+ distinct_min_count_one_free,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 4, Decimal('120.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('0.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('0.00'),
+ )
+ ),
+ (
+ distinct_min_count_one_free,
+ (
+ (1, 1, Decimal('3.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('2.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('1.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('1.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('2.00'), False, False, Decimal('0.00')),
+ (1, 4, Decimal('3.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('3.00'),
+ Decimal('2.00'),
+ Decimal('0.00'),
+ Decimal('0.00'),
+ Decimal('2.00'),
+ Decimal('3.00'),
+ )
+ ),
+ (
+ distinct_min_count_two_free,
+ (
+ (1, 1, Decimal('3.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('2.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('1.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('1.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('2.00'), False, False, Decimal('0.00')),
+ (1, 4, Decimal('3.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('3.00'),
+ Decimal('0.00'),
+ Decimal('0.00'),
+ Decimal('0.00'),
+ Decimal('0.00'),
+ Decimal('3.00'),
+ )
+ ),
+ (
+ distinct_min_count_one_free,
+ (
+ (1, 1, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 4, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 5, Decimal('120.00'), False, False, Decimal('0.00')),
+ (1, 6, Decimal('120.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('0.00'),
+ Decimal('120.00'),
+ Decimal('120.00'),
+ Decimal('0.00'),
+ )
+ ),
+ (
+ distinct_min_count_one_free,
+ (
+ (1, 1, Decimal('1.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('2.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('3.00'), False, False, Decimal('0.00')),
+ (1, 4, Decimal('4.00'), False, False, Decimal('0.00')),
+ (1, 5, Decimal('5.00'), False, False, Decimal('0.00')),
+ (1, 6, Decimal('6.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('0.00'),
+ Decimal('0.00'),
+ Decimal('3.00'),
+ Decimal('4.00'),
+ Decimal('5.00'),
+ Decimal('6.00'),
+ )
+ ),
+ (
+ distinct_min_count_one_free,
+ (
+ (1, 1, Decimal('4.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('4.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('4.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('6.00'), False, False, Decimal('0.00')),
+ (1, 2, Decimal('6.00'), False, False, Decimal('0.00')),
+ (1, 3, Decimal('6.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ # This one is unexpected, since the customer could get a lower price
+ # if they would split their order, but it's not really possible to solve
+ # that without giving up other desired effects.
+ Decimal('0.00'),
+ Decimal('0.00'),
+ Decimal('4.00'),
+ Decimal('6.00'),
+ Decimal('6.00'),
+ Decimal('6.00'),
+ )
+ ),
+
+ # Unconditional
+ (
+ (
+ Discount(condition_min_count=1, benefit_discount_matching_percent=20),
+ ),
+ (
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('80.00'),
+ )
+ ),
+ (
+ (
+ Discount(
+ condition_min_count=1,
+ benefit_discount_matching_percent=100,
+ benefit_only_apply_to_cheapest_n_matches=1
+ ),
+ ),
+ (
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('0.00'),
+ Decimal('0.00'),
+ )
+ ),
+
+ # Apply partial discount to partial items
+ (
+ (
+ Discount(
+ condition_min_count=3,
+ benefit_discount_matching_percent=20,
+ benefit_only_apply_to_cheapest_n_matches=2
+ ),
+ ),
+ (
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('100.00'),
+ Decimal('80.00'),
+ Decimal('80.00'),
+ )
+ ),
+
+ # Addon handling
+ (
+ (
+ Discount(
+ condition_min_count=3,
+ benefit_discount_matching_percent=20,
+ ),
+ ),
+ (
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), True, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), True, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('80.00'),
+ Decimal('80.00'),
+ Decimal('80.00'),
+ )
+ ),
+ (
+ (
+ Discount(
+ condition_min_count=3,
+ benefit_discount_matching_percent=20,
+ condition_apply_to_addons=False,
+ ),
+ ),
+ (
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), True, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), True, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('80.00'),
+ Decimal('80.00'),
+ Decimal('80.00'),
+ Decimal('100.00'),
+ Decimal('100.00'),
+ )
+ ),
+ (
+ (
+ Discount(
+ condition_min_count=3,
+ benefit_discount_matching_percent=20,
+ condition_apply_to_addons=False,
+ ),
+ ),
+ (
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), True, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), True, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('100.00'),
+ Decimal('100.00'),
+ Decimal('100.00'),
+ )
+ ),
+
+ # Ignore bundled
+ (
+ (
+ Discount(
+ condition_min_count=3,
+ benefit_discount_matching_percent=20,
+ condition_apply_to_addons=False,
+ ),
+ ),
+ (
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), True, True, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), True, True, Decimal('0.00')),
+ ),
+ (
+ Decimal('100.00'),
+ Decimal('100.00'),
+ Decimal('100.00'),
+ )
+ ),
+]
+
+
+testcases_multiple_rules = [
+ # min_count consumes all discounted
+ (
+ (
+ Discount(
+ condition_min_count=2,
+ benefit_discount_matching_percent=20,
+ ),
+ Discount(
+ condition_min_count=1,
+ benefit_discount_matching_percent=50,
+ ),
+ ),
+ (
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('80.00'),
+ Decimal('80.00'),
+ Decimal('80.00'),
+ )
+ ),
+ # reordered
+ (
+ (
+ Discount(
+ condition_min_count=1,
+ benefit_discount_matching_percent=50,
+ position=2,
+ ),
+ Discount(
+ condition_min_count=2,
+ benefit_discount_matching_percent=20,
+ position=1,
+ ),
+ ),
+ (
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('80.00'),
+ Decimal('80.00'),
+ Decimal('80.00'),
+ )
+ ),
+ # min_count does not consume uneven numbers if not required
+ (
+ (
+ Discount(
+ condition_min_count=2,
+ benefit_discount_matching_percent=20,
+ benefit_only_apply_to_cheapest_n_matches=1
+ ),
+ Discount(
+ condition_min_count=1,
+ benefit_discount_matching_percent=50,
+ ),
+ ),
+ (
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('100.00'),
+ Decimal('80.00'),
+ Decimal('50.00'),
+ )
+ ),
+ (
+ (
+ Discount(
+ condition_min_count=2,
+ benefit_discount_matching_percent=20,
+ benefit_only_apply_to_cheapest_n_matches=1
+ ),
+ Discount(
+ condition_min_count=1,
+ benefit_discount_matching_percent=50,
+ ),
+ ),
+ (
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('100.00'),
+ Decimal('80.00'),
+ Decimal('100.00'),
+ Decimal('80.00'),
+ Decimal('50.00'),
+ )
+ ),
+ # min_value consumes all matching
+ (
+ (
+ Discount(
+ condition_min_value=Decimal('5.00'),
+ benefit_discount_matching_percent=20,
+ ),
+ Discount(
+ condition_min_count=1,
+ benefit_discount_matching_percent=50,
+ ),
+ ),
+ (
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ (1, 1, Decimal('100.00'), False, False, Decimal('0.00')),
+ ),
+ (
+ Decimal('80.00'),
+ Decimal('80.00'),
+ Decimal('80.00'),
+ )
+ ),
+]
+
+
+@pytest.mark.parametrize("discounts,positions,expected", testcases_single_rule + testcases_multiple_rules)
+@pytest.mark.django_db
+@scopes_disabled()
+def test_discount_evaluation(event, item, subevent, discounts, positions, expected):
+ for d in discounts:
+ d = copy.copy(d)
+ d.event = event
+ d.internal_name = 'Discount'
+ d.full_clean()
+ d.save()
+ new_prices = [p for p, d in apply_discounts(event, 'web', positions)]
+ assert sorted(new_prices) == sorted(expected)
+
+
+@pytest.mark.django_db
+@scopes_disabled()
+def test_limit_products(event, item, item2):
+ d1 = Discount(event=event, condition_min_count=2, benefit_discount_matching_percent=20, condition_all_products=False)
+ d1.save()
+ d1.condition_limit_products.add(item2)
+ d2 = Discount(event=event, condition_min_count=2, benefit_discount_matching_percent=50, condition_all_products=True)
+ d2.save()
+
+ positions = (
+ (item.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
+ (item.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
+ (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
+ (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
+ )
+ expected = (
+ Decimal('80.00'),
+ Decimal('80.00'),
+ Decimal('50.00'),
+ Decimal('50.00'),
+ )
+
+ new_prices = [p for p, d in apply_discounts(event, 'web', positions)]
+ assert sorted(new_prices) == sorted(expected)
+
+
+@pytest.mark.django_db
+@scopes_disabled()
+def test_sales_channels(event, item):
+ d1 = Discount(event=event, condition_min_count=2, benefit_discount_matching_percent=20, sales_channels=['resellers'])
+ d1.save()
+ d2 = Discount(event=event, condition_min_count=2, benefit_discount_matching_percent=50, sales_channels=['web', 'resellers'])
+ d2.save()
+
+ positions = (
+ (item.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
+ (item.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
+ )
+
+ assert sorted([p for p, d in apply_discounts(event, 'resellers', positions)]) == [Decimal('80.00'), Decimal('80.00')]
+ assert sorted([p for p, d in apply_discounts(event, 'web', positions)]) == [Decimal('50.00'), Decimal('50.00')]
+
+
+@pytest.mark.django_db
+@scopes_disabled()
+def test_available_from(event, item):
+ d1 = Discount(event=event, condition_min_count=2, benefit_discount_matching_percent=20, available_from=now() + timedelta(days=1))
+ d1.save()
+ d2 = Discount(event=event, condition_min_count=2, benefit_discount_matching_percent=50, available_from=now() - timedelta(days=1))
+ d2.save()
+
+ positions = (
+ (item.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
+ (item.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
+ )
+
+ assert sorted([p for p, d in apply_discounts(event, 'web', positions)]) == [Decimal('50.00'), Decimal('50.00')]
+
+
+@pytest.mark.django_db
+@scopes_disabled()
+def test_available_until(event, item):
+ d1 = Discount(event=event, condition_min_count=2, benefit_discount_matching_percent=20, available_until=now() - timedelta(days=1))
+ d1.save()
+ d2 = Discount(event=event, condition_min_count=2, benefit_discount_matching_percent=50, available_until=now() + timedelta(days=1))
+ d2.save()
+
+ positions = (
+ (item.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
+ (item.pk, None, Decimal('100.00'), False, False, Decimal('0.00')),
+ )
+
+ assert sorted([p for p, d in apply_discounts(event, 'web', positions)]) == [Decimal('50.00'), Decimal('50.00')]
diff --git a/src/tests/control/test_items.py b/src/tests/control/test_items.py
index c44263e65..5379802d4 100644
--- a/src/tests/control/test_items.py
+++ b/src/tests/control/test_items.py
@@ -40,8 +40,8 @@ from django_scopes import scopes_disabled
from tests.base import SoupTest, extract_form_fields
from pretix.base.models import (
- Event, Item, ItemCategory, ItemVariation, Order, OrderPosition, Organizer,
- Question, Quota, Team, User,
+ Discount, Event, Item, ItemCategory, ItemVariation, Order, OrderPosition,
+ Organizer, Question, Quota, Team, User,
)
@@ -695,3 +695,64 @@ class ItemsTest(ItemFormTest):
i = Item.objects.get(name__icontains='New Item')
q = Quota.objects.get(name__icontains='New Quota')
assert q.items.filter(pk=i.pk).exists()
+
+
+class DiscountTest(ItemFormTest):
+
+ def test_create(self):
+ doc = self.get_doc('/control/event/%s/%s/discounts/add' % (self.orga1.slug, self.event1.slug))
+ form_data = extract_form_fields(doc.select('.container-fluid form')[0])
+ form_data['internal_name'] = 'Group discount'
+ form_data['condition_min_count'] = '2'
+ form_data['benefit_discount_matching_percent'] = '20'
+ doc = self.post_doc('/control/event/%s/%s/discounts/add' % (self.orga1.slug, self.event1.slug), form_data)
+ assert doc.select(".alert-success")
+ self.assertIn("Group discount", doc.select("#page-wrapper table")[0].text)
+
+ def test_update(self):
+ c = Discount.objects.create(event=self.event1, internal_name="2 for 1")
+ doc = self.get_doc('/control/event/%s/%s/discounts/%s/' % (self.orga1.slug, self.event1.slug, c.id))
+ form_data = extract_form_fields(doc.select('.container-fluid form')[0])
+ form_data['internal_name'] = 'Group discount'
+ form_data['condition_min_count'] = '2'
+ form_data['benefit_discount_matching_percent'] = '20'
+ doc = self.post_doc('/control/event/%s/%s/discounts/%s/' % (self.orga1.slug, self.event1.slug, c.id),
+ form_data)
+ assert doc.select(".alert-success")
+ self.assertIn("Group discount", doc.select("#page-wrapper table")[0].text)
+ self.assertNotIn("2 for 1", doc.select("#page-wrapper table")[0].text)
+ with scopes_disabled():
+ assert str(Discount.objects.get(id=c.id).benefit_discount_matching_percent) == '20.00'
+
+ def test_sort(self):
+ with scopes_disabled():
+ c1 = Discount.objects.create(event=self.event1, internal_name="Group discount", condition_min_value=2,
+ benefit_discount_matching_percent=20)
+ Discount.objects.create(event=self.event1, internal_name="Big group", condition_min_value=5,
+ benefit_discount_matching_percent=40)
+ doc = self.get_doc('/control/event/%s/%s/discounts/' % (self.orga1.slug, self.event1.slug))
+ self.assertIn("Group discount", doc.select("table > tbody > tr")[0].text)
+ self.assertIn("Big group", doc.select("table > tbody > tr")[1].text)
+
+ self.client.post('/control/event/%s/%s/discounts/%s/down' % (self.orga1.slug, self.event1.slug, c1.id))
+ doc = self.get_doc('/control/event/%s/%s/discounts/' % (self.orga1.slug, self.event1.slug))
+ self.assertIn("Group discount", doc.select("table > tbody > tr")[1].text)
+ self.assertIn("Big group", doc.select("table > tbody > tr")[0].text)
+
+ self.client.post('/control/event/%s/%s/discounts/%s/up' % (self.orga1.slug, self.event1.slug, c1.id))
+ doc = self.get_doc('/control/event/%s/%s/discounts/' % (self.orga1.slug, self.event1.slug))
+ self.assertIn("Group discount", doc.select("table > tbody > tr")[0].text)
+ self.assertIn("Big group", doc.select("table > tbody > tr")[1].text)
+
+ def test_delete(self):
+ with scopes_disabled():
+ c = Discount.objects.create(event=self.event1, internal_name="Group discount", condition_min_value=2,
+ benefit_discount_matching_percent=20)
+ doc = self.get_doc('/control/event/%s/%s/discounts/%s/delete' % (self.orga1.slug, self.event1.slug, c.id))
+ form_data = extract_form_fields(doc.select('.container-fluid form')[0])
+ doc = self.post_doc('/control/event/%s/%s/discounts/%s/delete' % (self.orga1.slug, self.event1.slug, c.id),
+ form_data)
+ assert doc.select(".alert-success")
+ self.assertNotIn("Group discount", doc.select("#page-wrapper")[0].text)
+ with scopes_disabled():
+ assert not Discount.objects.filter(id=c.id).exists()
diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py
index 6bcf8b918..afae3acfa 100644
--- a/src/tests/control/test_orders.py
+++ b/src/tests/control/test_orders.py
@@ -938,7 +938,7 @@ def test_order_extend_expired_voucher_budget_ok(client, env):
)
p = o.positions.first()
p.voucher = v
- p.price_before_voucher = p.price
+ p.voucher_budget_use = Decimal('1.50')
p.price -= Decimal('1.50')
p.save()
@@ -969,7 +969,7 @@ def test_order_extend_expired_voucher_budget_fail(client, env):
)
p = o.positions.first()
p.voucher = v
- p.price_before_voucher = p.price
+ p.voucher_budget_use = Decimal('1.50')
p.price -= Decimal('1.50')
p.save()
diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py
index c21d8c7f3..b8a1b1219 100644
--- a/src/tests/control/test_permissions.py
+++ b/src/tests/control/test_permissions.py
@@ -113,6 +113,12 @@ event_urls = [
"categories/2/up",
"categories/2/down",
"categories/2/delete",
+ "discounts/",
+ "discounts/add",
+ "discounts/2/",
+ "discounts/2/up",
+ "discounts/2/down",
+ "discounts/2/delete",
"questions/",
"questions/2/delete",
"questions/2/",
@@ -325,6 +331,16 @@ event_permission_urls = [
("can_change_items", "quotas/2/change", 404, HTTP_GET),
("can_change_items", "quotas/2/delete", 404, HTTP_GET),
("can_change_items", "quotas/add", 200, HTTP_GET),
+ # ("can_change_items", "discounts/", 200),
+ # We don't have to create categories and similar objects
+ # for testing this, it is enough to test that a 404 error
+ # is returned instead of a 403 one.
+ ("can_change_items", "discounts/2/", 404, HTTP_GET),
+ ("can_change_items", "discounts/2/delete", 404, HTTP_GET),
+ ("can_change_items", "discounts/2/up", 404, HTTP_POST),
+ ("can_change_items", "discounts/2/down", 404, HTTP_POST),
+ ("can_change_items", "discounts/reorder", 400, HTTP_POST),
+ ("can_change_items", "discounts/add", 200, HTTP_GET),
("can_change_event_settings", "subevents/", 200, HTTP_GET),
("can_change_event_settings", "subevents/2/", 404, HTTP_GET),
("can_change_event_settings", "subevents/2/delete", 404, HTTP_GET),
diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py
index 7b7ccbf3e..11ccb3311 100644
--- a/src/tests/presale/test_cart.py
+++ b/src/tests/presale/test_cart.py
@@ -46,15 +46,14 @@ from tests.testdummy.signals import FoobarSalesChannel
from pretix.base.decimal import round_decimal
from pretix.base.models import (
- CartPosition, Event, InvoiceAddress, Item, ItemCategory, ItemVariation,
- Organizer, Question, QuestionAnswer, Quota, SeatingPlan, Voucher,
+ CartPosition, Discount, Event, InvoiceAddress, Item, ItemCategory,
+ ItemVariation, Organizer, Question, QuestionAnswer, Quota, SeatingPlan,
+ Voucher,
)
from pretix.base.models.items import (
ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
)
-from pretix.base.services.cart import (
- CartError, CartManager, error_messages, update_tax_rates,
-)
+from pretix.base.services.cart import CartError, CartManager, error_messages
from pretix.testutils.scope import classscope
from pretix.testutils.sessions import get_cart_session_key
@@ -1557,7 +1556,8 @@ class CartTest(CartTestMixin, TestCase):
self.assertEqual(objs[0].item, self.ticket)
self.assertIsNone(objs[0].variation)
self.assertEqual(objs[0].price, Decimal('21.00'))
- self.assertEqual(objs[0].price_before_voucher, Decimal('23.00'))
+ self.assertEqual(objs[0].listed_price, Decimal('23.00'))
+ self.assertEqual(objs[0].price_after_voucher, Decimal('20.70'))
def test_voucher_free_price_before_voucher_cap(self):
with scopes_disabled():
@@ -1582,7 +1582,8 @@ class CartTest(CartTestMixin, TestCase):
self.assertEqual(objs[0].item, self.ticket)
self.assertIsNone(objs[0].variation)
self.assertEqual(objs[0].price, Decimal('41.00'))
- self.assertEqual(objs[0].price_before_voucher, Decimal('41.00'))
+ self.assertEqual(objs[0].listed_price, Decimal('23.00'))
+ self.assertEqual(objs[0].price_after_voucher, Decimal('20.70'))
def test_voucher_free_price_lower_bound(self):
with scopes_disabled():
@@ -1607,7 +1608,8 @@ class CartTest(CartTestMixin, TestCase):
self.assertEqual(objs[0].item, self.ticket)
self.assertIsNone(objs[0].variation)
self.assertEqual(objs[0].price, Decimal('20.70'))
- self.assertEqual(objs[0].price_before_voucher, Decimal('23.00'))
+ self.assertEqual(objs[0].listed_price, Decimal('23.00'))
+ self.assertEqual(objs[0].price_after_voucher, Decimal('20.70'))
def test_voucher_redemed(self):
with scopes_disabled():
@@ -1977,11 +1979,11 @@ class CartTest(CartTestMixin, TestCase):
with scopes_disabled():
cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=23, expires=now() + timedelta(minutes=10)
+ price=23, listed_price=23, price_after_voucher=23, expires=now() + timedelta(minutes=10)
)
cp2 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_blue,
- price=15, expires=now() + timedelta(minutes=10)
+ price=15, listed_price=15, price_after_voucher=15, expires=now() + timedelta(minutes=10)
)
v = Voucher.objects.create(
event=self.event, item=self.ticket, price_mode='set', value=Decimal('4.00')
@@ -2001,11 +2003,11 @@ class CartTest(CartTestMixin, TestCase):
with scopes_disabled():
cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=23, expires=now() + timedelta(minutes=10)
+ price=23, listed_price=23, price_after_voucher=23, expires=now() + timedelta(minutes=10)
)
cp2 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_blue,
- price=150, expires=now() + timedelta(minutes=10)
+ price=150, listed_price=150, price_after_voucher=150, expires=now() + timedelta(minutes=10)
)
v = Voucher.objects.create(
event=self.event, price_mode='set', value=Decimal('4.00'), max_usages=100, redeemed=99
@@ -2026,11 +2028,11 @@ class CartTest(CartTestMixin, TestCase):
with scopes_disabled():
cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=23, expires=now() + timedelta(minutes=10)
+ price=23, listed_price=23, price_after_voucher=23, expires=now() + timedelta(minutes=10)
)
cp2 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_blue,
- price=150, expires=now() + timedelta(minutes=10)
+ price=150, listed_price=150, price_after_voucher=150, expires=now() + timedelta(minutes=10)
)
v = Voucher.objects.create(
event=self.event, price_mode='set', quota=self.quota_all, value=Decimal('4.00'), max_usages=100
@@ -2051,14 +2053,14 @@ class CartTest(CartTestMixin, TestCase):
with scopes_disabled():
cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=23, expires=now() + timedelta(minutes=10)
+ price=23, listed_price=23, price_after_voucher=23, expires=now() + timedelta(minutes=10)
)
v2 = Voucher.objects.create(
- event=self.event, price_mode='set', quota=self.quota_all, value=Decimal('4.00'), max_usages=100
+ event=self.event, price_mode='set', quota=self.quota_all, value=Decimal('8.00'), max_usages=100
)
cp2 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_blue,
- price=150, expires=now() + timedelta(minutes=10), voucher=v2
+ price=8, expires=now() + timedelta(minutes=10), voucher=v2
)
v = Voucher.objects.create(
event=self.event, price_mode='set', quota=self.quota_all, value=Decimal('4.00'), max_usages=100
@@ -2073,7 +2075,7 @@ class CartTest(CartTestMixin, TestCase):
assert cp1.voucher == v
assert cp1.price == Decimal('4.00')
assert cp2.voucher == v2
- assert cp2.price == Decimal('150.00')
+ assert cp2.price == Decimal('8.00')
"""
def test_voucher_apply_only_positive(self):
@@ -2104,11 +2106,11 @@ class CartTest(CartTestMixin, TestCase):
with scopes_disabled():
cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=23, expires=now() + timedelta(minutes=10)
+ price=23, listed_price=23, price_after_voucher=23, expires=now() + timedelta(minutes=10)
)
cp2 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_blue,
- price=15, expires=now() + timedelta(minutes=10)
+ price=15, listed_price=15, price_after_voucher=15, expires=now() + timedelta(minutes=10)
)
v = Voucher.objects.create(
event=self.event, price_mode='set', value=Decimal('40.00'), max_usages=100,
@@ -2128,11 +2130,11 @@ class CartTest(CartTestMixin, TestCase):
with scopes_disabled():
cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=23, expires=now() + timedelta(minutes=10)
+ price=23, listed_price=23, price_after_voucher=23, expires=now() + timedelta(minutes=10)
)
cp2 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_blue,
- price=15, expires=now() + timedelta(minutes=10)
+ price=15, listed_price=15, price_after_voucher=15, expires=now() + timedelta(minutes=10)
)
v = Voucher.objects.create(
event=self.event, price_mode='set', value=Decimal('40.00'), max_usages=100, redeemed=100
@@ -2147,6 +2149,79 @@ class CartTest(CartTestMixin, TestCase):
assert cp1.voucher is None
assert cp2.voucher is None
+ def test_discount(self):
+ with scopes_disabled():
+ Discount.objects.create(event=self.event, condition_min_count=2, benefit_discount_matching_percent=20,
+ benefit_only_apply_to_cheapest_n_matches=1)
+ self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
+ 'item_%d' % self.ticket.id: '1',
+ }, follow=True)
+ with scopes_disabled():
+ objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
+ self.assertEqual(len(objs), 1)
+ self.assertEqual(objs[0].item, self.ticket)
+ self.assertIsNone(objs[0].variation)
+ self.assertEqual(objs[0].price, 23)
+ self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
+ 'item_%d' % self.ticket.id: '1',
+ }, follow=True)
+ with scopes_disabled():
+ objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
+ self.assertEqual(len(objs), 2)
+ self.assertEqual({objs[0].price, objs[1].price}, {Decimal('23.00'), Decimal('18.40')})
+
+ def test_discount_and_voucher_mixed(self):
+ with scopes_disabled():
+ Discount.objects.create(event=self.event, condition_min_count=2, benefit_discount_matching_percent=50,
+ benefit_only_apply_to_cheapest_n_matches=1)
+ v = Voucher.objects.create(
+ event=self.event, price_mode='set', value=Decimal('4.00'), max_usages=100
+ )
+ self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
+ 'item_%d' % self.ticket.id: '1',
+ '_voucher_code': v.code,
+ }, follow=True)
+ with scopes_disabled():
+ objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
+ self.assertEqual(len(objs), 1)
+ self.assertEqual(objs[0].item, self.ticket)
+ self.assertIsNone(objs[0].variation)
+ self.assertEqual(objs[0].price, 4)
+ self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
+ 'item_%d' % self.ticket.id: '1',
+ '_voucher_code': v.code,
+ }, follow=True)
+ with scopes_disabled():
+ objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
+ self.assertEqual(len(objs), 2)
+ self.assertEqual({objs[0].price, objs[1].price}, {Decimal('4.00'), Decimal('2.00')})
+
+ def test_discount_and_voucher_mix_forbidden(self):
+ with scopes_disabled():
+ Discount.objects.create(event=self.event, condition_min_count=2, benefit_discount_matching_percent=50,
+ benefit_only_apply_to_cheapest_n_matches=1, condition_ignore_voucher_discounted=True)
+ v = Voucher.objects.create(
+ event=self.event, price_mode='set', value=Decimal('4.00'), max_usages=100
+ )
+ self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
+ 'item_%d' % self.ticket.id: '1',
+ '_voucher_code': v.code,
+ }, follow=True)
+ with scopes_disabled():
+ objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
+ self.assertEqual(len(objs), 1)
+ self.assertEqual(objs[0].item, self.ticket)
+ self.assertIsNone(objs[0].variation)
+ self.assertEqual(objs[0].price, 4)
+ self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
+ 'item_%d' % self.ticket.id: '1',
+ '_voucher_code': v.code,
+ }, follow=True)
+ with scopes_disabled():
+ objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
+ self.assertEqual(len(objs), 2)
+ self.assertEqual({objs[0].price, objs[1].price}, {Decimal('4.00'), Decimal('4.00')})
+
class CartAddonTest(CartTestMixin, TestCase):
@scopes_disabled()
@@ -2836,7 +2911,9 @@ class CartAddonTest(CartTestMixin, TestCase):
self.cm.commit()
cp1.refresh_from_db()
assert cp1.expires > now()
- assert cp1.price_before_voucher == Decimal('23.00')
+ assert cp1.listed_price == Decimal('23.00')
+ assert cp1.price_after_voucher == Decimal('20.00')
+ assert cp1.price == Decimal('20.00')
class CartBundleTest(CartTestMixin, TestCase):
@@ -2911,7 +2988,8 @@ class CartBundleTest(CartTestMixin, TestCase):
assert cp.price == 23 - 1.5
assert cp.addons.count() == 1
assert cp.voucher == v
- assert cp.price_before_voucher == 23 - 1.5
+ assert cp.listed_price == 23
+ assert cp.price_after_voucher == 23
a = cp.addons.get()
assert a.item == self.trans
assert a.price == 1.5
@@ -2934,7 +3012,8 @@ class CartBundleTest(CartTestMixin, TestCase):
assert cp.price == 23 - 1.5 - 1.5
assert cp.addons.count() == 1
assert cp.voucher == v
- assert cp.price_before_voucher == 23 - 1.5
+ assert cp.listed_price == 23
+ assert cp.price_after_voucher == 23 - 1.5
a = cp.addons.get()
assert a.item == self.trans
assert a.price == 1.5
@@ -3221,13 +3300,11 @@ class CartBundleTest(CartTestMixin, TestCase):
assert cp.tax_rate == Decimal('19.00')
assert cp.tax_value == Decimal('3.43')
assert cp.addons.count() == 1
- assert cp.includes_tax
a = cp.addons.first()
assert a.item == self.trans
assert a.price == 1.5
assert a.tax_rate == Decimal('7.00')
assert a.tax_value == Decimal('0.10')
- assert a.includes_tax
@classscope(attr='orga')
def test_one_bundled_one_addon(self):
@@ -3421,15 +3498,14 @@ class CartBundleTest(CartTestMixin, TestCase):
event=self.event, cart_id=self.session_key, item=self.trans, addon_to=cp,
price=1.5, expires=now() - timedelta(minutes=10), is_bundled=True
)
- update_tax_rates(self.event, self.session_key, ia)
+ self.cm.invoice_address = ia
+ self.cm.recompute_final_prices_and_taxes()
cp.refresh_from_db()
a.refresh_from_db()
assert cp.price == Decimal('21.50')
assert cp.tax_rate == Decimal('19.00')
- assert cp.includes_tax
assert a.price == Decimal('1.40')
assert a.tax_rate == Decimal('0.00')
- assert not a.includes_tax
self.cm.invoice_address = ia
self.cm.commit()
@@ -3438,10 +3514,8 @@ class CartBundleTest(CartTestMixin, TestCase):
a.refresh_from_db()
assert cp.price == Decimal('21.50')
assert cp.tax_rate == Decimal('19.00')
- assert cp.includes_tax
assert a.price == Decimal('1.40')
assert a.tax_rate == 0
- assert not a.includes_tax
@classscope(attr='orga')
def test_expired_reverse_charge_all(self):
@@ -3464,15 +3538,14 @@ class CartBundleTest(CartTestMixin, TestCase):
event=self.event, cart_id=self.session_key, item=self.trans, addon_to=cp,
price=1.5, expires=now() - timedelta(minutes=10), is_bundled=True
)
- update_tax_rates(self.event, self.session_key, ia)
+ self.cm.invoice_address = ia
+ self.cm.recompute_final_prices_and_taxes()
cp.refresh_from_db()
a.refresh_from_db()
assert cp.price == Decimal('18.07')
assert cp.tax_rate == Decimal('0.00')
- assert not cp.includes_tax
assert a.price == Decimal('1.40')
assert a.tax_rate == Decimal('0.00')
- assert not a.includes_tax
self.cm.invoice_address = ia
self.cm.commit()
@@ -3481,10 +3554,8 @@ class CartBundleTest(CartTestMixin, TestCase):
a.refresh_from_db()
assert cp.price == Decimal('18.07')
assert cp.tax_rate == Decimal('0.00')
- assert not cp.includes_tax
assert a.price == Decimal('1.40')
assert a.tax_rate == Decimal('0.00')
- assert not a.includes_tax
@classscope(attr='orga')
def test_reverse_charge_all_add(self):
@@ -3513,10 +3584,8 @@ class CartBundleTest(CartTestMixin, TestCase):
a = CartPosition.objects.filter(addon_to__isnull=False).get()
assert cp.price == Decimal('18.07')
assert cp.tax_rate == Decimal('0.00')
- assert not cp.includes_tax
assert a.price == Decimal('1.40')
assert a.tax_rate == Decimal('0.00')
- assert not a.includes_tax
@classscope(attr='orga')
def test_reverse_charge_bundled_add_keep_gross_price(self):
@@ -3546,10 +3615,8 @@ class CartBundleTest(CartTestMixin, TestCase):
a = CartPosition.objects.filter(addon_to__isnull=False).get()
assert cp.price == Decimal('21.50')
assert cp.tax_rate == Decimal('19.00')
- assert cp.includes_tax
assert a.price == Decimal('1.50')
assert a.tax_rate == Decimal('0.00')
- assert not a.includes_tax
@classscope(attr='orga')
def test_reverse_charge_bundled_add(self):
@@ -3578,10 +3645,8 @@ class CartBundleTest(CartTestMixin, TestCase):
a = CartPosition.objects.filter(addon_to__isnull=False).get()
assert cp.price == Decimal('21.50')
assert cp.tax_rate == Decimal('19.00')
- assert cp.includes_tax
assert a.price == Decimal('1.40')
assert a.tax_rate == Decimal('0.00')
- assert not a.includes_tax
@classscope(attr='orga')
def test_expired_country_taxing_only_bundled(self):
@@ -3606,17 +3671,16 @@ class CartBundleTest(CartTestMixin, TestCase):
)
a = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.trans, addon_to=cp,
- price=1.47, expires=now() - timedelta(minutes=10), is_bundled=True, override_tax_rate=Decimal('5.00')
+ price=1.47, expires=now() - timedelta(minutes=10), is_bundled=True, tax_rate=Decimal('5.00')
)
- update_tax_rates(self.event, self.session_key, ia)
+ self.cm.invoice_address = ia
+ self.cm.recompute_final_prices_and_taxes()
cp.refresh_from_db()
a.refresh_from_db()
assert cp.price == Decimal('21.50')
assert cp.tax_rate == Decimal('19.00')
- assert cp.includes_tax
assert a.price == Decimal('1.47')
assert a.tax_rate == Decimal('5.00')
- assert a.includes_tax
self.cm.invoice_address = ia
self.cm.commit()
@@ -3625,10 +3689,8 @@ class CartBundleTest(CartTestMixin, TestCase):
a.refresh_from_db()
assert cp.price == Decimal('21.50')
assert cp.tax_rate == Decimal('19.00')
- assert cp.includes_tax
assert a.price == Decimal('1.47')
assert a.tax_rate == Decimal('5.00')
- assert a.includes_tax
@classscope(attr='orga')
def test_expired_country_tax_all(self):
@@ -3654,21 +3716,20 @@ class CartBundleTest(CartTestMixin, TestCase):
cp = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=21.68, expires=now() - timedelta(minutes=10), override_tax_rate=Decimal('20.00')
+ price=21.68, expires=now() - timedelta(minutes=10), tax_rate=Decimal('20.00')
)
a = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.trans, addon_to=cp,
- price=1.47, expires=now() - timedelta(minutes=10), is_bundled=True, override_tax_rate=Decimal('5.00')
+ price=1.47, expires=now() - timedelta(minutes=10), is_bundled=True, tax_rate=Decimal('5.00')
)
- update_tax_rates(self.event, self.session_key, ia)
+ self.cm.invoice_address = ia
+ self.cm.recompute_final_prices_and_taxes()
cp.refresh_from_db()
a.refresh_from_db()
assert cp.price == Decimal('21.68')
assert cp.tax_rate == Decimal('20.00')
- assert cp.includes_tax
assert a.price == Decimal('1.47')
assert a.tax_rate == Decimal('5.00')
- assert a.includes_tax
self.cm.invoice_address = ia
self.cm.commit()
@@ -3677,10 +3738,8 @@ class CartBundleTest(CartTestMixin, TestCase):
a.refresh_from_db()
assert cp.price == Decimal('21.68')
assert cp.tax_rate == Decimal('20.0')
- assert cp.includes_tax
assert a.price == Decimal('1.47')
assert a.tax_rate == Decimal('5.00')
- assert a.includes_tax
@classscope(attr='orga')
def test_country_tax_all_add(self):
@@ -3718,10 +3777,8 @@ class CartBundleTest(CartTestMixin, TestCase):
a = CartPosition.objects.filter(addon_to__isnull=False).get()
assert cp.price == Decimal('21.68')
assert cp.tax_rate == Decimal('20.00')
- assert cp.includes_tax
assert a.price == Decimal('1.47')
assert a.tax_rate == Decimal('5.00')
- assert a.includes_tax
@classscope(attr='orga')
def test_country_tax_bundled_add(self):
@@ -3754,10 +3811,8 @@ class CartBundleTest(CartTestMixin, TestCase):
a = CartPosition.objects.filter(addon_to__isnull=False).get()
assert cp.price == Decimal('21.50')
assert cp.tax_rate == Decimal('19.00')
- assert cp.includes_tax
assert a.price == Decimal('1.47')
assert a.tax_rate == Decimal('5.00')
- assert a.includes_tax
class CartSeatingTest(CartTestMixin, TestCase):
diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py
index 528ecb43f..2e53ba828 100644
--- a/src/tests/presale/test_checkout.py
+++ b/src/tests/presale/test_checkout.py
@@ -39,9 +39,9 @@ from django_scopes import scopes_disabled
from pretix.base.decimal import round_decimal
from pretix.base.models import (
- CartPosition, Event, Invoice, InvoiceAddress, Item, ItemCategory, Order,
- OrderPayment, OrderPosition, Organizer, Question, QuestionAnswer, Quota,
- SeatingPlan, Voucher,
+ CartPosition, Discount, Event, Invoice, InvoiceAddress, Item, ItemCategory,
+ Order, OrderPayment, OrderPosition, Organizer, Question, QuestionAnswer,
+ Quota, SeatingPlan, Voucher,
)
from pretix.base.models.items import (
ItemAddOn, ItemBundle, ItemVariation, SubEventItem, SubEventItemVariation,
@@ -539,7 +539,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
cr1.refresh_from_db()
assert cr1.price == Decimal('23.20')
- assert cr1.override_tax_rate == Decimal('20.00')
+ assert cr1.tax_rate == Decimal('20.00')
assert cr1.tax_value == Decimal('3.87')
return cr1
@@ -572,7 +572,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=23, expires=now() + timedelta(minutes=10),
+ price=23, listed_price=23, price_after_voucher=23, custom_price_input=23, custom_price_input_is_net=False,
+ expires=now() + timedelta(minutes=10),
voucher=self.event.vouchers.create()
)
@@ -621,21 +622,80 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
cr = CartPosition.objects.get(cart_id=self.session_key)
assert cr.price == Decimal('21.26')
- def test_free_price_net_price_reverse_charge_keep_gross(self):
+ def test_free_price_net_price_reverse_charge_keep_gross_but_enforce_min(self):
# This is an end-to-end test of a very confusing case in which the event is set to
# "show net prices" but the tax rate is set to "keep gross if rate changes" in
# combination of free prices.
# This means the user will be greeted with a display price of "23 EUR + VAT". If they
+ # then adjust the price to pay more, e.g. "24 EUR", it will be interpreted as a net
+ # value (since the event is set to shown net values). The cart position is therefore
+ # created with a gross price of 28.56 EUR. Then, the user enters their invoice address, which
+ # triggers reverse charge. The tax is now removed, and the price would be reverted to "24.00 + 0%",
+ # however that is now lower than the minimum price of "27.37 incl VAT", so the price is raised to 27.37.
+ self.event.settings.display_net_prices = True
+ self.ticket.free_price = True
+ self.ticket.save()
+ self.tr19.eu_reverse_charge = True
+ self.tr19.keep_gross_if_rate_changes = True
+ self.tr19.price_includes_tax = False
+ self.tr19.home_country = Country('DE')
+ self.tr19.save()
+ self.event.settings.invoice_address_vatid = True
+
+ response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
+ 'item_%d' % self.ticket.id: '1',
+ 'price_%d' % self.ticket.id: '24.00',
+ }, follow=True)
+ self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug),
+ target_status_code=200)
+
+ with scopes_disabled():
+ cr1 = CartPosition.objects.get()
+ assert cr1.listed_price == Decimal('23.00')
+ assert cr1.custom_price_input == Decimal('24.00')
+ assert cr1.custom_price_input_is_net
+ assert cr1.price == Decimal('28.56')
+ assert cr1.tax_rate == Decimal('19.00')
+
+ with mock.patch('vat_moss.id.validate') as mock_validate:
+ mock_validate.return_value = ('AT', 'AT123456', 'Foo')
+ self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
+ 'is_business': 'business',
+ 'company': 'Foo',
+ 'name': 'Bar',
+ 'street': 'Baz',
+ 'zipcode': '12345',
+ 'city': 'Here',
+ 'country': 'AT',
+ 'vat_id': 'AT123456',
+ 'email': 'admin@localhost'
+ }, follow=True)
+
+ cr1.refresh_from_db()
+ assert cr1.price == Decimal('27.37')
+ assert cr1.tax_rate == Decimal('0.00')
+
+ self._set_session('payment', 'banktransfer')
+
+ response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
+ doc = BeautifulSoup(response.content.decode(), "lxml")
+ self.assertEqual(len(doc.select(".thank-you")), 1)
+ with scopes_disabled():
+ op = OrderPosition.objects.get()
+ self.assertEqual(op.price, Decimal('27.37'))
+ self.assertEqual(op.tax_value, Decimal('0.00'))
+ self.assertEqual(op.tax_rate, Decimal('0.00'))
+
+ def test_free_price_net_price_reverse_charge_keep_gross(self):
+ # This is the slightly happier case of the previous test in which the event is set to
+ # "show net prices" but the tax rate is set to "keep gross if rate changes" in
+ # combination of free prices.
+ # This means the user will be greeted with a display price of "23 EUR + VAT". If they
# then adjust the price to pay more, e.g. "40 EUR", it will be interpreted as a net
# value (since the event is set to shown net values). The cart position is therefore
# created with a gross price of 47.60 EUR. Then, the user enters their invoice address, which
- # triggers reverse charge. The tax is now removed, but since the tax rule is set to
- # keep the gross price the same, the user will still need to pay 47.60 EUR (incl 0% VAT),
- # instead of the 40 EUR the maybe wanted in the first place.
- # While confusing, this behaviour is technically correct and the correct answer to anyone
- # complaining about this is "do not turn display_net_prices and keep_gross_if_rate_changes
- # on at the same time" (display_net_prices only makes sense if you're targeting a B2B
- # audience in which case keep_gross_if_rate_changes is useless or even harmful).
+ # triggers reverse charge. The tax is now removed, and the price is reverted to "40.00 + 0%"
+ # since that was the user's original intent.
self.event.settings.display_net_prices = True
self.ticket.free_price = True
self.ticket.save()
@@ -655,6 +715,9 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
with scopes_disabled():
cr1 = CartPosition.objects.get()
+ assert cr1.listed_price == Decimal('23.00')
+ assert cr1.custom_price_input == Decimal('40.00')
+ assert cr1.custom_price_input_is_net
assert cr1.price == Decimal('47.60')
assert cr1.tax_rate == Decimal('19.00')
@@ -673,7 +736,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
}, follow=True)
cr1.refresh_from_db()
- assert cr1.price == Decimal('47.60')
+ assert cr1.price == Decimal('40.00')
assert cr1.tax_rate == Decimal('0.00')
self._set_session('payment', 'banktransfer')
@@ -683,7 +746,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
self.assertEqual(len(doc.select(".thank-you")), 1)
with scopes_disabled():
op = OrderPosition.objects.get()
- self.assertEqual(op.price, Decimal('47.60'))
+ self.assertEqual(op.price, Decimal('40.00'))
self.assertEqual(op.tax_value, Decimal('0.00'))
self.assertEqual(op.tax_rate, Decimal('0.00'))
@@ -1662,7 +1725,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=42, expires=now() + timedelta(minutes=10)
+ price=42, listed_price=42, price_after_voucher=42, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
@@ -1682,7 +1745,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=0, expires=now() + timedelta(minutes=10)
+ price=0, listed_price=0, price_after_voucher=0, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
@@ -1701,7 +1764,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
price_included=True)
cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=0, expires=now() - timedelta(minutes=10)
+ price=0, listed_price=0, price_after_voucher=0, expires=now() - timedelta(minutes=10)
)
self.ticket.default_price = 0
self.ticket.save()
@@ -1712,7 +1775,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.workshop1,
- price=0, expires=now() - timedelta(minutes=10),
+ price=0, listed_price=0, price_after_voucher=0, expires=now() - timedelta(minutes=10),
addon_to=cp1
)
self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
@@ -1734,7 +1797,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=42, expires=now() + timedelta(minutes=10)
+ price=42, listed_price=42, price_after_voucher=42, expires=now() + timedelta(minutes=10)
)
self._set_session('payment', 'banktransfer')
@@ -1751,7 +1814,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=0, expires=now() + timedelta(minutes=10)
+ price=0, listed_price=0, price_after_voucher=0, expires=now() + timedelta(minutes=10)
)
self._set_session('payment', 'free')
@@ -1771,7 +1834,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=0, expires=now() + timedelta(minutes=10)
+ price=0, listed_price=0, price_after_voucher=0, expires=now() + timedelta(minutes=10)
)
self._set_session('payment', 'free')
@@ -2043,7 +2106,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=23, expires=now() - timedelta(minutes=10)
+ listed_price=23, price_after_voucher=23, custom_price_input=23, price=23,
+ expires=now() - timedelta(minutes=10)
)
self._set_session('payment', 'banktransfer')
@@ -2276,12 +2340,14 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
- self.assertEqual(len(doc.select(".alert-danger")), 1)
+ self.assertGreaterEqual(len(doc.select(".alert-danger")), 1)
with scopes_disabled():
- self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key).count(), 1)
+ self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key).count(), 0)
+ cr1 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.ticket,
+ price=12, expires=now() - timedelta(minutes=10), voucher=v
+ )
- cr1.voucher = v
- cr1.save()
self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
@@ -2342,6 +2408,48 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
self.assertEqual(Order.objects.count(), 1)
self.assertEqual(OrderPosition.objects.count(), 1)
+ def test_discount_success(self):
+ with scopes_disabled():
+ Discount.objects.create(event=self.event, condition_min_count=2, benefit_discount_matching_percent=20)
+ CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.ticket,
+ listed_price=23, price_after_voucher=23, price=18.4, expires=now() - timedelta(minutes=10),
+ )
+ CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.ticket,
+ listed_price=23, price_after_voucher=23, price=18.4, expires=now() - timedelta(minutes=10),
+ )
+ self._set_session('payment', 'banktransfer')
+
+ response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
+ doc = BeautifulSoup(response.content.decode(), "lxml")
+ with scopes_disabled():
+ self.assertFalse(CartPosition.objects.filter(cart_id=self.session_key).exists())
+ self.assertEqual(len(doc.select(".thank-you")), 1)
+ self.assertEqual(Order.objects.count(), 1)
+ self.assertEqual(OrderPosition.objects.count(), 2)
+ self.assertEqual(OrderPosition.objects.filter(price=18.4).count(), 2)
+
+ def test_discount_changed(self):
+ with scopes_disabled():
+ Discount.objects.create(event=self.event, condition_min_count=2, benefit_discount_matching_percent=20)
+ cr1 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.ticket,
+ listed_price=23, price_after_voucher=23, price=23, expires=now() - timedelta(minutes=10),
+ )
+ CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.ticket,
+ listed_price=23, price_after_voucher=23, price=23, expires=now() - timedelta(minutes=10),
+ )
+ self._set_session('payment', 'banktransfer')
+
+ response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
+ doc = BeautifulSoup(response.content.decode(), "lxml")
+ self.assertEqual(len(doc.select(".alert-danger")), 1)
+ with scopes_disabled():
+ cr1 = CartPosition.objects.get(id=cr1.id)
+ self.assertEqual(cr1.price, Decimal('18.40'))
+
def test_max_per_item_failed(self):
self.quota_tickets.size = 3
self.quota_tickets.save()
@@ -2964,7 +3072,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=0, expires=now() + timedelta(minutes=10)
+ price=0, listed_price=0, price_after_voucher=0, expires=now() + timedelta(minutes=10)
)
self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
@@ -2977,7 +3085,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.workshop2, variation=self.workshop2a,
- price=0, expires=now() + timedelta(minutes=10)
+ price=0, listed_price=0, price_after_voucher=0, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
@@ -2997,7 +3105,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.workshop2, variation=self.workshop2a,
- price=0, expires=now() + timedelta(minutes=10)
+ price=0, listed_price=0, price_after_voucher=0, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
@@ -3109,7 +3217,7 @@ class QuestionsTestCase(BaseCheckoutTestCase, TestCase):
)
cr2 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=20, expires=now() + timedelta(minutes=10)
+ price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
@@ -3352,11 +3460,11 @@ class CheckoutBundleTest(BaseCheckoutTestCase, TestCase):
)
self.cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=21.5, expires=now() + timedelta(minutes=10)
+ price=21.5, listed_price=23, price_after_voucher=23, expires=now() + timedelta(minutes=10)
)
self.bundled1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.trans, addon_to=self.cp1,
- price=1.5, expires=now() + timedelta(minutes=10), is_bundled=True
+ price=1.5, listed_price=1.5, price_after_voucher=1.5, expires=now() + timedelta(minutes=10), is_bundled=True
)
@classscope(attr='orga')
@@ -3417,6 +3525,10 @@ class CheckoutBundleTest(BaseCheckoutTestCase, TestCase):
self.ticket.free_price = True
self.ticket.default_price = 1
self.ticket.save()
+ self.cp1.custom_price_input = 20
+ self.cp1.listed_price = 1
+ self.cp1.price_after_voucher = 1
+ self.cp1.line_price = 20 - 1.5
self.cp1.price = 20 - 1.5
self.cp1.save()
@@ -3434,6 +3546,10 @@ class CheckoutBundleTest(BaseCheckoutTestCase, TestCase):
self.ticket.free_price = True
self.ticket.default_price = 1
self.ticket.save()
+ self.cp1.custom_price_input = 1
+ self.cp1.listed_price = 1
+ self.cp1.price_after_voucher = 1
+ self.cp1.line_price = 0
self.cp1.price = 0
self.cp1.save()
@@ -3514,12 +3630,12 @@ class CheckoutBundleTest(BaseCheckoutTestCase, TestCase):
self.cp1.save()
self.bundled1.expires = now() - timedelta(minutes=10)
self.bundled1.save()
- with self.assertRaises(OrderError):
- _perform_order(self.event, 'manual', [self.cp1.pk, self.bundled1.pk], 'admin@example.org', 'en', None, {}, 'web')
- self.cp1.refresh_from_db()
- self.bundled1.refresh_from_db()
- assert self.cp1.price == 21
- assert self.bundled1.price == 2
+ oid = _perform_order(self.event, 'manual', [self.cp1.pk, self.bundled1.pk], 'admin@example.org', 'en', None, {}, 'web')
+ o = Order.objects.get(pk=oid)
+ cp = o.positions.get(addon_to__isnull=True)
+ b = cp.addons.first()
+ assert cp.price == 21
+ assert b.price == 2
@classscope(attr='orga')
def test_expired_designated_price_changed_beyond_base_price(self):
@@ -3558,10 +3674,8 @@ class CheckoutBundleTest(BaseCheckoutTestCase, TestCase):
price=2.5, expires=now() - timedelta(minutes=10), is_bundled=False
)
self.cp1.expires = now() - timedelta(minutes=10)
- self.cp1.includes_tax = False
self.cp1.save()
self.bundled1.expires = now() - timedelta(minutes=10)
- self.bundled1.includes_tax = False
self.bundled1.save()
oid = _perform_order(self.event, 'manual', [self.cp1.pk, self.bundled1.pk, a.pk], 'admin@example.org', 'en', None, {}, 'web')
@@ -3632,7 +3746,6 @@ class CheckoutBundleTest(BaseCheckoutTestCase, TestCase):
self.cp1.save()
self.bundled1.expires = now() - timedelta(minutes=10)
self.bundled1.price = Decimal('1.40')
- self.bundled1.includes_tax = False
self.bundled1.save()
oid = _perform_order(self.event, 'manual', [self.cp1.pk, self.bundled1.pk], 'admin@example.org', 'en', ia.pk, {}, 'web')
@@ -3663,11 +3776,9 @@ class CheckoutBundleTest(BaseCheckoutTestCase, TestCase):
self.trans.save()
self.cp1.expires = now() - timedelta(minutes=10)
self.cp1.price = Decimal('18.07')
- self.cp1.includes_tax = False
self.cp1.save()
self.bundled1.expires = now() - timedelta(minutes=10)
self.bundled1.price = Decimal('1.40')
- self.bundled1.includes_tax = False
self.bundled1.save()
oid = _perform_order(self.event, 'manual', [self.cp1.pk, self.bundled1.pk], 'admin@example.org', 'en', ia.pk, {}, 'web')
@@ -3717,7 +3828,7 @@ class CheckoutSeatingTest(BaseCheckoutTestCase, TestCase):
self.seat_a3 = self.event.seats.create(seat_number="A3", product=self.ticket, seat_guid="A3")
self.cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price=21.5, expires=now() + timedelta(minutes=10), seat=self.seat_a1
+ price=21.5, listed_price=21.5, price_after_voucher=21.5, expires=now() + timedelta(minutes=10), seat=self.seat_a1
)
@scopes_disabled()
@@ -3800,11 +3911,11 @@ class CheckoutVoucherBudgetTest(BaseCheckoutTestCase, TestCase):
valid_until=now() + timedelta(days=2), max_usages=999, redeemed=0)
self.cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price_before_voucher=23, price=21.5, expires=now() + timedelta(minutes=10), voucher=self.v
+ price_after_voucher=21.5, listed_price=23, price=21.5, expires=now() + timedelta(minutes=10), voucher=self.v
)
self.cp2 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
- price_before_voucher=23, price=21.5, expires=now() + timedelta(minutes=10), voucher=self.v
+ price_after_voucher=21.5, listed_price=23, price=21.5, expires=now() + timedelta(minutes=10), voucher=self.v
)
@scopes_disabled()
@@ -3814,7 +3925,7 @@ class CheckoutVoucherBudgetTest(BaseCheckoutTestCase, TestCase):
o = Order.objects.get(pk=oid)
op = o.positions.first()
assert op.item == self.ticket
- assert op.price_before_voucher == Decimal('23.00')
+ assert op.voucher_budget_use == Decimal('1.50')
@scopes_disabled()
def test_budget_exceeded_for_second_order(self):