This commit is contained in:
Raphael Michel
2022-04-27 14:43:16 +02:00
committed by GitHub
parent 7730cc6170
commit 6fee0ac0a9
56 changed files with 4159 additions and 480 deletions

View File

@@ -37,9 +37,15 @@ from pretix.base.models import Quota, Seat
from pretix.base.models.orders import CartPosition
class TaxIncludedField(serializers.Field):
def to_representation(self, instance: CartPosition):
return not instance.custom_price_input_is_net
class CartPositionSerializer(I18nAwareModelSerializer):
answers = AnswerSerializer(many=True)
seat = InlineSeatSerializer()
includes_tax = TaxIncludedField(source='*')
class Meta:
model = CartPosition
@@ -54,6 +60,7 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
sales_channel = serializers.CharField(required=False, default='sales_channel')
includes_tax = serializers.BooleanField(required=False, allow_null=True)
class Meta:
model = CartPosition
@@ -127,6 +134,9 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError('The specified product requires to choose a seat.')
validated_data.pop('sales_channel')
validated_data['custom_price_input'] = validated_data['price'] # todo: does this make sense?
# todo: listed price, etc?
validated_data['custom_price_input_is_net'] = validated_data.pop('includes_tax')
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data:

View File

@@ -0,0 +1,49 @@
#
# 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 <https://pretix.eu/about/en/license>.
#
# 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
# <https://www.gnu.org/licenses/>.
#
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Discount
class DiscountSerializer(I18nAwareModelSerializer):
class Meta:
model = Discount
fields = ('id', 'active', 'internal_name', 'position', 'sales_channels', 'available_from',
'available_until', 'subevent_mode', 'condition_all_products', 'condition_limit_products',
'condition_apply_to_addons', 'condition_min_count', 'condition_min_value',
'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches',
'condition_ignore_voucher_discounted')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['condition_limit_products'].queryset = self.context['event'].items.all()
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
Discount.validate_config(full_data)
return data

View File

@@ -56,7 +56,9 @@ from pretix.base.models.orders import (
from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages
from pretix.base.services.locking import NoLockManager
from pretix.base.services.pricing import get_price
from pretix.base.services.pricing import (
apply_discounts, get_line_price, get_listed_price, is_included_for_free,
)
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
from pretix.base.signals import register_ticket_outputs
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -1040,29 +1042,18 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if v.budget is not None:
price = pos_data.get('price')
listed_price = get_listed_price(pos_data.get('item'), pos_data.get('variation'), pos_data.get('subevent'))
if pos_data.get('voucher'):
price_after_voucher = pos_data.get('voucher').calculate_price(listed_price)
else:
price_after_voucher = listed_price
if price is None:
price = get_price(
item=pos_data.get('item'),
variation=pos_data.get('variation'),
voucher=v,
custom_price=None,
subevent=pos_data.get('subevent'),
addon_to=pos_data.get('addon_to'),
invoice_address=ia,
).gross
pbv = get_price(
item=pos_data['item'],
variation=pos_data.get('variation'),
voucher=None,
custom_price=None,
subevent=pos_data.get('subevent'),
addon_to=pos_data.get('addon_to'),
invoice_address=ia,
)
price = price_after_voucher
if v not in v_budget:
v_budget[v] = v.budget - v.budget_used()
disc = pbv.gross - price
disc = max(listed_price - price, 0)
if disc > v_budget[v]:
new_disc = v_budget[v]
v_budget[v] -= new_disc
@@ -1158,52 +1149,85 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.invoice_address = ia
ia.last_modified = now()
# Generate position objects
pos_map = {}
for pos_data in positions_data:
answers_data = pos_data.pop('answers', [])
addon_to = pos_data.pop('addon_to', None)
attendee_name = pos_data.pop('attendee_name', '')
if attendee_name and not pos_data.get('attendee_name_parts'):
pos_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
pos = OrderPosition(**pos_data)
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers'})
if simulate:
pos.order = order._wrapped
else:
pos.order = order
if addon_to:
if simulate:
pos.addon_to = pos_map[addon_to]._wrapped
pos.addon_to = pos_map[addon_to]
else:
pos.addon_to = pos_map[addon_to]
if pos.price is None:
price = get_price(
item=pos.item,
variation=pos.variation,
voucher=pos.voucher,
custom_price=None,
subevent=pos.subevent,
addon_to=pos.addon_to,
invoice_address=ia,
)
pos.price = price.gross
pos.tax_rate = price.rate
pos.tax_value = price.tax
pos.tax_rule = pos.item.tax_rule
else:
pos._calculate_tax()
pos_map[pos.positionid] = pos
pos_data['__instance'] = pos
pos.price_before_voucher = get_price(
item=pos.item,
variation=pos.variation,
voucher=None,
custom_price=None,
subevent=pos.subevent,
addon_to=pos.addon_to,
invoice_address=ia,
).gross
# Calculate prices if not set
for pos_data in positions_data:
pos = pos_data['__instance']
if pos.addon_to_id and is_included_for_free(pos.item, pos.addon_to):
listed_price = Decimal('0.00')
else:
listed_price = get_listed_price(pos.item, pos.variation, pos.subevent)
if pos.price is None:
if pos.voucher:
price_after_voucher = pos.voucher.calculate_price(listed_price)
else:
price_after_voucher = listed_price
line_price = get_line_price(
price_after_voucher=price_after_voucher,
custom_price_input=None,
custom_price_input_is_net=False,
tax_rule=pos.item.tax_rule,
invoice_address=ia,
bundled_sum=Decimal('0.00'),
)
pos.price = line_price.gross
pos._auto_generated_price = True
else:
if pos.voucher:
if not pos.item.tax_rule or pos.item.tax_rule.price_includes_tax:
price_after_voucher = max(pos.price, pos.voucher.calculate_price(listed_price))
else:
price_after_voucher = max(pos.price - pos.tax_value, pos.voucher.calculate_price(listed_price))
else:
price_after_voucher = listed_price
pos._auto_generated_price = False
pos._voucher_discount = listed_price - price_after_voucher
if pos.voucher:
pos.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
order_positions = [pos_data['__instance'] for pos_data in positions_data]
discount_results = apply_discounts(
self.context['event'],
order.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.price, bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
for cp in order_positions
]
)
for cp, (new_price, discount) in zip(order_positions, discount_results):
if new_price != pos.price and pos._auto_generated_price:
pos.price = new_price
pos.discount = discount
# Save instances
for pos_data in positions_data:
answers_data = pos_data.pop('answers', [])
pos = pos_data['__instance']
pos._calculate_tax()
if simulate:
pos = WrappedModel(pos)
@@ -1216,6 +1240,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
answers.append(answ)
pos.answers = answers
pos.pseudonymization_id = "PREVIEW"
pos_map[pos.positionid] = pos
else:
if pos.voucher:
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
@@ -1238,7 +1263,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
else:
answ = pos.answers.create(**answ_data)
answ.options.add(*options)
pos_map[pos.positionid] = pos
if not simulate:
for cp in delete_cps:

View File

@@ -41,8 +41,8 @@ from rest_framework import routers
from pretix.api.views import cart
from .views import (
checkin, device, event, exporters, item, oauth, order, organizer, upload,
user, version, voucher, waitinglist, webhooks,
checkin, device, discount, event, exporters, item, oauth, order, organizer,
upload, user, version, voucher, waitinglist, webhooks,
)
router = routers.DefaultRouter()
@@ -72,6 +72,7 @@ event_router.register(r'clone', event.CloneEventViewSet)
event_router.register(r'items', item.ItemViewSet)
event_router.register(r'categories', item.ItemCategoryViewSet)
event_router.register(r'questions', item.QuestionViewSet)
event_router.register(r'discounts', discount.DiscountViewSet)
event_router.register(r'quotas', item.QuotaViewSet)
event_router.register(r'vouchers', voucher.VoucherViewSet)
event_router.register(r'orders', order.OrderViewSet)

View File

@@ -0,0 +1,99 @@
#
# 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 <https://pretix.eu/about/en/license>.
#
# 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
# <https://www.gnu.org/licenses/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Ture Gjørup
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets
from rest_framework.exceptions import PermissionDenied
from rest_framework.filters import OrderingFilter
from pretix.api.serializers.discount import DiscountSerializer
from pretix.api.views import ConditionalListView
from pretix.base.models import CartPosition, Discount
with scopes_disabled():
class DiscountFilter(FilterSet):
class Meta:
model = Discount
fields = ['active']
class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = DiscountSerializer
queryset = Discount.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
filterset_class = DiscountFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
permission = None
write_permission = 'can_change_items'
def get_queryset(self):
return self.request.event.discounts.all()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.discount.added',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
return ctx
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.discount.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied('You cannot delete this discount because it already has '
'been used as part of an order.')
instance.log_action(
'pretix.event.discount.deleted',
user=self.request.user,
auth=self.request.auth,
)
CartPosition.objects.filter(discount=instance).update(discount=None)
super().perform_destroy(instance)

