Fix #1001 -- Add product bundles (#1041)

* Data model + Editor

* Cart and order management

* Rebase migrations

* Fix typos, add tests on cart handling

* Add tests for checkout and quotas

* Add API endpoints

* Validation of settings

* Front page tax display

* Voucher handling

* Widget foo

* Show correct net pricing

* Front page tests

* reverse charge foo

* Allow to require bundling

* Fix test failure on postgres
This commit is contained in:
Raphael Michel
2019-03-22 14:48:48 +00:00
committed by GitHub
parent c4b18a4c81
commit 90f881c48e
34 changed files with 2747 additions and 153 deletions
+1
View File
@@ -11,6 +11,7 @@ Resources and endpoints
categories
items
item_variations
item_bundles
item_add-ons
questions
question_options
+1 -1
View File
@@ -189,7 +189,7 @@ Endpoints
{
"min_count": 0,
"max_count": 10,
"max_count": 10
}
**Example response**:
+242
View File
@@ -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.
+34 -9
View File
@@ -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
+49 -7
View File
@@ -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
+1
View File
@@ -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)
+73 -14
View File
@@ -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(
@@ -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'),
),
]
+3 -2
View File
@@ -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
+221 -37
View File
@@ -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,
+4 -2
View File
@@ -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")
+34 -3
View File
@@ -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))
+126 -21
View File
@@ -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
"""
+49 -14
View File
@@ -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):
+12 -1
View File
@@ -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')
+106 -2
View File
@@ -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',
]
@@ -22,6 +22,11 @@
{% trans "Add-Ons" %}
</a>
</li>
<li {% if "event.item.bundles" == url_name %}class="active"{% endif %}>
<a href="{% url 'control:event.item.bundles' organizer=request.event.organizer.slug event=request.event.slug item=object.id %}">
{% trans "Bundled products" %}
</a>
</li>
</ul>
{% else %}
<h1>{% trans "Create product" %}</h1>
@@ -0,0 +1,80 @@
{% extends "pretixcontrol/item/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load formset_tags %}
{% block inside %}
<p>
{% blocktrans trimmed %}
With bundles, you can specify products that are always automatically added as add-ons in the cart for this product.
{% endblocktrans %}
</p>
<form class="form-horizontal branches" method="post" action="">
{% csrf_token %}
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<div class="row">
<div class="col-sm-8">
<h3 class="panel-title">{% trans "Bundled product" %}</h3>
</div>
<div class="col-sm-4 text-right">
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_form_errors form %}
{% bootstrap_field form.itemvar layout="control" %}
{% bootstrap_field form.count layout="control" %}
{% bootstrap_field form.designated_price layout="control" %}
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<div class="row">
<div class="col-sm-8">
<h3 class="panel-title">{% trans "Bundled product" %}</h3>
</div>
<div class="col-sm-4 text-right">
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_field formset.empty_form.itemvar layout="control" %}
{% bootstrap_field formset.empty_form.count layout="control" %}
{% bootstrap_field formset.empty_form.designated_price layout="control" %}
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new bundled product" %}</button>
</p>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -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" %}
</fieldset>
<fieldset>
+2
View File
@@ -150,6 +150,8 @@ urlpatterns = [
name='event.item.variations'),
url(r'^items/(?P<item>\d+)/addons', item.ItemAddOns.as_view(),
name='event.item.addons'),
url(r'^items/(?P<item>\d+)/bundles', item.ItemBundles.as_view(),
name='event.item.bundles'),
url(r'^items/(?P<item>\d+)/up$', item.item_move_up, name='event.items.up'),
url(r'^items/(?P<item>\d+)/down$', item.item_move_down, name='event.items.down'),
url(r'^items/(?P<item>\d+)/delete$', item.ItemDelete.as_view(), name='event.items.delete'),
+90 -4
View File
@@ -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'
+2 -2
View File
@@ -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,
@@ -293,7 +293,13 @@
{% if item.original_price %}
</ins>
{% 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 %}
<small>{% trans "plus taxes" %}</small>
{% else %}
<small>{% trans "incl. taxes" %}</small>
{% endif %}
{% elif var.display_price.rate and var.display_price.gross and event.settings.display_net_prices %}
<small>{% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %}
<strong>plus</strong> {{ rate }}% {{ name }}
{% endblocktrans %}</small>
@@ -397,7 +403,13 @@
{% if item.original_price %}
</ins>
{% 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 %}
<small>{% trans "plus taxes" %}</small>
{% else %}
<small>{% trans "incl. taxes" %}</small>
{% endif %}
{% elif item.display_price.rate and item.display_price.gross and event.settings.display_net_prices %}
<small>{% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %}
<strong>plus</strong> {{ rate }}% {{ name }}
{% endblocktrans %}</small>
@@ -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 %}
<small>{% trans "plus taxes" %}</small>
{% else %}
<small>{% trans "incl. taxes" %}</small>
{% endif %}
{% elif var.display_price.rate and var.display_price.gross and event.settings.display_net_prices %}
<small>{% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %}
<strong>plus</strong> {{ rate }}% {{ name }}
{% endblocktrans %}</small>
@@ -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 %}
<small>{% trans "plus taxes" %}</small>
{% else %}
<small>{% trans "incl. taxes" %}</small>
{% endif %}
{% elif item.display_price.rate and item.display_price.gross and event.settings.display_net_prices %}
<small>{% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %}
<strong>plus</strong> {{ rate }}% {{ name }}
{% endblocktrans %}</small>
+20 -4
View File
@@ -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
+5 -4
View File
@@ -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
@@ -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);
}
}
}
}
+289 -6
View File
@@ -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)
+108 -1
View File
@@ -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):
+53
View File
@@ -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',
+602 -2
View File
@@ -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
+336 -1
View File
@@ -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)
+58
View File
@@ -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):
+34 -4
View File
@@ -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()