mirror of
https://github.com/pretix/pretix.git
synced 2026-10-01 19:14:43 +00:00
commit2177bebf6aMerge:ea613bf271d08216d8c5Author: Mira Weller <weller@rami.io> Date: Fri Aug 7 21:22:31 2026 +0200 Merge branch 'master' into questions-vue3 commitea613bf271Author: Mira Weller <weller@rami.io> Date: Fri Aug 7 21:21:35 2026 +0200 wip commitc8bed5e3b9Author: Mira Weller <weller@rami.io> Date: Fri Aug 7 10:15:00 2026 +0200 rename get_cart to get_cart_positions (keep old name alive) commit1ec27fff05Author: Mira Weller <weller@rami.io> Date: Fri Aug 7 10:01:38 2026 +0200 filter questionnaires by sales channel commit0294f205d0Author: Mira Weller <weller@rami.io> Date: Thu Aug 6 20:06:05 2026 +0200 refactor: extract build_requested_valid_from_field method commit3424aacdc3Author: Mira Weller <weller@rami.io> Date: Thu Aug 6 20:05:44 2026 +0200 rename presale question forms and mixins commit4012889076Author: Mira Weller <weller@rami.io> Date: Thu Aug 6 19:42:29 2026 +0200 rebase migration commitfe15d71cd2Merge:3ea7ea31b64d9dfa88feAuthor: Mira Weller <weller@rami.io> Date: Thu Aug 6 19:25:44 2026 +0200 Merge branch 'master' into questions-vue3 # Conflicts: # src/pretix/base/forms/questions.py # src/pretix/base/middleware.py # src/pretix/base/templates/pretixbase/redirect.html commit3ea7ea31b6Author: Mira Weller <weller@rami.io> Date: Fri Jul 24 17:37:46 2026 +0200 wip commit5b34a25fb5Author: Mira Weller <weller@rami.io> Date: Wed Jun 17 16:21:11 2026 +0200 use questionnaire data for rendering checkout questions step commit9467f1d08dAuthor: Mira Weller <weller@rami.io> Date: Wed Jun 17 15:54:14 2026 +0200 refactor: extract build_system_question_field method commit85344a7690Author: Mira Weller <weller@rami.io> Date: Wed Jun 17 15:50:41 2026 +0200 refactor: extract build_user_question_field method commit3b47f4aaa7Author: Mira Weller <weller@rami.io> Date: Wed Jun 17 15:49:34 2026 +0200 refactor: cartpos / orderpos commit0309b05121Author: Mira Weller <weller@rami.io> Date: Thu Mar 19 13:52:18 2026 +0100 vite config: allow cors access from pretix.work subdomains commitad092ae0d1Author: Mira Weller <weller@rami.io> Date: Wed Jun 17 12:35:33 2026 +0200 re-add vue-slicksort dep commit9f42d679ccAuthor: Mira Weller <weller@rami.io> Date: Wed Jun 17 12:28:53 2026 +0200 rebase migration commit9eae9c6de7Merge:84c51e031e694b915d89Author: Mira Weller <weller@rami.io> Date: Wed Jun 17 12:24:49 2026 +0200 Merge branch 'master' into questions-vue3 # Conflicts: # .github/workflows/tests.yml # .gitignore # Dockerfile # package-lock.json # package.json # src/pretix/base/management/commands/runserver.py # src/pretix/base/middleware.py # src/pretix/base/templatetags/vite.py # src/pretix/control/forms/global_settings.py # src/pretix/control/templates/pretixcontrol/checkin/list_edit.html # src/pretix/control/views/item.py # src/pretix/presale/views/widget.py # src/pretix/settings.py # src/pretix/static/pretixcontrol/js/ui/checkinrules/App.vue # src/pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts # src/pretix/static/pretixcontrol/js/ui/checkinrules/django-interop.ts # src/pretix/static/pretixcontrol/js/ui/checkinrules/lookup-select2.vue # src/pretix/static/pretixcontrol/js/ui/checkinrules/timefield.vue # src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue # src/pretix/static/pretixpresale/widget/index.html # src/pretix/static/pretixpresale/widget/src/api.ts # src/pretix/static/pretixpresale/widget/src/button.ts # src/pretix/static/pretixpresale/widget/src/components/PriceBox.vue # src/pretix/static/pretixpresale/widget/src/main.ts # src/pretix/static/pretixpresale/widget/src/sharedStore.ts # src/pretix/static/pretixpresale/widget/src/utils.ts # src/pretix/static/pretixpresale/widget/src/widget.ts # src/tests/e2e/conftest.py # vite.config.ts commit84c51e031eMerge:902ee9242e222b453b43Author: Mira Weller <weller@rami.io> Date: Wed Jun 17 12:20:05 2026 +0200 Merge branch 'master' into questions-vue3 commit902ee9242eAuthor: Mira Weller <weller@rami.io> Date: Wed Jun 17 12:19:26 2026 +0200 css comment commitb758efd4a9Author: Mira Weller <weller@rami.io> Date: Wed Jun 17 12:19:19 2026 +0200 question dependencies commitc68fe00502Author: Mira Weller <weller@rami.io> Date: Wed Jun 17 12:18:00 2026 +0200 add data field edit link commitb7da9bb6c1Author: Mira Weller <weller@rami.io> Date: Wed Jun 17 12:16:20 2026 +0200 handle hidden questions commit897120d3daAuthor: Mira Weller <weller@rami.io> Date: Tue May 12 13:04:08 2026 +0200 add ts model definitions commit09abc5725eAuthor: Mira Weller <weller@rami.io> Date: Tue May 12 11:30:12 2026 +0200 reorder and save commit9a7d5abb1fAuthor: Mira Weller <weller@rami.io> Date: Tue May 12 11:29:37 2026 +0200 fix unhandled exception in taxrules API commit3cee5f524bAuthor: Mira Weller <weller@rami.io> Date: Tue May 5 18:12:21 2026 +0200 use slicksort for reordering questionnaires and questions commit3a1a6b988eAuthor: Mira Weller <weller@rami.io> Date: Tue Apr 28 19:23:35 2026 +0200 improve i18n, implement "add text block" dialog commitbdc720f5a2Author: Mira Weller <weller@rami.io> Date: Fri Mar 27 18:57:22 2026 +0100 wip commit583184af38Author: Mira Weller <weller@rami.io> Date: Thu Mar 19 21:45:32 2026 +0100 change existing views commit63d9f7cea8Author: Mira Weller <weller@rami.io> Date: Thu Mar 19 21:45:32 2026 +0100 adapt vue app to new data model (wip) commitc3d6fb1bd6Author: Mira Weller <weller@rami.io> Date: Thu Mar 19 21:44:47 2026 +0100 add Questionnaire and QuestionnaireChild models and their API; migrate existing data commitbb43acd257Author: Mira Weller <weller@rami.io> Date: Thu Mar 19 13:52:28 2026 +0100 vite config: add questionnaires app commite0744951e8Author: Mira Weller <weller@rami.io> Date: Thu Mar 19 13:52:18 2026 +0100 vite config: allow cors access from pretix.work subdomains commitfef1e356f7Author: Mira Weller <weller@rami.io> Date: Thu Mar 19 13:33:24 2026 +0100 fix out-of-the-box experience commit81775f5d2dAuthor: Mira Weller <weller@rami.io> Date: Thu Mar 19 13:33:24 2026 +0100 setup questionnaires vue app, add code from question editor proof of concept commit4bbcc398ccMerge:a924d0a26658840a5fd6Author: Mira Weller <weller@rami.io> Date: Thu Mar 19 13:13:32 2026 +0100 Merge remote-tracking branch 'refs/remotes/origin/master' into questions-vue3
812 lines
35 KiB
Python
812 lines
35 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 (
|
|
CompatDecimalField, 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,
|
|
)
|
|
from pretix.base.models.items import Questionnaire, QuestionnaireChild
|
|
|
|
|
|
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', 'location')
|
|
|
|
|
|
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', 'location')
|
|
|
|
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(CompatDecimalField):
|
|
def to_representation(self, i):
|
|
if i.tax_rule:
|
|
return super().to_representation(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, max_digits=7, decimal_places=4)
|
|
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)
|
|
internal_name = serializers.CharField(allow_null=True, source='question', read_only=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_string_length_min', 'valid_file_portrait', 'internal_name',)
|
|
|
|
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, container_type=Question.ContainerType.ORDERPOSITION)
|
|
question.items.set(items)
|
|
for opt_data in options_data:
|
|
QuestionOption.objects.create(question=question, **opt_data)
|
|
return question
|
|
|
|
|
|
class QuestionRefField(serializers.PrimaryKeyRelatedField):
|
|
def to_representation(self, qc):
|
|
if not qc:
|
|
return None
|
|
elif qc.system_question:
|
|
return qc.system_question
|
|
elif qc.user_question_id:
|
|
return qc.user_question_id
|
|
else:
|
|
return None
|
|
|
|
def to_internal_value(self, data):
|
|
if type(data) == int:
|
|
return {'user_question': super().to_internal_value(data), 'system_question': None}
|
|
elif type(data) == str or data is None:
|
|
return {'user_question': None, 'system_question': data}
|
|
else:
|
|
self.fail('incorrect_type', data_type=type(data).__name__)
|
|
|
|
def use_pk_only_optimization(self):
|
|
return self.source == '*'
|
|
|
|
|
|
class InlineQuestionnaireChildSerializer(I18nAwareModelSerializer):
|
|
question = QuestionRefField(source='*', queryset=Question.objects.none())
|
|
dependency_question = QuestionRefField(allow_null=True, required=False, queryset=Question.objects.none())
|
|
|
|
class Meta:
|
|
model = QuestionnaireChild
|
|
fields = ('question', 'required', 'label', 'help_text', 'dependency_question', 'dependency_values')
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.fields["question"].queryset = self.context["event"].questions.all()
|
|
self.fields["dependency_question"].queryset = self.context["event"].questions.all()
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
return data
|
|
|
|
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
|
|
|
|
|
|
class QuestionnaireSerializer(I18nAwareModelSerializer):
|
|
limit_sales_channels = serializers.SlugRelatedField(
|
|
slug_field="identifier",
|
|
queryset=SalesChannel.objects.none(),
|
|
required=False,
|
|
allow_empty=True,
|
|
many=True,
|
|
)
|
|
|
|
class Meta:
|
|
model = Questionnaire
|
|
fields = ('id', 'type', 'internal_name', 'items', 'position', 'all_sales_channels', 'limit_sales_channels', 'children')
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.fields['children'] = InlineQuestionnaireChildSerializer(many=True, required=True, context=kwargs['context'], partial=False)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
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)
|
|
|
|
#if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
|
|
# raise ValidationError('Dependencies are not supported during check-in.')
|
|
|
|
#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_children(self, value):
|
|
prev_questions = {}
|
|
for child in value:
|
|
if child.get('dependency_question'):
|
|
if (child['dependency_question']['user_question'] or child['dependency_question']['system_question']) not in prev_questions:
|
|
raise ValidationError('A question can only depend on a previous question from the same questionnaire.')
|
|
|
|
if child['user_question']:
|
|
prev_questions[child['user_question']] = child
|
|
if child['system_question']:
|
|
prev_questions[child['system_question']] = child
|
|
return value
|
|
|
|
@transaction.atomic
|
|
def create(self, validated_data):
|
|
children_data = validated_data.pop('children') if 'children' in validated_data else []
|
|
questionnaire = super().create(validated_data)
|
|
self.set_children(questionnaire, children_data)
|
|
return questionnaire
|
|
|
|
@transaction.atomic
|
|
def update(self, instance, validated_data):
|
|
children_data = validated_data.pop('children', None)
|
|
questionnaire = super().update(instance, validated_data)
|
|
if children_data is not None:
|
|
self.set_children(questionnaire, children_data)
|
|
return questionnaire
|
|
|
|
def set_children(self, questionnaire, new_data):
|
|
result = []
|
|
child_serializer = self.fields['children'].child
|
|
existing = questionnaire.children.all()
|
|
for i, d in enumerate(new_data):
|
|
d['questionnaire'] = questionnaire
|
|
d['position'] = i + 1
|
|
d.setdefault('required', False)
|
|
d.setdefault('help_text', None)
|
|
d.setdefault('dependency_question', None)
|
|
d.setdefault('dependency_values', None)
|
|
updatable = min(len(existing), len(new_data))
|
|
for i in range(0, updatable):
|
|
result.append(child_serializer.update(existing[i], new_data[i]))
|
|
for i in range(updatable, len(new_data)):
|
|
result.append(child_serializer.create(new_data[i]))
|
|
for i in range(updatable, len(existing)):
|
|
existing[i].delete()
|
|
return result
|
|
|
|
|
|
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
|