Files
pretix_original/src/pretix/api/serializers/item.py
Richard Schreiber d7b6856322 Fix not allowing program times on event series (API/copy) (#5595)
* Fix not allowing program times on event series (API/copy)

* Return 400 when reading endpoint in event series

* add docs program times not available on event series

* fix isort
2025-11-17 15:36:53 +01:00

654 lines
28 KiB
Python

#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Patrick Arminio, Ture Gjørup, pajowu
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import os.path
from decimal import Decimal
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import QuerySet
from django.utils.functional import cached_property, lazy
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from pretix.api.serializers import SalesChannelMigrationMixin
from pretix.api.serializers.event import MetaDataField
from pretix.api.serializers.fields import UploadedFileField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemProgramTime,
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
SalesChannel,
)
class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=13,
coerce_to_string=True)
meta_data = MetaDataField(required=False, source='*')
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta:
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price', 'free_price_suggestion', 'require_approval',
'require_membership', 'require_membership_types', 'require_membership_hidden',
'checkin_attention', 'checkin_text',
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
'all_sales_channels', 'limit_sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['require_membership_types'].queryset = lazy(lambda: self.context['event'].organizer.membership_types.all(), QuerySet)
self.fields['limit_sales_channels'].child_relation.queryset = (
self.context['event'].organizer.sales_channels.all() if 'event' in self.context else SalesChannel.objects.none()
)
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
if key not in self.parent.parent.item_meta_properties:
raise ValidationError(_('Item meta data property \'{name}\' does not exist.').format(name=key))
return value
class ItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=13,
coerce_to_string=True)
meta_data = MetaDataField(required=False, source='*')
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta:
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price', 'free_price_suggestion', 'require_approval',
'require_membership', 'require_membership_types', 'require_membership_hidden',
'checkin_attention', 'checkin_text',
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
'all_sales_channels', 'limit_sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
@transaction.atomic
def create(self, validated_data):
meta_data = validated_data.pop('meta_data', None)
require_membership_types = validated_data.pop('require_membership_types', [])
limit_sales_channels = validated_data.pop('limit_sales_channels', [])
variation = ItemVariation.objects.create(**validated_data)
if require_membership_types:
variation.require_membership_types.add(*require_membership_types)
if limit_sales_channels:
variation.limit_sales_channels.add(*limit_sales_channels)
# Meta data
if meta_data is not None:
for key, value in meta_data.items():
ItemVariationMetaValue.objects.create(
property=self.item_meta_properties.get(key),
value=value,
variation=variation
)
return variation
@cached_property
def item_meta_properties(self):
return {
p.name: p for p in self.context['request'].event.item_meta_properties.all()
}
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
if key not in self.item_meta_properties:
raise ValidationError(_('Item meta data property \'{name}\' does not exist.').format(name=key))
return value
def update(self, instance, validated_data):
meta_data = validated_data.pop('meta_data', None)
variation = super().update(instance, validated_data)
# Meta data
if meta_data is not None:
current = {mv.property: mv for mv in variation.meta_values.select_related('property')}
for key, value in meta_data.items():
prop = self.item_meta_properties.get(key)
if prop in current:
current[prop].value = value
current[prop].save()
else:
variation.meta_values.create(
property=self.item_meta_properties.get(key),
value=value
)
for prop, current_object in current.items():
if prop.name not in meta_data:
current_object.delete()
return variation
class InlineItemBundleSerializer(serializers.ModelSerializer):
class Meta:
model = ItemBundle
fields = ('bundled_item', 'bundled_variation', 'count',
'designated_price')
class InlineItemAddOnSerializer(serializers.ModelSerializer):
class Meta:
model = ItemAddOn
fields = ('addon_category', 'min_count', 'max_count',
'position', 'price_included', 'multi_allowed')
class InlineItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta:
model = ItemProgramTime
fields = ('start', 'end')
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 ItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta:
model = ItemProgramTime
fields = ('id', 'start', 'end')
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
start = full_data.get('start')
if not start:
raise ValidationError(_("The program start must not be empty."))
end = full_data.get('end')
if not end:
raise ValidationError(_("The program end must not be empty."))
if start > end:
raise ValidationError(_("The program end must not be before the program start."))
event = self.context['event']
if event.has_subevents:
raise ValidationError({
_("You cannot use program times on an event series.")
})
return data
class ItemAddOnSerializer(serializers.ModelSerializer):
class Meta:
model = ItemAddOn
fields = ('id', 'addon_category', 'min_count', 'max_count',
'position', 'price_included', 'multi_allowed')
def validate(self, data):
data = super().validate(data)
ItemAddOn.clean_max_min_count(data.get('max_count'), data.get('min_count'))
return data
def validate_min_count(self, value):
ItemAddOn.clean_min_count(value)
return value
def validate_max_count(self, value):
ItemAddOn.clean_max_count(value)
return value
def validate_addon_category(self, value):
ItemAddOn.clean_categories(self.context['event'], self.context['item'], self.instance, value)
return value
class ItemTaxRateField(serializers.Field):
def to_representation(self, i):
if i.tax_rule:
return str(Decimal(i.tax_rule.rate))
else:
return str(Decimal('0.00'))
class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
addons = InlineItemAddOnSerializer(many=True, required=False)
bundles = InlineItemBundleSerializer(many=True, required=False)
variations = InlineItemVariationSerializer(many=True, required=False)
program_times = InlineItemProgramTimeSerializer(many=True, required=False)
tax_rate = ItemTaxRateField(source='*', read_only=True)
meta_data = MetaDataField(required=False, source='*')
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
'image/png', 'image/jpeg', 'image/gif'
), max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta:
model = Item
fields = ('id', 'category', 'name', 'internal_name', 'active', 'all_sales_channels', 'limit_sales_channels',
'description', 'default_price', 'free_price', 'free_price_suggestion', 'tax_rate', 'tax_rule', 'admission',
'personalized', 'position', 'picture',
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations',
'addons', 'bundles', 'program_times', 'original_price', 'require_approval', 'generate_tickets',
'show_quota_left', 'hidden_if_available', 'hidden_if_item_available', 'hidden_if_item_available_mode', 'allow_waitinglist',
'issue_giftcard', 'meta_data',
'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type',
'grant_membership_duration_like_event', 'grant_membership_duration_days',
'grant_membership_duration_months', 'validity_mode', 'validity_fixed_from', 'validity_fixed_until',
'validity_dynamic_duration_minutes', 'validity_dynamic_duration_hours', 'validity_dynamic_duration_days',
'validity_dynamic_duration_months', 'validity_dynamic_start_choice', 'validity_dynamic_start_choice_day_limit',
'media_policy', 'media_type')
read_only_fields = ('has_variations',)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['default_price'].allow_null = False
self.fields['default_price'].required = True
if not self.read_only:
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
self.fields['variations'].child.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
def validate(self, data):
data = super().validate(data)
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data or 'program_times' in data):
raise ValidationError(_('Updating add-ons, bundles, program times 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'))
Item.clean_available(data.get('available_from'), data.get('available_until'))
Item.clean_media_settings(self.context['event'], data.get('media_policy'), data.get('media_type'), data.get('issue_giftcard'))
if data.get('personalized') and not data.get('admission'):
raise ValidationError(_('Only admission products can currently be personalized.'))
if data.get('admission') and 'personalized' not in data and not self.instance:
# Backwards compatibility
data['personalized'] = True
elif 'admission' in data and not data['admission']:
data['personalized'] = False
if data.get('issue_giftcard'):
if data.get('tax_rule') and data.get('tax_rule').rate > 0:
raise ValidationError(
_("Gift card products should not be associated with non-zero tax rates since sales tax will be "
"applied when the gift card is redeemed.")
)
if data.get('admission'):
raise ValidationError(_(
"Gift card products should not be admission products at the same time."
))
return data
def validate_category(self, value):
Item.clean_category(value, self.context['event'])
return value
def validate_tax_rule(self, value):
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:
ItemAddOn.clean_categories(self.context['event'], None, self.instance, addon_data['addon_category'])
ItemAddOn.clean_min_count(addon_data.get('min_count', 0))
ItemAddOn.clean_max_count(addon_data.get('max_count', 0))
ItemAddOn.clean_max_min_count(addon_data.get('max_count', 0), addon_data.get('min_count', 0))
return value
def validate_program_times(self, value):
if not self.instance:
for program_time_data in value:
ItemProgramTime.clean_start_end(self, start=program_time_data.get('start', None),
end=program_time_data.get('end', None))
return value
@cached_property
def item_meta_properties(self):
return {
p.name: p for p in self.context['request'].event.item_meta_properties.all()
}
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
if key not in self.item_meta_properties:
raise ValidationError(_('Item meta data property \'{name}\' does not exist.').format(name=key))
return value
@transaction.atomic
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 {}
program_times_data = validated_data.pop('program_times') if 'program_times' in validated_data else {}
meta_data = validated_data.pop('meta_data', None)
picture = validated_data.pop('picture', None)
require_membership_types = validated_data.pop('require_membership_types', [])
limit_sales_channels = validated_data.pop('limit_sales_channels', [])
item = Item.objects.create(**validated_data)
if limit_sales_channels and not validated_data.get('all_sales_channels'):
item.limit_sales_channels.add(*limit_sales_channels)
if picture:
item.picture.save(os.path.basename(picture.name), picture)
if require_membership_types:
item.require_membership_types.add(*require_membership_types)
for variation_data in variations_data:
require_membership_types = variation_data.pop('require_membership_types', [])
limit_sales_channels = variation_data.pop('limit_sales_channels', [])
var_meta_data = variation_data.pop('meta_data', {})
v = ItemVariation.objects.create(item=item, **variation_data)
if require_membership_types:
v.require_membership_types.add(*require_membership_types)
if limit_sales_channels:
v.limit_sales_channels.add(*limit_sales_channels)
if var_meta_data is not None:
for key, value in var_meta_data.items():
ItemVariationMetaValue.objects.create(
property=self.item_meta_properties.get(key),
value=value,
variation=v
)
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)
for program_time_data in program_times_data:
ItemProgramTime.objects.create(item=item, **program_time_data)
# Meta data
if meta_data is not None:
for key, value in meta_data.items():
ItemMetaValue.objects.create(
property=self.item_meta_properties.get(key),
value=value,
item=item
)
return item
def update(self, instance, validated_data):
meta_data = validated_data.pop('meta_data', None)
picture = validated_data.pop('picture', None)
item = super().update(instance, validated_data)
if picture:
item.picture.save(os.path.basename(picture.name), picture)
# Meta data
if meta_data is not None:
current = {mv.property: mv for mv in item.meta_values.select_related('property')}
for key, value in meta_data.items():
prop = self.item_meta_properties.get(key)
if prop in current:
current[prop].value = value
current[prop].save()
else:
item.meta_values.create(
property=self.item_meta_properties.get(key),
value=value
)
for prop, current_object in current.items():
if prop.name not in meta_data:
current_object.delete()
return item
class ItemCategorySerializer(I18nAwareModelSerializer):
class Meta:
model = ItemCategory
fields = (
'id', 'name', 'internal_name', 'description', 'position',
'is_addon', 'cross_selling_mode',
'cross_selling_condition', 'cross_selling_match_products'
)
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
if full_data.get('is_addon') and full_data.get('cross_selling_mode'):
raise ValidationError('is_addon and cross_selling_mode are mutually exclusive')
return data
class QuestionOptionSerializer(I18nAwareModelSerializer):
identifier = serializers.CharField(allow_null=True)
class Meta:
model = QuestionOption
fields = ('id', 'identifier', 'answer', 'position')
def validate_identifier(self, value):
QuestionOption.clean_identifier(self.context['event'], value, self.instance)
return value
class InlineQuestionOptionSerializer(I18nAwareModelSerializer):
identifier = serializers.CharField(allow_null=True)
class Meta:
model = QuestionOption
fields = ('id', 'identifier', 'answer', 'position')
class LegacyDependencyValueField(serializers.CharField):
def to_representation(self, obj):
return obj[0] if obj else None
def to_internal_value(self, data):
return [data] if data else []
class QuestionSerializer(I18nAwareModelSerializer):
options = InlineQuestionOptionSerializer(many=True, required=False)
identifier = serializers.CharField(allow_null=True)
dependency_value = LegacyDependencyValueField(source='dependency_values', required=False, allow_null=True)
class Meta:
model = Question
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
'valid_string_length_max', 'valid_file_portrait')
def validate_identifier(self, value):
Question._clean_identifier(self.context['event'], value, self.instance)
return value
def validate_type(self, value):
if self.instance:
self.instance.clean_type_change(self.instance.type, value)
return value
def validate_dependency_question(self, value):
if value:
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
if value == self.instance:
raise ValidationError('A question cannot depend on itself.')
return value
def validate(self, data):
data = super().validate(data)
if self.instance and 'options' in data:
raise ValidationError(_('Updating options via PATCH/PUT is not supported. Please use the dedicated'
' nested endpoint.'))
event = self.context['event']
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
raise ValidationError('Dependencies are not supported during check-in.')
dep = full_data.get('dependency_question')
if dep:
if dep.ask_during_checkin:
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
seen_ids = {self.instance.pk} if self.instance else set()
while dep:
if dep.pk in seen_ids:
raise ValidationError(_('Circular dependency between questions detected.'))
seen_ids.add(dep.pk)
dep = dep.dependency_question
if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
raise ValidationError(_('This type of question cannot be asked during check-in.'))
if full_data.get('show_during_checkin') and full_data.get('type') in Question.SHOW_DURING_CHECKIN_UNSUPPORTED:
raise ValidationError(_('This type of question cannot be shown during check-in.'))
Question.clean_items(event, full_data.get('items') or [])
return data
def validate_options(self, value):
if not self.instance:
known = []
for opt_data in value:
if opt_data.get('identifier'):
QuestionOption.clean_identifier(self.context['event'], opt_data.get('identifier'), self.instance,
known)
known.append(opt_data.get('identifier'))
return value
@transaction.atomic
def create(self, validated_data):
options_data = validated_data.pop('options') if 'options' in validated_data else []
items = validated_data.pop('items', [])
question = Question.objects.create(**validated_data)
question.items.set(items)
for opt_data in options_data:
QuestionOption.objects.create(question=question, **opt_data)
return question
class QuotaSerializer(I18nAwareModelSerializer):
available = serializers.BooleanField(read_only=True)
available_number = serializers.IntegerField(read_only=True)
class Meta:
model = Quota
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent', 'closed', 'close_when_sold_out',
'release_after_exit', 'available', 'available_number', 'ignore_for_event_availability')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'request' not in self.context or self.context['request'].GET.get('with_availability') != 'true':
del self.fields['available']
del self.fields['available_number']
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)
Quota.clean_variations(full_data.get('items'), full_data.get('variations'))
Quota.clean_items(event, full_data.get('items'), full_data.get('variations'))
Quota.clean_subevent(event, full_data.get('subevent'))
return data