View File

@@ -89,6 +89,13 @@ class SalesChannel:
"""
return True
@property
def discounts_supported(self) -> bool:
"""
If this property is ``True``, this sales channel can be selected for automatic discounts.
"""
return True
def get_all_sales_channels():
global _ALL_CHANNELS

View File

@@ -0,0 +1,89 @@
# Generated by Django 3.2.2 on 2022-03-03 20:17
from decimal import Decimal
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0209_device_info'),
]
operations = [
migrations.AddField(
model_name='cartposition',
name='custom_price_input',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
migrations.AddField(
model_name='cartposition',
name='custom_price_input_is_net',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='cartposition',
name='line_price_gross',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
migrations.AddField(
model_name='cartposition',
name='listed_price',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
migrations.AddField(
model_name='cartposition',
name='price_after_voucher',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
migrations.AddField(
model_name='cartposition',
name='tax_rate',
field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=7),
),
migrations.CreateModel(
name='Discount',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('active', models.BooleanField(default=True)),
('internal_name', models.CharField(max_length=255)),
('position', models.PositiveIntegerField(default=0)),
('sales_channels', pretix.base.models.fields.MultiStringField(default=['web'])),
('available_from', models.DateTimeField(blank=True, null=True)),
('available_until', models.DateTimeField(blank=True, null=True)),
('subevent_mode', models.CharField(max_length=50, default='mixed')),
('condition_all_products', models.BooleanField(default=True)),
('condition_min_count', models.PositiveIntegerField(default=0)),
('condition_min_value', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
('benefit_discount_matching_percent', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
('benefit_only_apply_to_cheapest_n_matches', models.PositiveIntegerField(null=True)),
('condition_limit_products', models.ManyToManyField(to='pretixbase.Item')),
('condition_apply_to_addons', models.BooleanField(default=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='discounts', to='pretixbase.event')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.AddField(
model_name='cartposition',
name='discount',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, to='pretixbase.discount'),
),
migrations.AddField(
model_name='orderposition',
name='discount',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, to='pretixbase.discount'),
),
migrations.AddField(
model_name='orderposition',
name='voucher_budget_use',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 3.2.2 on 2022-03-14 20:01
from decimal import Decimal
from django.db import migrations
from django.db.models import F
from django.db.models.functions import Greatest
def migrate_voucher_budget_use(apps, schema_editor):
OrderPosition = apps.get_model('pretixbase', 'OrderPosition') # noqa
OrderPosition.all.filter(
price_before_voucher__isnull=False
).exclude(price=F('price_before_voucher')).update(
voucher_budget_use=Greatest(F('price') - F('price_before_voucher'), Decimal('0.00'))
)
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0210_auto_20220303_2017'),
]
operations = [
migrations.RunPython(
migrate_voucher_budget_use,
migrations.RunPython.noop,
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 3.2.12 on 2022-03-18 14:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0211_auto_20220314_2001'),
]
operations = [
migrations.RemoveField(
model_name='cartposition',
name='includes_tax',
),
migrations.RemoveField(
model_name='cartposition',
name='override_tax_rate',
),
migrations.RemoveField(
model_name='cartposition',
name='price_before_voucher',
),
migrations.RemoveField(
model_name='orderposition',
name='price_before_voucher',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.2 on 2022-04-13 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0212_auto_20220318_1408'),
]
operations = [
migrations.AddField(
model_name='discount',
name='condition_ignore_voucher_discounted',
field=models.BooleanField(default=False),
),
]

View File

@@ -25,6 +25,7 @@ from .base import CachedFile, LoggedModel, cachedfile_name
from .checkin import Checkin, CheckinList
from .customers import Customer
from .devices import Device, Gate
from .discount import Discount
from .event import (
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
SubEvent, SubEventMetaValue, generate_invite_token,

View File

@@ -0,0 +1,368 @@
#
# 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 <https://pretix.eu/about/en/license>.
#
# 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
# <https://www.gnu.org/licenses/>.
#
from collections import defaultdict
from decimal import Decimal
from itertools import groupby
from typing import Dict, Optional, Tuple
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal
from pretix.base.models import fields
from pretix.base.models.base import LoggedModel
class Discount(LoggedModel):
SUBEVENT_MODE_MIXED = 'mixed'
SUBEVENT_MODE_SAME = 'same'
SUBEVENT_MODE_DISTINCT = 'distinct'
SUBEVENT_MODE_CHOICES = (
(SUBEVENT_MODE_MIXED, pgettext_lazy('subevent', 'Dates can be mixed without limitation')),
(SUBEVENT_MODE_SAME, pgettext_lazy('subevent', 'All matching products must be for the same date')),
(SUBEVENT_MODE_DISTINCT, pgettext_lazy('subevent', 'Each matching product must be for a different date')),
)
event = models.ForeignKey(
'Event',
on_delete=models.CASCADE,
related_name='discounts',
)
active = models.BooleanField(
verbose_name=_("Active"),
default=True,
)
internal_name = models.CharField(
verbose_name=_("Internal name"),
max_length=255
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
sales_channels = fields.MultiStringField(
verbose_name=_('Sales channels'),
default=['web'],
blank=False,
)
available_from = models.DateTimeField(
verbose_name=_("Available from"),
null=True,
blank=True,
)
available_until = models.DateTimeField(
verbose_name=_("Available until"),
null=True,
blank=True,
)
subevent_mode = models.CharField(
verbose_name=_('Event series handling'),
max_length=50,
default=SUBEVENT_MODE_MIXED,
choices=SUBEVENT_MODE_CHOICES,
)
condition_all_products = models.BooleanField(
default=True,
verbose_name=_("Apply to all products (including newly created ones)")
)
condition_limit_products = models.ManyToManyField(
'Item',
verbose_name=_("Apply to specific products"),
blank=True
)
condition_apply_to_addons = models.BooleanField(
default=True,
verbose_name=_("Apply to add-on products"),
help_text=_("Discounts never apply to bundled products"),
)
condition_ignore_voucher_discounted = models.BooleanField(
default=False,
verbose_name=_("Ignore products discounted by a voucher"),
help_text=_("If this option is checked, products that already received a discount through a voucher will not "
"be considered for this discount. However, products that use a voucher only to e.g. unlock a "
"hidden product or gain access to sold-out quota will still receive the discount."),
)
condition_min_count = models.PositiveIntegerField(
verbose_name=_('Minimum number of matching products'),
default=0,
)
condition_min_value = models.DecimalField(
verbose_name=_('Minimum gross value of matching products'),
decimal_places=2,
max_digits=10,
default=Decimal('0.00'),
)
benefit_discount_matching_percent = models.DecimalField(
verbose_name=_('Percentual discount on matching products'),
decimal_places=2,
max_digits=10,
default=Decimal('0.00'),
validators=[MinValueValidator(Decimal('0.00'))],
)
benefit_only_apply_to_cheapest_n_matches = models.PositiveIntegerField(
verbose_name=_('Apply discount only to this number of matching products'),
help_text=_(
'This option allows you to create discounts of the type "buy X get Y reduced/for free". For example, if '
'you set "Minimum number of matching products" to four and this value to two, the customer\'s cart will be '
'split into grups of four tickets and the cheapest two tickets within every group will be discounted. If '
'you want to grant the discount on all matching products, keep this field empty.'
),
null=True,
blank=True,
validators=[MinValueValidator(1)],
)
# more feature ideas:
# - max_usages_per_order
# - promote_to_user_if_almost_satisfied
# - require_customer_account
objects = ScopedManager(organizer='event__organizer')
class Meta:
ordering = ('position', 'id')
def __str__(self):
return self.internal_name
@property
def sortkey(self):
return self.position, self.id
def __lt__(self, other) -> bool:
return self.sortkey < other.sortkey
@classmethod
def validate_config(cls, data):
# We forbid a few combinations of settings, because we don't think they are neccessary and at the same
# time they introduce edge cases, in which it becomes almost impossible to compute the discount optimally
# and also very hard to understand for the user what is going on.
if data.get('condition_min_count') and data.get('condition_min_value'):
raise ValidationError(
_('You can either set a minimum number of matching products or a minimum value, not both.')
)
if not data.get('condition_min_count') and not data.get('condition_min_value'):
raise ValidationError(
_('You need to either set a minimum number of matching products or a minimum value.')
)
if data.get('condition_min_value') and data.get('benefit_only_apply_to_cheapest_n_matches'):
raise ValidationError(
_('You cannot apply the discount only to some of the matched products if you are matching '
'on a minimum value.')
)
if data.get('subevent_mode') == cls.SUBEVENT_MODE_DISTINCT and data.get('condition_min_value'):
raise ValidationError(
_('You cannot apply the discount only to bookings of different dates if you are matching '
'on a minimum value.')
)
def allow_delete(self):
return not self.orderposition_set.exists()
def clean(self):
super().clean()
Discount.validate_config({
'condition_min_count': self.condition_min_count,
'condition_min_value': self.condition_min_value,
'benefit_only_apply_to_cheapest_n_matches': self.benefit_only_apply_to_cheapest_n_matches,
'subevent_mode': self.subevent_mode,
})
def _apply_min_value(self, positions, idx_group, result):
if self.condition_min_value and sum(positions[idx][2] for idx in idx_group) < self.condition_min_value:
return
if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches:
raise ValueError('Validation invariant violated.')
for idx in idx_group:
previous_price = positions[idx][2]
new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency,
)
result[idx] = new_price
def _apply_min_count(self, positions, idx_group, result):
if len(idx_group) < self.condition_min_count:
return
if not self.condition_min_count or self.condition_min_value:
raise ValueError('Validation invariant violated.')
if self.benefit_only_apply_to_cheapest_n_matches:
if not self.condition_min_count:
raise ValueError('Validation invariant violated.')
idx_group = sorted(idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
# want to match multiples of 3
consume_idx = idx_group[:len(idx_group) // self.condition_min_count * self.condition_min_count]
benefit_idx = idx_group[:len(idx_group) // self.condition_min_count * self.benefit_only_apply_to_cheapest_n_matches]
else:
consume_idx = idx_group
benefit_idx = idx_group
for idx in benefit_idx:
previous_price = positions[idx][2]
new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency,
)
result[idx] = new_price
for idx in consume_idx:
result.setdefault(idx, positions[idx][2])
def apply(self, positions: Dict[int, Tuple[int, Optional[int], Decimal, bool, Decimal]]) -> Dict[int, Decimal]:
"""
Tries to apply this discount to a cart
:param positions: Dictionary mapping IDs to tuples of the form
``(item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)``.
Bundled positions may not be included.
:return: A dictionary mapping keys from the input dictionary to new prices. All positions
contained in this dictionary are considered "consumed" and should not be considered
by other discounts.
"""
result = {}
if not self.active:
return result
limit_products = set()
if not self.condition_all_products:
limit_products = {p.pk for p in self.condition_limit_products.all()}
# First, filter out everything not even covered by our product scope
initial_candidates = [
idx
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items()
if (
(self.condition_all_products or item_id in limit_products) and
(self.condition_apply_to_addons or not is_addon_to) and
(not self.condition_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00'))
)
]
if self.subevent_mode == self.SUBEVENT_MODE_MIXED: # also applies to non-series events
if self.condition_min_count:
self._apply_min_count(positions, initial_candidates, result)
else:
self._apply_min_value(positions, initial_candidates, result)
elif self.subevent_mode == self.SUBEVENT_MODE_SAME:
def key(idx):
return positions[idx][1] # subevent_id
# Build groups of candidates with the same subevent, then apply our regular algorithm
# to each group
_groups = groupby(sorted(initial_candidates, key=key), key=key)
candidate_groups = [list(g) for k, g in _groups]
for g in candidate_groups:
if self.condition_min_count:
self._apply_min_count(positions, g, result)
else:
self._apply_min_value(positions, g, result)
elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT:
if self.condition_min_value:
raise ValueError('Validation invariant violated.')
# Build optimal groups of candidates with distinct subevents, then apply our regular algorithm
# to each group. Optimal, in this case, means:
# - First try to build as many groups of size condition_min_count as possible while trying to
# balance out the cheapest products so that they are not all in the same group
# - Then add remaining positions to existing groups if possible
candidate_groups = []
# Build a list of subevent IDs in descending order of frequency
subevent_to_idx = defaultdict(list)
for idx, p in positions.items():
subevent_to_idx[p[1]].append(idx)
for v in subevent_to_idx.values():
v.sort(key=lambda idx: positions[idx][2])
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
# Build groups of exactly condition_min_count distinct subevents
current_group = []
while True:
# Build a list of candidates, which is a list of all positions belonging to a subevent of the
# maximum cardinality, where the cardinality of a subevent is defined as the number of tickets
# for that subevent that are not yet part of any group
candidates = []
cardinality = None
for se, l in subevent_to_idx.items():
l = [ll for ll in l if ll not in current_group]
if cardinality and len(l) != cardinality:
continue
if se not in {positions[idx][1] for idx in current_group}:
candidates += l
cardinality = len(l)
if not candidates:
break
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
# and 2 from the end" scheme to optimize price distribution among groups
candidates = sorted(candidates, key=lambda idx: positions[idx][2])
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
candidate = candidates[0]
else:
candidate = candidates[-1]
current_group.append(candidate)
# Only add full groups to the list of groups
if len(current_group) >= max(self.condition_min_count, 1):
candidate_groups.append(current_group)
for c in current_group:
subevent_to_idx[positions[c][1]].remove(c)
current_group = []
# Distribute "leftovers"
for se in subevent_order:
if subevent_to_idx[se]:
for group in candidate_groups:
if se not in {positions[idx][1] for idx in group}:
group.append(subevent_to_idx[se].pop())
if not subevent_to_idx[se]:
break
for g in candidate_groups:
self._apply_min_count(positions, g, result)
return result

View File

@@ -138,8 +138,8 @@ class LogEntry(models.Model):
@cached_property
def display_object(self):
from . import (
Event, Item, ItemCategory, Order, Question, Quota, SubEvent,
TaxRule, Voucher,
Discount, Event, Item, ItemCategory, Order, Question, Quota,
SubEvent, TaxRule, Voucher,
)
try:
@@ -202,6 +202,16 @@ class LogEntry(models.Model):
}),
'val': escape(co.name),
}
elif isinstance(co, Discount):
a_text = _('Discount {val}')
a_map = {
'href': reverse('control:event.items.discounts.edit', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'discount': co.id
}),
'val': escape(co.internal_name),
}
elif isinstance(co, ItemCategory):
a_text = _('Category {val}')
a_map = {

View File

@@ -906,10 +906,10 @@ class Order(LockModel, LoggedModel):
if force:
continue
if op.voucher and op.voucher.budget is not None and op.price_before_voucher is not None:
if op.voucher and op.voucher.budget is not None and op.voucher_budget_use:
if op.voucher not in v_budget:
v_budget[op.voucher] = op.voucher.budget - op.voucher.budget_used()
disc = op.price_before_voucher - op.price
disc = op.voucher_budget_use
if disc > v_budget[op.voucher]:
raise Quota.QuotaExceededException(error_messages['voucher_budget'].format(
voucher=op.voucher.code
@@ -1275,9 +1275,6 @@ class AbstractPosition(models.Model):
verbose_name=_("Variation"),
on_delete=models.PROTECT
)
price_before_voucher = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
price = models.DecimalField(
decimal_places=2, max_digits=10,
verbose_name=_("Price")
@@ -1314,6 +1311,10 @@ class AbstractPosition(models.Model):
)
is_bundled = models.BooleanField(default=False)
discount = models.ForeignKey(
'Discount', null=True, blank=True, on_delete=models.RESTRICT
)
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'), null=True)
street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True, null=True)
@@ -2160,6 +2161,9 @@ class OrderPosition(AbstractPosition):
related_name='all_positions',
on_delete=models.PROTECT
)
voucher_budget_use = models.DecimalField(
max_digits=10, decimal_places=2, null=True, blank=True,
)
tax_rate = models.DecimalField(
max_digits=7, decimal_places=2,
verbose_name=_('Tax rate')
@@ -2232,6 +2236,8 @@ class OrderPosition(AbstractPosition):
else:
setattr(op, f.name, getattr(cartpos, f.name))
op._calculate_tax()
if cartpos.voucher:
op.voucher_budget_use = cartpos.listed_price - cartpos.price_after_voucher
op.positionid = i + 1
op.save()
ops.append(op)
@@ -2580,12 +2586,25 @@ class CartPosition(AbstractPosition):
verbose_name=_("Expiration date"),
db_index=True
)
includes_tax = models.BooleanField(
default=True
tax_rate = models.DecimalField(
max_digits=7, decimal_places=2, default=Decimal('0.00'),
verbose_name=_('Tax rate')
)
override_tax_rate = models.DecimalField(
max_digits=10, decimal_places=2,
null=True, blank=True
listed_price = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
price_after_voucher = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
custom_price_input = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
custom_price_input_is_net = models.BooleanField(
default=False,
)
line_price_gross = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
objects = ScopedManager(organizer='event__organizer')
@@ -2599,21 +2618,66 @@ class CartPosition(AbstractPosition):
self.item.id, self.variation.id if self.variation else 0, self.cart_id
)
@property
def tax_rate(self):
if self.includes_tax:
if self.override_tax_rate is not None:
return self.override_tax_rate
return self.item.tax(self.price, base_price_is='gross').rate
else:
return Decimal('0.00')
@property
def tax_value(self):
if self.includes_tax:
return self.item.tax(self.price, override_tax_rate=self.override_tax_rate, base_price_is='gross').tax
net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))),
self.event.currency)
return self.price - net
def update_listed_price_and_voucher(self, voucher_only=False, max_discount=None):
from pretix.base.services.pricing import (
get_listed_price, is_included_for_free,
)
if voucher_only:
listed_price = self.listed_price
else:
return Decimal('0.00')
if self.addon_to_id and is_included_for_free(self.item, self.addon_to):
listed_price = Decimal('0.00')
else:
listed_price = get_listed_price(self.item, self.variation, self.subevent)
if self.voucher:
price_after_voucher = self.voucher.calculate_price(listed_price, max_discount)
else:
price_after_voucher = listed_price
if self.is_bundled:
bundle = self.addon_to.item.bundles.filter(bundled_item=self.item, bundled_variation=self.variation).first()
if bundle:
listed_price = bundle.designated_price
price_after_voucher = bundle.designated_price
if listed_price != self.listed_price or price_after_voucher != self.price_after_voucher:
self.listed_price = listed_price
self.price_after_voucher = price_after_voucher
self.save(update_fields=['listed_price', 'price_after_voucher'])
def migrate_free_price_if_necessary(self):
# Migrate from pre-discounts position
if self.item.free_price and self.custom_price_input is None:
custom_price = self.price
if custom_price > 100000000:
raise ValueError('price_too_high')
self.custom_price_input = custom_price
self.custom_price_input_is_net = not False
self.save(update_fields=['custom_price_input', 'custom_price_input_is_net'])
def update_line_price(self, invoice_address, bundled_positions):
from pretix.base.services.pricing import get_line_price
line_price = get_line_price(
price_after_voucher=self.price_after_voucher,
custom_price_input=self.custom_price_input,
custom_price_input_is_net=self.custom_price_input_is_net,
tax_rule=self.item.tax_rule,
invoice_address=invoice_address,
bundled_sum=sum([b.price_after_voucher for b in bundled_positions]),
)
if line_price.gross != self.line_price_gross or line_price.rate != self.tax_rate:
self.line_price_gross = line_price.gross
self.tax_rate = line_price.rate
self.save(update_fields=['line_price_gross', 'tax_rate'])
class InvoiceAddress(models.Model):

View File

@@ -39,7 +39,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator
from django.db import connection, models
from django.db.models import F, OuterRef, Q, Subquery, Sum
from django.db.models import OuterRef, Q, Subquery, Sum
from django.db.models.functions import Coalesce
from django.utils.crypto import get_random_string
from django.utils.timezone import now
@@ -530,6 +530,8 @@ class Voucher(LoggedModel):
original price will be returned.
"""
if self.value is not None:
if not isinstance(self.value, Decimal):
self.value = Decimal(self.value)
if self.price_mode == 'set':
p = self.value
elif self.price_mode == 'subtract':
@@ -569,21 +571,21 @@ class Voucher(LoggedModel):
def annotate_budget_used_orders(cls, qs):
opq = OrderPosition.objects.filter(
voucher_id=OuterRef('pk'),
price_before_voucher__isnull=False,
voucher_budget_use__isnull=False,
order__status__in=[
Order.STATUS_PAID,
Order.STATUS_PENDING
]
).order_by().values('voucher_id').annotate(s=Sum(F('price_before_voucher') - F('price'))).values('s')
).order_by().values('voucher_id').annotate(s=Sum('voucher_budget_use')).values('s')
return qs.annotate(budget_used_orders=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=10, decimal_places=2)), Decimal('0.00')))
def budget_used(self):
ops = OrderPosition.objects.filter(
voucher=self,
price_before_voucher__isnull=False,
voucher_budget_use__isnull=False,
order__status__in=[
Order.STATUS_PAID,
Order.STATUS_PENDING
]
).aggregate(s=Sum(F('price_before_voucher') - F('price')))['s'] or Decimal('0.00')
).aggregate(s=Sum('voucher_budget_use'))['s'] or Decimal('0.00')
return ops

