diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index 93ae4010d3..00fed0e761 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -11,6 +11,7 @@ Resources and endpoints categories items item_variations + item_bundles item_add-ons questions question_options diff --git a/doc/api/resources/item_add-ons.rst b/doc/api/resources/item_add-ons.rst index cae647f9ba..8439044014 100644 --- a/doc/api/resources/item_add-ons.rst +++ b/doc/api/resources/item_add-ons.rst @@ -189,7 +189,7 @@ Endpoints { "min_count": 0, - "max_count": 10, + "max_count": 10 } **Example response**: diff --git a/doc/api/resources/item_bundles.rst b/doc/api/resources/item_bundles.rst new file mode 100644 index 0000000000..a5894f93d1 --- /dev/null +++ b/doc/api/resources/item_bundles.rst @@ -0,0 +1,242 @@ +Item bundles +============ + +Resource description +-------------------- + +With bundles, you can specify products that are included within other products. There are two premier use cases of this: + +* Package discounts. For example, you could offer a discounted package that includes three tickets but can only be + bought as a whole. With a bundle including three times the usual product, the package will automatically pull three + sub-items into the cart, making sure of correct quota calculation and issuance of the correct number of tickets. + +* Tax splitting. For example, if your conference ticket includes a part that is subject to different taxation and that + you need to put on the invoice separately. When you putting a "designated price" on a bundled sub-item, pretix will + use that price to show a split taxation. + +The bundles resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the bundling configuration +bundled_item integer Internal ID of the item that is included. +bundled_variation integer Internal ID of the variation of the item (or ``null``). +count integer Number of items included +designated_price money (string) Designated price of the bundled product. This will be + used to split the price of the base item e.g. for mixed + taxation. This is not added to the price. +===================================== ========================== ======================================================= + +.. versionchanged:: 2.6 + + This resource has been added. + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/ + + Returns a list of all bundles for a given item. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/items/11/bundles/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "id": 3, + "bundled_item": 3, + "bundled_variation": null, + "count": 1, + "designated_price": "0.00" + }, + { + "id": 3, + "bundled_item": 3, + "bundled_variation": null, + "count": 2, + "designated_price": "1.50" + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param item: The ``id`` field of the item to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event/item does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/(id)/ + + Returns information on one bundle configuration, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/items/1/bundles/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 3, + "bundled_item": 3, + "bundled_variation": null, + "count": 2, + "designated_price": "1.50" + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param item: The ``id`` field of the item to fetch + :param id: The ``id`` field of the bundle to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:post:: /api/v1/organizers/bigevents/events/sampleconf/items/1/bundles/ + + Creates a new bundle configuration + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content: application/json + + { + "bundled_item": 3, + "bundled_variation": null, + "count": 2, + "designated_price": "1.50" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "id": 3, + "bundled_item": 3, + "bundled_variation": null, + "count": 2, + "designated_price": "1.50" + } + + :param organizer: The ``slug`` field of the organizer of the event/item to create a bundle-configuration for + :param event: The ``slug`` field of the event to create a bundle configuration for + :param item: The ``id`` field of the item to create a bundle configuration for + :statuscode 201: no error + :statuscode 400: The bundle could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource. + +.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/(id)/ + + Update a bundle configuration. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all + fields of the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields + that you want to change. + + You can change all fields of the resource except the ``id`` field. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/bundles/3/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "count": 2 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 3, + "bundled_item": 3, + "bundled_variation": null, + "count": 2, + "designated_price": "1.50" + } + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param item: The ``id`` field of the item to modify + :param id: The ``id`` field of the bundle to modify + :statuscode 200: no error + :statuscode 400: The bundle configuration could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource. + +.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/bundles/(id)/ + + Delete a bundle configuration. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/bundles/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param id: The ``id`` field of the item to modify + :param id: The ``id`` field of the bundle to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index cebc10a3ac..76096268b3 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -64,6 +64,7 @@ original_price money (string) An original pri require_approval boolean If ``True``, orders with this product will need to be approved by the event organizer before they can be paid. +require_bundling boolean If ``True``, this item is only available as part of bundles. generate_tickets boolean If ``False``, tickets are never generated for this product, regardless of other settings. If ``True``, tickets are generated even if this is a @@ -91,8 +92,17 @@ addons list of objects Definition of a chosen from. ├ min_count integer The minimal number of add-ons that need to be chosen. ├ max_count integer The maximal number of add-ons that can be chosen. -└ position integer An integer, used for sorting +├ position integer An integer, used for sorting └ price_included boolean Adding this add-on to the item is free +bundles list of objects Definition of bundles that are included in this item. + Only writable during creation, + use separate endpoint to modify this later. +├ bundled_item integer Internal ID of the item that is included. +├ bundled_variation integer Internal ID of the variation of the item (or ``null``). +├ count integer Number of items included +└ designated_price money (string) Designated price of the bundled product. This will be + used to split the price of the base item e.g. for mixed + taxation. This is not added to the price. ===================================== ========================== ======================================================= .. versionchanged:: 1.7 @@ -121,15 +131,20 @@ addons list of objects Definition of a The ``generate_tickets`` attribute has been added. +.. versionchanged:: 2.6 + + The ``bundles`` and ``require_bundling`` attributes have been added. + Notes ----- + Please note that an item either always has variations or never has. Once created with variations the item can never change to an item without and vice versa. To create an item with variations ensure that you POST an item with at least one variation. -Also note that ``variations`` and ``addons`` are only supported on ``POST``. To update/delete variations and add-ons please -use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT`` with nested -``variations`` and/or ``addons``. +Also note that ``variations``, ``bundles``, and ``addons`` are only supported on ``POST``. To update/delete variations, +bundles, and add-ons please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT`` +with nested ``variations``, ``bundles`` and/or ``addons``. Endpoints --------- @@ -186,6 +201,7 @@ Endpoints "has_variations": false, "generate_tickets": null, "require_approval": false, + "require_bundling": false, "variations": [ { "value": {"en": "Student"}, @@ -204,7 +220,8 @@ Endpoints "position": 1 } ], - "addons": [] + "addons": [], + "bundles": [] } ] } @@ -273,6 +290,7 @@ Endpoints "checkin_attention": false, "has_variations": false, "require_approval": false, + "require_bundling": false, "variations": [ { "value": {"en": "Student"}, @@ -291,7 +309,8 @@ Endpoints "position": 1 } ], - "addons": [] + "addons": [], + "bundles": [] } :param organizer: The ``slug`` field of the organizer to fetch @@ -340,6 +359,7 @@ Endpoints "max_per_order": null, "checkin_attention": false, "require_approval": false, + "require_bundling": false, "variations": [ { "value": {"en": "Student"}, @@ -358,7 +378,8 @@ Endpoints "position": 1 } ], - "addons": [] + "addons": [], + "bundles": [] } **Example response**: @@ -396,6 +417,7 @@ Endpoints "checkin_attention": false, "has_variations": true, "require_approval": false, + "require_bundling": false, "variations": [ { "value": {"en": "Student"}, @@ -414,7 +436,8 @@ Endpoints "position": 1 } ], - "addons": [] + "addons": [], + "bundles": [] } :param organizer: The ``slug`` field of the organizer of the event to create an item for @@ -483,6 +506,7 @@ Endpoints "checkin_attention": false, "has_variations": true, "require_approval": false, + "require_bundling": false, "variations": [ { "value": {"en": "Student"}, @@ -501,7 +525,8 @@ Endpoints "position": 1 } ], - "addons": [] + "addons": [], + "bundles": [] } :param organizer: The ``slug`` field of the organizer to modify diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index eb91ef8268..49a26c7a80 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -7,8 +7,8 @@ from rest_framework import serializers from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.base.models import ( - Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption, - Quota, + Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question, + QuestionOption, Quota, ) @@ -26,6 +26,13 @@ class ItemVariationSerializer(I18nAwareModelSerializer): 'position', 'default_price', 'price') +class InlineItemBundleSerializer(serializers.ModelSerializer): + class Meta: + model = ItemBundle + fields = ('bundled_item', 'bundled_variation', 'count', + 'designated_price') + + class InlineItemAddOnSerializer(serializers.ModelSerializer): class Meta: model = ItemAddOn @@ -33,6 +40,31 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer): 'position', 'price_included') +class ItemBundleSerializer(serializers.ModelSerializer): + class Meta: + model = ItemBundle + fields = ('id', 'bundled_item', 'bundled_variation', 'count', + 'designated_price') + + def validate(self, data): + data = super().validate(data) + event = self.context['event'] + + full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} + full_data.update(data) + + ItemBundle.clean_itemvar(event, full_data.get('bundled_item'), full_data.get('bundled_variation')) + + item = self.context['item'] + if item == full_data.get('bundled_item'): + raise ValidationError(_("The bundled item must not be the same item as the bundling one.")) + if full_data.get('bundled_item'): + if full_data['bundled_item'].bundles.exists(): + raise ValidationError(_("The bundled item must not have bundles on its own.")) + + return data + + class ItemAddOnSerializer(serializers.ModelSerializer): class Meta: model = ItemAddOn @@ -69,6 +101,7 @@ class ItemTaxRateField(serializers.Field): class ItemSerializer(I18nAwareModelSerializer): addons = InlineItemAddOnSerializer(many=True, required=False) + bundles = InlineItemBundleSerializer(many=True, required=False) variations = InlineItemVariationSerializer(many=True, required=False) tax_rate = ItemTaxRateField(source='*', read_only=True) @@ -77,9 +110,9 @@ class ItemSerializer(I18nAwareModelSerializer): fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description', 'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission', 'position', 'picture', 'available_from', 'available_until', - 'require_voucher', 'hide_without_voucher', 'allow_cancel', - 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', - 'variations', 'addons', 'original_price', 'require_approval', 'generate_tickets') + 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', + 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations', + 'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets') read_only_fields = ('has_variations', 'picture') def get_serializer_context(self): @@ -87,8 +120,8 @@ class ItemSerializer(I18nAwareModelSerializer): def validate(self, data): data = super().validate(data) - if self.instance and ('addons' in data or 'variations' in data): - raise ValidationError(_('Updating add-ons or variations via PATCH/PUT is not supported. Please use the ' + if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data): + raise ValidationError(_('Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use the ' 'dedicated nested endpoint.')) Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order')) @@ -104,6 +137,12 @@ class ItemSerializer(I18nAwareModelSerializer): Item.clean_tax_rule(value, self.context['event']) return value + def validate_bundles(self, value): + if not self.instance: + for b_data in value: + ItemBundle.clean_itemvar(self.context['event'], b_data['bundled_item'], b_data['bundled_variation']) + return value + def validate_addons(self, value): if not self.instance: for addon_data in value: @@ -117,11 +156,14 @@ class ItemSerializer(I18nAwareModelSerializer): def create(self, validated_data): variations_data = validated_data.pop('variations') if 'variations' in validated_data else {} addons_data = validated_data.pop('addons') if 'addons' in validated_data else {} + bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {} item = Item.objects.create(**validated_data) for variation_data in variations_data: ItemVariation.objects.create(item=item, **variation_data) for addon_data in addons_data: ItemAddOn.objects.create(base_item=item, **addon_data) + for bundle_data in bundles_data: + ItemBundle.objects.create(base_item=item, **bundle_data) return item diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 18f022f336..b4527644d0 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -44,6 +44,7 @@ question_router.register(r'options', item.QuestionOptionViewSet) item_router = routers.DefaultRouter() item_router.register(r'variations', item.ItemVariationViewSet) item_router.register(r'addons', item.ItemAddOnViewSet) +item_router.register(r'bundles', item.ItemBundleViewSet) order_router = routers.DefaultRouter() order_router.register(r'payments', order.PaymentViewSet) diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index d9699a5b63..4c85f9a3db 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -1,6 +1,7 @@ import django_filters from django.db.models import Q from django.shortcuts import get_object_or_404 +from django.utils.functional import cached_property from django_filters.rest_framework import DjangoFilterBackend, FilterSet from rest_framework import viewsets from rest_framework.decorators import detail_route @@ -9,14 +10,14 @@ from rest_framework.filters import OrderingFilter from rest_framework.response import Response from pretix.api.serializers.item import ( - ItemAddOnSerializer, ItemCategorySerializer, ItemSerializer, - ItemVariationSerializer, QuestionOptionSerializer, QuestionSerializer, - QuotaSerializer, + ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer, + ItemSerializer, ItemVariationSerializer, QuestionOptionSerializer, + QuestionSerializer, QuotaSerializer, ) from pretix.api.views import ConditionalListView from pretix.base.models import ( - Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption, - Quota, + Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question, + QuestionOption, Quota, ) from pretix.helpers.dicts import merge_dicts @@ -46,7 +47,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): write_permission = 'can_change_items' def get_queryset(self): - return self.request.event.items.select_related('tax_rule').prefetch_related('variations', 'addons').all() + return self.request.event.items.select_related('tax_rule').prefetch_related('variations', 'addons', 'bundles').all() def perform_create(self, serializer): serializer.save(event=self.request.event) @@ -96,17 +97,20 @@ class ItemVariationViewSet(viewsets.ModelViewSet): permission = None write_permission = 'can_change_items' + @cached_property + def item(self): + return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + def get_queryset(self): - item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) - return item.variations.all() + return self.item.variations.all() def get_serializer_context(self): ctx = super().get_serializer_context() - ctx['item'] = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + ctx['item'] = self.item return ctx def perform_create(self, serializer): - item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + item = self.item if not item.has_variations: raise PermissionDenied('This variation cannot be created because the item does not have variations. ' 'Changing a product without variations to a product with variations is not allowed.') @@ -149,6 +153,58 @@ class ItemVariationViewSet(viewsets.ModelViewSet): ) +class ItemBundleViewSet(viewsets.ModelViewSet): + serializer_class = ItemBundleSerializer + queryset = ItemBundle.objects.none() + filter_backends = (DjangoFilterBackend, OrderingFilter,) + ordering_fields = ('id',) + ordering = ('id',) + permission = None + write_permission = 'can_change_items' + + @cached_property + def item(self): + return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + + def get_queryset(self): + return self.item.bundles.all() + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['event'] = self.request.event + ctx['item'] = self.item + return ctx + + def perform_create(self, serializer): + item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + serializer.save(base_item=item) + item.log_action( + 'pretix.event.item.bundles.added', + user=self.request.user, + auth=self.request.auth, + data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) + ) + + def perform_update(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.base_item.log_action( + 'pretix.event.item.bundles.changed', + user=self.request.user, + auth=self.request.auth, + data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) + ) + + def perform_destroy(self, instance): + super().perform_destroy(instance) + instance.base_item.log_action( + 'pretix.event.item.bundles.removed', + user=self.request.user, + auth=self.request.auth, + data={'bundled_item': instance.bundled_item.pk, 'bundled_variation': instance.bundled_variation.pk if instance.bundled_variation else None, + 'count': instance.count, 'designated_price': instance.designated_price} + ) + + class ItemAddOnViewSet(viewsets.ModelViewSet): serializer_class = ItemAddOnSerializer queryset = ItemAddOn.objects.none() @@ -158,18 +214,21 @@ class ItemAddOnViewSet(viewsets.ModelViewSet): permission = None write_permission = 'can_change_items' + @cached_property + def item(self): + return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + def get_queryset(self): - item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) - return item.addons.all() + return self.item.addons.all() def get_serializer_context(self): ctx = super().get_serializer_context() ctx['event'] = self.request.event - ctx['item'] = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + ctx['item'] = self.item return ctx def perform_create(self, serializer): - item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + item = self.item category = get_object_or_404(ItemCategory, pk=self.request.data['addon_category']) serializer.save(base_item=item, addon_category=category) item.log_action( diff --git a/src/pretix/base/migrations/0114_auto_20190316_1014.py b/src/pretix/base/migrations/0114_auto_20190316_1014.py new file mode 100644 index 0000000000..b74854a620 --- /dev/null +++ b/src/pretix/base/migrations/0114_auto_20190316_1014.py @@ -0,0 +1,60 @@ +# Generated by Django 2.1.7 on 2019-03-16 10:14 + +import django.db.models.deletion +import jsonfallback.fields +from django.db import migrations, models + +import pretix.base.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0113_auto_20190312_0942'), + ] + + operations = [ + migrations.CreateModel( + name='ItemBundle', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('count', models.PositiveIntegerField(default=1, verbose_name='Number')), + ('designated_price', models.DecimalField(blank=True, decimal_places=2, help_text="If set, it will be shown that this bundled item is responsible for the given value of the total price. This might be important in cases of mixed taxation, but can be kept blank otherwise. This value will NOT be added to the base item's price.", max_digits=10, null=True, verbose_name='Designated price part')), + ], + ), + migrations.AddField( + model_name='cartposition', + name='is_bundled', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='cartposition', + name='addon_to', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='addons', to='pretixbase.CartPosition'), + ), + migrations.AlterField( + model_name='orderposition', + name='addon_to', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='addons', to='pretixbase.OrderPosition'), + ), + migrations.AddField( + model_name='itembundle', + name='base_item', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bundles', to='pretixbase.Item'), + ), + migrations.AddField( + model_name='itembundle', + name='bundled_item', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bundled_with', to='pretixbase.Item', verbose_name='Bundled item'), + ), + migrations.AddField( + model_name='itembundle', + name='bundled_variation', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='bundled_with', to='pretixbase.ItemVariation', verbose_name='Bundled variation'), + ), + migrations.AddField( + model_name='item', + name='require_bundling', + field=models.BooleanField(default=False, help_text='If this option is set, the product will only be sold as part of bundle products.', verbose_name='Only sell this product as part of a bundle'), + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index b3614d5d32..fb6da192dc 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -9,8 +9,9 @@ from .event import ( ) from .invoices import Invoice, InvoiceLine, invoice_filename from .items import ( - Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption, - Quota, SubEventItem, SubEventItemVariation, itempicture_upload_to, + Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question, + QuestionOption, Quota, SubEventItem, SubEventItemVariation, + itempicture_upload_to, ) from .log import LogEntry from .notifications import NotificationSetting diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 8e14595fa2..fee963a25a 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -1,5 +1,6 @@ import sys import uuid +from collections import Counter from datetime import date, datetime, time from decimal import Decimal, DecimalException from typing import Tuple @@ -161,7 +162,7 @@ class ItemQuerySet(models.QuerySet): Q(active=True) & Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) & Q(Q(available_until__isnull=True) | Q(available_until__gte=now())) - & Q(sales_channels__contains=channel) + & Q(sales_channels__contains=channel) & Q(require_bundling=False) ) if not allow_addons: q &= Q(Q(category__isnull=True) | Q(category__is_addon=False)) @@ -328,6 +329,11 @@ class Item(LoggedModel): help_text=_('This product will be hidden from the event page until the user enters a voucher ' 'code that is specifically tied to this product (and not via a quota).') ) + require_bundling = models.BooleanField( + verbose_name=_('Only sell this product as part of a bundle'), + default=False, + help_text=_('If this option is set, the product will only be sold as part of bundle products.') + ) allow_cancel = models.BooleanField( verbose_name=_('Allow product to be canceled'), default=True, @@ -386,12 +392,28 @@ class Item(LoggedModel): if self.event: self.event.cache.clear() - def tax(self, price=None, base_price_is='auto'): + def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False): price = price if price is not None else self.default_price + if not self.tax_rule: - return TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), - rate=Decimal('0.00'), name='') - return self.tax_rule.tax(price, base_price_is=base_price_is) + t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), + rate=Decimal('0.00'), name='') + else: + t = self.tax_rule.tax(price, base_price_is=base_price_is, currency=currency) + + if include_bundled: + for b in self.bundles.all(): + if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id: + if b.bundled_variation: + bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency) + else: + bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency) + compare_price = self.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency) + t.net += bprice.net - compare_price.net + t.tax += bprice.tax - compare_price.tax + t.name = "MIXED!" + + return t def is_available_by_time(self, now_dt: datetime=None) -> bool: now_dt = now_dt or now() @@ -411,7 +433,18 @@ class Item(LoggedModel): return False return True - def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None): + def _get_quotas(self, ignored_quotas=None, subevent=None): + check_quotas = set(getattr( + self, '_subevent_quotas', # Utilize cache in product list + self.quotas.filter(subevent=subevent).select_related('subevent') + if subevent else self.quotas.all() + )) + if ignored_quotas: + check_quotas -= set(ignored_quotas) + return check_quotas + + def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None, + include_bundled=False, trust_parameters=False): """ This method is used to determine whether this Item is currently available for sale. @@ -420,33 +453,60 @@ class Item(LoggedModel): quotas will be ignored in the calculation. If this leads to no quotas being checked at all, this method will return unlimited availability. + :param include_bundled: Also take availability of bundled items into consideration. + :param trust_parameters: Disable checking of the subevent parameter and disable checking if + any variations exist (performance optimization). :returns: any of the return codes of :py:meth:`Quota.availability()`. :raises ValueError: if you call this on an item which has variations associated with it. Please use the method on the ItemVariation object you are interested in. """ - check_quotas = set(getattr( - self, '_subevent_quotas', # Utilize cache in product list - self.quotas.select_related('subevent').filter(subevent=subevent) - if subevent else self.quotas.all() - )) - if not subevent and self.event.has_subevents: + if not trust_parameters and not subevent and self.event.has_subevents: raise TypeError('You need to supply a subevent.') - if ignored_quotas: - check_quotas -= set(ignored_quotas) - if not check_quotas: - return Quota.AVAILABILITY_OK, sys.maxsize - if self.has_variations: # NOQA - raise ValueError('Do not call this directly on items which have variations ' - 'but call this on their ItemVariation objects') - return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas], - key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize)) + check_quotas = self._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent) + quotacounter = Counter() + res = Quota.AVAILABILITY_OK, None + for q in check_quotas: + quotacounter[q] += 1 + + if include_bundled: + for b in self.bundles.all(): + bundled_check_quotas = (b.bundled_variation or b.bundled_item)._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent) + if not bundled_check_quotas: + return Quota.AVAILABILITY_GONE, 0 + for q in bundled_check_quotas: + quotacounter[q] += b.count + + for q, n in quotacounter.items(): + a = q.availability(count_waitinglist=count_waitinglist, _cache=_cache) + if a[1] is None: + continue + + num_avail = a[1] // n + code_avail = Quota.AVAILABILITY_GONE if a[1] >= 1 and num_avail < 1 else a[0] + # this is not entirely accurate, as it shows "sold out" even if it is actually just "reserved", + # since we do not know that distinction here if at least one item is available. However, this + # is only relevant in connection with bundles. + + if code_avail < res[0] or res[1] is None or num_avail < res[1]: + res = (code_avail, num_avail) + + if len(quotacounter) == 0: + return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility + return res def allow_delete(self): from pretix.base.models.orders import OrderPosition return not OrderPosition.all.filter(item=self).exists() + @property + def includes_mixed_tax_rate(self): + for b in self.bundles.all(): + if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id: + return True + return False + @cached_property def has_variations(self): return self.variations.exists() @@ -531,11 +591,28 @@ class ItemVariation(models.Model): def price(self): return self.default_price if self.default_price is not None else self.item.default_price - def tax(self, price=None): + def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False): price = price if price is not None else self.price + if not self.item.tax_rule: - return TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), rate=Decimal('0.00'), name='') - return self.item.tax_rule.tax(price) + t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), + rate=Decimal('0.00'), name='') + else: + t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency) + + if include_bundled: + for b in self.item.bundles.all(): + if b.designated_price and b.bundled_item.tax_rule_id != self.item.tax_rule_id: + if b.bundled_variation: + bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency) + else: + bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency) + compare_price = self.item.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency) + t.net += bprice.net - compare_price.net + t.tax += bprice.tax - compare_price.tax + t.name = "MIXED!" + + return t def delete(self, *args, **kwargs): super().delete(*args, **kwargs) @@ -547,7 +624,18 @@ class ItemVariation(models.Model): if self.item: self.item.event.cache.clear() - def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None) -> Tuple[int, int]: + def _get_quotas(self, ignored_quotas=None, subevent=None): + check_quotas = set(getattr( + self, '_subevent_quotas', # Utilize cache in product list + self.quotas.filter(subevent=subevent).select_related('subevent') + if subevent else self.quotas.all() + )) + if ignored_quotas: + check_quotas -= set(ignored_quotas) + return check_quotas + + def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None, + include_bundled=False, trust_parameters=False) -> Tuple[int, int]: """ This method is used to determine whether this ItemVariation is currently available for sale in terms of quotas. @@ -559,19 +647,38 @@ class ItemVariation(models.Model): :param count_waitinglist: If ``False``, waiting list entries will be ignored for quota calculation. :returns: any of the return codes of :py:meth:`Quota.availability()`. """ - check_quotas = set(getattr( - self, '_subevent_quotas', # Utilize cache in product list - self.quotas.filter(subevent=subevent).select_related('subevent') - if subevent else self.quotas.all() - )) - if ignored_quotas: - check_quotas -= set(ignored_quotas) - if not subevent and self.item.event.has_subevents: # NOQA + if not trust_parameters and not subevent and self.item.event.has_subevents: # NOQA raise TypeError('You need to supply a subevent.') - if not check_quotas: - return Quota.AVAILABILITY_OK, sys.maxsize - return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas], - key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize)) + check_quotas = self._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent) + quotacounter = Counter() + res = Quota.AVAILABILITY_OK, None + for q in check_quotas: + quotacounter[q] += 1 + + if include_bundled: + for b in self.item.bundles.all(): + bundled_check_quotas = (b.bundled_variation or b.bundled_item)._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent) + if not bundled_check_quotas: + return Quota.AVAILABILITY_GONE, 0 + for q in bundled_check_quotas: + quotacounter[q] += b.count + + for q, n in quotacounter.items(): + a = q.availability(count_waitinglist=count_waitinglist, _cache=_cache) + if a[1] is None: + continue + + num_avail = a[1] // n + code_avail = Quota.AVAILABILITY_GONE if a[1] >= 1 and num_avail < 1 else a[0] + # this is not entirely accurate, as it shows "sold out" even if it is actually just "reserved", + # since we do not know that distinction here if at least one item is available. However, this + # is only relevant in connection with bundles. + + if code_avail < res[0] or res[1] is None or num_avail < res[1]: + res = (code_avail, num_avail) + if len(quotacounter) == 0: + return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility + return res def __lt__(self, other): if self.position == other.position: @@ -672,6 +779,83 @@ class ItemAddOn(models.Model): raise ValidationError(_('The maximum count needs to be greater than the minimum count.')) +class ItemBundle(models.Model): + """ + An instance of this model indicates that buying a ticket of the type ``base_item`` + automatically also buys ``count`` items of type ``bundled_item``. + + :param base_item: The base item the bundle is attached to + :type base_item: Item + :param bundled_item: The bundled item + :type bundled_item: Item + :param bundled_variation: The variation, if the bundled item has variations + :type bundled_variation: ItemVariation + :param count: The number of items to bundle + :type count: int + :param designated_price: The designated part price (optional) + :type designated_price: bool + """ + base_item = models.ForeignKey( + Item, + related_name='bundles', + on_delete=models.CASCADE + ) + bundled_item = models.ForeignKey( + Item, + related_name='bundled_with', + verbose_name=_('Bundled item'), + on_delete=models.CASCADE + ) + bundled_variation = models.ForeignKey( + ItemVariation, + related_name='bundled_with', + verbose_name=_('Bundled variation'), + null=True, blank=True, + on_delete=models.CASCADE + ) + count = models.PositiveIntegerField( + default=1, + verbose_name=_('Number') + ) + designated_price = models.DecimalField( + null=True, blank=True, + decimal_places=2, max_digits=10, + verbose_name=_('Designated price part'), + help_text=_('If set, it will be shown that this bundled item is responsible for the given value of the total ' + 'gross price. This might be important in cases of mixed taxation, but can be kept blank otherwise. This ' + 'value will NOT be added to the base item\'s price.') + ) + + def clean(self): + self.clean_count(self.count) + + def describe(self): + if self.count == 1: + if self.bundled_variation_id: + return "{} – {}".format(self.bundled_item.name, self.bundled_variation.value) + else: + return self.bundled_item.name + else: + if self.bundled_variation_id: + return "{}× {} – {}".format(self.count, self.bundled_item.name, self.bundled_variation.value) + else: + return "{}x {}".format(self.count, self.bundled_item.name) + + @staticmethod + def clean_itemvar(event, bundled_item, bundled_variation): + if event != bundled_item.event: + raise ValidationError(_('The bundled item must belong to the same event as the item.')) + if bundled_item.has_variations and not bundled_variation: + raise ValidationError(_('A variation needs to be set for this item.')) + if bundled_variation and bundled_variation.item != bundled_item: + raise ValidationError(_('The chosen variation does not belong to this item.')) + + @staticmethod + def clean_count(count): + if count < 0: + raise ValidationError(_('The count needs to be equal to or greater than zero.')) + + class Question(LoggedModel): """ A question is an input field that can be used to extend a ticket by custom information, diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index e0c230a317..984eaf0185 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1691,8 +1691,9 @@ class OrderPosition(AbstractPosition): # Delete afterwards. Deleting in between might cause deletion of things related to add-ons # due to the deletion cascade. for cartpos in cp: - cartpos.addons.all().delete() - cartpos.delete() + if cartpos.pk: + cartpos.addons.all().delete() + cartpos.delete() return ops def __str__(self): @@ -1789,6 +1790,7 @@ class CartPosition(AbstractPosition): includes_tax = models.BooleanField( default=True ) + is_bundled = models.BooleanField(default=False) class Meta: verbose_name = _("Cart position") diff --git a/src/pretix/base/models/tax.py b/src/pretix/base/models/tax.py index d2128f3b42..76f4a9db51 100644 --- a/src/pretix/base/models/tax.py +++ b/src/pretix/base/models/tax.py @@ -33,6 +33,32 @@ class TaxedPrice: money_filter(self.gross, currency) ) + def __sub__(self, other): + newgross = self.gross - other.gross + newnet = round_decimal(newgross - (newgross * (1 - 100 / (100 + self.rate)))).quantize( + Decimal('10') ** self.gross.as_tuple().exponent + ) + return TaxedPrice( + gross=newgross, + net=newnet, + tax=newgross - newnet, + rate=self.rate, + name=self.name, + ) + + def __mul__(self, other): + newgross = self.gross * other + newnet = round_decimal(newgross - (newgross * (1 - 100 / (100 + self.rate)))).quantize( + Decimal('10') ** self.gross.as_tuple().exponent + ) + return TaxedPrice( + gross=newgross, + net=newnet, + tax=newgross - newnet, + rate=self.rate, + name=self.name, + ) + TAXED_ZERO = TaxedPrice( gross=Decimal('0.00'), @@ -129,7 +155,12 @@ class TaxRule(LoggedModel): def has_custom_rules(self): return self.custom_rules and self.custom_rules != '[]' - def tax(self, base_price, base_price_is='auto'): + def tax(self, base_price, base_price_is='auto', currency=None): + from .event import Event + try: + currency = currency or self.event.currency + except Event.DoesNotExist: + pass if self.rate == Decimal('0.00'): return TaxedPrice( net=base_price, gross=base_price, tax=Decimal('0.00'), @@ -145,11 +176,11 @@ class TaxRule(LoggedModel): if base_price_is == 'gross': gross = base_price net = round_decimal(gross - (base_price * (1 - 100 / (100 + self.rate))), - self.event.currency if self.event else None) + currency) elif base_price_is == 'net': net = base_price gross = round_decimal((net * (1 + self.rate / 100)), - self.event.currency if self.event else None) + currency) else: raise ValueError('Unknown base price type: {}'.format(base_price_is)) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 9be624a1d2..3aea9dce8d 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -13,7 +13,8 @@ from django.utils.translation import pgettext_lazy, ugettext as _ from pretix.base.i18n import language from pretix.base.models import ( - CartPosition, Event, InvoiceAddress, Item, ItemVariation, Voucher, + CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation, + Voucher, ) from pretix.base.models.event import SubEvent from pretix.base.models.orders import OrderFee @@ -87,12 +88,13 @@ error_messages = { 'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the ' 'product %(base)s.'), 'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'), + 'bundled_only': _('One of the products you selected can only be bought part of a bundle.'), } class CartManager: AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas', - 'addon_to', 'subevent', 'includes_tax')) + 'addon_to', 'subevent', 'includes_tax', 'bundled')) RemoveOperation = namedtuple('RemoveOperation', ('position',)) ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher', 'quotas', 'subevent')) @@ -162,7 +164,7 @@ class CartManager: self._items_cache.update({ i.pk: i for i in self.event.items.select_related('category').prefetch_related( - 'addons', 'addons__addon_category', 'quotas' + 'addons', 'bundles', 'addons__addon_category', 'quotas' ).filter( id__in=[i for i in item_ids if i and i not in self._items_cache] ) @@ -215,9 +217,12 @@ class CartManager: raise CartError(error_messages['ended']) if isinstance(op, self.AddOperation): - if op.item.category and op.item.category.is_addon and not op.addon_to: + if op.item.category and op.item.category.is_addon and not (op.addon_to and op.addon_to != 'FAKE'): raise CartError(error_messages['addon_only']) + if op.item.require_bundling and not op.addon_to == 'FAKE': + raise CartError(error_messages['bundled_only']) + if op.item.max_per_order or op.item.min_per_order: new_total = ( len([1 for p in self.positions if p.item_id == op.item.pk]) + @@ -246,12 +251,13 @@ class CartManager: def _get_price(self, item: Item, variation: Optional[ItemVariation], voucher: Optional[Voucher], custom_price: Optional[Decimal], - subevent: Optional[SubEvent], cp_is_net: bool=None): + subevent: Optional[SubEvent], cp_is_net: bool=None, force_custom_price=False, + bundled_sum=Decimal('0.00')): try: return get_price( item, variation, voucher, custom_price, subevent, custom_price_is_net=cp_is_net if cp_is_net is not None else self.event.settings.display_net_prices, - invoice_address=self.invoice_address + invoice_address=self.invoice_address, force_custom_price=force_custom_price, bundled_sum=bundled_sum ) except ValueError as e: if str(e) == 'price_too_high': @@ -261,22 +267,52 @@ class CartManager: def extend_expired_positions(self): expired = self.positions.filter(expires__lte=self.now_dt).select_related( - 'item', 'variation', 'voucher' - ).prefetch_related('item__quotas', 'variation__quotas') + 'item', 'variation', 'voucher', 'addon_to', 'addon_to__item' + ).prefetch_related( + 'item__quotas', + 'variation__quotas', + 'addons' + ).order_by('-is_bundled') err = None + changed_prices = {} for cp in expired: - if not cp.includes_tax: - price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, - cp_is_net=True) - price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='') + if cp.is_bundled: + try: + bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation) + price = bundle.designated_price or 0 + except ItemBundle.DoesNotExist: + 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) else: - price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent) + 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=0, tax=0, name='') + else: + price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, + bundled_sum=bundled_sum) quotas = list(cp.quotas) if not quotas: self._operations.append(self.RemoveOperation(position=cp)) - continue err = error_messages['unavailable'] + continue if not cp.voucher or (not cp.voucher.allow_ignore_quota and not cp.voucher.block_quota): for quota in quotas: @@ -341,10 +377,48 @@ class CartManager: else: quotas = [] - price = self._get_price(item, variation, voucher, i.get('price'), subevent) + # 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: + if bundle.bundled_item_id not in self._items_cache or ( + bundle.bundled_variation_id and bundle.bundled_variation_id not in self._variations_cache + ): + raise CartError(error_messages['not_for_sale']) + bitem = self._items_cache[bundle.bundled_item_id] + bvar = self._variations_cache[bundle.bundled_variation_id] if bundle.bundled_variation_id else None + bundle_quotas = list(bitem.quotas.filter(subevent=subevent) + if bvar is None else bvar.quotas.filter(subevent=subevent)) + if not bundle_quotas: + raise CartError(error_messages['unavailable']) + if not voucher or not voucher.allow_ignore_quota: + for quota in bundle_quotas: + quota_diff[quota] += bundle.count * i['count'] + 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=[] + ) + self._check_item_constraints(bop) + bundled.append(bop) + + price = self._get_price(item, variation, voucher, i.get('price'), subevent, bundled_sum=bundled_sum) + 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) + addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled ) self._check_item_constraints(op) operations.append(op) @@ -403,6 +477,7 @@ class CartManager: current_addons[cp] = { (a.item_id, a.variation_id): a for a in cp.addons.all() + if not a.is_bundled } # Create operations, perform various checks @@ -449,7 +524,7 @@ class CartManager: op = self.AddOperation( count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas, - addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate) + addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[] ) self._check_item_constraints(op) operations.append(op) @@ -609,11 +684,29 @@ class CartManager: available_count = min(quota_available_count, voucher_available_count) + if isinstance(op, self.AddOperation): + for b in op.bundled: + b_quota_available_count = min(available_count * b.count, min(quotas_ok[q] for q in b.quotas)) + if b_quota_available_count < b.count: + err = err or error_messages['unavailable'] + available_count = 0 + elif b_quota_available_count < available_count * b.count: + err = err or error_messages['in_part'] + available_count = b_quota_available_count // b.count + for q in b.quotas: + quotas_ok[q] -= available_count * b.count + # TODO: is this correct? + for q in op.quotas: quotas_ok[q] -= available_count if op.voucher: vouchers_ok[op.voucher] -= available_count + if any(qa < 0 for qa in quotas_ok.values()): + # Safeguard, shouldn't happen + err = err or error_messages['unavailable'] + available_count = 0 + if isinstance(op, self.AddOperation): for k in range(available_count): cp = CartPosition( @@ -646,6 +739,17 @@ class CartManager: except ValidationError: pass + 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: + 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, + subevent=b.subevent, includes_tax=b.includes_tax, is_bundled=True + )) + new_cart_positions.append(cp) elif isinstance(op, self.ExtendOperation): if available_count == 1: @@ -659,10 +763,11 @@ class CartManager: raise AssertionError("ExtendOperation cannot affect more than one item") for p in new_cart_positions: - if p._answers: - p.save() + if getattr(p, '_answers', None): + if not p.pk: # We stored some to the database already before + p.save() _save_answers(p, {}, p._answers) - CartPosition.objects.bulk_create([p for p in new_cart_positions if not p._answers]) + CartPosition.objects.bulk_create([p for p in new_cart_positions if not getattr(p, '_answers', None) and not p.pk]) return err def commit(self): @@ -747,7 +852,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo """ Adds a list of items to a user's cart. :param event: The event ID in question - :param items: A list of dicts with the keys item, variation, number, custom_price, voucher + :param items: A list of dicts with the keys item, variation, count, custom_price, voucher :param cart_id: Session ID of a guest :raises CartError: On any error that occured """ diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index bf672d5fc0..3d82c21867 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -26,6 +26,7 @@ from pretix.base.models import ( OrderPosition, Quota, User, Voucher, ) from pretix.base.models.event import SubEvent +from pretix.base.models.items import ItemBundle from pretix.base.models.orders import ( CachedCombinedTicket, CachedTicket, InvoiceAddress, OrderFee, OrderRefund, generate_position_secret, generate_secret, @@ -400,10 +401,27 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio _check_date(event, now_dt) products_seen = Counter() - for i, cp in enumerate(positions): + changed_prices = {} + deleted_positions = set() + + def delete(cp): + # Delete a cart position, including parents and children, if applicable + if cp.is_bundled: + delete(cp.addon_to) + else: + for p in cp.addons.all(): + deleted_positions.add(p.pk) + p.delete() + deleted_positions.add(cp.pk) + cp.delete() + + for i, cp in enumerate(sorted(positions, key=lambda s: -int(s.is_bundled))): + if cp.pk in deleted_positions: + continue + if not cp.item.is_available() or (cp.variation and not cp.variation.active): err = err or error_messages['unavailable'] - cp.delete() + delete(cp) continue quotas = list(cp.quotas) @@ -412,7 +430,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio err = error_messages['max_items_per_product'] errargs = {'max': cp.item.max_per_order, 'product': cp.item.name} - cp.delete() # Sorry! + delete(cp) break if cp.voucher: @@ -422,27 +440,27 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count() if v_avail < 1: err = err or error_messages['voucher_redeemed'] - cp.delete() # Sorry! + delete(cp) continue if cp.subevent and cp.subevent.presale_start and now_dt < cp.subevent.presale_start: err = err or error_messages['some_subevent_not_started'] - cp.delete() + delete(cp) break if cp.subevent and cp.subevent.presale_has_ended: err = err or error_messages['some_subevent_ended'] - cp.delete() + delete(cp) break if cp.item.require_voucher and cp.voucher is None: - cp.delete() + delete(cp) err = err or error_messages['voucher_required'] break if cp.item.hide_without_voucher and (cp.voucher is None or cp.voucher.item is None or cp.voucher.item.pk != cp.item.pk): - cp.delete() + delete(cp) err = error_messages['voucher_required'] break @@ -450,18 +468,34 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio # Other checks are not necessary continue - 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) + 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 + price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False, + invoice_address=address, force_custom_price=True) + changed_prices[cp.pk] = bprice + else: + bundled_sum = 0 + 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) if price is False or len(quotas) == 0: err = err or error_messages['unavailable'] - cp.delete() + delete(cp) continue if cp.voucher: if cp.voucher.valid_until and cp.voucher.valid_until < now_dt: err = err or error_messages['voucher_expired'] - cp.delete() + delete(cp) continue if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross): @@ -494,7 +528,8 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio minutes=event.settings.get('reservation_time', as_type=int)) cp.save() else: - cp.delete() # Sorry! + # Sorry, can't let you keep that! + delete(cp) if err: raise OrderError(err, errargs) @@ -599,7 +634,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str], with event.lock() as now_dt: positions = list(CartPosition.objects.filter( - id__in=position_ids).select_related('item', 'variation', 'subevent')) + id__in=position_ids).select_related('item', 'variation', 'subevent', 'addon_to').prefetch_related('addons')) if len(positions) == 0: raise OrderError(error_messages['empty']) if len(position_ids) != len(positions): diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index f63247924e..e46c8f9ef9 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -11,7 +11,8 @@ 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, - addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None) -> TaxedPrice: + addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None, + force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00')) -> TaxedPrice: if addon_to: try: iao = addon_to.item.addons.get(addon_category_id=item.category_id) @@ -44,6 +45,11 @@ def get_price(item: Item, variation: ItemVariation = None, ) price = tax_rule.tax(price) + if force_custom_price and custom_price is not None and custom_price != "": + if custom_price_is_net: + price = tax_rule.tax(custom_price, base_price_is='net') + else: + price = tax_rule.tax(custom_price, base_price_is='gross') if item.free_price and custom_price is not None and custom_price != "": if not isinstance(custom_price, Decimal): custom_price = Decimal(str(custom_price).replace(",", ".")) @@ -54,6 +60,11 @@ def get_price(item: Item, variation: ItemVariation = None, else: price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross') + if bundled_sum: + price = price - TaxedPrice(net=bundled_sum, gross=bundled_sum, rate=0, tax=0, name='') + if price.gross < Decimal('0.00'): + return TAXED_ZERO + if invoice_address and not tax_rule.tax_applicable(invoice_address): price.tax = Decimal('0.00') price.rate = Decimal('0.00') diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index b267c303c9..7cc2843fc9 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -13,7 +13,7 @@ from pretix.base.forms import I18nFormSet, I18nModelForm from pretix.base.models import ( Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota, ) -from pretix.base.models.items import ItemAddOn +from pretix.base.models.items import ItemAddOn, ItemBundle from pretix.base.signals import item_copy_data from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget from pretix.control.forms.widgets import Select2 @@ -300,6 +300,12 @@ class ItemCreateForm(I18nModelForm): if self.cleaned_data.get('copy_from'): for question in self.cleaned_data['copy_from'].questions.all(): question.items.add(instance) + for a in self.cleaned_data['copy_from'].addons.all(): + instance.addons.create(addon_category=a.addon_category, min_count=a.min_count, max_count=a.max_count, + price_included=a.price_included, position=a.position) + for b in self.cleaned_data['copy_from'].bundles.all(): + instance.bundles.create(bundled_item=b.bundled_item, bundled_variation=b.bundled_variation, + count=b.count, designated_price=b.designated_price) item_copy_data.send(sender=self.event, source=self.cleaned_data['copy_from'], target=instance) @@ -391,7 +397,8 @@ class ItemUpdateForm(I18nModelForm): 'min_per_order', 'checkin_attention', 'generate_tickets', - 'original_price' + 'original_price', + 'require_bundling', ] field_classes = { 'available_from': SplitDateTimeField, @@ -522,3 +529,100 @@ class ItemAddOnForm(I18nModelForm): 'min_count': _('Be aware that setting a minimal number makes it impossible to buy this product if all ' 'available add-ons are sold out.') } + + +class ItemBundleFormSet(I18nFormSet): + def __init__(self, *args, **kwargs): + self.event = kwargs.get('event') + self.item = kwargs.pop('item') + super().__init__(*args, **kwargs) + + def _construct_form(self, i, **kwargs): + kwargs['event'] = self.event + kwargs['item'] = self.item + return super()._construct_form(i, **kwargs) + + @property + def empty_form(self): + self.is_valid() + form = self.form( + auto_id=self.auto_id, + prefix=self.add_prefix('__prefix__'), + empty_permitted=True, + use_required_attribute=False, + locales=self.locales, + item=self.item, + event=self.event + ) + self.add_fields(form, None) + return form + + +class ItemBundleForm(I18nModelForm): + itemvar = forms.ChoiceField(label=_('Bundled product')) + + def __init__(self, *args, **kwargs): + self.item = kwargs.pop('item') + super().__init__(*args, **kwargs) + instance = kwargs.get('instance', None) + initial = kwargs.get('initial', {}) + + if instance: + try: + if instance.bundled_variation: + initial['itemvar'] = '%d-%d' % (instance.bundled_item.pk, instance.bundled_variation.pk) + elif instance.bundled_item: + initial['itemvar'] = str(instance.bundled_item.pk) + except Item.DoesNotExist: + pass + + kwargs['initial'] = initial + super().__init__(*args, **kwargs) + + choices = [] + for i in self.event.items.prefetch_related('variations').all(): + pname = str(i) + if not i.is_available(): + pname += ' ({})'.format(_('inactive')) + variations = list(i.variations.all()) + + if variations: + for v in variations: + choices.append(('%d-%d' % (i.pk, v.pk), + '%s – %s' % (pname, v.value))) + else: + choices.append((str(i.pk), '%s' % pname)) + self.fields['itemvar'].choices = choices + change_decimal_field(self.fields['designated_price'], self.event.currency) + + def clean(self): + d = super().clean() + if 'itemvar' in self.cleaned_data: + if '-' in self.cleaned_data['itemvar']: + itemid, varid = self.cleaned_data['itemvar'].split('-') + else: + itemid, varid = self.cleaned_data['itemvar'], None + + item = Item.objects.get(pk=itemid, event=self.event) + if varid: + variation = ItemVariation.objects.get(pk=varid, item=item) + else: + variation = None + + if item == self.item: + raise ValidationError(_("The bundled item must not be the same item as the bundling one.")) + if item.bundles.exists(): + raise ValidationError(_("The bundled item must not have bundles on its own.")) + + self.instance.bundled_item = item + self.instance.bundled_variation = variation + + return d + + class Meta: + model = ItemBundle + localized_fields = '__all__' + fields = [ + 'count', + 'designated_price', + ] diff --git a/src/pretix/control/templates/pretixcontrol/item/base.html b/src/pretix/control/templates/pretixcontrol/item/base.html index 6359e3b8fe..fc6a05995d 100644 --- a/src/pretix/control/templates/pretixcontrol/item/base.html +++ b/src/pretix/control/templates/pretixcontrol/item/base.html @@ -22,6 +22,11 @@ {% trans "Add-Ons" %} +
  • + + {% trans "Bundled products" %} + +
  • {% else %}

    {% trans "Create product" %}

    diff --git a/src/pretix/control/templates/pretixcontrol/item/bundles.html b/src/pretix/control/templates/pretixcontrol/item/bundles.html new file mode 100644 index 0000000000..d19ca4c350 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/item/bundles.html @@ -0,0 +1,80 @@ +{% extends "pretixcontrol/item/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load formset_tags %} +{% block inside %} +

    + {% blocktrans trimmed %} + With bundles, you can specify products that are always automatically added as add-ons in the cart for this product. + {% endblocktrans %} +

    +
    + {% csrf_token %} +
    + {{ formset.management_form }} + {% bootstrap_formset_errors formset %} +
    + {% for form in formset %} +
    +
    + {{ form.id }} + {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} +
    +
    +
    +
    +

    {% trans "Bundled product" %}

    +
    +
    + +
    +
    +
    +
    + {% bootstrap_form_errors form %} + {% bootstrap_field form.itemvar layout="control" %} + {% bootstrap_field form.count layout="control" %} + {% bootstrap_field form.designated_price layout="control" %} +
    +
    + {% endfor %} +
    + +

    + +

    +
    +
    + +
    +
    +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/item/index.html b/src/pretix/control/templates/pretixcontrol/item/index.html index 0af425fad4..0b863af599 100644 --- a/src/pretix/control/templates/pretixcontrol/item/index.html +++ b/src/pretix/control/templates/pretixcontrol/item/index.html @@ -33,6 +33,7 @@ {% bootstrap_field form.min_per_order layout="control" %} {% bootstrap_field form.require_voucher layout="control" %} {% bootstrap_field form.hide_without_voucher layout="control" %} + {% bootstrap_field form.require_bundling layout="control" %} {% bootstrap_field form.allow_cancel layout="control" %}
    diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index f76cbb366a..f0886c0afd 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -150,6 +150,8 @@ urlpatterns = [ name='event.item.variations'), url(r'^items/(?P\d+)/addons', item.ItemAddOns.as_view(), name='event.item.addons'), + url(r'^items/(?P\d+)/bundles', item.ItemBundles.as_view(), + name='event.item.bundles'), url(r'^items/(?P\d+)/up$', item.item_move_up, name='event.items.up'), url(r'^items/(?P\d+)/down$', item.item_move_down, name='event.items.down'), url(r'^items/(?P\d+)/delete$', item.ItemDelete.as_view(), name='event.items.delete'), diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index 95e8da73bd..afbc4e6a08 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -22,11 +22,11 @@ from pretix.base.models import ( QuestionAnswer, QuestionOption, Quota, Voucher, ) from pretix.base.models.event import SubEvent -from pretix.base.models.items import ItemAddOn +from pretix.base.models.items import ItemAddOn, ItemBundle from pretix.control.forms.item import ( - CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemCreateForm, - ItemUpdateForm, ItemVariationForm, ItemVariationsFormSet, QuestionForm, - QuestionOptionForm, QuotaForm, + CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm, + ItemBundleFormSet, ItemCreateForm, ItemUpdateForm, ItemVariationForm, + ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm, ) from pretix.control.permissions import ( EventPermissionRequiredMixin, event_permission_required, @@ -1043,6 +1043,92 @@ class ItemAddOns(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView): return context +class ItemBundles(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView): + permission = 'can_change_items' + template_name = 'pretixcontrol/item/bundles.html' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.item = None + + @cached_property + def formset(self): + formsetclass = inlineformset_factory( + Item, ItemBundle, + form=ItemBundleForm, formset=ItemBundleFormSet, + fk_name='base_item', + can_order=False, can_delete=True, extra=0 + ) + return formsetclass(self.request.POST if self.request.method == "POST" else None, + queryset=ItemBundle.objects.filter(base_item=self.get_object()), + event=self.request.event, item=self.item) + + def post(self, request, *args, **kwargs): + with transaction.atomic(): + if self.formset.is_valid(): + for form in self.formset.deleted_forms: + if not form.instance.pk: + continue + self.get_object().log_action( + 'pretix.event.item.bundles.removed', user=self.request.user, data={ + 'bundled_item': form.instance.bundled_item.pk, + 'bundled_variation': (form.instance.bundled_variation.pk if form.instance.bundled_variation else None), + 'count': form.instance.count, + 'designated_price': str(form.instance.designated_price), + } + ) + form.instance.delete() + form.instance.pk = None + + forms = [ + ef for ef in self.formset.forms + if ef not in self.formset.deleted_forms + ] + for i, form in enumerate(forms): + form.instance.base_item = self.get_object() + created = not form.instance.pk + form.save() + if form.has_changed(): + change_data = {k: form.cleaned_data.get(k) for k in form.changed_data} + change_data['id'] = form.instance.pk + self.get_object().log_action( + 'pretix.event.item.bundles.changed' if not created else + 'pretix.event.item.bundles.added', + user=self.request.user, data=change_data + ) + + messages.success(self.request, _('Your changes have been saved.')) + return redirect(self.get_success_url()) + return self.get(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + if self.get_object().category and self.get_object().category.is_addon: + messages.error(self.request, _('You cannot add bundles to a product that is only available as an add-on ' + 'itself.')) + return redirect(self.get_previous_url()) + + return super().get(request, *args, **kwargs) + + def get_previous_url(self) -> str: + return reverse('control:event.item', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + 'item': self.get_object().id, + }) + + def get_success_url(self) -> str: + return reverse('control:event.item.bundles', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + 'item': self.get_object().id, + }) + + def get_context_data(self, **kwargs) -> dict: + context = super().get_context_data(**kwargs) + context['formset'] = self.formset + return context + + class ItemDelete(EventPermissionRequiredMixin, DeleteView): model = Item template_name = 'pretixcontrol/item/delete.html' diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 5f3e65d28e..347d0db3e7 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -196,7 +196,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): ): a = cartpos.addons.all() for iao in cartpos.item.addons.all(): - found = len([1 for p in a if p.item.category_id == iao.addon_category_id]) + found = len([1 for p in a if p.item.category_id == iao.addon_category_id and not p.is_bundled]) if found < iao.min_count or found > iao.max_count: self._completed = False return False @@ -216,7 +216,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): 'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation', ).order_by('pk'): current_addon_products = { - a.item_id: a.variation_id for a in cartpos.addons.all() + a.item_id: a.variation_id for a in cartpos.addons.all() if not a.is_bundled } formsetentry = { 'cartpos': cartpos, diff --git a/src/pretix/presale/templates/pretixpresale/event/index.html b/src/pretix/presale/templates/pretixpresale/event/index.html index 72d14d59bf..4a1f6188a6 100644 --- a/src/pretix/presale/templates/pretixpresale/event/index.html +++ b/src/pretix/presale/templates/pretixpresale/event/index.html @@ -293,7 +293,13 @@ {% if item.original_price %} {% endif %} - {% if var.display_price.rate and var.display_price.gross and event.settings.display_net_prices %} + {% if item.includes_mixed_tax_rate %} + {% if event.settings.display_net_prices %} + {% trans "plus taxes" %} + {% else %} + {% trans "incl. taxes" %} + {% endif %} + {% elif var.display_price.rate and var.display_price.gross and event.settings.display_net_prices %} {% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %} plus {{ rate }}% {{ name }} {% endblocktrans %} @@ -397,7 +403,13 @@ {% if item.original_price %} {% endif %} - {% if item.display_price.rate and item.display_price.gross and event.settings.display_net_prices %} + {% if item.includes_mixed_tax_rate %} + {% if event.settings.display_net_prices %} + {% trans "plus taxes" %} + {% else %} + {% trans "incl. taxes" %} + {% endif %} + {% elif item.display_price.rate and item.display_price.gross and event.settings.display_net_prices %} {% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %} plus {{ rate }}% {{ name }} {% endblocktrans %} diff --git a/src/pretix/presale/templates/pretixpresale/event/voucher.html b/src/pretix/presale/templates/pretixpresale/event/voucher.html index 522f23300d..8ba9981030 100644 --- a/src/pretix/presale/templates/pretixpresale/event/voucher.html +++ b/src/pretix/presale/templates/pretixpresale/event/voucher.html @@ -112,7 +112,13 @@ {% else %} {{ var.display_price.gross|money:event.currency }} {% endif %} - {% if var.display_price.rate and var.display_price.gross and event.settings.display_net_prices %} + {% if item.includes_mixed_tax_rate %} + {% if event.settings.display_net_prices %} + {% trans "plus taxes" %} + {% else %} + {% trans "incl. taxes" %} + {% endif %} + {% elif var.display_price.rate and var.display_price.gross and event.settings.display_net_prices %} {% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %} plus {{ rate }}% {{ name }} {% endblocktrans %} @@ -201,7 +207,13 @@ {% else %} {{ item.display_price.gross|money:event.currency }} {% endif %} - {% if item.display_price.rate and item.display_price.gross and event.settings.display_net_prices %} + {% if item.includes_mixed_tax_rate %} + {% if event.settings.display_net_prices %} + {% trans "plus taxes" %} + {% else %} + {% trans "incl. taxes" %} + {% endif %} + {% elif item.display_price.rate and item.display_price.gross and event.settings.display_net_prices %} {% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %} plus {{ rate }}% {{ name }} {% endblocktrans %} diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index f0fbca7750..bded03c151 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -19,6 +19,7 @@ from django.views.generic import TemplateView from pretix.base.models import ItemVariation, Quota from pretix.base.models.event import SubEvent +from pretix.base.models.items import ItemBundle from pretix.multidomain.urlreverse import eventreverse from pretix.presale.ical import get_ical from pretix.presale.views.organizer import ( @@ -54,6 +55,21 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web'): Prefetch('quotas', to_attr='_subevent_quotas', queryset=event.quotas.filter(subevent=subevent)), + Prefetch('bundles', + queryset=ItemBundle.objects.prefetch_related( + Prefetch('bundled_item', + queryset=event.items.select_related('tax_rule').prefetch_related( + Prefetch('quotas', + to_attr='_subevent_quotas', + queryset=event.quotas.filter(subevent=subevent)), + )), + Prefetch('bundled_variation', + queryset=ItemVariation.objects.select_related('item', 'item__tax_rule').filter(item__event=event).prefetch_related( + Prefetch('quotas', + to_attr='_subevent_quotas', + queryset=event.quotas.filter(subevent=subevent)), + )), + )), Prefetch('variations', to_attr='available_variations', queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).prefetch_related( Prefetch('quotas', @@ -94,7 +110,7 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web'): ) else: item.cached_availability = list( - item.check_quotas(subevent=subevent, _cache=quota_cache) + item.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True) ) item.order_max = min( @@ -106,7 +122,7 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web'): price = item_price_override.get(item.pk, item.default_price) if voucher: price = voucher.calculate_price(price) - item.display_price = item.tax(price) + item.display_price = item.tax(price, currency=event.currency, include_bundled=True) display_add_to_cart = display_add_to_cart or item.order_max > 0 else: @@ -117,7 +133,7 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web'): ) else: var.cached_availability = list( - var.check_quotas(subevent=subevent, _cache=quota_cache) + var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True) ) var.order_max = min( @@ -129,7 +145,7 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web'): price = var_price_override.get(var.pk, var.price) if voucher: price = voucher.calculate_price(price) - var.display_price = var.tax(price) + var.display_price = var.tax(price, currency=event.currency, include_bundled=True) display_add_to_cart = display_add_to_cart or var.order_max > 0 diff --git a/src/pretix/presale/views/widget.py b/src/pretix/presale/views/widget.py index 1fe214aaf6..5c896ea8d7 100644 --- a/src/pretix/presale/views/widget.py +++ b/src/pretix/presale/views/widget.py @@ -149,13 +149,14 @@ def widget_js(request, lang, **kwargs): return resp -def price_dict(price): +def price_dict(item, price): return { 'gross': price.gross, 'net': price.net, 'tax': price.tax, 'rate': price.rate, - 'name': str(price.name) + 'name': str(price.name), + 'includes_mixed_tax_rate': item.includes_mixed_tax_rate, } @@ -185,7 +186,7 @@ class WidgetAPIProductList(EventListMixin, View): 'require_voucher': item.require_voucher, 'order_min': item.min_per_order, 'order_max': item.order_max if not item.has_variations else None, - 'price': price_dict(item.display_price) if not item.has_variations else None, + 'price': price_dict(item, item.display_price) if not item.has_variations else None, 'min_price': item.min_price if item.has_variations else None, 'max_price': item.max_price if item.has_variations else None, 'free_price': item.free_price, @@ -200,7 +201,7 @@ class WidgetAPIProductList(EventListMixin, View): 'value': str(var.value), 'order_max': var.order_max, 'description': str(rich_text(var.description, safelinks=False)) if var.description else None, - 'price': price_dict(var.display_price), + 'price': price_dict(item, var.display_price), 'avail': [ var.cached_availability[0], var.cached_availability[1] if self.request.event.settings.show_quota_left else None diff --git a/src/pretix/static/pretixpresale/js/widget/widget.js b/src/pretix/static/pretixpresale/js/widget/widget.js index 77f2b0c79b..c2e4114fb4 100644 --- a/src/pretix/static/pretixpresale/js/widget/widget.js +++ b/src/pretix/static/pretixpresale/js/widget/widget.js @@ -18,6 +18,8 @@ var strings = { 'price_from': django.pgettext('widget', 'from %(currency)s %(price)s'), 'tax_incl': django.pgettext('widget', 'incl. %(rate)s% %(taxname)s'), 'tax_plus': django.pgettext('widget', 'plus %(rate)s% %(taxname)s'), + 'tax_incl_mixed': django.pgettext('widget', 'incl. taxes'), + 'tax_plus_mixed': django.pgettext('widget', 'plus taxes'), 'quota_left': django.pgettext('widget', 'currently available: %s'), 'voucher_required': django.pgettext('widget', 'Only available with a voucher'), 'order_min': django.pgettext('widget', 'minimum amount to order: %s'), @@ -263,15 +265,23 @@ Vue.component('pricebox', { }, taxline: function () { if (this.$root.display_net_prices) { - return django.interpolate(strings.tax_plus, { - 'rate': autofloatformat(this.price.rate, 2), - 'taxname': this.price.name - }, true); + if (this.price.includes_mixed_tax_rate) { + return strings.tax_plus_mixed; + } else { + return django.interpolate(strings.tax_plus, { + 'rate': autofloatformat(this.price.rate, 2), + 'taxname': this.price.name + }, true); + } } else { - return django.interpolate(strings.tax_incl, { - 'rate': autofloatformat(this.price.rate, 2), - 'taxname': this.price.name - }, true); + if (this.price.includes_mixed_tax_rate) { + return strings.tax_incl_mixed; + } else { + return django.interpolate(strings.tax_incl, { + 'rate': autofloatformat(this.price.rate, 2), + 'taxname': this.price.name + }, true); + } } } } diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 1c643c45cb..93695eb581 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -8,8 +8,8 @@ from django_countries.fields import Country from pytz import UTC from pretix.base.models import ( - CartPosition, InvoiceAddress, Item, ItemAddOn, ItemCategory, ItemVariation, - Order, OrderPosition, Question, QuestionOption, Quota, + CartPosition, InvoiceAddress, Item, ItemAddOn, ItemBundle, ItemCategory, + ItemVariation, Order, OrderPosition, Question, QuestionOption, Quota, ) from pretix.base.models.orders import OrderFee @@ -225,6 +225,7 @@ TEST_ITEM_RES = { "picture": None, "available_from": None, "available_until": None, + "require_bundling": False, "require_voucher": False, "hide_without_voucher": False, "allow_cancel": True, @@ -235,6 +236,7 @@ TEST_ITEM_RES = { "require_approval": False, "variations": [], "addons": [], + "bundles": [], "original_price": None } @@ -344,6 +346,25 @@ def test_item_detail_addons(token_client, organizer, event, team, item, category assert res == resp.data +@pytest.mark.django_db +def test_item_detail_bundles(token_client, organizer, event, team, item, category): + i = event.items.create(name="Included thing", default_price=2) + item.bundles.create(bundled_item=i, count=1, designated_price=2) + res = dict(TEST_ITEM_RES) + + res["id"] = item.pk + res["bundles"] = [{ + "bundled_item": i.pk, + "bundled_variation": None, + "count": 1, + "designated_price": '2.00', + }] + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, + item.pk)) + assert resp.status_code == 200 + assert res == resp.data + + @pytest.mark.django_db def test_item_create(token_client, organizer, event, item, category, taxrule): resp = token_client.post( @@ -601,7 +622,134 @@ def test_item_create_with_addon(token_client, organizer, event, item, category, @pytest.mark.django_db -def test_item_update(token_client, organizer, event, item, category, category2, taxrule2): +def test_item_create_with_bundle(token_client, organizer, event, item, category, item2, taxrule): + i = event.items.create(name="Included thing", default_price=2) + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug), + { + "category": category.pk, + "name": { + "en": "Ticket" + }, + "active": True, + "description": None, + "default_price": "23.00", + "free_price": False, + "tax_rate": "19.00", + "tax_rule": taxrule.pk, + "admission": True, + "position": 0, + "picture": None, + "available_from": None, + "available_until": None, + "require_voucher": False, + "hide_without_voucher": False, + "allow_cancel": True, + "min_per_order": None, + "max_per_order": None, + "checkin_attention": False, + "has_variations": True, + "bundles": [ + { + "bundled_item": i.pk, + "bundled_variation": None, + "count": 2, + "designated_price": "3.00", + } + ] + }, + format='json' + ) + assert resp.status_code == 201 + item = Item.objects.get(pk=resp.data['id']) + b = item.bundles.first() + assert b.bundled_item == i + assert b.bundled_variation is None + assert b.count == 2 + assert b.designated_price == 3 + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug), + { + "category": category.pk, + "name": { + "en": "Ticket" + }, + "active": True, + "description": None, + "default_price": "23.00", + "free_price": False, + "tax_rate": "19.00", + "tax_rule": taxrule.pk, + "admission": True, + "position": 0, + "picture": None, + "available_from": None, + "available_until": None, + "require_voucher": False, + "hide_without_voucher": False, + "allow_cancel": True, + "min_per_order": None, + "max_per_order": None, + "checkin_attention": False, + "has_variations": True, + "bundles": [ + { + "bundled_item": item2.pk, + "bundled_variation": None, + "count": 2, + "designated_price": "3.00", + } + ] + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"bundles":["The bundled item must belong to the same event as the item."]}' + + v = item2.variations.create(value="foo") + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug), + { + "category": category.pk, + "name": { + "en": "Ticket" + }, + "active": True, + "description": None, + "default_price": "23.00", + "free_price": False, + "tax_rate": "19.00", + "tax_rule": taxrule.pk, + "admission": True, + "position": 0, + "picture": None, + "available_from": None, + "available_until": None, + "require_voucher": False, + "hide_without_voucher": False, + "allow_cancel": True, + "min_per_order": None, + "max_per_order": None, + "checkin_attention": False, + "has_variations": True, + "bundles": [ + { + "bundled_item": item.pk, + "bundled_variation": v.pk, + "count": 2, + "designated_price": "3.00", + } + ] + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"bundles":["The chosen variation does not belong to this item."]}' + + +@pytest.mark.django_db +def test_item_update(token_client, organizer, event, item, category, item2, category2, taxrule2): resp = token_client.patch( '/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk), { @@ -673,7 +821,25 @@ def test_item_update(token_client, organizer, event, item, category, category2, format='json' ) assert resp.status_code == 400 - assert resp.content.decode() == '{"non_field_errors":["Updating add-ons or variations via PATCH/PUT is not supported. Please use ' \ + assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use ' \ + 'the dedicated nested endpoint."]}' + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk), + { + "bundles": [ + { + "bundled_item": item2.pk, + "bundled_variation": None, + "count": 2, + "designated_price": "3.00", + } + ] + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use ' \ 'the dedicated nested endpoint."]}' @@ -699,7 +865,7 @@ def test_item_update_with_variation(token_client, organizer, event, item): format='json' ) assert resp.status_code == 400 - assert resp.content.decode() == '{"non_field_errors":["Updating add-ons or variations via PATCH/PUT is not supported. Please use ' \ + assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use ' \ 'the dedicated nested endpoint."]}' @@ -721,7 +887,7 @@ def test_item_update_with_addon(token_client, organizer, event, item, category): format='json' ) assert resp.status_code == 400 - assert resp.content.decode() == '{"non_field_errors":["Updating add-ons or variations via PATCH/PUT is not supported. Please use ' \ + assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use ' \ 'the dedicated nested endpoint."]}' @@ -922,6 +1088,123 @@ def test_only_variation_not_delete(token_client, organizer, event, item, variati assert item.variations.filter(pk=variation.id).exists() +@pytest.fixture +def bundle(item, item3, category): + return item.bundles.create(bundled_item=item3, count=1, designated_price=2) + + +TEST_BUNDLE_RES = { + "bundled_item": 0, + "bundled_variation": None, + "count": 1, + "designated_price": "2.00" +} + + +@pytest.mark.django_db +def test_bundles_list(token_client, organizer, event, item, bundle, item3): + res = dict(TEST_BUNDLE_RES) + res["id"] = bundle.pk + res["bundled_item"] = item3.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/bundles/'.format(organizer.slug, event.slug, + item.pk)) + assert resp.status_code == 200 + assert res == resp.data['results'][0] + + +@pytest.mark.django_db +def test_bundles_detail(token_client, organizer, event, item, bundle, item3): + res = dict(TEST_BUNDLE_RES) + res["id"] = bundle.pk + res["bundled_item"] = item3.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/bundles/{}/'.format(organizer.slug, event.slug, + item.pk, bundle.pk)) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.mark.django_db +def test_bundles_create(token_client, organizer, event, item, item2, item3): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/{}/bundles/'.format(organizer.slug, event.slug, item.pk), + { + "bundled_item": item3.pk, + "bundled_variation": None, + "count": 1, + "designated_price": "1.50", + }, + format='json' + ) + assert resp.status_code == 201 + b = ItemBundle.objects.get(pk=resp.data['id']) + assert b.bundled_item == item3 + assert b.bundled_variation is None + assert b.designated_price == 1.5 + assert b.count == 1 + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/{}/bundles/'.format(organizer.slug, event.slug, item.pk), + { + "bundled_item": item2.pk, + "bundled_variation": None, + "count": 1, + "designated_price": "1.50", + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["The bundled item must belong to the same event as the item."]}' + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/{}/bundles/'.format(organizer.slug, event.slug, item.pk), + { + "bundled_item": item.pk, + "bundled_variation": None, + "count": 1, + "designated_price": "1.50", + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["The bundled item must not be the same item as the bundling one."]}' + + item3.bundles.create(bundled_item=item, count=1, designated_price=3) + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/{}/bundles/'.format(organizer.slug, event.slug, item.pk), + { + "bundled_item": item3.pk, + "bundled_variation": None, + "count": 1, + "designated_price": "1.50", + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["The bundled item must not have bundles on its own."]}' + + +@pytest.mark.django_db +def test_bundles_update(token_client, organizer, event, item, bundle): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/items/{}/bundles/{}/'.format(organizer.slug, event.slug, item.pk, bundle.pk), + { + "count": 3, + }, + format='json' + ) + assert resp.status_code == 200 + a = ItemBundle.objects.get(pk=bundle.pk) + assert a.count == 3 + + +@pytest.mark.django_db +def test_bundles_delete(token_client, organizer, event, item, bundle): + resp = token_client.delete('/api/v1/organizers/{}/events/{}/items/{}/bundles/{}/'.format(organizer.slug, event.slug, + item.pk, bundle.pk)) + assert resp.status_code == 204 + assert not item.bundles.filter(pk=bundle.id).exists() + + @pytest.fixture def addon(item, category): return item.addons.create(addon_category=category, min_count=0, max_count=10, position=1) diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index f1a8939563..cf6b4e554a 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -20,7 +20,9 @@ from pretix.base.models import ( Organizer, Question, Quota, User, Voucher, WaitingListEntry, ) from pretix.base.models.event import SubEvent -from pretix.base.models.items import SubEventItem, SubEventItemVariation +from pretix.base.models.items import ( + ItemBundle, SubEventItem, SubEventItemVariation, +) from pretix.base.reldate import RelativeDate, RelativeDateWrapper from pretix.base.services.orders import OrderError, cancel_order, perform_order @@ -453,6 +455,111 @@ class QuotaTestCase(BaseQuotaTestCase): self.event.save() +class BundleQuotaTestCase(BaseQuotaTestCase): + def setUp(self): + super().setUp() + self.quota.size = 5 + self.quota.save() + self.trans = Item.objects.create(event=self.event, name='Public Transport Ticket', + default_price=2.50) + self.transquota = Quota.objects.create(event=self.event, name='Transport', size=10) + self.transquota.items.add(self.trans) + self.quota.items.add(self.item1) + self.quota.items.add(self.item2) + self.quota.variations.add(self.var1) + self.bundle1 = ItemBundle.objects.create( + base_item=self.item1, + bundled_item=self.trans, + designated_price=1.5, + count=1 + ) + self.bundle2 = ItemBundle.objects.create( + base_item=self.item2, + bundled_item=self.trans, + designated_price=1.5, + count=1 + ) + + def test_only_respect_with_flag(self): + assert self.item1.check_quotas() == (Quota.AVAILABILITY_OK, 5) + + def test_do_not_exceed(self): + assert self.item1.check_quotas(include_bundled=True) == (Quota.AVAILABILITY_OK, 5) + + def test_limited_by_bundled_quita(self): + self.transquota.size = 3 + self.transquota.save() + assert self.item1.check_quotas(include_bundled=True) == (Quota.AVAILABILITY_OK, 3) + + def test_multiple_bundles(self): + ItemBundle.objects.create( + base_item=self.item1, + bundled_item=self.trans, + designated_price=1.5, + count=1 + ) + self.transquota.size = 3 + self.transquota.save() + assert self.item1.check_quotas(include_bundled=True) == (Quota.AVAILABILITY_OK, 1) + + def test_bundle_count(self): + self.bundle1.count = 2 + self.bundle1.save() + self.transquota.size = 3 + self.transquota.save() + assert self.item1.check_quotas(include_bundled=True) == (Quota.AVAILABILITY_OK, 1) + + def test_bundled_unlimited(self): + self.transquota.size = None + self.transquota.save() + assert self.item1.check_quotas(include_bundled=True) == (Quota.AVAILABILITY_OK, 5) + assert self.var1.check_quotas(include_bundled=True) == (Quota.AVAILABILITY_OK, 5) + + def test_item_unlimited(self): + self.quota.size = None + self.quota.save() + assert self.item1.check_quotas(include_bundled=True) == (Quota.AVAILABILITY_OK, 10) + assert self.var1.check_quotas(include_bundled=True) == (Quota.AVAILABILITY_OK, 10) + + def test_var_only_respect_with_flag(self): + assert self.var1.check_quotas() == (Quota.AVAILABILITY_OK, 5) + + def test_var_do_not_exceed(self): + assert self.var1.check_quotas(include_bundled=True) == (Quota.AVAILABILITY_OK, 5) + + def test_var_limited_by_bundled_quita(self): + self.transquota.size = 3 + self.transquota.save() + assert self.var1.check_quotas(include_bundled=True) == (Quota.AVAILABILITY_OK, 3) + + def test_var_multiple_bundles(self): + ItemBundle.objects.create( + base_item=self.item2, + bundled_item=self.trans, + designated_price=1.5, + count=1 + ) + self.transquota.size = 3 + self.transquota.save() + assert self.var1.check_quotas(include_bundled=True) == (Quota.AVAILABILITY_OK, 1) + + def test_var_bundle_count(self): + self.bundle2.count = 2 + self.bundle2.save() + self.transquota.size = 3 + self.transquota.save() + assert self.var1.check_quotas(include_bundled=True) == (Quota.AVAILABILITY_OK, 1) + + def test_bundled_variation(self): + v = self.trans.variations.create(value="foo", default_price=4) + self.transquota.variations.add(v) + self.bundle2.bundled_variation = v + self.bundle2.save() + self.transquota.size = 3 + self.transquota.save() + assert self.var1.check_quotas(include_bundled=True) == (Quota.AVAILABILITY_OK, 3) + + class WaitingListTestCase(BaseQuotaTestCase): def test_duplicate(self): diff --git a/src/tests/control/test_items.py b/src/tests/control/test_items.py index 0f76a638d1..d3dc9d330d 100644 --- a/src/tests/control/test_items.py +++ b/src/tests/control/test_items.py @@ -392,6 +392,59 @@ class ItemsTest(ItemFormTest): }) assert not self.item2.addons.exists() + def test_manipulate_bundles(self): + self.client.post('/control/event/%s/%s/items/%d/bundles' % (self.orga1.slug, self.event1.slug, self.item2.id), { + 'form-TOTAL_FORMS': '1', + 'form-INITIAL_FORMS': '0', + 'form-MIN_NUM_FORMS': '0', + 'form-MAX_NUM_FORMS': '1000', + 'form-0-id': '', + 'form-0-itemvar': str(self.item1.pk), + 'form-0-count': '2', + 'form-0-designated_price': '2.00', + }) + assert self.item2.bundles.exists() + assert self.item2.bundles.first().bundled_item == self.item1 + self.client.post('/control/event/%s/%s/items/%d/bundles' % (self.orga1.slug, self.event1.slug, self.item2.id), { + 'form-TOTAL_FORMS': '1', + 'form-INITIAL_FORMS': '1', + 'form-MIN_NUM_FORMS': '0', + 'form-MAX_NUM_FORMS': '1000', + 'form-0-id': str(self.item2.bundles.first().pk), + 'form-0-itemvar': str(self.item1.pk), + 'form-0-count': '2', + 'form-0-designated_price': '2.00', + 'form-0-DELETE': 'on', + }) + assert not self.item2.bundles.exists() + + # Do not allow self-reference + self.client.post('/control/event/%s/%s/items/%d/addons' % (self.orga1.slug, self.event1.slug, self.item2.id), { + 'form-TOTAL_FORMS': '1', + 'form-INITIAL_FORMS': '0', + 'form-MIN_NUM_FORMS': '0', + 'form-MAX_NUM_FORMS': '1000', + 'form-0-id': '', + 'form-0-itemvar': str(self.item2.pk), + 'form-0-count': '2', + 'form-0-designated_price': '2.00', + }) + assert not self.item2.bundles.exists() + + # Do not allow multi-level bundles + self.item1.bundles.create(bundled_item=self.item1, count=1, designated_price=0) + self.client.post('/control/event/%s/%s/items/%d/bundles' % (self.orga1.slug, self.event1.slug, self.item2.id), { + 'form-TOTAL_FORMS': '1', + 'form-INITIAL_FORMS': '0', + 'form-MIN_NUM_FORMS': '0', + 'form-MAX_NUM_FORMS': '1000', + 'form-0-id': '', + 'form-0-itemvar': str(self.item1.pk), + 'form-0-count': '2', + 'form-0-designated_price': '2.00', + }) + assert not self.item2.bundles.exists() + def test_update_variations(self): self.client.post('/control/event/%s/%s/items/%d/variations' % (self.orga1.slug, self.event1.slug, self.item2.id), { 'form-TOTAL_FORMS': '2', diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index c0c316fea1..6ad8e71289 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -14,9 +14,11 @@ from pretix.base.models import ( Organizer, Question, QuestionAnswer, Quota, Voucher, ) from pretix.base.models.items import ( - ItemAddOn, SubEventItem, SubEventItemVariation, + 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.sessions import get_cart_session_key @@ -1968,3 +1970,601 @@ class CartAddonTest(CartTestMixin, TestCase): assert cp1.expires > now() assert cp2.expires > now() assert cp2.addon_to_id == cp1.pk + + +class CartBundleTest(CartTestMixin, TestCase): + def setUp(self): + super().setUp() + self.trans = Item.objects.create(event=self.event, name='Public Transport Ticket', + default_price=2.50, require_bundling=True) + self.transquota = Quota.objects.create(event=self.event, name='Transport', size=5) + self.transquota.items.add(self.trans) + self.bundle1 = ItemBundle.objects.create( + base_item=self.ticket, + bundled_item=self.trans, + designated_price=1.5, + count=1 + ) + self.cm = CartManager(event=self.event, cart_id=self.session_key) + + def test_simple_bundle(self): + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1 + } + ]) + self.cm.commit() + cp = CartPosition.objects.get(addon_to__isnull=True) + assert cp.item == self.ticket + assert cp.price == 23 - 1.5 + assert cp.addons.count() == 1 + a = cp.addons.get() + assert a.item == self.trans + assert a.price == 1.5 + + def test_voucher_on_base_product(self): + v = self.event.vouchers.create(code="foo", item=self.ticket) + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'voucher': v.code, + 'count': 1 + } + ]) + self.cm.commit() + cp = CartPosition.objects.get(addon_to__isnull=True) + assert cp.item == self.ticket + assert cp.price == 23 - 1.5 + assert cp.addons.count() == 1 + assert cp.voucher == v + a = cp.addons.get() + assert a.item == self.trans + assert a.price == 1.5 + assert not a.voucher + + def test_simple_bundle_with_variation(self): + v = self.trans.variations.create(value="foo", default_price=4) + self.transquota.variations.add(v) + self.bundle1.bundled_variation = v + self.bundle1.save() + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1 + } + ]) + self.cm.commit() + cp = CartPosition.objects.get(addon_to__isnull=True) + assert cp.item == self.ticket + assert cp.price == 23 - 1.5 + assert cp.addons.count() == 1 + a = cp.addons.get() + assert a.item == self.trans + assert a.variation == v + assert a.price == 1.5 + + def test_multiple_bundles(self): + ItemBundle.objects.create( + base_item=self.ticket, bundled_item=self.trans, designated_price=1.5, count=1 + ) + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1 + } + ]) + self.cm.commit() + cp = CartPosition.objects.get(addon_to__isnull=True) + assert cp.item == self.ticket + assert cp.price == 23 - 1.5 - 1.5 + assert cp.addons.count() == 2 + a = cp.addons.first() + assert a.item == self.trans + assert a.price == 1.5 + a = cp.addons.last() + assert a.item == self.trans + assert a.price == 1.5 + + def test_bundle_with_count(self): + self.bundle1.count = 2 + self.bundle1.save() + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1 + } + ]) + self.cm.commit() + cp = CartPosition.objects.get(addon_to__isnull=True) + assert cp.item == self.ticket + assert cp.price == 23 - 1.5 - 1.5 + assert cp.addons.count() == 2 + a = cp.addons.first() + assert a.item == self.trans + assert a.price == 1.5 + a = cp.addons.last() + assert a.item == self.trans + assert a.price == 1.5 + + def test_bundle_position_multiple(self): + self.bundle1.count = 2 + self.bundle1.save() + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 2 + } + ]) + self.cm.commit() + assert CartPosition.objects.filter(addon_to__isnull=True).count() == 2 + assert CartPosition.objects.count() == 6 + cp = CartPosition.objects.filter(addon_to__isnull=True).first() + assert cp.item == self.ticket + assert cp.price == 23 - 1.5 - 1.5 + assert cp.addons.count() == 2 + a = cp.addons.first() + assert a.item == self.trans + assert a.price == 1.5 + + def test_bundle_position_free_price(self): + self.ticket.free_price = True + self.ticket.default_price = 1 + self.ticket.save() + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1, + 'price': 20 + } + ]) + self.cm.commit() + cp = CartPosition.objects.get(addon_to__isnull=True) + assert cp.item == self.ticket + assert cp.price == 20 - 1.5 + a = cp.addons.get() + assert a.item == self.trans + assert a.price == 1.5 + + def test_bundle_position_free_price_lower_than_designated_price(self): + self.ticket.free_price = True + self.ticket.default_price = 1 + self.ticket.save() + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1, + 'price': 1.2 + } + ]) + self.cm.commit() + cp = CartPosition.objects.get(addon_to__isnull=True) + assert cp.item == self.ticket + assert cp.price == Decimal('0.00') + a = cp.addons.get() + assert a.item == self.trans + assert a.price == Decimal('1.50') + + def test_bundle_position_without_designated_price(self): + self.bundle1.designated_price = 0 + self.bundle1.save() + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1, + } + ]) + self.cm.commit() + cp = CartPosition.objects.get(addon_to__isnull=True) + assert cp.item == self.ticket + assert cp.price == 23 + a = cp.addons.get() + assert a.item == self.trans + assert a.price == 0 + + def test_bundle_sold_out(self): + self.transquota.size = 0 + self.transquota.save() + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1, + } + ]) + with self.assertRaises(CartError): + self.cm.commit() + assert not CartPosition.objects.exists() + + def test_bundle_sold_partial_in_bundle(self): + self.bundle1.count = 2 + self.bundle1.save() + self.transquota.size = 1 + self.transquota.save() + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1, + } + ]) + with self.assertRaises(CartError): + self.cm.commit() + assert not CartPosition.objects.exists() + + def test_bundle_sold_partial_in_bundle_multiple_positions(self): + self.bundle1.count = 2 + self.bundle1.save() + self.transquota.size = 3 + self.transquota.save() + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 2, + } + ]) + with self.assertRaises(CartError): + self.cm.commit() + assert CartPosition.objects.filter(addon_to__isnull=True).count() == 1 + assert CartPosition.objects.filter(addon_to__isnull=False).count() == 2 + + def test_multiple_bundles_sold_out_partially(self): + ItemBundle.objects.create( + base_item=self.ticket, bundled_item=self.trans, designated_price=1.5, count=1 + ) + self.transquota.size = 1 + self.transquota.save() + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1 + } + ]) + with self.assertRaises(CartError): + self.cm.commit() + assert not CartPosition.objects.exists() + + def test_require_bundling(self): + self.ticket.require_bundling = True + self.ticket.save() + with self.assertRaises(CartError): + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1 + } + ]) + assert not CartPosition.objects.exists() + + def test_bundle_item_disabled(self): + self.ticket.active = False + self.ticket.save() + with self.assertRaises(CartError): + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1 + } + ]) + assert not CartPosition.objects.exists() + + def test_bundle_different_tax_rates(self): + tr19 = self.event.tax_rules.create( + name='VAT', + rate=Decimal('19.00') + ) + tr7 = self.event.tax_rules.create( + name='VAT', + rate=Decimal('7.00'), + price_includes_tax=True, # will be ignored + ) + self.event.settings.display_net_prices = True # will be ignored + self.ticket.tax_rule = tr19 + self.ticket.save() + self.trans.tax_rule = tr7 + self.trans.save() + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1 + } + ]) + self.cm.commit() + assert CartPosition.objects.filter(addon_to__isnull=True).count() == 1 + assert CartPosition.objects.count() == 2 + cp = CartPosition.objects.filter(addon_to__isnull=True).first() + assert cp.item == self.ticket + assert cp.price == Decimal('21.50') + 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 + + def test_one_bundled_one_addon(self): + cat = self.event.categories.create(name="addons") + self.trans.require_bundling = False + self.trans.category = cat + self.trans.save() + ItemAddOn.objects.create(base_item=self.ticket, addon_category=cat) + + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1 + } + ]) + self.cm.commit() + + cp = CartPosition.objects.filter(addon_to__isnull=True).first() + assert cp.item == self.ticket + assert cp.price == Decimal('21.50') + b = cp.addons.first() + assert b.item == self.trans + + self.cm = CartManager(event=self.event, cart_id=self.session_key) + self.cm.set_addons([ + { + 'addon_to': cp.pk, + 'item': self.trans.pk, + 'variation': None + } + ]) + self.cm.commit() + assert cp.addons.count() == 2 + a = cp.addons.exclude(pk=b.pk).get() + assert a.item == self.trans + assert a.price == 2.5 + + def test_extend_keep_price(self): + cp = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=21.5, expires=now() - timedelta(minutes=10) + ) + b = CartPosition.objects.create( + 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 + ) + self.cm.commit() + cp.refresh_from_db() + b.refresh_from_db() + assert cp.price == 21.5 + assert b.price == 1.5 + + def test_extend_designated_price_changed(self): + cp = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=21.5, expires=now() - timedelta(minutes=10) + ) + b = CartPosition.objects.create( + 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 + ) + self.bundle1.designated_price = Decimal('2.00') + self.bundle1.save() + self.cm.commit() + cp.refresh_from_db() + b.refresh_from_db() + assert cp.price == 21 + assert b.price == 2 + + def test_extend_designated_price_changed_beyond_base_price(self): + cp = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=21.5, expires=now() - timedelta(minutes=10) + ) + b = CartPosition.objects.create( + 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 + ) + self.bundle1.designated_price = Decimal('40.00') + self.bundle1.save() + self.cm.commit() + cp.refresh_from_db() + b.refresh_from_db() + assert cp.price == 0 + assert b.price == 40 + + def test_extend_base_price_changed(self): + cp = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=21.5, expires=now() - timedelta(minutes=10) + ) + b = CartPosition.objects.create( + 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 + ) + self.ticket.default_price = Decimal('25.00') + self.ticket.save() + self.cm.commit() + cp.refresh_from_db() + b.refresh_from_db() + assert cp.price == 23.5 + assert b.price == 1.5 + + def test_extend_bundled_and_addon(self): + cp = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=21.5, expires=now() - timedelta(minutes=10) + ) + a = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.trans, addon_to=cp, + price=1.5, expires=now() - timedelta(minutes=10), is_bundled=False + ) + b = CartPosition.objects.create( + 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 + ) + self.cm.commit() + cp.refresh_from_db() + b.refresh_from_db() + a.refresh_from_db() + assert cp.price == 21.5 + assert b.price == 1.5 + assert a.price == 2.5 + + def test_expired_reverse_charge_only_bundled(self): + tr19 = self.event.tax_rules.create(name='VAT', rate=Decimal('19.00')) + ia = InvoiceAddress.objects.create( + is_business=True, vat_id='ATU1234567', vat_id_validated=True, + country=Country('AT') + ) + tr7 = self.event.tax_rules.create(name='VAT', rate=Decimal('7.00'), eu_reverse_charge=True, home_country=Country('DE')) + self.ticket.tax_rule = tr19 + self.ticket.save() + self.trans.tax_rule = tr7 + self.trans.save() + + cp = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=21.5, expires=now() - timedelta(minutes=10) + ) + a = CartPosition.objects.create( + 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) + 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() + + 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 == 0 + assert not a.includes_tax + + def test_expired_reverse_charge_all(self): + ia = InvoiceAddress.objects.create( + is_business=True, vat_id='ATU1234567', vat_id_validated=True, + country=Country('AT') + ) + tr19 = self.event.tax_rules.create(name='VAT', rate=Decimal('19.00'), eu_reverse_charge=True, home_country=Country('DE')) + tr7 = self.event.tax_rules.create(name='VAT', rate=Decimal('7.00'), eu_reverse_charge=True, home_country=Country('DE')) + self.ticket.tax_rule = tr19 + self.ticket.save() + self.trans.tax_rule = tr7 + self.trans.save() + + cp = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=21.5, expires=now() - timedelta(minutes=10) + ) + a = CartPosition.objects.create( + 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) + 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() + + 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 + + def test_reverse_charge_all_add(self): + ia = InvoiceAddress.objects.create( + is_business=True, vat_id='ATU1234567', vat_id_validated=True, + country=Country('AT') + ) + tr19 = self.event.tax_rules.create(name='VAT', rate=Decimal('19.00'), eu_reverse_charge=True, home_country=Country('DE')) + tr7 = self.event.tax_rules.create(name='VAT', rate=Decimal('7.00'), eu_reverse_charge=True, home_country=Country('DE')) + self.ticket.tax_rule = tr19 + self.ticket.save() + self.trans.tax_rule = tr7 + self.trans.save() + + self.cm.invoice_address = ia + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1 + } + ]) + self.cm.commit() + + cp = CartPosition.objects.filter(addon_to__isnull=True).get() + 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 + + def test_reverse_charge_bundled_add(self): + ia = InvoiceAddress.objects.create( + is_business=True, vat_id='ATU1234567', vat_id_validated=True, + country=Country('AT') + ) + tr19 = self.event.tax_rules.create(name='VAT', rate=Decimal('19.00')) + tr7 = self.event.tax_rules.create(name='VAT', rate=Decimal('7.00'), eu_reverse_charge=True, home_country=Country('DE')) + self.ticket.tax_rule = tr19 + self.ticket.save() + self.trans.tax_rule = tr7 + self.trans.save() + + self.cm.invoice_address = ia + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1 + } + ]) + self.cm.commit() + + cp = CartPosition.objects.filter(addon_to__isnull=True).get() + 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 diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index a61b508280..19e92692d6 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -18,7 +18,10 @@ from pretix.base.models import ( OrderPayment, OrderPosition, Organizer, Question, QuestionAnswer, Quota, Voucher, ) -from pretix.base.models.items import ItemAddOn, ItemVariation, SubEventItem +from pretix.base.models.items import ( + ItemAddOn, ItemBundle, ItemVariation, SubEventItem, +) +from pretix.base.services.orders import OrderError, _perform_order from pretix.testutils.sessions import get_cart_session_key @@ -1981,3 +1984,335 @@ class QuestionsTestCase(BaseCheckoutTestCase, TestCase): self.q2a: 'DEV', self.q3: 'False', }, should_fail=True) + + +class CheckoutBundleTest(BaseCheckoutTestCase, TestCase): + def setUp(self): + super().setUp() + self.trans = Item.objects.create(event=self.event, name='Public Transport Ticket', + default_price=2.50) + self.transquota = Quota.objects.create(event=self.event, name='Transport', size=5) + self.transquota.items.add(self.trans) + self.bundle1 = ItemBundle.objects.create( + base_item=self.ticket, + bundled_item=self.trans, + designated_price=1.5, + count=1 + ) + self.cp1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=21.5, 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 + ) + + def test_simple_bundle(self): + oid = _perform_order(self.event.pk, '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) + assert cp.item == self.ticket + assert cp.price == 23 - 1.5 + assert cp.addons.count() == 1 + a = cp.addons.get() + assert a.item == self.trans + assert a.price == 1.5 + + def test_simple_bundle_with_variation(self): + v = self.trans.variations.create(value="foo", default_price=4) + self.transquota.variations.add(v) + self.bundle1.bundled_variation = v + self.bundle1.save() + self.bundled1.variation = v + self.bundled1.save() + + oid = _perform_order(self.event.pk, '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) + assert cp.item == self.ticket + assert cp.price == 23 - 1.5 + assert cp.addons.count() == 1 + a = cp.addons.get() + assert a.item == self.trans + assert a.variation == v + assert a.price == 1.5 + + def test_bundle_with_count(self): + self.cp1.price -= 1.5 + self.cp1.save() + bundled2 = 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 + ) + oid = _perform_order(self.event.pk, 'manual', [self.cp1.pk, self.bundled1.pk, bundled2.pk], 'admin@example.org', 'en', None, {}, 'web') + o = Order.objects.get(pk=oid) + cp = o.positions.get(addon_to__isnull=True) + assert cp.item == self.ticket + assert cp.price == 23 - 1.5 - 1.5 + assert cp.addons.count() == 2 + a = cp.addons.first() + assert a.item == self.trans + assert a.price == 1.5 + a = cp.addons.last() + assert a.item == self.trans + assert a.price == 1.5 + + def test_bundle_position_free_price(self): + self.ticket.free_price = True + self.ticket.default_price = 1 + self.ticket.save() + self.cp1.price = 20 - 1.5 + self.cp1.save() + + oid = _perform_order(self.event.pk, '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) + assert cp.item == self.ticket + assert cp.price == 20 - 1.5 + a = cp.addons.get() + assert a.item == self.trans + assert a.price == 1.5 + + def test_bundle_position_free_price_lower_than_designated_price(self): + self.ticket.free_price = True + self.ticket.default_price = 1 + self.ticket.save() + self.cp1.price = 0 + self.cp1.save() + + oid = _perform_order(self.event.pk, '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) + assert cp.item == self.ticket + assert cp.price == Decimal('0.00') + a = cp.addons.get() + assert a.item == self.trans + assert a.price == Decimal('1.50') + + def test_bundle_different_tax_rates(self): + tr19 = self.event.tax_rules.create( + name='VAT', + rate=Decimal('19.00') + ) + tr7 = self.event.tax_rules.create( + name='VAT', + rate=Decimal('7.00'), + price_includes_tax=True, # will be ignored + ) + self.ticket.tax_rule = tr19 + self.ticket.save() + self.trans.tax_rule = tr7 + self.trans.save() + + oid = _perform_order(self.event.pk, '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) + assert cp.item == self.ticket + assert cp.price == Decimal('21.50') + assert cp.tax_rate == Decimal('19.00') + assert cp.tax_value == Decimal('3.43') + assert cp.addons.count() == 1 + 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') + + def test_expired_keep_price(self): + self.cp1.expires = now() - timedelta(minutes=10) + self.cp1.save() + self.bundled1.expires = now() - timedelta(minutes=10) + self.bundled1.save() + + oid = _perform_order(self.event.pk, '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.5 + assert b.price == 1.5 + + def test_expired_designated_price_changed(self): + self.bundle1.designated_price = Decimal('2.00') + self.bundle1.save() + self.cp1.expires = now() - timedelta(minutes=10) + self.cp1.save() + self.bundled1.expires = now() - timedelta(minutes=10) + self.bundled1.save() + with self.assertRaises(OrderError): + _perform_order(self.event.pk, '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 + + def test_expired_designated_price_changed_beyond_base_price(self): + self.bundle1.designated_price = Decimal('40.00') + self.bundle1.save() + self.cp1.expires = now() - timedelta(minutes=10) + self.cp1.save() + self.bundled1.expires = now() - timedelta(minutes=10) + self.bundled1.save() + with self.assertRaises(OrderError): + _perform_order(self.event.pk, '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 == 0 + assert self.bundled1.price == 40 + + def test_expired_base_price_changed(self): + self.ticket.default_price = Decimal('25.00') + self.ticket.save() + self.cp1.expires = now() - timedelta(minutes=10) + self.cp1.save() + self.bundled1.expires = now() - timedelta(minutes=10) + self.bundled1.save() + with self.assertRaises(OrderError): + _perform_order(self.event.pk, '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 == 23.5 + assert self.bundled1.price == 1.5 + + def test_expired_bundled_and_addon(self): + a = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.trans, addon_to=self.cp1, + 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.pk, 'manual', [self.cp1.pk, self.bundled1.pk, a.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() + a = cp.addons.last() + assert cp.price == 21.5 + assert b.price == 1.5 + assert cp.price == 21.5 + assert b.price == 1.5 + assert a.price == 2.5 + + def test_expired_base_product_sold_out(self): + self.quota_tickets.size = 0 + self.quota_tickets.save() + self.cp1.expires = now() - timedelta(minutes=10) + self.cp1.save() + self.bundled1.expires = now() - timedelta(minutes=10) + self.bundled1.save() + with self.assertRaises(OrderError): + _perform_order(self.event.pk, 'manual', [self.cp1.pk, self.bundled1.pk], 'admin@example.org', 'en', None, {}, 'web') + assert not CartPosition.objects.exists() + + def test_expired_bundled_product_sold_out(self): + self.transquota.size = 0 + self.transquota.save() + self.cp1.expires = now() - timedelta(minutes=10) + self.cp1.save() + self.bundled1.expires = now() - timedelta(minutes=10) + self.bundled1.save() + with self.assertRaises(OrderError): + _perform_order(self.event.pk, 'manual', [self.cp1.pk, self.bundled1.pk], 'admin@example.org', 'en', None, {}, 'web') + assert not CartPosition.objects.exists() + + def test_expired_bundled_products_sold_out_partially(self): + self.transquota.size = 1 + self.transquota.save() + a = 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 + ) + self.cp1.price -= 1.5 + self.cp1.expires = now() - timedelta(minutes=10) + self.cp1.save() + self.bundled1.expires = now() - timedelta(minutes=10) + self.bundled1.save() + with self.assertRaises(OrderError): + _perform_order(self.event.pk, 'manual', [self.cp1.pk, self.bundled1.pk, a.pk], 'admin@example.org', 'en', None, {}, 'web') + assert not CartPosition.objects.exists() + + def test_expired_reverse_charge_only_bundled(self): + tr19 = self.event.tax_rules.create(name='VAT', rate=Decimal('19.00')) + ia = InvoiceAddress.objects.create( + is_business=True, vat_id='ATU1234567', vat_id_validated=True, + country=Country('AT') + ) + tr7 = self.event.tax_rules.create(name='VAT', rate=Decimal('7.00'), eu_reverse_charge=True, home_country=Country('DE')) + self.ticket.tax_rule = tr19 + self.ticket.save() + self.trans.tax_rule = tr7 + self.trans.save() + self.cp1.expires = now() - timedelta(minutes=10) + 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.pk, 'manual', [self.cp1.pk, self.bundled1.pk], 'admin@example.org', 'en', ia.pk, {}, 'web') + o = Order.objects.get(pk=oid) + cp = o.positions.get(addon_to__isnull=True) + assert cp.item == self.ticket + assert cp.price == Decimal('21.50') + assert cp.tax_rate == Decimal('19.00') + assert cp.tax_value == Decimal('3.43') + assert cp.addons.count() == 1 + a = cp.addons.first() + assert a.item == self.trans + assert a.price == Decimal('1.40') + assert a.tax_rate == Decimal('0.00') + assert a.tax_value == Decimal('0.00') + + def test_expired_reverse_charge_all(self): + ia = InvoiceAddress.objects.create( + is_business=True, vat_id='ATU1234567', vat_id_validated=True, + country=Country('AT') + ) + tr19 = self.event.tax_rules.create(name='VAT', rate=Decimal('19.00'), eu_reverse_charge=True, home_country=Country('DE')) + tr7 = self.event.tax_rules.create(name='VAT', rate=Decimal('7.00'), eu_reverse_charge=True, home_country=Country('DE')) + self.ticket.tax_rule = tr19 + self.ticket.save() + self.trans.tax_rule = tr7 + 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.pk, 'manual', [self.cp1.pk, self.bundled1.pk], 'admin@example.org', 'en', ia.pk, {}, 'web') + o = Order.objects.get(pk=oid) + cp = o.positions.get(addon_to__isnull=True) + assert cp.item == self.ticket + assert cp.price == Decimal('18.07') + assert cp.tax_rate == Decimal('0.00') + assert cp.tax_value == Decimal('0.00') + assert cp.addons.count() == 1 + a = cp.addons.first() + assert a.item == self.trans + assert a.price == Decimal('1.40') + assert a.tax_rate == Decimal('0.00') + assert a.tax_value == Decimal('0.00') + + def test_addon_and_bundle_through_frontend_stack(self): + cat = self.event.categories.create(name="addons") + self.trans.category = cat + self.trans.save() + ItemAddOn.objects.create(base_item=self.ticket, addon_category=cat, min_count=1, + price_included=True) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.trans, addon_to=self.cp1, + price=0, expires=now() + timedelta(minutes=10), is_bundled=False + ) + + self._set_session('payment', 'banktransfer') + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select(".thank-you")), 1) diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index d40e7387c9..b6a624a3cc 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -312,6 +312,64 @@ class ItemDisplayTest(EventTestMixin, SoupTest): self.assertIn("Black", doc.select("section:nth-of-type(1) div.variation")[1].text) self.assertIn("12.00", doc.select("section:nth-of-type(1) div.variation")[1].text) + def test_require_bundling(self): + q = Quota.objects.create(event=self.event, name='Quota', size=2) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=12) + q.items.add(item) + q2 = Quota.objects.create(event=self.event, name='Quota', size=2) + item2 = Item.objects.create(event=self.event, name='Dinner', default_price=12, require_bundling=True) + q2.items.add(item2) + item.bundles.create(bundled_item=item2, designated_price=2, count=1) + + doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertEqual(1, len(doc.select(".availability-box"))) + + def test_bundle_sold_out(self): + q = Quota.objects.create(event=self.event, name='Quota', size=2) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=12) + q.items.add(item) + q2 = Quota.objects.create(event=self.event, name='Quota', size=0) + item2 = Item.objects.create(event=self.event, name='Dinner', default_price=12, position=10) + q2.items.add(item2) + item.bundles.create(bundled_item=item2, designated_price=2, count=1) + + doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertIn("Early-bird", doc.select("section:nth-of-type(1) div:nth-of-type(1)")[0].text) + self.assertIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text) + + def test_bundle_mixed_tax_rate(self): + tr19 = self.event.tax_rules.create(rate=Decimal('19.00')) + tr7 = self.event.tax_rules.create(rate=Decimal('7.00')) + q = Quota.objects.create(event=self.event, name='Quota', size=2) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=12, tax_rule=tr19) + q.items.add(item) + q2 = Quota.objects.create(event=self.event, name='Quota', size=0) + item2 = Item.objects.create(event=self.event, name='Dinner', default_price=12, tax_rule=tr7, position=10) + q2.items.add(item2) + item.bundles.create(bundled_item=item2, designated_price=2, count=1) + + doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertIn("Early-bird", doc.select("section:nth-of-type(1) div:nth-of-type(1)")[0].text) + self.assertIn("12.00", doc.select("section:nth-of-type(1) div.price")[0].text) + self.assertIn("incl. taxes", doc.select("section:nth-of-type(1) div.price")[0].text) + + def test_bundle_mixed_tax_rate_show_net(self): + self.event.settings.display_net_prices = True + tr19 = self.event.tax_rules.create(rate=Decimal('19.00')) + tr7 = self.event.tax_rules.create(rate=Decimal('7.00')) + q = Quota.objects.create(event=self.event, name='Quota', size=2) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=12, tax_rule=tr19) + q.items.add(item) + q2 = Quota.objects.create(event=self.event, name='Quota', size=0) + item2 = Item.objects.create(event=self.event, name='Dinner', default_price=12, tax_rule=tr7, position=10) + q2.items.add(item2) + item.bundles.create(bundled_item=item2, designated_price=2, count=1) + + doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertIn("Early-bird", doc.select("section:nth-of-type(1) div:nth-of-type(1)")[0].text) + self.assertIn("10.27", doc.select("section:nth-of-type(1) div.price")[0].text) + self.assertIn("plus taxes", doc.select("section:nth-of-type(1) div.price")[0].text) + class VoucherRedeemItemDisplayTest(EventTestMixin, SoupTest): def setUp(self): diff --git a/src/tests/presale/test_widget.py b/src/tests/presale/test_widget.py index 7e2294d3f3..008a0007a9 100644 --- a/src/tests/presale/test_widget.py +++ b/src/tests/presale/test_widget.py @@ -138,7 +138,7 @@ class WidgetCartTest(CartTestMixin, TestCase): "require_voucher": False, "order_min": None, "max_price": None, - "price": {"gross": "23.00", "net": "19.33", "tax": "3.67", "name": "", "rate": "19.00"}, + "price": {"gross": "23.00", "net": "19.33", "tax": "3.67", "name": "", "rate": "19.00", "includes_mixed_tax_rate": False}, "picture": None, "has_variations": 0, "description": None, @@ -166,7 +166,7 @@ class WidgetCartTest(CartTestMixin, TestCase): "value": "Red", "id": self.shirt_red.pk, "price": {"gross": "14.00", "net": "11.76", "tax": "2.24", "name": "", - "rate": "19.00"}, + "rate": "19.00", "includes_mixed_tax_rate": False}, "description": None, "avail": [100, None], "order_max": 2 @@ -175,7 +175,7 @@ class WidgetCartTest(CartTestMixin, TestCase): "value": "Blue", "id": self.shirt_blue.pk, "price": {"gross": "12.00", "net": "10.08", "tax": "1.92", "name": "", - "rate": "19.00"}, + "rate": "19.00", "includes_mixed_tax_rate": False}, "description": None, "avail": [100, None], "order_max": 2 @@ -218,7 +218,7 @@ class WidgetCartTest(CartTestMixin, TestCase): "require_voucher": False, "order_min": None, "max_price": None, - "price": {"gross": "23.00", "net": "19.33", "tax": "3.67", "name": "", "rate": "19.00"}, + "price": {"gross": "23.00", "net": "19.33", "tax": "3.67", "name": "", "rate": "19.00", "includes_mixed_tax_rate": False}, "picture": None, "has_variations": 0, "description": None, @@ -294,6 +294,36 @@ class WidgetCartTest(CartTestMixin, TestCase): assert '%m/%d/%Y' not in c assert '%d.%m.%Y' in c + def test_product_list_view_with_bundle_sold_out(self): + self.quota_shirts.size = 0 + self.quota_shirts.save() + self.ticket.bundles.create(bundled_item=self.shirt, bundled_variation=self.shirt_blue, + designated_price=2, count=1) + response = self.client.get('/%s/%s/widget/product_list' % (self.orga.slug, self.event.slug)) + assert response['Access-Control-Allow-Origin'] == '*' + data = json.loads(response.content.decode()) + assert data["items_by_category"][0]["items"][0]["avail"] == [0, None] + + def test_product_list_view_with_bundle_mixed_tax_rate(self): + self.tr7 = self.event.tax_rules.create(rate=Decimal('7.00')) + self.shirt.tax_rule = self.tr7 + self.shirt.require_bundling = True + self.shirt.save() + self.ticket.bundles.create(bundled_item=self.shirt, bundled_variation=self.shirt_blue, + designated_price=2, count=1) + response = self.client.get('/%s/%s/widget/product_list' % (self.orga.slug, self.event.slug)) + assert response['Access-Control-Allow-Origin'] == '*' + data = json.loads(response.content.decode()) + assert len(data["items_by_category"][0]["items"]) == 1 + assert data["items_by_category"][0]["items"][0]["price"] == { + "gross": "23.00", + "net": "19.52", + "tax": "3.48", + "name": "MIXED!", + "rate": "19.00", + "includes_mixed_tax_rate": True + } + def test_subevent_list(self): self.event.has_subevents = True self.event.save()