forked from CGM_Public/pretix_original
Discounts (#2510)
This commit is contained in:
@@ -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:
|
||||
|
||||
49
src/pretix/api/serializers/discount.py
Normal file
49
src/pretix/api/serializers/discount.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
99
src/pretix/api/views/discount.py
Normal file
99
src/pretix/api/views/discount.py
Normal 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)
|
||||
Reference in New Issue
Block a user