View File

@@ -54,11 +54,14 @@ from pretix.base.models import (
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.models.tax import TaxRule
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.checkin import _save_answers
from pretix.base.services.locking import LockTimeoutException, NoLockManager
from pretix.base.services.pricing import get_price
from pretix.base.services.pricing import (
apply_discounts, get_line_price, get_listed_price, get_price,
is_included_for_free,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.settings import PERSON_NAME_SCHEMES, LazyI18nStringList
@@ -145,13 +148,15 @@ error_messages = {
class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat',
'price_before_voucher'))
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas',
'addon_to', 'subevent', 'bundled', 'seat', 'listed_price',
'price_after_voucher', 'custom_price_input',
'custom_price_input_is_net'))
RemoveOperation = namedtuple('RemoveOperation', ('position',))
VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price'))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
'quotas', 'subevent', 'seat', 'price_before_voucher'))
VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price_after_voucher'))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'voucher',
'quotas', 'subevent', 'seat', 'listed_price',
'price_after_voucher'))
order = {
RemoveOperation: 10,
VoucherOperation: 15,
@@ -178,8 +183,8 @@ class CartManager:
@property
def positions(self):
return CartPosition.objects.filter(
Q(cart_id=self.cart_id) & Q(event=self.event)
return self.event.cartposition_set.filter(
Q(cart_id=self.cart_id)
).select_related('item', 'subevent')
def _is_seated(self, item, subevent):
@@ -390,7 +395,6 @@ class CartManager:
'addons'
).order_by('-is_bundled')
err = None
changed_prices = {}
for cp in expired:
removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)}
if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions):
@@ -401,40 +405,16 @@ class CartManager:
if cp.is_bundled:
bundle = cp.addon_to.item.bundles.filter(bundled_item=cp.item, bundled_variation=cp.variation).first()
if bundle:
price = bundle.designated_price or 0
listed_price = bundle.designated_price or 0
else:
price = cp.price
changed_prices[cp.pk] = price
if not cp.includes_tax:
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
force_custom_price=True, cp_is_net=False)
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
force_custom_price=True)
pbv = TAXED_ZERO
listed_price = cp.price
price_after_voucher = listed_price
else:
bundled_sum = Decimal('0.00')
if not cp.addon_to_id:
for bundledp in cp.addons.all():
if bundledp.is_bundled:
bundledprice = changed_prices.get(bundledp.pk, bundledp.price)
bundled_sum += bundledprice
if not cp.includes_tax:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
cp_is_net=True, bundled_sum=bundled_sum)
price = TaxedPrice(net=price.net, gross=price.net, rate=Decimal('0'), tax=Decimal('0'), name='')
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
cp_is_net=True, bundled_sum=bundled_sum)
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=Decimal('0'), tax=Decimal('0'), name='')
listed_price = get_listed_price(cp.item, cp.variation, cp.subevent)
if cp.voucher:
price_after_voucher = cp.voucher.calculate_price(listed_price)
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
bundled_sum=bundled_sum)
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
bundled_sum=bundled_sum)
price_after_voucher = listed_price
quotas = list(cp.quotas)
if not quotas:
@@ -450,7 +430,8 @@ class CartManager:
op = self.ExtendOperation(
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat, price_before_voucher=pbv
quotas=quotas, subevent=cp.subevent, seat=cp.seat, listed_price=listed_price,
price_after_voucher=price_after_voucher,
)
self._check_item_constraints(op)
@@ -489,26 +470,22 @@ class CartManager:
if p.is_bundled:
continue
bundled_sum = Decimal('0.00')
if not p.addon_to_id:
for bundledp in p.addons.all():
if bundledp.is_bundled:
bundledprice = bundledp.price
bundled_sum += bundledprice
price = self._get_price(p.item, p.variation, voucher, None, p.subevent, bundled_sum=bundled_sum)
"""
if price.gross > p.price:
continue
"""
if p.listed_price is None:
if p.addon_to_id and is_included_for_free(p.item, p.addon_to):
listed_price = Decimal('0.00')
else:
listed_price = get_listed_price(p.item, p.variation, p.subevent)
else:
listed_price = p.listed_price
price_after_voucher = voucher.calculate_price(listed_price)
voucher_use_diff[voucher] += 1
ops.append((p.price - price.gross, self.VoucherOperation(p, voucher, price)))
ops.append((listed_price - price_after_voucher, self.VoucherOperation(p, voucher, price_after_voucher)))
# If there are not enough voucher usages left for the full cart, let's apply them in the order that benefits
# the user the most.
ops.sort(key=lambda k: k[0], reverse=True)
self._operations += [k[1] for k in ops]\
self._operations += [k[1] for k in ops]
if not voucher_use_diff:
raise CartError(error_messages['voucher_no_match'])
@@ -575,7 +552,6 @@ class CartManager:
# Fetch bundled items
bundled = []
bundled_sum = Decimal('0.00')
db_bundles = list(item.bundles.all())
self._update_items_cache([b.bundled_item_id for b in db_bundles], [b.bundled_variation_id for b in db_bundles])
for bundle in db_bundles:
@@ -595,28 +571,49 @@ class CartManager:
else:
bundle_quotas = []
if bundle.designated_price:
bprice = self._get_price(bitem, bvar, None, bundle.designated_price, subevent, force_custom_price=True,
cp_is_net=False)
else:
bprice = TAXED_ZERO
bundled_sum += bundle.designated_price * bundle.count
bop = self.AddOperation(
count=bundle.count, item=bitem, variation=bvar, price=bprice,
voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent,
includes_tax=bool(bprice.rate), bundled=[], seat=None, price_before_voucher=bprice,
count=bundle.count,
item=bitem,
variation=bvar,
voucher=None,
quotas=bundle_quotas,
addon_to='FAKE',
subevent=subevent,
bundled=[],
seat=None,
listed_price=bundle.designated_price,
price_after_voucher=bundle.designated_price,
custom_price_input=None,
custom_price_input_is_net=False,
)
self._check_item_constraints(bop, operations)
bundled.append(bop)
price = self._get_price(item, variation, voucher, i.get('price'), subevent, bundled_sum=bundled_sum)
pbv = self._get_price(item, variation, None, i.get('price'), subevent, bundled_sum=bundled_sum)
listed_price = get_listed_price(item, variation, subevent)
if voucher:
price_after_voucher = voucher.calculate_price(listed_price)
else:
price_after_voucher = listed_price
custom_price = None
if item.free_price and i.get('price'):
custom_price = Decimal(str(i.get('price')).replace(",", "."))
if custom_price > 100000000:
raise ValueError('price_too_high')
op = self.AddOperation(
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat,
price_before_voucher=pbv
count=i['count'],
item=item,
variation=variation,
voucher=voucher,
quotas=quotas,
addon_to=False,
subevent=subevent,
bundled=bundled,
seat=seat,
listed_price=listed_price,
price_after_voucher=price_after_voucher,
custom_price_input=custom_price,
custom_price_input_is_net=self.event.settings.display_net_prices,
)
self._check_item_constraints(op, operations)
operations.append(op)
@@ -707,16 +704,27 @@ class CartManager:
input_addons[cp.id][a['item'], a['variation']] = a.get('count', 1)
selected_addons[cp.id, item.category_id][a['item'], a['variation']] = a.get('count', 1)
if price_included[cp.pk].get(item.category_id):
price = TAXED_ZERO
if is_included_for_free(item, cp):
listed_price = Decimal('0.00')
else:
price = self._get_price(item, variation, None, a.get('price'), cp.subevent)
listed_price = get_listed_price(item, variation, cp.subevent)
custom_price = None
if item.free_price and a.get('price'):
custom_price = Decimal(str(a.get('price')).replace(",", "."))
if custom_price > 100000000:
raise ValueError('price_too_high')
# Fix positions with wrong price (TODO: happens out-of-cartmanager-transaction and therefore a little hacky)
for ca in current_addons[cp][a['item'], a['variation']]:
if ca.price != price.gross:
ca.price = price.gross
ca.save(update_fields=['price'])
if ca.listed_price != listed_price:
ca.listed_price = ca.listed_price
ca.price_after_voucher = ca.price_after_voucher
ca.save(update_fields=['listed_price', 'price_after_voucher'])
if ca.custom_price_input != custom_price:
ca.custom_price_input = custom_price
ca.custom_price_input_is_net = self.event.settings.display_net_prices
ca.price_after_voucher = ca.price_after_voucher
ca.save(update_fields=['custom_price_input', 'custom_price_input'])
if a.get('count', 1) > len(current_addons[cp][a['item'], a['variation']]):
# This add-on is new, add it to the cart
@@ -725,9 +733,18 @@ class CartManager:
op = self.AddOperation(
count=a.get('count', 1) - len(current_addons[cp][a['item'], a['variation']]),
item=item, variation=variation, price=price, voucher=None, quotas=quotas,
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None,
price_before_voucher=None
item=item,
variation=variation,
voucher=None,
quotas=quotas,
addon_to=cp,
subevent=cp.subevent,
bundled=[],
seat=None,
listed_price=listed_price,
price_after_voucher=listed_price,
custom_price_input=custom_price,
custom_price_input_is_net=self.event.settings.display_net_prices,
)
self._check_item_constraints(op, operations)
operations.append(op)
@@ -972,13 +989,31 @@ class CartManager:
err = err or error_messages['seat_unavailable']
for k in range(available_count):
line_price = get_line_price(
price_after_voucher=op.price_after_voucher,
custom_price_input=op.custom_price_input,
custom_price_input_is_net=op.custom_price_input_is_net,
tax_rule=op.item.tax_rule,
invoice_address=self.invoice_address,
bundled_sum=sum([pp.count * pp.price_after_voucher for pp in op.bundled]),
)
cp = CartPosition(
event=self.event, item=op.item, variation=op.variation,
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat,
override_tax_rate=op.price.rate,
price_before_voucher=op.price_before_voucher.gross if op.price_before_voucher is not None else None
event=self.event,
item=op.item,
variation=op.variation,
expires=self._expiry,
cart_id=self.cart_id,
voucher=op.voucher,
addon_to=op.addon_to if op.addon_to else None,
subevent=op.subevent,
seat=op.seat,
listed_price=op.listed_price,
price_after_voucher=op.price_after_voucher,
custom_price_input=op.custom_price_input,
custom_price_input_is_net=op.custom_price_input_is_net,
line_price_gross=line_price.gross,
tax_rate=line_price.tax,
price=line_price.gross,
)
if self.event.settings.attendee_names_asked:
scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
@@ -1007,12 +1042,26 @@ class CartManager:
if op.bundled:
cp.save() # Needs to be in the database already so we have a PK that we can reference
for b in op.bundled:
bline_price = (
b.item.tax_rule or TaxRule(rate=Decimal('0.00'))
).tax(b.listed_price, base_price_is='gross', invoice_address=self.invoice_address) # todo compare with previous behaviour
for j in range(b.count):
new_cart_positions.append(CartPosition(
event=self.event, item=b.item, variation=b.variation,
price=b.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=None, addon_to=cp, override_tax_rate=b.price.rate,
subevent=b.subevent, includes_tax=b.includes_tax, is_bundled=True
event=self.event,
item=b.item,
variation=b.variation,
expires=self._expiry, cart_id=self.cart_id,
voucher=None,
addon_to=cp,
subevent=b.subevent,
listed_price=b.listed_price,
price_after_voucher=b.price_after_voucher,
custom_price_input=b.custom_price_input,
custom_price_input_is_net=b.custom_price_input_is_net,
line_price_gross=bline_price.gross,
tax_rate=bline_price.tax,
price=bline_price.gross,
is_bundled=True
))
new_cart_positions.append(cp)
@@ -1024,11 +1073,11 @@ class CartManager:
op.position.delete()
elif available_count == 1:
op.position.expires = self._expiry
op.position.price = op.price.gross
if op.price_before_voucher is not None:
op.position.price_before_voucher = op.price_before_voucher.gross
op.position.listed_price = op.listed_price
op.position.price_after_voucher = op.price_after_voucher
# op.position.price will be updated by recompute_final_prices_and_taxes()
try:
op.position.save(force_update=True)
op.position.save(force_update=True, update_fields=['expires', 'listed_price', 'price_after_voucher'])
except DatabaseError:
# Best effort... The position might have been deleted in the meantime!
pass
@@ -1046,10 +1095,10 @@ class CartManager:
# be expected
continue
op.position.price_before_voucher = op.position.price
op.position.price = op.price.gross
op.position.price_after_voucher = op.price_after_voucher
op.position.voucher = op.voucher
op.position.save()
# op.posiiton.price will be set in recompute_final_prices_and_taxes
op.position.save(update_fields=['price_after_voucher', 'voucher'])
vouchers_ok[op.voucher] -= 1
for p in new_cart_positions:
@@ -1074,6 +1123,35 @@ class CartManager:
return False
def recompute_final_prices_and_taxes(self):
positions = sorted(list(self.positions), key=lambda op: -(op.addon_to_id or 0))
diff = Decimal('0.00')
for cp in positions:
if cp.listed_price is None:
# migration from old system? also used in unit tests
cp.update_listed_price_and_voucher()
cp.migrate_free_price_if_necessary()
cp.update_line_price(self.invoice_address, [b for b in positions if b.addon_to_id == cp.pk and b.is_bundled])
discount_results = apply_discounts(
self.event,
self._sales_channel,
[
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in positions
]
)
for cp, (new_price, discount) in zip(positions, discount_results):
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
diff += new_price - cp.price
cp.price = new_price
cp.discount = discount
cp.save(update_fields=['price', 'discount'])
return diff
def commit(self):
self._check_presale_dates()
self._check_max_cart_size()
@@ -1091,33 +1169,11 @@ class CartManager:
self.now_dt = now_dt
self._extend_expiry_of_valid_existing_positions()
err = self._perform_operations() or err
self.recompute_final_prices_and_taxes()
if err:
raise CartError(err)
def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress):
positions = CartPosition.objects.filter(
cart_id=cart_id, event=event
).select_related('item', 'item__tax_rule')
totaldiff = Decimal('0.00')
for pos in positions:
if not pos.item.tax_rule:
continue
rate = pos.item.tax_rule.tax_rate_for(invoice_address)
if pos.tax_rate != rate:
if not pos.item.tax_rule.keep_gross_if_rate_changes:
current_net = pos.price - pos.tax_value
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
totaldiff += new_gross - pos.price
pos.price = new_gross
pos.includes_tax = rate != Decimal('0.00')
pos.override_tax_rate = rate
pos.save(update_fields=['price', 'includes_tax', 'override_tax_rate'])
return totaldiff
def get_fees(event, request, total, invoice_address, provider, positions):
from pretix.presale.views.cart import cart_session

View File

@@ -35,6 +35,7 @@
import json
import logging
import sys
from collections import Counter, defaultdict, namedtuple
from datetime import datetime, time, timedelta
from decimal import Decimal
@@ -68,7 +69,6 @@ from pretix.base.models import (
Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemBundle
from pretix.base.models.orders import (
InvoiceAddress, OrderFee, OrderRefund, generate_secret,
)
@@ -86,7 +86,9 @@ from pretix.base.services.mail import SendMailException
from pretix.base.services.memberships import (
create_membership, validate_memberships_in_order,
)
from pretix.base.services.pricing import get_price
from pretix.base.services.pricing import (
apply_discounts, get_listed_price, get_price,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
from pretix.base.signals import (
@@ -565,7 +567,8 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
_check_date(event, now_dt)
products_seen = Counter()
changed_prices = {}
q_avail = Counter()
v_avail = Counter()
v_budget = {}
deleted_positions = set()
seats_seen = set()
@@ -582,6 +585,8 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
cp.delete()
sorted_positions = sorted(positions, key=lambda s: -int(s.is_bundled))
# Check availability
for i, cp in enumerate(sorted_positions):
if cp.pk in deleted_positions:
continue
@@ -601,29 +606,17 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
break
if cp.voucher:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
).exclude(pk=cp.pk)
v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
if v_avail < 1:
if cp.voucher not in v_avail:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
).exclude(cart_id=cp.cart_id)
v_avail[cp.voucher] = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
v_avail[cp.voucher] -= 1
if v_avail[cp.voucher] < 0:
err = err or error_messages['voucher_redeemed']
delete(cp)
continue
if cp.voucher.budget is not None:
if cp.voucher not in v_budget:
v_budget[cp.voucher] = cp.voucher.budget - cp.voucher.budget_used()
disc = cp.price_before_voucher - cp.price
if disc > v_budget[cp.voucher]:
new_disc = max(0, v_budget[cp.voucher])
cp.price = cp.price + (disc - new_disc)
cp.save()
err = err or error_messages['voucher_budget_used']
v_budget[cp.voucher] -= new_disc
continue
else:
v_budget[cp.voucher] -= disc
if cp.subevent and cp.subevent.presale_start and now_dt < cp.subevent.presale_start:
err = err or error_messages['some_subevent_not_started']
delete(cp)
@@ -662,7 +655,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation)
) and not cp.is_bundled:
delete(cp)
cp.delete()
err = error_messages['voucher_required']
break
@@ -671,56 +663,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
# time, since we absolutely can not overbook a seat.
if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel):
err = err or error_messages['seat_unavailable']
cp.delete()
delete(cp)
continue
if cp.expires >= now_dt and not cp.voucher:
# Other checks are not necessary
continue
max_discount = None
if cp.price_before_voucher is not None and cp.voucher in v_budget:
current_discount = cp.price_before_voucher - cp.price
max_discount = max(v_budget[cp.voucher] + current_discount, 0)
try:
if cp.is_bundled:
try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
bprice = bundle.designated_price or 0
except ItemBundle.DoesNotExist:
bprice = cp.price
except ItemBundle.MultipleObjectsReturned:
raise OrderError("Invalid product configuration (duplicate bundle)")
price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False,
custom_price_is_tax_rate=cp.override_tax_rate,
invoice_address=address, force_custom_price=True, max_discount=max_discount)
pbv = get_price(cp.item, cp.variation, None, bprice, cp.subevent, custom_price_is_net=False,
custom_price_is_tax_rate=cp.override_tax_rate,
invoice_address=address, force_custom_price=True, max_discount=max_discount)
changed_prices[cp.pk] = bprice
else:
bundled_sum = Decimal('0.00')
if not cp.addon_to_id:
for bundledp in cp.addons.all():
if bundledp.is_bundled:
bundled_sum += changed_prices.get(bundledp.pk, bundledp.price)
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
pbv = get_price(cp.item, cp.variation, None, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
except TaxRule.SaleNotAllowed:
err = err or error_messages['country_blocked']
cp.delete()
continue
if max_discount is not None:
v_budget[cp.voucher] = v_budget[cp.voucher] + current_discount - (pbv.gross - price.gross)
if price is False or len(quotas) == 0:
if len(quotas) == 0:
err = err or error_messages['unavailable']
delete(cp)
continue
@@ -742,42 +692,88 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp)
continue
if pbv is not None and pbv.gross != price.gross:
cp.price_before_voucher = pbv.gross
else:
cp.price_before_voucher = None
if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross):
cp.price = price.gross
cp.includes_tax = bool(price.rate)
cp.save()
err = err or error_messages['price_changed']
continue
quota_ok = True
ignore_all_quotas = cp.expires >= now_dt or (
cp.voucher and (cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)))
cp.voucher and (
cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)
)
)
if not ignore_all_quotas:
for quota in quotas:
if cp.voucher and cp.voucher.block_quota and cp.voucher.quota_id == quota.pk:
continue
avail = quota.availability(now_dt)
if avail[0] != Quota.AVAILABILITY_OK:
# This quota is sold out/currently unavailable, so do not sell this at all
if quota not in q_avail:
avail = quota.availability(now_dt)
q_avail[quota] = avail[1] if avail[1] is not None else sys.maxsize
q_avail[quota] -= 1
if q_avail[quota] < 0:
err = err or error_messages['unavailable']
quota_ok = False
break
if quota_ok:
cp.expires = now_dt + timedelta(
minutes=event.settings.get('reservation_time', as_type=int))
cp.save()
else:
if not quota_ok:
# Sorry, can't let you keep that!
delete(cp)
# Check prices
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
old_total = sum(cp.price for cp in sorted_positions)
for i, cp in enumerate(sorted_positions):
if cp.listed_price is None:
# migration from pre-discount cart positions
cp.update_listed_price_and_voucher(max_discount=None)
cp.migrate_free_price_if_necessary()
# deal with max discount
max_discount = None
if cp.voucher and cp.voucher.budget is not None:
if cp.voucher not in v_budget:
v_budget[cp.voucher] = cp.voucher.budget - cp.voucher.budget_used()
max_discount = max(v_budget[cp.voucher], 0)
if cp.expires < now_dt or cp.listed_price is None:
# Guarantee on listed price is expired
cp.update_listed_price_and_voucher(max_discount=max_discount)
elif cp.voucher:
cp.update_listed_price_and_voucher(max_discount=max_discount, voucher_only=True)
if max_discount is not None:
v_budget[cp.voucher] = v_budget[cp.voucher] - (cp.listed_price - cp.price_after_voucher)
try:
cp.update_line_price(address, [b for b in sorted_positions if b.addon_to_id == cp.pk and b.is_bundled and b.pk and b.pk not in deleted_positions])
except TaxRule.SaleNotAllowed:
err = err or error_messages['country_blocked']
delete(cp)
continue
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
discount_results = apply_discounts(
event,
sales_channel,
[
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in sorted_positions
]
)
for cp, (new_price, discount) in zip(sorted_positions, discount_results):
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
cp.price = new_price
cp.discount = discount
cp.save(update_fields=['price', 'discount'])
new_total = sum(cp.price for cp in sorted_positions)
if old_total != new_total:
err = err or error_messages['price_changed']
# Store updated positions
for cp in sorted_positions:
cp.expires = now_dt + timedelta(
minutes=event.settings.get('reservation_time', as_type=int))
cp.save()
if err:
raise OrderError(err, errargs)
@@ -1858,16 +1854,14 @@ class OrderChangeManager:
op.position.item = op.item
op.position.variation = op.variation
op.position._calculate_tax()
if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id:
op.position.price_before_voucher = max(
op.position.price,
get_price(
op.position.item, op.position.variation,
subevent=op.position.subevent,
custom_price=op.position.price,
invoice_address=self._invoice_address
).gross
)
if op.position.voucher_budget_use is not None and op.position.voucher and not op.position.addon_to_id:
listed_price = get_listed_price(op.position.item, op.position.variation, op.position.subevent)
if not op.position.item.tax_rule or op.position.item.tax_rule.price_includes_tax:
price_after_voucher = max(op.position.price, op.position.voucher.calculate_price(listed_price))
else:
price_after_voucher = max(op.position.price - op.position.tax_value, op.position.voucher.calculate_price(listed_price))
op.position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=False, save=False
)
@@ -1908,16 +1902,13 @@ class OrderChangeManager:
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=False, save=False
)
if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id:
op.position.price_before_voucher = max(
op.position.price,
get_price(
op.position.item, op.position.variation,
subevent=op.position.subevent,
custom_price=op.position.price,
invoice_address=self._invoice_address
).gross
)
if op.position.voucher_budget_use is not None and op.position.voucher and not op.position.addon_to_id:
listed_price = get_listed_price(op.position.item, op.position.variation, op.position.subevent)
if not op.position.item.tax_rule or op.position.item.tax_rule.price_includes_tax:
price_after_voucher = max(op.position.price, op.position.voucher.calculate_price(listed_price))
else:
price_after_voucher = max(op.position.price - op.position.tax_value, op.position.voucher.calculate_price(listed_price))
op.position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
op.position.save()
elif isinstance(op, self.AddFeeOperation):
self.order.log_action('pretix.event.order.changed.addfee', user=self.user, auth=self.auth, data={

View File

@@ -20,39 +20,30 @@
# <https://www.gnu.org/licenses/>.
#
from decimal import Decimal
from typing import List, Optional, Tuple
from django.db.models import Q
from django.utils.timezone import now
from pretix.base.decimal import round_decimal
from pretix.base.models import (
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.event import Event, SubEvent
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
def get_price(item: Item, variation: ItemVariation = None,
voucher: Voucher = None, custom_price: Decimal = None,
subevent: SubEvent = None, custom_price_is_net: bool = False,
custom_price_is_tax_rate: Decimal=None,
custom_price_is_tax_rate: Decimal = None,
addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None,
force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00'),
max_discount: Decimal = None, tax_rule=None) -> TaxedPrice:
if addon_to:
try:
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
if iao.price_included:
return TAXED_ZERO
except ItemAddOn.DoesNotExist:
pass
if is_included_for_free(item, addon_to):
return TAXED_ZERO
price = item.default_price
if subevent and item.pk in subevent.item_price_overrides:
price = subevent.item_price_overrides[item.pk]
if variation is not None:
if variation.default_price is not None:
price = variation.default_price
if subevent and variation.pk in subevent.var_price_overrides:
price = subevent.var_price_overrides[variation.pk]
price = get_listed_price(item, variation, subevent)
if voucher:
price = voucher.calculate_price(price, max_discount=max_discount)
@@ -85,10 +76,10 @@ def get_price(item: Item, variation: ItemVariation = None,
price = tax_rule.tax(price, invoice_address=invoice_address)
if custom_price_is_net:
price = tax_rule.tax(max(custom_price, price.net), base_price_is='net',
price = tax_rule.tax(max(custom_price, price.net), base_price_is='net', override_tax_rate=price.rate,
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross', gross_price_is_tax_rate=custom_price_is_tax_rate,
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross', override_tax_rate=price.rate,
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(price, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
@@ -98,3 +89,83 @@ def get_price(item: Item, variation: ItemVariation = None,
price.tax = price.gross - price.net
return price
def is_included_for_free(item: Item, addon_to: AbstractPosition):
if addon_to:
try:
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
if iao.price_included:
return True
except ItemAddOn.DoesNotExist:
pass
return False
def get_listed_price(item: Item, variation: ItemVariation = None, subevent: SubEvent = None) -> Decimal:
price = item.default_price
if subevent and item.pk in subevent.item_price_overrides:
price = subevent.item_price_overrides[item.pk]
if variation is not None:
if variation.default_price is not None:
price = variation.default_price
if subevent and variation.pk in subevent.var_price_overrides:
price = subevent.var_price_overrides[variation.pk]
return price
def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool,
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal) -> TaxedPrice:
if not tax_rule:
tax_rule = TaxRule(
name='',
rate=Decimal('0.00'),
price_includes_tax=True,
eu_reverse_charge=False,
)
if custom_price_input:
price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address)
if custom_price_input_is_net:
price = tax_rule.tax(max(custom_price_input, price.net), base_price_is='net', override_tax_rate=price.rate,
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate,
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
return price
def apply_discounts(event: Event, sales_channel: str,
positions: List[Tuple[int, Optional[int], Decimal, bool, bool]]) -> List[Decimal]:
"""
Applies any dynamic discounts to a cart
:param event: Event the cart belongs to
:param sales_channel: Sales channel the cart was created with
:param positions: Tuple of the form ``(item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount)``
:return: A list of ``(new_gross_price, discount)`` tuples in the same order as the input
"""
new_prices = {}
discount_qs = event.discounts.filter(
Q(available_from__isnull=True) | Q(available_from__lte=now()),
Q(available_until__isnull=True) | Q(available_until__gte=now()),
sales_channels__contains=sales_channel,
active=True,
).prefetch_related('condition_limit_products').order_by('position', 'pk')
for discount in discount_qs:
result = discount.apply({
idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount) in enumerate(positions)
if not is_bundled and idx not in new_prices
})
for k in result.keys():
result[k] = (result[k], discount)
new_prices.update(result)
return [new_prices.get(idx, (p[2], None)) for idx, p in enumerate(positions)]

View File

@@ -217,6 +217,30 @@ def timeline_for_event(event, subevent=None):
})
))
for d in event.discounts.filter(Q(available_from__isnull=False) | Q(available_until__isnull=False)):
if d.available_from:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=d.available_from,
description=pgettext_lazy('timeline', 'Discount "{name}" becomes active').format(name=str(d)),
edit_url=reverse('control:event.items.discounts.edit', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'discount': d.pk,
})
))
if d.available_until:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=d.available_until,
description=pgettext_lazy('timeline', 'Discount "{name}" becomes inactive').format(name=str(d)),
edit_url=reverse('control:event.items.discounts.edit', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'discount': d.pk,
})
))
for p in event.items.filter(Q(available_from__isnull=False) | Q(available_until__isnull=False)):
if p.available_from:
tl.append(TimelineEvent(

View File

@@ -0,0 +1,109 @@
#
# 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 <https://pretix.eu/about/en/license>.
#
# 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
# <https://www.gnu.org/licenses/>.
#
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

View File

@@ -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.'),

View File

@@ -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,
},
]
})

View File

@@ -0,0 +1,74 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Automatic discount" %}{% endblock %}
{% block inside %}
<h1>{% trans "Automatic discount" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors form %}
<div class="row">
<div class="col-xs-12{% if discount %} col-lg-10{% endif %}">
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.active layout="control" %}
{% bootstrap_field form.internal_name layout="control" %}
{% bootstrap_field form.available_from layout="control" %}
{% bootstrap_field form.available_until layout="control" %}
{% bootstrap_field form.sales_channels layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Condition" context "discount" %}</legend>
{% bootstrap_field form.condition_all_products layout="control" %}
{% bootstrap_field form.condition_limit_products layout="control" %}
{% bootstrap_field form.condition_apply_to_addons layout="control" %}
{% bootstrap_field form.condition_ignore_voucher_discounted layout="control" %}
{% if form.subevent_mode %}
{% bootstrap_field form.subevent_mode layout="control" %}
{% endif %}
<div class="form-group form-alternatives">
<label class="col-md-3 control-label">
{% trans "Minimum cart content" %}<br>
<span class="optional">{% trans "Optional" %}</span>
</label>
<div class="col-md-4">
{% bootstrap_field form.condition_min_count form_group_class="" %}
</div>
<div class="col-md-1 text-center condition-or" data-display-dependency="#id_subevent_mode_2" data-inverse>
<div class="hr">
<div class="sep">
<div class="sepText">{% trans "OR" %}</div>
</div>
</div>
</div>
<div class="col-md-4" data-display-dependency="#id_subevent_mode_2" data-inverse>
{% bootstrap_field form.condition_min_value form_group_class="" %}
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "Benefit" context "discount" %}</legend>
{% bootstrap_field form.benefit_discount_matching_percent layout="control" addon_after="%" %}
{% bootstrap_field form.benefit_only_apply_to_cheapest_n_matches layout="control" %}
</fieldset>
</div>
{% if discount %}
<div class="col-xs-12 col-lg-2">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Discount history" %}
</h3>
</div>
{% include "pretixcontrol/includes/logs.html" with obj=discount %}
</div>
</div>
{% endif %}
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Delete discount" %}{% endblock %}
{% block inside %}
<h1>{% trans "Delete discount" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% if not possible and not item.active %}
<p>{% blocktrans %}You cannot delete the discount <strong>{{ discount }}</strong> because it already has
been used as part of an order.{% endblocktrans %}</p>
<div class="form-group submit-group">
<a href="{% url "control:event.discounts" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<div class="clearfix"></div>
</div>
{% else %}
{% if possible %}
<p>{% blocktrans trimmed with name=discount.internal_name %}
Are you sure you want to delete the discount <strong>{{ name }}</strong>?
{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans trimmed with name=discount.internal_name %}
You cannot delete the discount <strong>{{ name }}</strong> because it already has been used as part
of an order, but you can deactivate it.
{% endblocktrans %}</p>
{% endif %}
<div class="form-group submit-group">
<a href="{% url "control:event.items.discounts" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% if possible %}{% trans "Delete" %}{% else %}{% trans "Deactivate" %}{% endif %}
</button>
</div>
{% endif %}
</form>
{% endblock %}

View File

@@ -0,0 +1,147 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% block title %}{% trans "Automatic discounts" %}{% endblock %}
{% block inside %}
<h1>{% trans "Automatic discounts" %}</h1>
<p>
{% 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 %}
</p>
<p>
{% 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 %}
</p>
<p>
{% 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 %}
</p>
<p>
{% 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 %}
</p>
{% if discounts|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any discounts yet.
{% endblocktrans %}
</p>
<a href="{% url "control:event.items.discounts.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new discount" %}</a>
</div>
{% else %}
<p>
<a href="{% url "control:event.items.discounts.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new discount" %}
</a>
</p>
<form method="post">
{% csrf_token %}
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Internal name" %}</th>
<th></th>
<th></th>
<th>{% trans "Products" %}</th>
<th class="action-col-2"></th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody data-dnd-url="{% url "control:event.items.discounts.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
{% for d in discounts %}
<tr data-dnd-id="{{ d.id }}">
<td>
{% if d.active %}
<strong>
{% else %}
<del>
{% endif %}
<a href="{% url "control:event.items.discounts.edit" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}">
{{ d.internal_name }}</a>
{% if d.active %}
</strong>
{% else %}
</del>
{% endif %}
</td>
<td>
{% for k, c in sales_channels.items %}
{% if k in d.sales_channels %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted"
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
{% else %}
{% endif %}
{% endfor %}
</td>
<td>
{% if d.available_from or d.available_until %}
{% if not d.is_available_by_time %}
<span class="label label-danger" data-toggle="tooltip"
title="{% trans "Currently unavailable since a limited timeframe for this product has been set" %}">
<span class="fa fa-clock-o fa-fw" data-toggle="tooltip">
</span>
</span>
{% else %}
<span class="fa fa-clock-o fa-fw text-muted" data-toggle="tooltip"
title="{% trans "Only available in a limited timeframe" %}">
</span>
{% endif %}
{% endif %}
</td>
<td>
{% if d.condition_all_products %}
<em>{% trans "All" %}</em>
{% else %}
<ul>
{% for item in d.condition_limit_products.all %}
<li>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</td>
<td>
<button formaction="{% url "control:event.items.discounts.up" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-default btn-sm sortable-up"
{% if forloop.counter0 == 0 and not page_obj.has_previous %}
disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button formaction="{% url "control:event.items.discounts.down" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-default btn-sm sortable-down"
{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}>
<i class="fa fa-arrow-down"></i></button>
<span class="dnd-container"></span>
</td>
<td class="text-right flip">
<a href="{% url "control:event.items.discounts.edit" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.discounts.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ d.id }}"
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
<a href="{% url "control:event.items.discounts.delete" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</form>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}

View File

@@ -387,7 +387,7 @@
{% if line.voucher %}
<br/><span class="fa fa-tags fa-fw"></span> {% trans "Voucher code used:" %}
<a
{% if line.price_before_voucher|default_if_none:"NONE" != "NONE" %}data-toggle="tooltip" title="{% blocktrans trimmed with price=line.price_before_voucher|money:request.event.currency %}Original price: {{ price }}{% endblocktrans %}"{% endif %}
{% if line.voucher.budget and line.voucher_budget_use|default_if_none:"NONE" != "NONE" %}data-toggle="tooltip" title="{% blocktrans trimmed with amount=line.voucher_budget_use|money:request.event.currency %}Used {{ amount }} discount from budget{% endblocktrans %}"{% endif %}
href="{% url "control:event.voucher" event=request.event.slug organizer=request.event.organizer.slug voucher=line.voucher.pk %}">
{{ line.voucher.code }}
</a>
@@ -406,6 +406,15 @@
{{ line.used_membership }}
</a>
{% endif %}
{% if line.discount %}
<br />
<span class="text-success discounted" data-toggle="tooltip" title="{% trans "The price of this product was reduced because of an automatic discount or this product was part of the discount calculation for a different product in this order." %}">
<span class="fa fa-percent fa-fw" aria-hidden="true"></span>
<a href="{% url "control:event.items.discounts.edit" organizer=request.event.organizer.slug event=request.event.slug discount=line.discount.id %}">
{{ line.discount.internal_name }}
</a>
</span>
{% endif %}
{% if not line.canceled %}
<div class="position-buttons">
{% if line.generate_ticket %}

View File

@@ -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<quota>\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<discount>\d+)/delete$', discounts.DiscountDelete.as_view(),
name='event.items.discounts.delete'),
re_path(r'^discounts/(?P<discount>\d+)/up$', discounts.discount_move_up, name='event.items.discounts.up'),
re_path(r'^discounts/(?P<discount>\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<discount>\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'),

View File

@@ -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 <https://pretix.eu/about/en/license>.
#
# 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
# <https://www.gnu.org/licenses/>.
#
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()

View File

@@ -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))

View File

@@ -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')),

View File

@@ -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 "

View File

@@ -231,6 +231,13 @@
{% else %}
{{ line.price|money:event.currency }}
{% endif %}
{% if line.discount and line.line_price_gross != line.price %}
<span class="text-success discounted" data-toggle="tooltip" title="{% trans "The price of this product was reduced because of an automatic discount." %}">
<br>
<span class="fa fa-percent fa-fw" aria-hidden="true"></span>
{% trans "Discounted" %}
</span>
{% endif %}
</div>
{% else %}
<div role="cell" class="count">
@@ -285,6 +292,13 @@
{% else %}
{{ line.price|money:event.currency }}
{% endif %}
{% if line.discount and line.line_price_gross != line.price %}
<span class="text-success discounted" data-toggle="tooltip" title="{% trans "The price of this product was reduced because of an automatic discount." %}">
<br>
<span class="fa fa-percent fa-fw" aria-hidden="true"></span>
{% trans "Discounted" %}
</span>
{% endif %}
</div>
{% endif %}
<div role="cell" class="totalprice price">

View File

@@ -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;
}

View File

@@ -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%;
}
}
}

View File

@@ -20,6 +20,10 @@
display: block;
line-height: 1;
}
.price .discounted {
font-size: 85%;
line-height: 1;
}
.dl-indented {
padding-left: 20px;

View File

@@ -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 <https://pretix.eu/about/en/license>.
#
# 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
# <https://www.gnu.org/licenses/>.
#
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

View File

@@ -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),

View File

@@ -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')

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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()

View File

@@ -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()

View File

@@ -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),

View File

@@ -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):

View File

@@ -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):