Compare commits

..
Author SHA1 Message Date
Raphael Michel c13ed9b7a8 Discounts: Add restriction to memberships (Z#23229305) 2026-09-13 22:25:48 +02:00
Raphael Michel 0f504fa9e1 Fix query 2026-09-13 20:34:49 +02:00
Raphael Michel 8159a039c8 . 2026-09-13 20:32:39 +02:00
Raphael Michel ea40a670f4 Fix a typo 2026-09-13 20:31:50 +02:00
Raphael Michel 96576b8235 Fix query count 2026-09-13 16:43:22 +02:00
Raphael Michel 10aec6d8dc Show (some) discounts in product list
Discounts are currently only shown in the cart, after products have been
selected. This was not a big problem since we invented the discount
machinery specifically for group discounts, which can only be applied in
the cart. However it *is* possible to define a Discount that applies to
a group of one. Then, one would expect that the discounted price would
be shown already on the front page of the shop.

Back then, I decided against this for complexity and performance
concerns, but after looking into it, it's not actually that bad.

On the user side, I think this is a game changer for the discount
feature. Now, I could use discount rules also to implement early-bird
pricing without duplicating producs/variations, or I can do a christmas
discount on all subevents in december without modifying all individual
subevents.
2026-09-11 18:18:19 +02:00
82 changed files with 1588 additions and 2064 deletions
-2
View File
@@ -1,2 +0,0 @@
# Format pre-vue code with eslint where possible (2026-09-10)
d85e52c83ed3639e040372fd0052e842e4899d90
+12
View File
@@ -30,6 +30,8 @@ available_from datetime The first da
(or ``null``).
available_until datetime The last date time at which this discount can be applied
(or ``null``).
require_membership boolean If ``true``, booking this variation requires an active membership.
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
subevent_mode strings Determines how the discount is handled when used in an
event series. Can be ``"mixed"`` (no special effect),
``"same"`` (discount is only applied for groups within
@@ -108,6 +110,8 @@ Endpoints
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"require_membership_hidden": false,
"require_membership_types": [],
"subevent_mode": "mixed",
"subevent_date_from": null,
"subevent_date_until": null,
@@ -168,6 +172,8 @@ Endpoints
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"require_membership_hidden": false,
"require_membership_types": [],
"subevent_mode": "mixed",
"subevent_date_from": null,
"subevent_date_until": null,
@@ -214,6 +220,8 @@ Endpoints
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"require_membership_hidden": false,
"require_membership_types": [],
"subevent_mode": "mixed",
"subevent_date_from": null,
"subevent_date_until": null,
@@ -249,6 +257,8 @@ Endpoints
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"require_membership_hidden": false,
"require_membership_types": [],
"subevent_mode": "mixed",
"subevent_date_from": null,
"subevent_date_until": null,
@@ -313,6 +323,8 @@ Endpoints
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"require_membership_hidden": false,
"require_membership_types": [],
"subevent_mode": "mixed",
"subevent_date_from": null,
"subevent_date_until": null,
+4 -14
View File
@@ -9,8 +9,7 @@
"version": "1.0.0",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"vue": "^3.5.30",
"vue-slicksort": "^2.0.5"
"vue": "^3.5.30"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -4149,9 +4148,9 @@
}
},
"node_modules/smol-toml": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz",
"integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==",
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
"integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -4636,15 +4635,6 @@
"vue-eslint-parser": "^10.0.0"
}
},
"node_modules/vue-slicksort": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/vue-slicksort/-/vue-slicksort-2.0.5.tgz",
"integrity": "sha512-fXz1YrNjhUbJK7o0tMk27mIr4pMAZYLSYvtmLazCtfpvz+zafPCn34ILDL8B7hT7WLVZKreYs6JVe5VWymqmzA==",
"license": "MIT",
"peerDependencies": {
"vue": ">=3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+1 -2
View File
@@ -27,8 +27,7 @@
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"vue": "^3.5.30",
"vue-slicksort": "^2.0.5"
"vue": "^3.5.30"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+2 -2
View File
@@ -37,7 +37,7 @@ dependencies = [
"css-inline==0.21.*",
"defusedcsv>=3.0.0",
"dnspython==2.*",
"Django[argon2]==5.2.*,>=5.2.17",
"Django[argon2]==5.2.*",
"django-bootstrap3==26.2",
"django-compressor==4.6.0",
"django-countries==9.0.*",
@@ -84,7 +84,7 @@ dependencies = [
"pycountry",
"pycparser==3.0",
"pycryptodome==3.23.*",
"pypdf==6.11.*",
"pypdf==6.5.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*",
"pytz",
+5 -1
View File
@@ -19,6 +19,8 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.db.models import QuerySet
from django.utils.functional import lazy
from rest_framework import serializers
from pretix.api.serializers import SalesChannelMigrationMixin
@@ -38,7 +40,8 @@ class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
class Meta:
model = Discount
fields = ('id', 'active', 'internal_name', 'position', 'all_sales_channels', 'limit_sales_channels',
'available_from', 'available_until', 'subevent_mode', 'subevent_date_from', 'subevent_date_until',
'available_from', 'available_until', 'require_membership', 'require_membership_types',
'subevent_mode', 'subevent_date_from', 'subevent_date_until',
'condition_all_products', 'condition_limit_products', 'condition_apply_to_addons',
'condition_min_count', 'condition_min_value', 'benefit_discount_matching_percent',
'benefit_only_apply_to_cheapest_n_matches', 'benefit_same_products', 'benefit_limit_products',
@@ -50,6 +53,7 @@ class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
self.fields['condition_limit_products'].queryset = self.context['event'].items.all()
self.fields['benefit_limit_products'].queryset = self.context['event'].items.all()
self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
self.fields['require_membership_types'].queryset = lazy(lambda: self.context['event'].organizer.membership_types.all(), QuerySet)
def validate(self, data):
data = super().validate(data)
+1 -1
View File
@@ -979,7 +979,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'reusable_media_type_nfc_mf0aes',
'reusable_media_type_nfc_mf0aes_random_uid',
'reusable_media_usage_enforced',
'system_question_order', # TODO(questionnaires) - remove or replace
'system_question_order',
'tax_rule_payment',
'tax_rule_cancellation',
]
+1 -157
View File
@@ -53,7 +53,6 @@ from pretix.base.models import (
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
SalesChannel,
)
from pretix.base.models.items import Questionnaire, QuestionnaireChild
class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
@@ -543,7 +542,6 @@ class LegacyDependencyValueField(serializers.CharField):
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:
@@ -552,7 +550,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
'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',)
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait')
def validate_identifier(self, value):
Question._clean_identifier(self.context['event'], value, self.instance)
@@ -628,160 +626,6 @@ class QuestionSerializer(I18nAwareModelSerializer):
return question
class QuestionRefField(serializers.PrimaryKeyRelatedField):
def to_representation(self, qc):
if not qc:
return None
elif qc.system_datafield:
return qc.system_datafield
elif qc.user_datafield_id:
return qc.user_datafield_id
else:
return None
def to_internal_value(self, data):
if type(data) == int:
return {'user_datafield': super().to_internal_value(data), 'system_datafield': None}
elif type(data) == str or data is None:
return {'user_datafield': None, 'system_datafield': 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_datafield'] or child['dependency_question']['system_datafield']) not in prev_questions:
raise ValidationError('A question can only depend on a previous question from the same questionnaire.')
if child['user_datafield']:
prev_questions[child['user_datafield']] = child
if child['system_datafield']:
prev_questions[child['system_datafield']] = 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)
+11 -1
View File
@@ -1585,6 +1585,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.invoice_address = ia
ia.last_modified = now()
if order.customer_id:
memberships = order.customer.memberships.filter(testmode=order.testmode).select_related('membership_type')
else:
memberships = []
# Generate position objects
pos_map = {}
for pos_data in positions_data:
@@ -1655,7 +1660,12 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
cp.addon_to, cp.is_bundled, pos._voucher_discount)
cp.addon_to, cp.is_bundled, pos._voucher_discount, [
m for m in memberships if m.is_valid(
cp.subevent or self.context['event'],
cp.valid_from,
)
])
for cp in order_positions
]
)
+1 -2
View File
@@ -79,8 +79,7 @@ event_router.register(r'subevents', event.SubEventViewSet)
event_router.register(r'clone', event.CloneEventViewSet)
event_router.register(r'items', item.ItemViewSet)
event_router.register(r'categories', item.ItemCategoryViewSet)
event_router.register(r'datafields', item.QuestionViewSet)
event_router.register(r'questionnaires', item.QuestionnaireViewSet)
event_router.register(r'questions', item.QuestionViewSet)
event_router.register(r'discounts', discount.DiscountViewSet)
event_router.register(r'quotas', item.QuotaViewSet)
event_router.register(r'vouchers', voucher.VoucherViewSet)
+1 -1
View File
@@ -62,7 +62,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_queryset(self):
return self.request.event.discounts.prefetch_related(
'limit_sales_channels',
'limit_sales_channels', 'require_membership_types',
)
@transaction.atomic()
+1 -48
View File
@@ -48,15 +48,13 @@ from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
QuestionnaireSerializer, QuestionOptionSerializer, QuestionSerializer,
QuotaSerializer,
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
)
from pretix.api.views import ConditionalListView
from pretix.base.models import (
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
ItemVariation, Question, QuestionOption, Quota,
)
from pretix.base.models.items import Questionnaire
from pretix.base.services.quotas import QuotaAvailability
from pretix.helpers.dicts import merge_dicts
from pretix.helpers.i18n import i18ncomp
@@ -568,51 +566,6 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance)
class QuestionnaireViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = QuestionnaireSerializer
queryset = Questionnaire.objects.none()
#filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
#filterset_class = QuestionFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
permission = None
write_permission = 'event.items:write'
def get_queryset(self):
return self.request.event.questionnaires.prefetch_related('children').all()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.questionnaire.added',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
return ctx
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.questionnaire.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def perform_destroy(self, instance):
instance.log_action(
'pretix.event.questionnaire.deleted',
user=self.request.user,
auth=self.request.auth,
)
super().perform_destroy(instance)
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
pass
+142 -116
View File
@@ -36,6 +36,7 @@ import copy
import json
import logging
import re
from collections import namedtuple
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
@@ -349,22 +350,16 @@ class WrappedPhonePrefixSelect(Select):
return super().render(name, value or self.initial, *args, **kwargs)
def get_context(self, name, value, attrs):
# self.choices is lazy evaluated, needs to be realized to be modifiable
choices = list(self.choices)
if value and choices[1][0] != value:
matching_choices = len([1 for p, c in choices if p == value])
if value and self.choices[1][0] != value:
matching_choices = len([1 for p, c in self.choices if p == value])
if matching_choices > 1:
# Some countries share a phone prefix, for example +1 is used all over the Americas.
# This causes a UX problem: If the default value or the existing data is +12125552368,
# the widget will just show the first <option> entry with value="+1" as selected,
# which alphabetically is America Samoa, although most numbers statistically are from
# the US. As a workaround, we detect this case and add an additional choice value with
# the US. As a workaround, we detect this case and add an aditional choice value with
# just <option value="+1">+1</option> without an explicit country.
self.choices = [
choices[0],
(value, value),
*choices[1:],
]
self.choices.insert(1, (value, value))
context = super().get_context(name, value, attrs)
return context
@@ -642,22 +637,49 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
super().__init__(*args, **kwargs)
FakeQuestion = namedtuple(
'FakeQuestion', 'id question position required help_text container_type', defaults=('', Question.ContainerType.ORDERPOSITION)
)
def get_fake_attendee_questions(settings):
fq = []
sqo = settings.system_question_order
if settings.attendee_names_asked:
fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), settings.attendee_names_required))
if settings.attendee_emails_asked:
fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), settings.attendee_emails_required))
if settings.attendee_company_asked:
fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), settings.attendee_company_required))
if settings.attendee_addresses_asked:
fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('state', _('State'), sqo.get('country', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), settings.attendee_addresses_required))
return fq
class BaseQuestionsForm(forms.Form):
"""
This is the base form class responsible for asking order- or ticket-related questions.
"""
address_validation = False
def build_user_question_field(self, request, event, answerlist, qc, datafield):
def build_user_question_field(self, request, event, answerlist, q):
# Do we already have an answer? Provide it as the initial value
answers = [a for a in answerlist if a.question_id == datafield.id]
answers = [a for a in answerlist if a.question_id == q.id]
if answers:
initial = answers[0]
else:
initial = None
tz = ZoneInfo(event.settings.timezone)
required = qc.required and not self.all_optional
if datafield.type == Question.TYPE_BOOLEAN:
required = q.required and not self.all_optional
if q.type == Question.TYPE_BOOLEAN:
if required:
# For some reason, django-bootstrap3 does not set the required attribute
# itself.
@@ -671,105 +693,107 @@ class BaseQuestionsForm(forms.Form):
initialbool = False
field = forms.BooleanField(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
initial=initialbool, widget=widget,
)
elif datafield.type == Question.TYPE_NUMBER:
elif q.type == Question.TYPE_NUMBER:
field = forms.DecimalField(
label=escape(qc.label), required=required,
min_value=datafield.valid_number_min or Decimal('0.00'),
max_value=datafield.valid_number_max,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
min_value=q.valid_number_min or Decimal('0.00'),
max_value=q.valid_number_max,
help_text=rich_text(q.help_text),
initial=initial.answer if initial else None,
)
elif datafield.type == Question.TYPE_STRING:
elif q.type == Question.TYPE_STRING:
field = forms.CharField(
label=escape(qc.label), required=required,
min_length=datafield.valid_string_length_min,
max_length=datafield.valid_string_length_max,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
min_length=q.valid_string_length_min,
max_length=q.valid_string_length_max,
help_text=rich_text(q.help_text),
initial=initial.answer if initial else None,
)
elif datafield.type == Question.TYPE_TEXT:
elif q.type == Question.TYPE_TEXT:
field = forms.CharField(
label=escape(qc.label), required=required,
min_length=datafield.valid_string_length_min,
max_length=datafield.valid_string_length_max,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
min_length=q.valid_string_length_min,
max_length=q.valid_string_length_max,
help_text=rich_text(q.help_text),
widget=forms.Textarea,
initial=initial.answer if initial else None,
)
elif datafield.type == Question.TYPE_COUNTRYCODE:
elif q.type == Question.TYPE_COUNTRYCODE:
field = CountryField(
countries=CachedCountries,
blank=True, null=True, blank_label=' ',
).formfield(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
widget=forms.Select,
empty_label=' ',
initial=initial.answer if initial else (guess_country_from_request(request, event) if required else None),
initial=initial.answer if initial else (
guess_country_from_request(request, event) if required else None),
)
elif datafield.type == Question.TYPE_CHOICE:
elif q.type == Question.TYPE_CHOICE:
field = forms.ModelChoiceField(
queryset=datafield.options,
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
queryset=q.options,
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
widget=forms.Select,
to_field_name='identifier',
empty_label='',
initial=initial.options.first() if initial else None,
)
elif datafield.type == Question.TYPE_CHOICE_MULTIPLE:
elif q.type == Question.TYPE_CHOICE_MULTIPLE:
field = forms.ModelMultipleChoiceField(
queryset=datafield.options,
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
queryset=q.options,
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
to_field_name='identifier',
widget=QuestionCheckboxSelectMultiple,
initial=initial.options.all() if initial else None,
)
elif datafield.type == Question.TYPE_FILE:
if datafield.valid_file_portrait:
elif q.type == Question.TYPE_FILE:
if q.valid_file_portrait:
field = PortraitImageField(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
initial=initial.file if initial else None,
widget=PortraitImageWidget(answer=initial, request=request, attrs={'data-portrait-photo': 'true'}),
widget=PortraitImageWidget(answer=initial, request=request,
attrs={'data-portrait-photo': 'true'}),
)
else:
field = ExtFileField(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
initial=initial.file if initial else None,
widget=UploadedFileWidget(answer=initial, request=request),
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
)
elif datafield.type == Question.TYPE_DATE:
elif q.type == Question.TYPE_DATE:
attrs = {}
if datafield.valid_date_min:
attrs['data-min'] = datafield.valid_date_min.isoformat()
if datafield.valid_date_max:
attrs['data-max'] = datafield.valid_date_max.isoformat()
help_text = qc.help_text
if q.valid_date_min:
attrs['data-min'] = q.valid_date_min.isoformat()
if q.valid_date_max:
attrs['data-max'] = q.valid_date_max.isoformat()
help_text = q.help_text
if not help_text:
if datafield.valid_date_min and datafield.valid_date_max:
if q.valid_date_min and q.valid_date_max:
help_text = format_lazy(
_('Please enter a date between {min} and {max}.'),
min=date_format(datafield.valid_date_min, "SHORT_DATE_FORMAT"),
max=date_format(datafield.valid_date_max, "SHORT_DATE_FORMAT"),
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
)
elif datafield.valid_date_min:
elif q.valid_date_min:
help_text = format_lazy(
_('Please enter a date no earlier than {min}.'),
min=date_format(datafield.valid_date_min, "SHORT_DATE_FORMAT"),
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
)
elif datafield.valid_date_max:
elif q.valid_date_max:
help_text = format_lazy(
_('Please enter a date no later than {max}.'),
max=date_format(datafield.valid_date_max, "SHORT_DATE_FORMAT"),
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
)
if initial and initial.answer:
try:
@@ -779,16 +803,16 @@ class BaseQuestionsForm(forms.Form):
else:
_initial = None
field = forms.DateField(
label=escape(qc.label), required=required,
label=escape(q.question), required=required,
help_text=rich_text(help_text),
initial=_initial,
widget=DatePickerWidget(attrs),
)
if datafield.valid_date_min:
field.validators.append(MinDateValidator(datafield.valid_date_min))
if datafield.valid_date_max:
field.validators.append(MaxDateValidator(datafield.valid_date_max))
elif datafield.type == Question.TYPE_TIME:
if q.valid_date_min:
field.validators.append(MinDateValidator(q.valid_date_min))
if q.valid_date_max:
field.validators.append(MaxDateValidator(q.valid_date_max))
elif q.type == Question.TYPE_TIME:
if initial and initial.answer:
try:
_initial = dateutil.parser.parse(initial.answer).time()
@@ -797,29 +821,29 @@ class BaseQuestionsForm(forms.Form):
else:
_initial = None
field = forms.TimeField(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
initial=_initial,
widget=TimePickerWidget(without_seconds=True),
)
elif datafield.type == Question.TYPE_DATETIME:
help_text = qc.help_text
elif q.type == Question.TYPE_DATETIME:
help_text = q.help_text
if not help_text:
if datafield.valid_datetime_min and datafield.valid_datetime_max:
if q.valid_datetime_min and q.valid_datetime_max:
help_text = format_lazy(
_('Please enter a date and time between {min} and {max}.'),
min=date_format(datafield.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
max=date_format(datafield.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
)
elif datafield.valid_datetime_min:
elif q.valid_datetime_min:
help_text = format_lazy(
_('Please enter a date and time no earlier than {min}.'),
min=date_format(datafield.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
)
elif datafield.valid_datetime_max:
elif q.valid_datetime_max:
help_text = format_lazy(
_('Please enter a date and time no later than {max}.'),
max=date_format(datafield.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
)
if initial and initial.answer:
@@ -831,20 +855,20 @@ class BaseQuestionsForm(forms.Form):
_initial = None
field = SplitDateTimeField(
label=escape(qc.label), required=required,
label=escape(q.question), required=required,
help_text=rich_text(help_text),
initial=_initial,
widget=SplitDateTimePickerWidget(
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
min_date=datafield.valid_datetime_min,
max_date=datafield.valid_datetime_max
min_date=q.valid_datetime_min,
max_date=q.valid_datetime_max
),
)
if datafield.valid_datetime_min:
field.validators.append(MinDateTimeValidator(datafield.valid_datetime_min))
if datafield.valid_datetime_max:
field.validators.append(MaxDateTimeValidator(datafield.valid_datetime_max))
elif datafield.type == Question.TYPE_PHONENUMBER:
if q.valid_datetime_min:
field.validators.append(MinDateTimeValidator(q.valid_datetime_min))
if q.valid_datetime_max:
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
elif q.type == Question.TYPE_PHONENUMBER:
if initial:
try:
initial = PhoneNumber().from_string(initial.answer)
@@ -857,27 +881,26 @@ class BaseQuestionsForm(forms.Form):
initial = "+{}.".format(phone_prefix)
field = PhoneNumberField(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
# the future.
initial=initial,
widget=WrappedPhoneNumberPrefixWidget()
)
field.datafield = datafield
field.question = q
if answers:
# Cache the answer object for later use
field.answer = answers[0]
if qc.dependency_question_id:
field.widget.attrs['data-question-dependency'] = qc.dependency_question_id
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(qc.dependency_values))
if datafield.type != 'M':
field.widget.attrs['required'] = qc.required and not self.all_optional
field._required = qc.required and not self.all_optional
if q.dependency_question_id:
field.widget.attrs['data-question-dependency'] = q.dependency_question_id
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values))
if q.type != 'M':
field.widget.attrs['required'] = q.required and not self.all_optional
field._required = q.required and not self.all_optional
field.required = False
return field
def check_user_questions(self, d):
@@ -941,7 +964,6 @@ class OrderLevelQuestionsForm(BaseQuestionsForm):
super().__init__(*args, **kwargs)
# TODO(questionnaires) - switch olq's to questionnaires !
questions = Question.objects.filter(
event=event, container_type=Question.ContainerType.ORDER,
ask_during_checkin=False, hidden=False,
@@ -976,7 +998,6 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
orderpos = self.orderpos = kwargs.pop('orderpos', None)
pos = cartpos or orderpos
item = pos.item
questionnaires = pos.item.relevant_questionnaires
event = kwargs.pop('event')
self.all_optional = kwargs.pop('all_optional', False)
self.attendee_addresses_required = event.settings.attendee_addresses_required and not self.all_optional
@@ -986,13 +1007,18 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
self.fields['requested_valid_from'] = self.build_requested_valid_from_field(event, pos, item)
for questionnaire in questionnaires:
for child in getattr(questionnaire, 'childlist', questionnaire.children.all()):
if child.user_datafield:
df = child.user_datafield
self.fields['question_%s' % df.id] = self.build_user_question_field(request, event, pos.answerlist, child, df)
elif child.system_datafield:
self.fields[child.system_datafield] = self.build_system_question_field(request, event, pos, child)
questions = []
if item.ask_attendee_data:
questions += get_fake_attendee_questions(event.settings)
questions += pos.item.questions_to_ask
questions.sort(key=lambda q: q.position)
for q in questions:
if isinstance(q, FakeQuestion):
self.fields[q.id] = self.build_system_question_field(request, event, pos, q)
else:
self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, pos.answerlist, q)
responses = question_form_fields.send(sender=event, position=pos)
data = pos.meta_info_data
@@ -1057,21 +1083,21 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
)
def build_system_question_field(self, request, event, pos, qc):
field_name = qc.system_datafield
field_name = qc.id
if field_name == 'attendee_name_parts':
return NamePartsFormField(
max_length=255,
required=qc.required and not self.all_optional,
scheme=event.settings.name_scheme,
titles=event.settings.name_scheme_titles,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
initial=pos.attendee_name_parts,
)
if field_name == 'attendee_email':
return forms.EmailField(
required=qc.required and not self.all_optional,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
initial=pos.attendee_email,
widget=forms.EmailInput(
@@ -1083,7 +1109,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if field_name == 'company':
return forms.CharField(
required=qc.required and not self.all_optional,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
max_length=255,
initial=pos.company,
@@ -1092,7 +1118,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if field_name == 'street':
return forms.CharField(
required=qc.required and not self.all_optional,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
widget=forms.Textarea(attrs={
'rows': 2,
@@ -1105,7 +1131,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
return forms.CharField(
required=False,
max_length=30,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
initial=pos.zipcode,
widget=forms.TextInput(attrs={
@@ -1115,7 +1141,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if field_name == 'city':
return forms.CharField(
required=False,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
max_length=255,
initial=pos.city,
@@ -1129,7 +1155,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
countries=CachedCountries
).formfield(
required=qc.required and not self.all_optional,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
initial=country,
widget=forms.Select(attrs={
@@ -1157,7 +1183,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
del self.data[fprefix + 'state']
field = forms.ChoiceField(
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
required=False,
choices=c,
@@ -1,196 +0,0 @@
# Generated by Django 4.2.29 on 2026-03-19 14:24
import json
from collections import namedtuple
from itertools import chain, groupby
from django.db import migrations, models
import django.db.models.deletion
import i18nfield.fields
from i18nfield.strings import LazyI18nString
import pretix.base.models.base
import pretix.base.models.fields
FakeQuestion = namedtuple(
'FakeQuestion', 'id question position required'
)
def get_fake_questions(settings):
def b(s):
return s == 'True'
fq = []
sqo = json.loads(settings.get('system_question_order', '{}'))
_ = LazyI18nString.from_gettext
if b(settings.get('attendee_names_asked', 'True')):
fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), b(settings.get('attendee_names_required'))))
if b(settings.get('attendee_emails_asked')):
fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), b(settings.get('attendee_emails_required'))))
if b(settings.get('attendee_company_asked')):
fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), b(settings.get('attendee_company_required'))))
if b(settings.get('attendee_addresses_asked')):
fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), b(settings.get('attendee_addresses_required'))))
return fq
def migrate_questions_forward(apps, schema_editor):
Event = apps.get_model("pretixbase", "Event")
Item = apps.get_model("pretixbase", "Item")
Question = apps.get_model("pretixbase", "Question")
Questionnaire = apps.get_model("pretixbase", "Questionnaire")
QuestionnaireChild = apps.get_model("pretixbase", "QuestionnaireChild")
EventSettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
def create_grouped_item_questionnaires(event, children, label_prefix, questionnaire_type):
# group by item, creating a unique questionnaire per item
item_questionnaires = (([t[3] for t in children], item_id) for item_id, children in
groupby(children, key=lambda t: t[0]))
# group again, merging all questionnaires with identical children
merged_questionnaires = groupby(sorted(item_questionnaires, key=lambda t: [q.id for q in t[0]]),
key=lambda t: t[0])
for children, iterator in merged_questionnaires:
items = [item for _c, item in iterator]
# create questionnaires and children
questionnaire = Questionnaire.objects.create(
event=event, type=questionnaire_type, position=0, all_sales_channels=True,
internal_name=label_prefix + ', '.join(str(iname or name) for (id, iname, name) in items)
)
questionnaire.items.set([id for (id, iname, name) in items])
deps = {}
for position, child in enumerate(children):
if isinstance(child, FakeQuestion):
QuestionnaireChild.objects.create(
questionnaire=questionnaire,
position=position + 1,
system_datafield=child.id,
required=child.required,
label=child.question,
)
else:
deps[child.id] = QuestionnaireChild.objects.create(
questionnaire=questionnaire,
position=position + 1,
user_datafield=child,
required=child.required,
label=child.question,
help_text=child.help_text,
dependency_question=deps[child.dependency_question.id] if child.dependency_question else None,
dependency_values=child.dependency_values,
)
for event in Event.objects.iterator():
# get relevant settings
settings = {
setting.key: setting.value for setting in EventSettingsStore.objects.filter(object_id=event.id, key__in=(
'system_question_order', 'attendee_names_asked', 'attendee_names_required', 'attendee_emails_asked', 'attendee_emails_required',
'attendee_company_asked', 'attendee_company_required', 'attendee_addresses_asked', 'attendee_addresses_required',
))
}
# get all ticket-level questions (user-defined and system provided), along with the products for which they're asked
questions = event.questions.filter(container_type='P')
children = sorted(chain((
(item, q.position, 0, q)
for q in get_fake_questions(settings)
for item in event.items.filter(personalized=True).values_list('id', 'internal_name', 'name')
), (
(item, q.position, q.id, q)
for q in questions.filter(hidden=False)
for item in q.items.values_list('id', 'internal_name', 'name')
)), key=lambda t: (t[0], t[1]))
create_grouped_item_questionnaires(event, children, '', 'PS')
children = sorted(chain((
(item, q.position, q.id, q)
for q in questions.filter(hidden=True)
for item in q.items.values_list('id', 'internal_name', 'name')
)), key=lambda t: (t[0], t[1]))
create_grouped_item_questionnaires(event, children, 'Hidden questions for ', 'PH')
# get all order-level questions
questions = list(event.questions.filter(container_type='O', hidden=False).order_by('position'))
if questions:
# create questionnaires and children
questionnaire = Questionnaire.objects.create(
event=event, type='OS', position=0, all_sales_channels=True,
internal_name='Per-order questions',
)
deps = {}
for position, child in enumerate(questions):
deps[child.id] = QuestionnaireChild.objects.create(
questionnaire=questionnaire,
position=position + 1,
user_datafield=child,
required=child.required,
label=child.question,
help_text=child.help_text,
dependency_question=deps[child.dependency_question.id] if child.dependency_question else None,
dependency_values=child.dependency_values,
)
def migrate_questions_backward(apps, schema_editor):
pass # as long as we don't delete the old columns, this is a no op. after that, it gets complicated...
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0309_alter_questionanswer_unique_together_and_more'),
]
operations = [
migrations.CreateModel(
name='Questionnaire',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('internal_name', models.CharField(max_length=255)),
('type', models.CharField(max_length=5)),
('position', models.PositiveIntegerField(default=0)),
('all_sales_channels', models.BooleanField(default=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questionnaires', to='pretixbase.event')),
('items', models.ManyToManyField(related_name='questionnaires', to='pretixbase.item')),
('limit_sales_channels', models.ManyToManyField(to='pretixbase.saleschannel')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='QuestionnaireChild',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('position', models.PositiveIntegerField(default=0)),
('system_datafield', models.CharField(max_length=25, null=True)),
('required', models.BooleanField(default=False)),
('label', i18nfield.fields.I18nTextField()),
('help_text', i18nfield.fields.I18nTextField(null=True)),
('dependency_values', pretix.base.models.fields.MultiStringField(default=[])),
('dependency_question', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dependent_questions', to='pretixbase.questionnairechild')),
('questionnaire', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='children', to='pretixbase.questionnaire')),
('user_datafield', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='references', to='pretixbase.question')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.RunPython(
migrate_questions_forward,
migrate_questions_backward,
),
# TODO(questionnaires) remove old columns from Question model
]
@@ -0,0 +1,24 @@
# Generated by Django 5.2.17 on 2026-09-13 18:32
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0310_question_valid_string_length_min"),
]
operations = [
migrations.AddField(
model_name="discount",
name="require_membership",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="discount",
name="require_membership_types",
field=models.ManyToManyField(to="pretixbase.membershiptype"),
),
]
+2 -2
View File
@@ -37,8 +37,8 @@ from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question,
Questionnaire, QuestionnaireChild, QuestionOption, Quota, SubEventItem,
SubEventItemVariation, itempicture_upload_to,
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
itempicture_upload_to,
)
from .log import LogEntry
from .mail import OutgoingMail
+1 -1
View File
@@ -296,7 +296,7 @@ class Customer(LoggedModel):
return self.memberships.active(for_event).with_usages().filter(
Q(membership_type__max_usages__isnull=True) | Q(usages__lt=F('membership_type__max_usages')),
testmode=testmode,
)
).select_related('membership_type')
def send_activation_mail(self):
from pretix.base.services.mail import mail
+123 -18
View File
@@ -19,12 +19,12 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from collections import defaultdict, namedtuple
import datetime
from collections import defaultdict
from decimal import Decimal
from itertools import groupby
from math import ceil, inf
from typing import Dict
from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
@@ -35,10 +35,20 @@ from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
from pretix.base.timemachine import time_machine_now
PositionInfo = namedtuple('PositionInfo',
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'addon_to',
'voucher_discount'])
if TYPE_CHECKING:
from pretix.base.models import Membership
class PositionInfo(NamedTuple):
item_id: int
subevent_id: Optional[int]
subevent_date_from: Optional[datetime.datetime]
line_price_gross: Decimal
addon_to: bool
voucher_discount: Decimal
memberships_valid_by_time: Optional[List["Membership"]] = None
class Discount(LoggedModel):
@@ -96,6 +106,16 @@ class Discount(LoggedModel):
choices=SUBEVENT_MODE_CHOICES,
)
require_membership = models.BooleanField(
verbose_name=_('Require a valid membership'),
default=False,
)
require_membership_types = models.ManyToManyField(
'MembershipType',
verbose_name=_('Allowed membership types'),
blank=True,
)
condition_all_products = models.BooleanField(
default=True,
verbose_name=_("Apply to all products (including newly created ones)")
@@ -239,6 +259,46 @@ class Discount(LoggedModel):
]}
)
if data.get('require_membership'):
if not data.get('require_membership_types'):
raise ValidationError(
{'require_membership_types': [
_("If a valid membership is required, at least one valid membership type needs to be selected.")
]}
)
for mt in data.get('require_membership_types', []):
# These conditions are a bit annoying/surprising, we could also just "ignore" the
# transferable/allow_parallel_usage/max_usages flags, but maybe we DO want to honor them for discounts
# some day for use cases like "every member can get one discounted ticket per show for themselves", and
# then we'd be unable to move forward if we historically ignored them.
if not mt.transferable:
raise ValidationError(
{'require_membership_types': [
_('The membership type "{type}" cannot be used for discounts since it is not transferable, '
'which is currently not supported for discounts.').format(
type=mt.name,
)
]}
)
elif not mt.allow_parallel_usage:
raise ValidationError(
{'require_membership_types': [
_('The membership type "{type}" cannot be used for discounts since it does not allow parallel '
'usage, which is currently not supported for discounts.').format(
type=mt.name,
)
]}
)
elif mt.max_usages is not None:
raise ValidationError(
{'require_membership_types': [
_('The membership type "{type}" cannot be used for discounts since it has a maximum number of '
'usages, which is currently not supported for discounts.').format(
type=mt.name,
)
]}
)
def allow_delete(self):
return not self.orderposition_set.exists()
@@ -250,6 +310,7 @@ class Discount(LoggedModel):
'benefit_only_apply_to_cheapest_n_matches': self.benefit_only_apply_to_cheapest_n_matches,
'subevent_mode': self.subevent_mode,
'benefit_same_products': self.benefit_same_products,
# many-to-many is not passed here, therefore we also call validate_config in form and serializer
})
def is_available_by_time(self, now_dt=None) -> bool:
@@ -343,7 +404,7 @@ class Discount(LoggedModel):
if collect_potential_discounts is not None:
if n_groups * self.benefit_only_apply_to_cheapest_n_matches > len(benefit_idx_group):
# partially used discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket
# but only 1 t-shirt) -> 1 shirt definitiv potential discount
# but only 1 t-shirt) -> 1 shirt definitively potential discount
for idx in consume_idx:
collect_potential_discounts[idx] = [
(self, n_groups * self.benefit_only_apply_to_cheapest_n_matches - len(benefit_idx_group), -1, subevent_id)
@@ -400,19 +461,25 @@ class Discount(LoggedModel):
limit_products = set()
if not self.condition_all_products:
limit_products = {p.pk for p in self.condition_limit_products.all()}
membership_types = set()
if self.require_membership and self.require_membership_types:
membership_types = {mt.pk for mt in self.require_membership_types.all()}
# First, filter out everything not even covered by our product scope
condition_candidates = [
idx
for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in
for idx, p in
positions.items()
if (
(self.condition_all_products or item_id in limit_products) and
(self.condition_apply_to_addons or not is_addon_to) and
(not self.condition_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00'))
and (not subevent_id or (
self.subevent_date_from is None or subevent_date_from >= self.subevent_date_from)) and (
self.subevent_date_until is None or subevent_date_from <= self.subevent_date_until)
(self.condition_all_products or p.item_id in limit_products) and
(self.condition_apply_to_addons or not p.addon_to) and
(not self.condition_ignore_voucher_discounted or p.voucher_discount is None or p.voucher_discount == Decimal('0.00')) and
(not p.subevent_id or (
self.subevent_date_from is None or p.subevent_date_from >= self.subevent_date_from)) and (
self.subevent_date_until is None or p.subevent_date_from <= self.subevent_date_until) and
(not self.require_membership or (
p.memberships_valid_by_time and any(m.membership_type_id in membership_types for m in p.memberships_valid_by_time)
))
)
]
@@ -422,12 +489,15 @@ class Discount(LoggedModel):
benefit_products = {p.pk for p in self.benefit_limit_products.all()}
benefit_candidates = [
idx
for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in
for idx, p in
positions.items()
if (
item_id in benefit_products and
(self.benefit_apply_to_addons or not is_addon_to) and
(not self.benefit_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00'))
p.item_id in benefit_products and
(self.benefit_apply_to_addons or not p.addon_to) and
(not self.benefit_ignore_voucher_discounted or p.voucher_discount is None or p.voucher_discount == Decimal('0.00')) and
(not self.require_membership or (
p.memberships_valid_by_time and any(m.membership_type_id in membership_types for m in p.memberships_valid_by_time)
))
)
]
@@ -528,3 +598,38 @@ class Discount(LoggedModel):
None
)
return result
def condition_matches_product(self, item) -> bool:
return self.condition_all_products or item in self.condition_limit_products.all()
def valid_for_memberships(self, memberships, event, subevent, item, requested_valid_from=None) -> bool:
if not self.require_membership_types:
return True
if not memberships:
return False
memberships = [
m for m in memberships
# Filter currently unsupported configurations
if (
m.membership_type.max_usages is None and
m.membership_type.transferable and
m.membership_type.allow_parallel_usage and
# Filter list of membership types
m.membership_type in self.require_membership_types.all()
)
]
valid_from, _ = item.compute_validity(
requested_start=(
max(requested_valid_from, time_machine_now())
if requested_valid_from and item.validity_dynamic_start_choice
else time_machine_now()
),
override_tz=self.event.timezone,
)
return any(
m.is_valid(
subevent or event,
valid_from,
valid_from_not_chosen=item.validity_dynamic_start_choice and not requested_valid_from
) for m in memberships
)
+3 -1
View File
@@ -1081,6 +1081,7 @@ class Event(EventMixin, LoggedModel):
c_items = list(d.condition_limit_products.all())
b_items = list(d.benefit_limit_products.all())
limit_sales_channels = list(d.limit_sales_channels.all())
require_membership_types = list(d.require_membership_types.all())
d.pk = None
d.event = self
d._prefetched_objects_cache = {}
@@ -1092,9 +1093,10 @@ class Event(EventMixin, LoggedModel):
for i in b_items:
if i.pk in item_map:
d.benefit_limit_products.add(item_map[i.pk])
if not d.all_sales_channels:
d.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
if require_membership_types and not is_cross_organizer:
d.require_membership_types.set(require_membership_types)
question_map = {}
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
+19 -120
View File
@@ -510,7 +510,9 @@ class Item(LoggedModel):
verbose_name=_("Free price input"),
help_text=_("If this option is active, your users can choose the price themselves. The price configured above "
"is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect "
"additional donations for your event.")
"additional donations for your event. We recommend against combining this feature with automatic "
"discounts since discounts are applied as the last step of price computation, which means that the "
"price entered by the customer will be modified again.")
)
free_price_suggestion = models.DecimalField(
verbose_name=_("Suggested price"),
@@ -1569,12 +1571,10 @@ class ItemBundle(models.Model):
class Question(LoggedModel):
"""
A question is a data field that can be used to extend an order or a ticket by custom
information, e.g. "Attendee age". To be actually useful, questions need to be added to
one or multiple Questionnaires. The answers may be found in QuestionAnswers, attached
to Orders, OrderPositions or CartPositions.
A question can allow one of several input types, currently:
A question is an input field that can be used to extend a ticket by custom information,
e.g. "Attendee age". The answers are found next to the position. The answers may be found
in QuestionAnswers, attached to OrderPositions/CartPositions. A question can allow one of
several input types, currently:
* a number (``TYPE_NUMBER``)
* a one-line string (``TYPE_STRING``)
@@ -1594,7 +1594,7 @@ class Question(LoggedModel):
:param required: Whether answering this question is required for submitting an order including
items associated with this question.
:type required: bool
:param items: TO BE REMOVED
:param items: A set of ``Items`` objects that this question should be applied to
:param ask_during_checkin: Whether to ask this question during check-in instead of during check-out.
:type ask_during_checkin: bool
:param show_during_checkin: Whether to show the answer to this question during check-in.
@@ -1653,7 +1653,6 @@ class Question(LoggedModel):
default=ContainerType.ORDERPOSITION,
)
question = I18nTextField(
# TODO(questionnaires) : to be renamed to 'internal_name'
verbose_name=_("Question")
)
identifier = models.CharField(
@@ -1669,7 +1668,6 @@ class Question(LoggedModel):
],
)
help_text = I18nTextField(
# TODO(questionnaires) : to be removed
verbose_name=_("Help text"),
help_text=_("If the question needs to be explained or clarified, do it here!"),
null=True, blank=True,
@@ -1679,22 +1677,22 @@ class Question(LoggedModel):
choices=TYPE_CHOICES,
verbose_name=_("Question type")
)
required = models.BooleanField( # TODO(questionnaires) : to be removed, -> QuestionnaireChild
required = models.BooleanField(
default=False,
verbose_name=_("Required question")
)
items = models.ManyToManyField( # TODO(questionnaires) : to be removed, -> Questionnaire
items = models.ManyToManyField(
Item,
related_name='questions',
verbose_name=_("Products"),
blank=True,
help_text=_('This question will be asked to buyers of the selected products')
)
position = models.PositiveIntegerField( # TODO(questionnaires) : to be removed, -> Questionnaire + QuestionnaireChild
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
ask_during_checkin = models.BooleanField( # TODO(questionnaires) : to be removed
ask_during_checkin = models.BooleanField(
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
help_text=_('Not supported by all check-in apps for all question types.'),
default=False
@@ -1704,7 +1702,7 @@ class Question(LoggedModel):
help_text=_('Not supported by all check-in apps for all question types.'),
default=False
)
hidden = models.BooleanField( # to be removed
hidden = models.BooleanField(
verbose_name=_('Hidden question'),
help_text=_('This question will only show up in the backend.'),
default=False
@@ -1713,10 +1711,10 @@ class Question(LoggedModel):
verbose_name=_('Print answer on invoices'),
default=False
)
dependency_question = models.ForeignKey( # TODO(questionnaires) : to be removed, -> QuestionnaireChild
dependency_question = models.ForeignKey(
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
)
dependency_values = MultiStringField(default=[]) # TODO(questionnaires) : to be removed, -> QuestionnaireChild
dependency_values = MultiStringField(default=[])
valid_number_min = models.DecimalField(decimal_places=6, max_digits=30, null=True, blank=True,
verbose_name=_('Minimum value'),
help_text=_('Currently not supported in our apps and during check-in'))
@@ -1755,9 +1753,9 @@ class Question(LoggedModel):
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Data field")
verbose_name_plural = _("Data fields")
ordering = ('question', 'id')
verbose_name = _("Question")
verbose_name_plural = _("Questions")
ordering = ('position', 'id')
unique_together = (('event', 'identifier'),)
def __str__(self):
@@ -1908,7 +1906,7 @@ class Question(LoggedModel):
return answer
@staticmethod
def clean_items(event, items): # TODO(questionnaires) : remove method / move to qc
def clean_items(event, items):
for item in items:
if event != item.event:
raise ValidationError(_('One or more items do not belong to this event.'))
@@ -1989,105 +1987,6 @@ class QuestionOption(models.Model):
ordering = ('position', 'id')
class Questionnaire(LoggedModel):
TYPE_ORDER_SALE = "OS"
TYPE_ORDER_POSITION_SALE = "PS"
TYPE_ORDER_POSITION_ATTENDEE_ONLY = "PA"
TYPE_ORDER_POSITION_CHECKIN = "PC"
TYPE_ORDER_POSITION_HIDDEN = "PH"
TYPE_CHOICES = (
(TYPE_ORDER_SALE, _("Order-wide, before purchase")),
(TYPE_ORDER_POSITION_SALE, _("Per product, before purchase")),
(TYPE_ORDER_POSITION_ATTENDEE_ONLY, _("Per product, via attendee link")),
(TYPE_ORDER_POSITION_CHECKIN, _("Per product, at check-in")),
(TYPE_ORDER_POSITION_HIDDEN, _("Per product, hidden")),
)
event = models.ForeignKey(
Event,
related_name="questionnaires",
on_delete=models.CASCADE
)
internal_name = models.CharField(
verbose_name=_("Internal name"),
max_length=255,
)
type = models.CharField(
max_length=5,
choices=TYPE_CHOICES,
verbose_name=_("Questionnaire type")
)
items = models.ManyToManyField(
Item,
related_name='questionnaires',
verbose_name=_("Products"),
blank=True,
help_text=_('This questionnaire will be asked to buyers of the selected products')
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
all_sales_channels = models.BooleanField(
verbose_name=_("Sell on all sales channels the product is sold on"),
default=True,
)
limit_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_("Restrict to specific sales channels"),
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
'selected here but not on product level, the variation will not be available.'),
blank=True,
)
class QuestionnaireChild(LoggedModel):
SYSTEM_QUESTION_CHOICES = (
('attendee_name_parts', _('Attendee name')),
('attendee_email', _('Attendee email')),
('company', _('Company')),
('street', _('Street')),
('zipcode', _('ZIP code')),
('city', _('City')),
('country', _('Country')),
)
questionnaire = models.ForeignKey(
Questionnaire,
related_name="children",
on_delete=models.CASCADE
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
user_datafield = models.ForeignKey(
Question,
related_name="references",
on_delete=models.CASCADE,
null=True, blank=True,
)
system_datafield = models.CharField(
max_length=25,
choices=SYSTEM_QUESTION_CHOICES,
null=True, blank=True,
)
required = models.BooleanField(
default=False,
verbose_name=_("Required question")
)
label = I18nTextField(
verbose_name=_("Question")
)
help_text = I18nTextField(
verbose_name=_("Help text"),
help_text=_("If the question needs to be explained or clarified, do it here!"),
null=True, blank=True,
)
dependency_question = models.ForeignKey(
'QuestionnaireChild', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
)
dependency_values = MultiStringField(default=[])
class Quota(LoggedModel):
"""
A quota is a "pool of tickets". It is there to limit the number of items
+41 -65
View File
@@ -1449,12 +1449,6 @@ class QuestionAnswer(models.Model):
else:
return self.answer
def to_dependency_values(self):
if self.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
return [o.identifier for o in self.options.all()]
elif self.question.type in (Question.TYPE_BOOLEAN, Question.TYPE_COUNTRYCODE):
return self.answer
def save(self, *args, **kwargs):
if self.orderposition and self.cartposition:
raise ValueError('QuestionAnswer cannot be linked to an order and a cart position at the same time.')
@@ -1599,80 +1593,53 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
def cache_answers(self, all=True):
"""
Creates a new property on the object:
questions: a list of Question objects, extended by an 'answer' property
Creates two properties on the object.
(1) answ: a dictionary of question.id → answer string
(2) questions: a list of Question objects, extended by an 'answer' property
"""
self.answ = {}
for a in getattr(self, 'answerlist', self.answers.all()): # use prefetch_related cache from get_cart
self.answ[a.question_id] = a
# We need to clone our question objects, otherwise we will override the cached
# answers of other items in the same cart if the question objects have been
# selected via prefetch_related
if not all:
if hasattr(self.item, 'relevant_questionnaires'):
children = list(copy.copy(qc) for qq in self.item.relevant_questionnaires for qc in qq.childlist)
if hasattr(self.item, 'questions_to_ask'):
questions = list(copy.copy(q) for q in self.item.questions_to_ask)
else:
children = list(copy.copy(qc) for qq in self.item.questionnaires.filter(type='PS') for qc in qq.children.all())
questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False,
hidden=False))
else:
children = list(copy.copy(qc) for qq in self.item.questionnaires.filter(type__startswith='P') for qc in qq.children.all())
questions = list(copy.copy(q) for q in self.item.questions.all())
qc_cache = {
q.pk: q for q in children
question_cache = {
q.pk: q for q in questions
}
def qc_is_visible(parentid, qvals):
if parentid not in qc_cache:
def question_is_visible(parentid, qvals):
if parentid not in question_cache:
return False
parentqc = qc_cache[parentid]
if parentqc.dependency_question_id and not qc_is_visible(parentqc.dependency_question_id, parentqc.dependency_values):
parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
return False
answer_values = self.get_dependency_answer_values(parentqc)
return any(qval in answer_values for qval in qvals)
if parentid not in self.answ:
return False
return (
('True' in qvals and self.answ[parentid].answer == 'True')
or ('False' in qvals and self.answ[parentid].answer == 'False')
or (any(qval in [o.identifier for o in self.answ[parentid].options.all()] for qval in qvals))
)
self.questions = []
for qc in children:
if qc.user_datafield_id and qc.user_datafield_id in self.answer_cache:
qc.answer = self.answer_cache[qc.user_datafield_id]
#qc.answer.question = qc # cache object
elif qc.system_datafield:
qc.answer = self.get_system_answer(qc.system_datafield)
#qc.answer.question = qc # cache object
for q in questions:
if q.id in self.answ:
q.answer = self.answ[q.id]
q.answer.question = q # cache object
else:
qc.answer = ""
if not qc.dependency_question_id or qc_is_visible(qc.dependency_question_id, qc.dependency_values):
self.questions.append(qc)
@cached_property
def answer_cache(self):
return {
aw.question_id: aw for aw in getattr(self, 'answerlist', self.answers.all())
}
def get_dependency_answer_values(self, qc):
if qc.user_datafield_id:
if qc.user_datafield_id not in self.answer_cache:
return None
answer = self.answer_cache[qc.user_datafield_id]
return answer.to_dependency_values()
elif qc.system_datafield:
return [self.get_system_answer(qc.system_datafield)]
else:
raise ValueError('Questionnaire child without datafield has no answer')
def get_system_answer(self, system_datafield_name):
if system_datafield_name == 'attendee_name_parts':
return self.attendee_name_parts
elif system_datafield_name == 'attendee_email':
return self.attendee_email
elif system_datafield_name == 'street':
return self.street
elif system_datafield_name == 'zipcode':
return self.zipcode
elif system_datafield_name == 'city':
return self.city
elif system_datafield_name == 'state':
return self.state
elif system_datafield_name == 'country':
return self.country
else:
raise ValueError('Unknown system question name')
q.answer = ""
if not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values):
self.questions.append(q)
@property
def net_price(self):
@@ -3446,6 +3413,15 @@ class CartPosition(AbstractPosition):
def valid_until(self):
return self.predicted_validity[1]
def memberships_valid_by_time(self, memberships):
return [
m for m in memberships if m.is_valid(
self.subevent or self.event,
self.valid_from,
valid_from_not_chosen=self.item.validity_dynamic_start_choice and not self.requested_valid_from
) for m in memberships
]
class InvoiceAddress(models.Model):
last_modified = models.DateTimeField(auto_now=True)
+3 -3
View File
@@ -72,7 +72,7 @@ from pretix.helpers.countries import CachedCountries
from pretix.helpers.format import format_map
from pretix.helpers.money import DecimalTextInput
from pretix.multidomain.urlreverse import eventreverse_absolute
from pretix.presale.views import get_cart_positions
from pretix.presale.views import get_cart
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
logger = logging.getLogger(__name__)
@@ -1165,7 +1165,7 @@ class FreeOrderProvider(BasePaymentProvider):
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
from .services.cart import get_fees
cart = get_cart_positions(request)
cart = get_cart(request)
try:
fees = get_fees(event=request.event, request=request,
@@ -1433,7 +1433,7 @@ class GiftCardPayment(BasePaymentProvider):
for p in cs.get('payments', [])
if p.get('info_data', {}).get('gift_card')
]
positions = get_cart_positions(request)
positions = get_cart(request)
testmode = self.event.testmode
else:
used_cards = []
+25 -16
View File
@@ -1232,7 +1232,9 @@ class Renderer:
for i, page in enumerate(fg_pdf.pages):
bg_page = self.bg_pdf.pages[i]
_merge_with_correct_page_media_box(output, page, bg_page)
_correct_page_media_box(bg_page)
page.merge_page(bg_page, over=False)
output.add_page(page)
# pdf_header is a string like "%pdf-X.X"
if float(self.bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
@@ -1297,32 +1299,39 @@ def merge_background(fg_pdf: PdfWriter, bg_pdf: PdfWriter, out_file, compress):
bg_pdf.write(bg_filename)
subprocess.run(pdftk_cmd, check=True, stdout=out_file)
else:
output = PdfWriter()
for i, page in enumerate(fg_pdf.pages):
bg_page = bg_pdf.pages[i]
_merge_with_correct_page_media_box(output, page, bg_page)
_correct_page_media_box(bg_page)
page.merge_page(bg_page, over=False)
# pdf_header is a string like "%pdf-X.X"
output.pdf_header = (
bg_pdf.pdf_header
if float(bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:])
else fg_pdf.pdf_header
)
output.write(out_file)
if float(bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
fg_pdf.pdf_header = bg_pdf.pdf_header
fg_pdf.write(out_file)
def _merge_with_correct_page_media_box(output: pypdf.PdfWriter, fg_page: pypdf.PageObject, bg_page: pypdf.PageObject):
if bg_page.rotation != 0:
bg_page.transfer_rotation_to_content()
media_box = bg_page.mediabox
def _correct_page_media_box(page: pypdf.PageObject):
if page.rotation != 0:
page.transfer_rotation_to_content()
media_box = page.mediabox
trsf = pypdf.Transformation()
if media_box.bottom != 0:
trsf = trsf.translate(0, -media_box.bottom)
if media_box.left != 0:
trsf = trsf.translate(-media_box.left, 0)
fg_page = output.add_page(fg_page)
fg_page.merge_transformed_page(bg_page, trsf, over=False, expand=False)
page.add_transformation(trsf, False)
for b in ["/MediaBox", "/CropBox", "/BleedBox", "/TrimBox", "/ArtBox"]:
if b in page:
rr = pypdf.generic.RectangleObject(page[b])
pt1 = trsf.apply_on(rr.lower_left)
pt2 = trsf.apply_on(rr.upper_right)
page[pypdf.generic.NameObject(b)] = pypdf.generic.RectangleObject((
min(pt1[0], pt2[0]),
min(pt1[1], pt2[1]),
max(pt1[0], pt2[0]),
max(pt1[1], pt2[1]),
))
@deconstructible
+59 -16
View File
@@ -47,6 +47,7 @@ from django.db import DatabaseError, transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Value
from django.db.models.aggregates import Min
from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import (
gettext as _, gettext_lazy, ngettext_lazy, pgettext_lazy,
@@ -57,8 +58,8 @@ from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemVariation, SalesChannel,
Seat, SeatCategoryMapping, Voucher,
CartPosition, Customer, Event, InvoiceAddress, Item, ItemVariation,
SalesChannel, Seat, SeatCategoryMapping, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import CheckoutSession, OrderFee
@@ -417,13 +418,15 @@ class CartManager:
}
def __init__(self, event: Event, cart_id: str, sales_channel: SalesChannel,
invoice_address: InvoiceAddress=None, widget_data=None, reservation_time: timedelta=None):
invoice_address: InvoiceAddress=None, widget_data=None, reservation_time: timedelta=None,
customer: Customer=None):
"""
Creates a new CartManager for an event.
"""
self.event = event
self.cart_id = cart_id
self.real_now_dt = now()
self.customer = customer
self._operations = []
self._quota_diff = Counter()
self._voucher_use_diff = Counter()
@@ -1544,7 +1547,7 @@ class CartManager:
self._sales_channel.identifier,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher, cp.memberships_valid_by_time(self.memberships))
for cp in positions
]
)
@@ -1559,6 +1562,13 @@ class CartManager:
return diff
@cached_property
def memberships(self):
if self.customer:
return self.customer.memberships.filter(testmode=self.event.testmode).select_related('membership_type')
else:
return []
def _remove_parents_if_bundles_are_removed(self):
removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)}
for op in self._operations:
@@ -1712,7 +1722,8 @@ def get_fees(event, request, _total_ignored_=None, invoice_address=None, payment
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None, widget_data=None, sales_channel='web', override_now_dt: datetime=None) -> None:
invoice_address: int=None, widget_data=None, sales_channel='web', override_now_dt: datetime=None,
customer: int=None) -> None:
"""
Adds a list of items to a user's cart.
:param event: The event ID in question
@@ -1734,10 +1745,16 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
if customer:
try:
customer = event.organizer.customers.get(pk=customer)
except Customer.DoesNotExist:
raise CartError("Invalid customer.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data,
sales_channel=sales_channel)
sales_channel=sales_channel, customer=customer)
cm.add_new_items(items)
cm.commit()
except LockTimeoutException:
@@ -1747,7 +1764,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
"""
:param event: The event ID in question
:param voucher: A voucher code
@@ -1758,9 +1775,14 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
if customer:
try:
customer = event.organizer.customers.get(pk=customer)
except Customer.DoesNotExist:
raise CartError("Invalid customer.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
cm.apply_voucher(voucher)
cm.commit()
except LockTimeoutException:
@@ -1770,7 +1792,8 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en',
sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
"""
Removes an item specified by its position ID from a user's cart.
:param event: The event ID in question
@@ -1782,9 +1805,14 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
if customer:
try:
customer = event.organizer.customers.get(pk=customer)
except Customer.DoesNotExist:
raise CartError("Invalid customer.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
cm.remove_item(position)
cm.commit()
except LockTimeoutException:
@@ -1794,7 +1822,7 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
"""
Removes all items from a user's cart.
:param event: The event ID in question
@@ -1805,9 +1833,14 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
if customer:
try:
customer = event.organizer.customers.get(pk=customer)
except Customer.DoesNotExist:
raise CartError("Invalid customer.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
cm.clear()
cm.commit()
except LockTimeoutException:
@@ -1817,7 +1850,7 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> dict:
def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None, customer=None) -> dict:
"""
Resets the expiry time of a cart to the configured reservation time of this event.
Limited to 11x the reservation time.
@@ -1830,9 +1863,14 @@ def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en',
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
if customer:
try:
customer = event.organizer.customers.get(pk=customer)
except Customer.DoesNotExist:
raise CartError("Invalid customer.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
cm.commit()
return {
"success": cm.num_extended_positions,
@@ -1848,7 +1886,7 @@ def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en',
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None:
invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
"""
Assigns addons to eligible products in a user's cart, adding and removing the addon products as necessary to
ensure the requested addon state.
@@ -1869,9 +1907,14 @@ def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: L
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
if customer:
try:
customer = event.organizer.customers.get(pk=customer)
except Customer.DoesNotExist:
raise CartError("Invalid customer.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel, customer=customer)
cm.set_addons(addons)
cm.add_new_items(add_to_cart_items)
cm.commit()
+11 -1
View File
@@ -51,6 +51,7 @@ class CrossSellingService:
self.sales_channel = sales_channel
self.cartpositions = cartpositions
self.customer = customer
self._discount_cache = {}
def get_data(self):
if self.event.has_subevents:
@@ -111,6 +112,13 @@ class CrossSellingService:
}
return category.items.filter(pk__in=potential_discount_items), potential_discount_items
@cached_property
def memberships(self):
if self.customer:
return self.customer.memberships.filter(testmode=self.event.testmode).select_related('membership_type')
else:
return []
@cached_property
def _potential_discounts_by_subevent_and_item_for_current_cart(self):
potential_discounts_by_cartpos = defaultdict(list)
@@ -122,7 +130,8 @@ class CrossSellingService:
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
cp.addon_to, cp.is_bundled,
cp.listed_price - cp.price_after_voucher)
cp.listed_price - cp.price_after_voucher,
cp.memberships_valid_by_time(self.memberships))
for cp in self.cartpositions
],
collect_potential_discounts=potential_discounts_by_cartpos
@@ -177,6 +186,7 @@ class CrossSellingService:
)
if self.customer else None
),
_discount_cache=self._discount_cache,
)
new_items = list()
for item in items:
+1 -1
View File
@@ -86,7 +86,7 @@ def create_membership(customer: Customer, position: OrderPosition):
def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None, testmode=False,
valid_from_not_chosen=False):
"""
Validate that a set of cart or order positions. This currently does not validate
Validate that a set of cart or order positions.
:param customer: Customer to validate for
:param positions: List of order or cart positions
+8 -2
View File
@@ -56,7 +56,7 @@ from django.db.models import (
from django.db.models.functions import Cast, Greatest
from django.db.transaction import get_connection
from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.functional import cached_property, lazy
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext as _, gettext_lazy, ngettext_lazy
from django_scopes import scopes_disabled
@@ -923,12 +923,18 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
continue
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted
if customer:
memberships = lazy(lambda: list(customer.memberships.filter(testmode=event.testmode).select_related('membership_type')), list)()
else:
memberships = []
discount_results = apply_discounts(
event,
sales_channel.identifier,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher, cp.memberships_valid_by_time(memberships))
for cp in sorted_positions
]
)
+19 -5
View File
@@ -33,7 +33,7 @@ from django.db.models import Q
from pretix.base.decimal import round_decimal
from pretix.base.models import (
AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn,
ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher,
ItemVariation, Membership, OrderFee, OrderPosition, SalesChannel, Voucher,
)
from pretix.base.models.discount import Discount, PositionInfo
from pretix.base.models.event import Event, SubEvent
@@ -167,14 +167,15 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]],
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal, List[Membership]]],
collect_potential_discounts: Optional[defaultdict] = None) -> List[Tuple[Decimal, Optional[Discount]]]:
"""
Applies any dynamic discounts to a cart
:param event: Event the cart belongs to
:param sales_channel: Sales channel the cart was created with
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to_id, is_bundled, voucher_discount)``
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to_id, is_bundled,
voucher_discount, memberships_valid_by_time)``
``addon_to_id`` does not have to be the proper ID, any identifier is okay, even ``True``/``False`` are accepted, but
a better result may be given if addons to the same main product have the same distinct value.
:param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart
@@ -190,6 +191,17 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
sales_channel = sales_channel.identifier
new_prices = {}
def _filter_memberships(memberships: List[Membership]) -> List[Membership]:
return [
# Currently unsupported configurations
m for m in memberships
if (
m.membership_type.max_usages is None and
m.membership_type.transferable and
m.membership_type.allow_parallel_usage
)
]
discount_qs = event.discounts.filter(
Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()),
Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()),
@@ -198,9 +210,11 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
for discount in discount_qs:
result = discount.apply({
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, voucher_discount)
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, voucher_discount,
_filter_memberships(memberships_valid_by_time))
for
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount)
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount,
memberships_valid_by_time)
in enumerate(positions)
if not is_bundled and idx not in new_prices
}, collect_potential_discounts)
File diff suppressed because one or more lines are too long
+15 -25
View File
@@ -27,7 +27,7 @@ from decimal import Decimal
from django import forms
from django.core.files.uploadedfile import UploadedFile
from django.db import IntegrityError
from django.db.models import Prefetch, Q, QuerySet
from django.db.models import Prefetch, QuerySet
from django.utils.functional import cached_property
from django.utils.timezone import make_aware
@@ -37,7 +37,7 @@ from pretix.base.forms.questions import (
)
from pretix.base.models import (
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
QuestionnaireChild, QuestionOption,
QuestionOption,
)
from pretix.base.models.customers import AttendeeProfile
from pretix.base.models.orders import CheckoutSession, Order
@@ -348,37 +348,27 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
@cached_property
def positions(self):
qqs = self.request.event.questionnaires.all()
qqs = self.request.event.questions.all()
if self.only_user_visible:
qqs = qqs.filter(type='PS')
else:
qqs = qqs.filter(type__startswith='P')
qqs = qqs.filter(
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=self.order.sales_channel.identifier)
)
qqs = qqs.filter(ask_during_checkin=False, hidden=False, container_type=Question.ContainerType.ORDERPOSITION)
return list(self.order.positions.select_related(
'item', 'variation'
).prefetch_related(
Prefetch('answers',
QuestionAnswer.objects.prefetch_related('options'),
to_attr='answerlist'),
Prefetch('item__questionnaires',
Prefetch('item__questions',
qqs.prefetch_related(
Prefetch('children', QuestionnaireChild.objects.prefetch_related(
Prefetch('user_datafield', Question.objects.prefetch_related(
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
'question',
Question.objects.none(),
to_attr='dummy'
)))
))
),
to_attr='childlist')
),
to_attr='relevant_questionnaires')
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
'question',
Question.objects.none(),
to_attr='dummy'
)))
).select_related('dependency_question'),
to_attr='questions_to_ask')
))
@cached_property
-2
View File
@@ -26,7 +26,6 @@ from django.core import signing
from django.http import HttpResponseBadRequest, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.html import format_html
logger = logging.getLogger(__name__)
@@ -61,7 +60,6 @@ def redir_view(request):
u = urllib.parse.urlparse(url)
return render(request, 'pretixbase/redirect.html', {
'hostname': u.hostname,
'bold_hostname': format_html("<strong>{}</strong>", u.hostname),
'url': url,
})
+15
View File
@@ -45,6 +45,8 @@ class DiscountForm(I18nModelForm):
'limit_sales_channels',
'available_from',
'available_until',
'require_membership',
'require_membership_types',
'subevent_date_from',
'subevent_date_until',
'subevent_mode',
@@ -69,6 +71,7 @@ class DiscountForm(I18nModelForm):
'condition_limit_products': ItemMultipleChoiceField,
'benefit_limit_products': ItemMultipleChoiceField,
'limit_sales_channels': SafeModelMultipleChoiceField,
'require_membership_types': SafeModelMultipleChoiceField,
}
widgets = {
'subevent_mode': forms.RadioSelect,
@@ -88,6 +91,9 @@ class DiscountForm(I18nModelForm):
'data-display-dependency': '#id_condition_min_count',
}
),
'require_membership_types': forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice'
}),
}
def __init__(self, *args, **kwargs):
@@ -107,6 +113,13 @@ class DiscountForm(I18nModelForm):
self.fields['condition_min_value'].required = False
self.fields['condition_min_value'].widget.is_required = False
qs = self.event.organizer.membership_types.all()
if qs:
self.fields['require_membership_types'].queryset = qs
else:
del self.fields['require_membership']
del self.fields['require_membership_types']
if not self.event.has_subevents:
del self.fields['subevent_mode']
@@ -123,4 +136,6 @@ class DiscountForm(I18nModelForm):
d['condition_min_count'] = 0
if d.get('condition_min_value') is None:
d['condition_min_value'] = Decimal('0.00')
Discount.validate_config(d)
return d
+3 -3
View File
@@ -105,12 +105,12 @@ class GlobalSettingsForm(SettingsForm):
domain=settings.SITE_URL
)
)),
('widget_vue2_origins', forms.CharField(
('widget_vite_origins', forms.CharField(
widget=forms.Textarea(attrs={'rows': '3'}),
required=False,
# Not translated on purpose, this is a temporary feature and contains too many special case words
label="Vue2 widget origins",
help_text="One origin per line (e.g. https://example.com). Requests from these origins will be served the old vue2-based widget.",
label="Vite widget origins",
help_text="One origin per line (e.g. https://example.com). Requests from these origins will be served the new vite-based widget.",
))
])
responses = register_global_settings.send(self)
-3
View File
@@ -861,9 +861,6 @@ class OrganizerPluginStateLogEntryType(LogEntryType):
'pretix.event.question.option.added': _('An answer option has been added to the question.'),
'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'),
'pretix.event.question.option.changed': _('An answer option has been changed.'),
'pretix.event.questionnaire.added': _('A questionnaire has been created.'),
'pretix.event.questionnaire.deleted': _('A questionnaire has been deleted.'),
'pretix.event.questionnaire.changed': _('A questionnaire has been changed.'),
'pretix.event.permissions.added': _('A user has been added to the event team.'),
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
+3 -3
View File
@@ -182,12 +182,12 @@ def get_event_navigation(request: HttpRequest):
'active': 'event.items.categories' in url.url_name,
},
{
'label': _('Questionnaires'),
'url': reverse('control:event.items.questionnaires', kwargs={
'label': _('Questions'),
'url': reverse('control:event.items.questions', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.items.questionnaires' in url.url_name or 'event.items.questions' in url.url_name,
'active': 'event.items.questions' in url.url_name,
},
{
'label': _('Discounts'),
@@ -17,6 +17,12 @@
{% bootstrap_field form.available_until layout="control" %}
{% bootstrap_field form.all_sales_channels layout="control" %}
{% bootstrap_field form.limit_sales_channels layout="control" %}
{% if form.require_membership %}
{% bootstrap_field form.require_membership layout="control" %}
<div data-display-dependency="#{{ form.require_membership.id_for_label }}">
{% bootstrap_field form.require_membership_types layout="control" %}
</div>
{% endif %}
</fieldset>
<fieldset>
<legend>{% trans "Condition" context "discount" %}</legend>
@@ -12,120 +12,151 @@
{% endblock %}
{% block inside %}
{% if question %}
<h1>{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}</h1>
<h1>{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}</h1>
{% else %}
<h1>{% trans "Data field" %}</h1>
<h1>{% trans "Question" %}</h1>
{% endif %}
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors form %}
{% bootstrap_field form.question layout="control" %}
{% bootstrap_field form.type layout="control" %}
<div id="valid-number">
{% bootstrap_field form.valid_number_min layout="control" %}
{% bootstrap_field form.valid_number_max layout="control" %}
</div>
<div id="valid-date">
{% bootstrap_field form.valid_date_min layout="control" %}
{% bootstrap_field form.valid_date_max layout="control" %}
</div>
<div id="valid-datetime">
{% bootstrap_field form.valid_datetime_min layout="control" %}
{% bootstrap_field form.valid_datetime_max layout="control" %}
</div>
<div id="valid-string">
{% bootstrap_field form.valid_string_length_min layout="control" %}
{% bootstrap_field form.valid_string_length_max layout="control" %}
</div>
<div id="valid-file">
{% bootstrap_field form.valid_file_portrait layout="control" %}
</div>
<div id="answer-options">
<h3>{% trans "Answer options" %}</h3>
<noscript>
<p>{% trans "Only applicable if you choose 'Choose one/multiple from a list' above." %}</p>
</noscript>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" data-formset-delete-confirm-text="{% trans "If you delete an answer option, you will no longer be able to see statistical data on customers who previously selected this option, and when such customers edit their answers, they need to select a different option." %}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% blocktrans trimmed with id=form.instance.identifier %}
Answer option {{ id }}
{% endblocktrans %}
</span>
{% bootstrap_form_errors form %}
{% bootstrap_field form.answer layout='inline' form_group_class="" %}
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
{% endfor %}
<div class="tabbed-form">
<fieldset>
<legend>{% trans "General" %}</legend>
{% bootstrap_field form.question layout="control" %}
{% bootstrap_field form.type layout="control" %}
{% if form.items %}
{% bootstrap_field form.items layout="control" %}
{% endif %}
{% bootstrap_field form.required layout="control" %}
<div class="alert alert-info alert-required-boolean">
{% blocktrans trimmed %}
If you mark a Yes/No question as required, it means that the user has to select Yes and No is not
accepted. If you want to allow both options, do not make this field required.
{% endblocktrans %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% trans "New answer option" %}
</span>
{% bootstrap_field formset.empty_form.answer layout='inline' form_group_class="" %}
<div id="valid-number">
{% bootstrap_field form.valid_number_min layout="control" %}
{% bootstrap_field form.valid_number_max layout="control" %}
</div>
<div id="valid-date">
{% bootstrap_field form.valid_date_min layout="control" %}
{% bootstrap_field form.valid_date_max layout="control" %}
</div>
<div id="valid-datetime">
{% bootstrap_field form.valid_datetime_min layout="control" %}
{% bootstrap_field form.valid_datetime_max layout="control" %}
</div>
<div id="valid-string">
{% bootstrap_field form.valid_string_length_min layout="control" %}
{% bootstrap_field form.valid_string_length_max layout="control" %}
</div>
<div id="valid-file">
{% bootstrap_field form.valid_file_portrait layout="control" %}
</div>
<div id="answer-options">
<h3>{% trans "Answer options" %}</h3>
<noscript>
<p>{% trans "Only applicable if you choose 'Choose one/multiple from a list' above." %}</p>
</noscript>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" data-formset-delete-confirm-text="{% trans "If you delete an answer option, you will no longer be able to see statistical data on customers who previously selected this option, and when such customers edit their answers, they need to select a different option." %}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% blocktrans trimmed with id=form.instance.identifier %}
Answer option {{ id }}
{% endblocktrans %}
</span>
{% bootstrap_form_errors form %}
{% bootstrap_field form.answer layout='inline' form_group_class="" %}
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new option" %}</button>
</p>
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% trans "New answer option" %}
</span>
{% bootstrap_field formset.empty_form.answer layout='inline' form_group_class="" %}
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</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 option" %}</button>
</p>
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "Advanced" %}</legend>
{% bootstrap_field form.help_text layout="control" %}
{% bootstrap_field form.identifier layout="control" %}
{% if form.ask_during_checkin %}
{% bootstrap_field form.ask_during_checkin layout="control" %}
{% endif %}
{% if form.show_during_checkin %}
{% bootstrap_field form.show_during_checkin layout="control" %}
{% endif %}
{% bootstrap_field form.hidden layout="control" %}
{% if form.print_on_invoice %}
{% bootstrap_field form.print_on_invoice layout="control" %}
{% endif %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_dependency_question">
{% trans "Question dependency" %}
<br><span class="optional">{% trans "Optional" context "form" %}</span>
</label>
<div class="col-md-4">
{% bootstrap_field form.dependency_question layout="inline" form_group_class="inner" %}
</div>
<div class="col-md-5">
<script type="text/plain" id="dependency_value_val">{{ form.instance.dependency_values|escapejson_dumps }}</script>
{% bootstrap_field form.dependency_values layout="inline" form_group_class="inner" %}
</div>
</div>
</fieldset>
</div>
{% bootstrap_field form.identifier layout="control" %}
{% if form.ask_during_checkin %}
{% bootstrap_field form.ask_during_checkin layout="control" %}
{% endif %}
{% if form.show_during_checkin %}
{% bootstrap_field form.show_during_checkin layout="control" %}
{% endif %}
{% bootstrap_field form.hidden layout="control" %}
{% if form.print_on_invoice %}
{% bootstrap_field form.print_on_invoice layout="control" %}
{% endif %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
@@ -1,41 +0,0 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load static %}
{% load icon %}
{% load compress %}
{% load vite %}
{% block title %}
{% trans "Questionnaires" %}
{% endblock %}
{% block inside %}
<h1>
{% trans "Questionnaires" %}
<a href="{% url "control:event.items.questions" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default pull-right">
{% icon "wrench" %} {% trans "Manage data fields" %}
</a>
</h1>
<p>
{% blocktrans trimmed %}
Questionaires allow your attendees to fill in additional data about their ticket. If you provide food, one
example might be to ask your users about dietary requirements.
{% endblocktrans %}
</p>
<p>
TODO(questionnaires) : add more specific explanation of the questionnaire concept.
</p>
{{ request.event.settings.locales|json_script:"event_locales" }}
{{ questionnaire_type_choices|json_script:"questionnaire_type_choices" }}
{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=0 as datafield_edit_url %}
{{ datafield_edit_url|json_script:"datafield_edit_url" }}
<div id="questionnaires-editor">
<!-- Vue app mount point -->
</div>
{% vite_hmr %}
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/questionnaires/index.ts" %}
{% endblock %}
@@ -1,8 +1,8 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% block title %}{% trans "Data fields" %}{% endblock %}
{% block title %}{% trans "Questions" %}{% endblock %}
{% block inside %}
<h1>{% trans "Data fields" %}</h1>
<h1>{% trans "Questions" %}</h1>
<p>
{% blocktrans trimmed %}
Questions allow your attendees to fill in additional data about their ticket. If you provide food, one
@@ -14,19 +14,19 @@
{% if request.event.settings.feature_flag_order_level_questions %}
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new per-ticket data field" %}
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new per-ticket question" %}
</a>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new order-level data field" %}
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new order-level question" %}
</a>
</p>
{% endif %}
<h2>{% trans "Per-ticket data fields" %}</h2>
<p>{% trans "These data field can be used on individual tickets." %}</p>
<h2>{% trans "Per-ticket questions" %}</h2>
<p>{% trans "These questions are asked for every ticket, so possibly multiple times in the same order." %}</p>
{% else %}
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new data field" %}
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new question" %}
</a>
</p>
{% endif %}
@@ -35,27 +35,39 @@
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Internal name" %}</th>
<th>{% trans "Question" %}</th>
<th>{% trans "Type" %}</th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th>{% trans "Products" %}</th>
{% if 'event.items:write' in request.eventpermset %}
<th class="action-col-2"></th>
{% endif %}
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P">
{% for q in questions %}{% if q.container_type == "P" %}
<tr>
<tr data-dnd-id="{{ q.id }}">
<td>
<strong>
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
{{ q.question }}
</a>
{% if q.pk %}
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
{% endif %}
{{ q.question }}
{% if q.pk %}
</a>
{% endif %}
</strong><br>
<small class="text-muted">{{ q.identifier }}</small>
</td>
<td>
{{ q.get_type_display }}
{% if q.pk %}
{{ q.get_type_display }}
{% else %}
{% trans "System question" %}
{% endif %}
</td>
<td>
{% if q.required %}
@@ -72,11 +84,35 @@
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
{% endif %}
</td>
<td>
{% if q.pk %}
<ul>
{% for item in q.items.all %}
<li>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a>
</li>
{% endfor %}
</ul>
{% else %}
<small>{% trans "All personalized products" %}</small>
{% endif %}
</td>
{% if 'event.items:write' in request.eventpermset %}
<td class="dnd-container">
</td>
{% endif %}
<td class="text-right flip">
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-bar-chart"></i></a>
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% if q.pk %}
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-bar-chart"></i></a>
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
{% else %}
{% if 'event.settings.general:write' in request.eventpermset %}
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
class="btn btn-default btn-sm"><i class="fa fa-wrench"></i></a>
{% endif %}
{% endif %}
</td>
</tr>
@@ -87,31 +123,34 @@
{% if request.event.settings.feature_flag_order_level_questions %}
<h2>
{% trans "Per-order data fields" %}
{% trans "Per-order questions" %}
<small><span class="label label-info" title="
{% trans "This functionality is in active development and expected to change significantly over the coming months." %}
{% trans "In pretixPOS, per-order data fields are currently not supported and will not be displayed." %}
{% trans "Per-order questions are currently not supported and will not be displayed in pretixPOS." %}
" data-toggle="tooltip">
<span class="fa fa-flask" aria-hidden="true"></span>
{% trans "Experimental feature" %}
</span></small>
</h2>
<p>{% trans "These data fields are asked once per order." %}</p>
<p>{% trans "These questions are asked once per order." %}</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Internal name" %}</th>
<th>{% trans "Question" %}</th>
<th>{% trans "Type" %}</th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="iconcol"></th>
{% if 'event.items:write' in request.eventpermset %}
<th class="action-col-2"></th>
{% endif %}
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O">
{% for q in questions %}{% if q.container_type == "O" %}
<tr>
<tr data-dnd-id="{{ q.id }}">
<td>
<strong>
{{ q.question }}
@@ -119,7 +158,11 @@
<small class="text-muted">{{ q.identifier }}</small>
</td>
<td>
{{ q.get_type_display }}
{% if q.pk %}
{{ q.get_type_display }}
{% else %}
{% trans "System question" %}
{% endif %}
</td>
<td>
{% if q.required %}
@@ -136,6 +179,10 @@
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
{% endif %}
</td>
{% if 'event.items:write' in request.eventpermset %}
<td class="dnd-container">
</td>
{% endif %}
<td class="text-right flip">
{% if q.pk %}
{% if 'event.items:write' in request.eventpermset %}
@@ -20,10 +20,8 @@
{% endif %}
{% elif question.type == "M" %}
{{ answer.to_string_i18n|rich_text_snippet }}
{% elif question.type %}
{{ answer.to_string_i18n|rich_text_snippet }}
{% else %}
{{ answer|linebreaksbr }}
{{ answer.to_string_i18n|linebreaksbr }}
{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
@@ -610,9 +610,60 @@
{% endif %}
{% if line.has_questions %}
<dl>
{% if line.item.ask_attendee_data and event.settings.attendee_names_asked %}
<dt>{% trans "Attendee name" %}</dt>
<dd>{% if line.attendee_name %}{{ line.attendee_name_all_components }}{% else %}
<em>{% trans "not answered" %}</em>{% endif %}</dd>
{% endif %}
{% if line.item.ask_attendee_data and event.settings.attendee_emails_asked %}
<dt>{% trans "Attendee email" %}</dt>
<dd>
{% if line.attendee_email %}
{{ line.attendee_email }}
{% if not line.addon_to %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}">
{% csrf_token %}
<a href="{% url "control:event.order.position.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}"
class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
</a>
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>
</form>
{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
</dd>
{% endif %}
{% if line.item.ask_attendee_data and event.settings.attendee_company_asked %}
<dt>
{% trans "Attendee company" %}
</dt>
<dd>
{% if line.company %}{{ line.company }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}
</dd>
{% endif %}
{% if line.item.ask_attendee_data and event.settings.attendee_addresses_asked %}
<dt>
{% trans "Attendee address" %}
</dt>
<dd>
{% if line.street or line.zipcode or line.city or line.country %}
{{ line.street|default_if_none:""|linebreaksbr }}<br>
{{ line.zipcode|default_if_none:"" }} {{ line.city|default_if_none:"" }}<br>
{% if line.state %}{{ line.state_for_address }}<br>{% endif %}
{{ line.country.name|default_if_none:"" }}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
</dd>
{% endif %}
{% for q in line.questions %}
<dt>
{{ q.label }}
{{ q.question }}
{% if q.ask_during_checkin %}
<span class="fa fa-qrcode text-muted"
data-toggle="tooltip"
@@ -621,49 +672,7 @@
{% endif %}
</dt>
<dd>
{#
{% if q.answer %}
{% if q.answer.file %}
<span class="fa fa-file"></span>
<a href="{{ q.answer.backend_file_url }}?token={% answer_token request q.answer %}">
{{ q.answer.file_name }}
</a>
<span class="label label-danger" data-toggle="tooltip"
title="{% trans "This file has been uploaded by a user and could contain viruses or other malicious content." %}">
{% trans "UNSAFE" %}
</span>
{% if q.answer.is_image %}
<br>
<a href="{{ q.answer.backend_file_url }}?token={% answer_token request q.answer %}" data-lightbox="order"
class="answer-thumb">
<img src="{{ q.answer.backend_file_url }}?token={% answer_token request q.answer %}">
</a>
{% endif %}
{% elif q.type == "M" %}
{{ q.answer.to_string_i18n|rich_text_snippet }}
{% elif q.type %}
{{ q.answer.to_string_i18n|linebreaksbr }}
{% else %}<!-- TODO(questionnaires): proper separation of QuestionAnswer objects and system answers...... -->
{{ q.answer|linebreaksbr }}
{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
#}
{% include "pretixcontrol/order/fragment_question_answer.html" with request=request question=q answer=q.answer %}
{% if q.system_datafield == "attendee_email" and not line.addon_to %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}">
{% csrf_token %}
<a href="{% url "control:event.order.position.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}"
class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
</a>
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>
</form>
{% endif %}
</dd>
{% endfor %}
{% for q in line.additional_fields %}
@@ -9,7 +9,23 @@
{% endif %}
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form form layout="control" %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Usage restrictions" %}</legend>
<p>
{% blocktrans trimmed %}
The following restrictions apply whenever the membership is used to buy a product or variation that
is only available to membership holders.
{% endblocktrans %}
</p>
{% bootstrap_field form.transferable layout="control" %}
{% bootstrap_field form.allow_parallel_usage layout="control" %}
{% bootstrap_field form.max_usages layout="control" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
-1
View File
@@ -350,7 +350,6 @@ urlpatterns = [
re_path(r'^questions/(?P<question>\d+)/change$', item.QuestionUpdate.as_view(),
name='event.items.questions.edit'),
re_path(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'),
re_path(r'^questionnaires/$', item.QuestionnairesEditor.as_view(), name='event.items.questionnaires'),
re_path(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'),
re_path(r'^quotas/bulk_action$', item.QuotaBulkAction.as_view(), name='event.items.quotas.bulkaction'),
re_path(r'^quotas/bulk_edit$', item.QuotaBulkUpdateView.as_view(), name='event.items.quotas.bulkedit'),
-3
View File
@@ -163,9 +163,6 @@ class DiscountCreate(EventPermissionRequiredMixin, CreateView):
i = modelcopy(self.copy_from)
i.pk = None
kwargs['instance'] = i
kwargs["initial"]["limit_sales_channels"] = self.copy_from.limit_sales_channels.all()
kwargs["initial"]["condition_limit_products"] = self.copy_from.condition_limit_products.all()
kwargs["initial"]["benefit_limit_products"] = self.copy_from.benefit_limit_products.all()
else:
kwargs['instance'] = Discount(event=self.request.event)
+62 -7
View File
@@ -56,7 +56,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _
from django.views.decorators.http import require_http_methods
from django.views.generic import FormView, ListView, TemplateView, View
from django.views.generic import FormView, ListView, View
from django.views.generic.detail import DetailView, SingleObjectMixin
from django_countries.fields import Country
@@ -65,6 +65,7 @@ from pretix.api.serializers.item import (
ItemVariationSerializer,
)
from pretix.base.forms import I18nFormSet
from pretix.base.forms.questions import get_fake_attendee_questions
from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, LogEntry,
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
@@ -436,7 +437,66 @@ class QuestionList(ListView):
template_name = 'pretixcontrol/items/questions.html'
def get_queryset(self):
return self.request.event.questions
return self.request.event.questions.prefetch_related('items')
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
questions = get_fake_attendee_questions(self.request.event.settings)
questions += list(ctx['questions'])
questions.sort(key=lambda q: q.position)
ctx['questions'] = questions
return ctx
@transaction.atomic
@event_permission_required("event.items:write")
@require_http_methods(["POST"])
def reorder_questions(request, organizer, event):
try:
ids = json.loads(request.body.decode('utf-8'))['ids']
except (JSONDecodeError, KeyError, ValueError):
return HttpResponseBadRequest("expected JSON: {ids:[]}")
qs = request.event.questions.filter(container_type=request.GET['container_type'])
# filter system_questions - normal questions are int/digit, system_questions strings
custom_question_ids = [i for i in ids if i.isdigit()]
input_questions = list(qs.filter(id__in=custom_question_ids))
if len(input_questions) != len(custom_question_ids):
raise Http404(_("Some of the provided object ids are invalid."))
if len(input_questions) != qs.count():
raise Http404(_("Not all objects have been selected."))
for q in input_questions:
pos = ids.index(str(q.pk))
if pos != q.position: # Save unneccessary UPDATE queries
q.position = pos
q.save(update_fields=['position'])
q.log_action(
'pretix.event.question.reordered', user=request.user, data={
'position': pos,
}
)
if request.GET['container_type'] == Question.ContainerType.ORDERPOSITION:
system_question_order = {}
for s in ('attendee_name_parts', 'attendee_email', 'company', 'street', 'zipcode', 'city', 'country'):
if s in ids:
system_question_order[s] = ids.index(s)
else:
system_question_order[s] = -1
request.event.settings.system_question_order = system_question_order
request.event.log_action(
'pretix.event.settings', user=request.user, data={
'system_question_order': system_question_order,
}
)
return HttpResponse()
class QuestionDelete(EventPermissionRequiredMixin, CompatDeleteView):
@@ -706,11 +766,6 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
return ret
class QuestionnairesEditor(EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_items'
template_name = 'pretixcontrol/items/questionnaires.html'
class QuotaQueryMixin:
@cached_property
+2 -4
View File
@@ -144,7 +144,7 @@ class VoucherList(VoucherQueryMixin, PaginationMixin, EventPermissionRequiredMix
headers = [
_('Voucher code'), _('Valid until'), _('Product'), _('Reserve quota'), _('Bypass quota'),
_('Price effect'), _('Value'), _('Tag'), _('Redeemed'), _('Maximum usages'), _('Seat'),
_('Comment'), _('Budget'), _('Budget used')
_('Comment')
]
writer.writerow(headers)
@@ -170,9 +170,7 @@ class VoucherList(VoucherQueryMixin, PaginationMixin, EventPermissionRequiredMix
str(v.redeemed),
str(v.max_usages),
str(v.seat) if v.seat else "",
str(v.comment) if v.comment else "",
str(v.budget) if v.budget is not None else "",
str(v.budget_used) if v.budget is not None else "",
str(v.comment) if v.comment else ""
]
writer.writerow(row)
+7 -7
View File
@@ -8,16 +8,16 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
"PO-Revision-Date: 2026-09-04 14:29+0000\n"
"PO-Revision-Date: 2026-08-24 14:30+0000\n"
"Last-Translator: Albizuri <oier@puntu.eus>\n"
"Language-Team: Basque <https://translate.pretix.eu/projects/pretix/pretix/"
"eu/>\n"
"Language-Team: Basque <https://translate.pretix.eu/projects/pretix/pretix/eu/"
">\n"
"Language: eu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.9\n"
"X-Generator: Weblate 2026.8.1\n"
#: pretix/_base_settings.py
msgid "English"
@@ -891,7 +891,7 @@ msgstr "Produktuaren data"
#: pretix/control/templates/pretixcontrol/order/index.html
#: pretix/presale/templates/pretixpresale/event/order.html
msgid "Order details"
msgstr "Eskaeraren xehetasunak"
msgstr ""
#: pretix/base/datasync/sourcefields.py pretix/base/modelimport_orders.py
#: pretix/control/forms/filter.py
@@ -17296,7 +17296,7 @@ msgstr ""
#: pretix/control/logdisplay.py
msgid "The order details have been changed."
msgstr "Eskaeraren xehetasunak aldatu dira."
msgstr ""
#: pretix/control/logdisplay.py
msgid "The order has been marked as unpaid."
@@ -23106,7 +23106,7 @@ msgstr ""
#: pretix/control/templates/pretixcontrol/order/index.html
#, python-format
msgid "Order details: %(code)s"
msgstr "Eskaeraren xehetasunak: %(code)s"
msgstr ""
#: pretix/control/templates/pretixcontrol/order/index.html
#: pretix/control/templates/pretixcontrol/orders/index.html
+70 -51
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
"PO-Revision-Date: 2026-09-07 18:00+0000\n"
"Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\n"
"PO-Revision-Date: 2026-08-16 22:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix/"
"ja/>\n"
"Language: ja\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 2026.9\n"
"X-Generator: Weblate 2026.8.1\n"
#: pretix/_base_settings.py
msgid "English"
@@ -93,7 +93,7 @@ msgstr "ヘブライ語"
#: pretix/_base_settings.py
msgid "Hungarian"
msgstr "ハンガリー語"
msgstr ""
#: pretix/_base_settings.py
msgid "Indonesian"
@@ -220,7 +220,7 @@ msgstr "ターゲットURL"
#: pretix/api/models.py pretix/base/models/devices.py
#: pretix/base/models/organizer.py
msgid "All events (including newly created ones)"
msgstr "すべてのイベント(今後作成されるものを含む)"
msgstr "すべてのイベント(最近作成されたイベントを含む)"
#: pretix/api/models.py pretix/base/models/devices.py
#: pretix/base/models/organizer.py
@@ -428,8 +428,10 @@ msgid "You cannot exchange a medium for a medium."
msgstr "メディアを別のメディアに変更することはできません。"
#: pretix/api/views/checkin.py
#, fuzzy
#| msgid "Product does not support medium exchange."
msgid "You cannot simulate a medium exchange."
msgstr "メディアの交換をシミュレートすることはできません。"
msgstr "本製品は中程度の交換に対応していません。"
#: pretix/api/views/oauth.py pretix/control/logdisplay.py
#, python-brace-format
@@ -5689,7 +5691,7 @@ msgstr "国コード(ISO 3166-1 alpha-2"
#: pretix/base/models/items.py
msgid "Asked on"
msgstr "質問日"
msgstr ""
#: pretix/base/models/items.py pretix/base/models/organizer.py
msgid ""
@@ -7338,9 +7340,12 @@ msgid "The payment for this invoice has already been received."
msgstr "この請求書の支払いはすでに受領済みです。"
#: pretix/base/payment.py
#, fuzzy
#| msgid ""
#| "This payment is already being processed and can not be canceled any more."
msgid ""
"This payment is already being processed and cannot be canceled any more."
msgstr "この支払いはすでに処理中のため、キャンセルできません。"
msgstr "この支払いは処理中のため、キャンセルできません。"
#: pretix/base/payment.py
msgid "Automatic refunds are not supported by this payment provider."
@@ -8100,12 +8105,16 @@ msgid "Presale end"
msgstr "前売り終了"
#: pretix/base/reldate.py
#, fuzzy
#| msgid "Order email"
msgid "Order creation"
msgstr "注文の作成"
msgstr "注文者メール"
#: pretix/base/reldate.py
#, fuzzy
#| msgid "Order expired"
msgid "Order expiry"
msgstr "注文の失効"
msgstr "注文の有効期限が切れました"
#: pretix/base/reldate.py
msgid "before"
@@ -8134,22 +8143,22 @@ msgstr "未設定"
#: pretix/base/reldate.py
#, python-brace-format
msgid "A relative date cannot be expressed as \"before\" for \"{}\""
msgstr "相対的な日付は、「{}」に対して「before」で表すことはできません"
msgstr ""
#: pretix/base/reldate.py
#, python-brace-format
msgid "A relative date cannot be expressed as \"after\" for \"{}\""
msgstr "相対的な日付は「{}」に対して「after」として表すことはできません"
msgstr ""
#: pretix/base/reldate.py
#, python-brace-format
msgid "A relative time cannot be expressed as \"before\" for \"{}\""
msgstr "相対時間は「{}」に対して「before」で表すことはできません"
msgstr ""
#: pretix/base/reldate.py
#, python-brace-format
msgid "A relative time cannot be expressed as \"after\" for \"{}\""
msgstr "相対時間は「{}」に対して「after」で表すことはできません"
msgstr ""
#: pretix/base/secrets.py
msgid "Random (default, works with all pretix apps)"
@@ -10058,12 +10067,12 @@ msgstr ""
#: pretix/base/settings.py
msgid "No dates match your criteria."
msgstr "条件に合致する日付はありません。"
msgstr ""
#: pretix/base/settings.py
msgctxt "subevents"
msgid "Text for empty date results"
msgstr "空の日付結果のテキスト"
msgstr ""
#: pretix/base/settings.py
msgctxt "subevents"
@@ -10074,10 +10083,6 @@ msgid ""
"touch with you to arrange further dates. We do not recommend more than one "
"or two sentences."
msgstr ""
"このテキストは、カレンダーまたは日付リストが空の場合に表示されます。たとえば"
"、月に日付が含まれていない場合や、ユーザーが選択したフィルターに結果が見つか"
"らない場合です。これをご利用いただくことで、今後の日程調整のために連絡する方"
"法を宣伝できます。1文または2文以上は推奨いたしません。"
#: pretix/base/settings.py
msgid "Guidance text"
@@ -13897,20 +13902,26 @@ msgid ""
msgstr "ギフトカードの有効期限を発行年を含めた{}年に設定しました。"
#: pretix/control/forms/event.py
#, fuzzy
#| msgid "Payment date"
msgid "Payment term"
msgstr "支払条件"
msgstr "支払い日"
#: pretix/control/forms/event.py
msgid "same as above"
msgstr "上記と同じ"
msgstr ""
#: pretix/control/forms/event.py
#, fuzzy
#| msgid "Payment term in days"
msgid "different payment term in days"
msgstr "日数で表す異なる支払条件"
msgstr "支払条件(日数)"
#: pretix/control/forms/event.py
#, fuzzy
#| msgid "Payment term in minutes"
msgid "different payment term in minutes"
msgstr "分で表す異なる支払条件"
msgstr "支払い期限(分単位)"
#: pretix/control/forms/event.py
msgid "Prices including tax"
@@ -22522,24 +22533,28 @@ msgstr ""
"食事を提供する場合、ユーザーに食事制限について尋ねることができる一例です。"
#: pretix/control/templates/pretixcontrol/items/questions.html
#, fuzzy
#| msgid "Create a new question"
msgid "Create a new per-ticket question"
msgstr "チケットごとに新しい質問を作成"
msgstr "新しい質問を作成"
#: pretix/control/templates/pretixcontrol/items/questions.html
#, fuzzy
#| msgid "Create a new question"
msgid "Create a new order-level question"
msgstr "注文ごとに新しい質問を作成"
msgstr "新しい質問を作成"
#: pretix/control/templates/pretixcontrol/items/questions.html
#, fuzzy
#| msgid "Delete question"
msgid "Per-ticket questions"
msgstr "チケットごとの質問"
msgstr "質問を削除"
#: pretix/control/templates/pretixcontrol/items/questions.html
msgid ""
"These questions are asked for every ticket, so possibly multiple times in "
"the same order."
msgstr ""
"これらの質問はすべてのチケットに対して尋ねられるため、同じ順序で複数回になる"
"可能性があります。"
#: pretix/control/templates/pretixcontrol/items/questions.html
msgid "Create a new question"
@@ -22558,24 +22573,28 @@ msgid "All personalized products"
msgstr "すべてのパーソナライズされた製品"
#: pretix/control/templates/pretixcontrol/items/questions.html
#, fuzzy
#| msgid "Include questions"
msgid "Per-order questions"
msgstr "注文ごとの質問"
msgstr "質問を含む"
#: pretix/control/templates/pretixcontrol/items/questions.html
msgid ""
"This functionality is in active development and expected to change "
"significantly over the coming months."
msgstr "この機能は現在開発が活発で、今後数か月で大幅に変化すると予想されています。"
msgstr ""
#: pretix/control/templates/pretixcontrol/items/questions.html
msgid ""
"Per-order questions are currently not supported and will not be displayed in "
"pretixPOS."
msgstr "注文ごとの質問は現在サポートされておらず、pretixPOSでは表示されません。"
msgstr ""
#: pretix/control/templates/pretixcontrol/items/questions.html
#, fuzzy
#| msgid "The question has been reordered."
msgid "These questions are asked once per order."
msgstr "これらの質問は、注文ごとに1回ずつ尋ねられます。"
msgstr "質問の並び順が変更されました。"
#: pretix/control/templates/pretixcontrol/items/quota.html
#: pretix/control/templates/pretixcontrol/items/quota_edit.html
@@ -23167,8 +23186,10 @@ msgstr "(任意)"
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html
#: pretix/presale/templates/pretixpresale/event/checkout_questions.html
#: pretix/presale/templates/pretixpresale/event/order_modify.html
#, fuzzy
#| msgid "Additional information"
msgid "Additional order information"
msgstr "追加の注文情報"
msgstr "追加情報"
#: pretix/control/templates/pretixcontrol/order/delete.html
msgid "Delete order"
@@ -24835,13 +24856,14 @@ msgid "Hardware model"
msgstr "ハードウェアの機種"
#: pretix/control/templates/pretixcontrol/organizers/devices.html
#, python-format
#, fuzzy, python-format
#| msgid "Begin: %(time)s"
msgid "Last seen: %(time)s"
msgstr "最後の閲覧: %(time)s"
msgstr "開始: %(time)s"
#: pretix/control/templates/pretixcontrol/organizers/devices.html
msgid "No recent contact"
msgstr "最近の連絡なし"
msgstr ""
#: pretix/control/templates/pretixcontrol/organizers/devices.html
msgid "Not yet initialized"
@@ -28198,14 +28220,19 @@ msgstr ""
"に高いことを意味します。ドメインのDNS設定を更新すべきです。"
#: pretix/control/views/mailsetup.py
#, fuzzy
#| msgid ""
#| "We did not find DMARC record for your domain. This means that there is a "
#| "very high chance most of the emails will be rejected or marked as spam. "
#| "You should update the DNS settings of your domain."
msgid ""
"We did not find a DMARC record for your domain. This means that there is a "
"very high chance most of the emails will be rejected or marked as spam. You "
"should update the DNS settings of your domain."
msgstr ""
"あなたのドメインのDMARCレコードが見つかりませんでした。これは、ほとんどの"
"メールが拒否されたスパムとしてマークされた可能性が非常に高いことを意味し"
"す。ドメインのDNS設定を更新すべきです。"
"お客様のドメインのDMARCレコードが見つかりませんでした。これは、ほとんどのメー"
"ルが拒否されたスパムとしてマークされたりする可能性が非常に高いことを意味し"
"す。ドメインのDNS設定を更新すべきです。"
#: pretix/control/views/mailsetup.py
msgid "The verification code was incorrect, please try again."
@@ -30879,7 +30906,7 @@ msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid "Allow further payments during compliance hold"
msgstr "コンプライアンス保留の間、追加の支払いを許可する"
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid ""
@@ -30889,20 +30916,16 @@ msgid ""
"attempts during that window. This might result in them being charged twice "
"if the original payment is approved."
msgstr ""
"PayPalの不正防止は、個々の支払いの処理をかなりの期間ブロックする可能性があり"
"ます。この期間中、支払いは「保留中」とマークされています。顧客がその期間中に"
"別の支払い試行を開始できるように許可することができます。元の支払いが承認され"
"た場合、二重に請求される可能性があります。"
#: pretix/plugins/paypal2/payment.py
msgid "Timeout further payment attempts"
msgstr "追加の支払い試行がタイムアウト"
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid ""
"Time duration in minutes after which another payment attempt is possible, "
"while the last payment is still under investigation."
msgstr "最後の支払いがまだ調査中である間、別の支払い試行が可能となる時間(分)。"
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid "-- Automatic --"
@@ -31167,10 +31190,6 @@ msgid ""
"twice in case PayPal allows your initial payment attempt. Please contact us "
"to resolve this case."
msgstr ""
"支払いはPayPalで処理されています。通常より時間がかかります。PayPalが支払いを"
"確認するまでお待ちいただくか、こちらまたは別の支払い方法で再度お支払いをお試"
"しください。PayPalが最初の支払い試行を許可した場合、二重に請求される可能性が"
"あります。この件を解決するために、弊社までご連絡ください。"
#: pretix/plugins/paypal2/views.py
msgid ""
+6 -11
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
"PO-Revision-Date: 2026-09-02 08:35+0000\n"
"PO-Revision-Date: 2026-03-30 21:00+0000\n"
"Last-Translator: Renne Rocha <renne@rocha.dev.br>\n"
"Language-Team: Portuguese (Brazil) <https://translate.pretix.eu/projects/"
"pretix/pretix/pt_BR/>\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 2026.8.1\n"
"X-Generator: Weblate 5.16.2\n"
#: pretix/_base_settings.py
msgid "English"
@@ -93,7 +93,7 @@ msgstr "Hebraico"
#: pretix/_base_settings.py
msgid "Hungarian"
msgstr "Húngaro"
msgstr ""
#: pretix/_base_settings.py
msgid "Indonesian"
@@ -153,7 +153,7 @@ msgstr "Espanhol (América latina)"
#: pretix/_base_settings.py
msgid "Thai"
msgstr "Tailandês"
msgstr ""
#: pretix/_base_settings.py
msgid "Turkish"
@@ -3022,12 +3022,11 @@ msgid ""
"The field \"%(label)s\" may not contain special characters such as "
"\"%(chars)s\"."
msgstr ""
"O campo \"%(label)s\" não pode conter caracteres especiais como %(chars)s\"."
#: pretix/base/forms/questions.py
#, python-format
msgid "The field \"%(label)s\" may not contain an URL (%(url)s)."
msgstr "O campo \"%(label)s\" não pode conter uma URL (%(url)s)."
msgstr ""
#: pretix/base/forms/questions.py
msgctxt "phonenumber"
@@ -8174,7 +8173,7 @@ msgstr "Permitida"
#: pretix/base/permissions.py
msgctxt "permission_level"
msgid "Access existing events"
msgstr "Acessar eventos existentes"
msgstr ""
#: pretix/base/permissions.py
msgctxt "permission_level"
@@ -13608,15 +13607,11 @@ msgstr "Por favor, continue em uma nova aba"
#: pretix/base/templates/pretixbase/framebreak.html
msgid "For security reasons, the following step is only possible in a new tab."
msgstr ""
"Por razões de segurança, o passo seguinte só é possível de ser feito em uma "
"nova aba."
#: pretix/base/templates/pretixbase/framebreak.html
msgid ""
"If the new tab did not open automatically, please click the following button:"
msgstr ""
"Se uma nova aba não abrir automaticamente, por favor, clique no botão a "
"seguir:"
#: pretix/base/templates/pretixbase/framebreak.html
#: pretix/presale/templates/pretixpresale/event/cookies.html
+4 -5
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
"PO-Revision-Date: 2026-09-04 14:29+0000\n"
"Last-Translator: Linnea Thelander <linnea@coeo.events>\n"
"PO-Revision-Date: 2026-08-09 06:00+0000\n"
"Last-Translator: Julien <julien@circusiloveyou.com>\n"
"Language-Team: Swedish <https://translate.pretix.eu/projects/pretix/pretix/"
"sv/>\n"
"Language: sv\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.9\n"
"X-Generator: Weblate 2026.8.1\n"
#: pretix/_base_settings.py
msgid "English"
@@ -19170,9 +19170,8 @@ msgstr ""
#: pretix/presale/templates/pretixpresale/event/order_pay.html
#: pretix/presale/templates/pretixpresale/event/order_pay_change.html
#: pretix/presale/templates/pretixpresale/event/position_change.html
#, fuzzy
msgid "Continue"
msgstr "Fortsätt"
msgstr "Fortsätta"
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
msgid "Authorize an application"
+4 -5
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-06 15:52+0000\n"
"PO-Revision-Date: 2026-09-04 14:29+0000\n"
"Last-Translator: Linnea Thelander <linnea@coeo.events>\n"
"PO-Revision-Date: 2026-08-09 06:00+0000\n"
"Last-Translator: Julien <julien@circusiloveyou.com>\n"
"Language-Team: Swedish <https://translate.pretix.eu/projects/pretix/pretix-"
"js/sv/>\n"
"Language: sv\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.9\n"
"X-Generator: Weblate 2026.8.1\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js
msgid "Marked as paid"
@@ -133,9 +133,8 @@ msgstr "Mercado Pago"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
#: pretix/static/pretixpresale/js/ui/cart.js
#, fuzzy
msgid "Continue"
msgstr "Fortsätt"
msgstr "Fortsätta"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js
+9 -1
View File
@@ -55,6 +55,7 @@ from django.db.models.functions import Cast, Coalesce
from django.utils.timezone import make_aware
from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
from pypdf import PageObject, PdfReader, PdfWriter, Transformation
from pypdf.generic import RectangleObject
from reportlab.lib import pagesizes
from reportlab.lib.units import inch, mm
from reportlab.pdfgen import canvas
@@ -237,8 +238,15 @@ def _render_nup_page(nup_pdf: PdfWriter, input_pages: PageObject, opt: dict) ->
di = i % badges_per_page
tx = opt['margins'][3] + (di % opt['cols']) * opt['offsets'][0]
ty = opt['margins'][2] + (opt['rows'] - 1 - (di // opt['cols'])) * opt['offsets'][1]
page.add_transformation(Transformation().translate(tx, ty))
page.mediabox = RectangleObject((
Decimal('%.5f' % (page.mediabox.left.as_numeric() + tx)),
Decimal('%.5f' % (page.mediabox.bottom.as_numeric() + ty)),
Decimal('%.5f' % (page.mediabox.right.as_numeric() + tx)),
Decimal('%.5f' % (page.mediabox.top.as_numeric() + ty))
))
page.trimbox = page.cropbox = page.mediabox
nup_page.merge_transformed_page(page, Transformation().translate(tx, ty))
nup_page.merge_page(page)
return nup_page
+3 -15
View File
@@ -55,8 +55,6 @@ from django_countries.fields import Country
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.units import mm
from reportlab.lib.utils import simpleSplit
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import PageBreak, Spacer, Table, TableStyle
@@ -228,20 +226,10 @@ class ReportlabExportMixin:
def page_header(self, canvas, doc):
from reportlab.lib.units import mm
font_name = 'OpenSans'
font_size = 10
left_string = self.get_left_header_string()
right_string = self.get_right_header_string()
right_width = stringWidth(right_string, font_name, font_size)
max_left_width = self.pagesize[0] - doc.leftMargin - doc.rightMargin - right_width - 5 * mm
left_string_lines = simpleSplit(left_string, font_name, font_size, max_left_width)
if len(left_string_lines) > 1:
left_string = left_string_lines[0] + ""
canvas.setFont(font_name, font_size)
canvas.drawString(doc.leftMargin, self.pagesize[1] - 15 * mm, left_string)
canvas.setFont('OpenSans', 10)
canvas.drawString(doc.leftMargin, self.pagesize[1] - 15 * mm, self.get_left_header_string())
canvas.drawRightString(self.pagesize[0] - doc.rightMargin, self.pagesize[1] - 15 * mm,
right_string)
self.get_right_header_string())
canvas.setStrokeColorRGB(0, 0, 0)
canvas.line(doc.leftMargin, self.pagesize[1] - 17 * mm,
self.pagesize[0] - doc.rightMargin, self.pagesize[1] - 17 * mm)
+2 -2
View File
@@ -355,9 +355,9 @@ class OrderSendView(BaseSenderView):
statusq |= Q(status=Order.STATUS_PENDING, require_approval=False, valid_if_pending=True)
orders = qs.filter(statusq)
opq = OrderPosition.objects.with_scopes_disabled().filter(
opq = OrderPosition.objects.filter(
Q(item_id__in=[i.pk for i in form.cleaned_data.get('items')]) | Q(Exists(
OrderPosition.objects.with_scopes_disabled().filter(
OrderPosition.objects.filter(
addon_to_id=OuterRef('pk'),
item_id__in=[i.pk for i in form.cleaned_data.get('items')]
)
+89 -40
View File
@@ -98,9 +98,7 @@ from pretix.presale.signals import (
question_form_fields_overrides,
)
from pretix.presale.utils import customer_login
from pretix.presale.views import (
CartMixin, get_cart_is_free, get_cart_positions,
)
from pretix.presale.views import CartMixin, get_cart, get_cart_is_free
from pretix.presale.views.cart import (
_items_from_post_data, cart_session, create_empty_cart_id,
get_or_create_cart_id,
@@ -250,7 +248,19 @@ class TemplateFlowStep(TemplateResponseMixin, BaseCheckoutFlowStep):
raise NotImplementedError()
class CustomerStep(CartMixin, TemplateFlowStep):
class RecomputeMixin:
def recompute_cart(self):
cm = CartManager(
event=self.request.event,
cart_id=get_or_create_cart_id(self.request),
invoice_address=self.invoice_address,
sales_channel=self.request.sales_channel,
customer=self.cart_customer,
)
return cm.recompute_final_prices_and_taxes()
class CustomerStep(CartMixin, RecomputeMixin, TemplateFlowStep):
priority = 45
identifier = "customer"
template_name = "pretixpresale/event/checkout_customer.html"
@@ -328,28 +338,34 @@ class CustomerStep(CartMixin, TemplateFlowStep):
customer_login(self.request, customer)
return True
def _proceed_to_next(self):
diff = self.recompute_cart()
if abs(diff) > Decimal('0.001'):
messages.info(self.request, _('Due to the information you entered, the total of your cart has changed.'))
return redirect_to_url(self.get_next_url(self.request))
def post(self, request):
self.request = request
if request.POST.get("customer_mode") == 'login':
if self.cart_session.get('customer'):
return redirect_to_url(self.get_next_url(request))
return self._proceed_to_next()
elif request.customer:
self.cart_session['customer_mode'] = 'login'
self.cart_session['customer'] = request.customer.pk
self.cart_session['customer_cart_tied_to_login'] = True
return redirect_to_url(self.get_next_url(request))
return self._proceed_to_next()
elif self.request.POST.get("login-sso-data"):
if not self._handle_sso_login():
messages.error(request, _('We failed to process your authentication request, please try again.'))
return self.render()
return redirect_to_url(self.get_next_url(request))
return self._proceed_to_next()
elif self.event.settings.customer_accounts_native and self.login_form.is_valid():
customer_login(self.request, self.login_form.get_customer())
self.cart_session['customer_mode'] = 'login'
self.cart_session['customer'] = self.login_form.get_customer().pk
self.cart_session['customer_cart_tied_to_login'] = True
return redirect_to_url(self.get_next_url(request))
return self._proceed_to_next()
else:
return self.render()
elif request.POST.get("customer_mode") == 'register' and self.signup_allowed:
@@ -358,13 +374,13 @@ class CustomerStep(CartMixin, TemplateFlowStep):
self.cart_session['customer_mode'] = 'login'
self.cart_session['customer'] = customer.pk
self.cart_session['customer_cart_tied_to_login'] = False
return redirect_to_url(self.get_next_url(request))
return self._proceed_to_next()
else:
return self.render()
elif request.POST.get("customer_mode") == 'guest' and self.guest_allowed:
self.cart_session['customer'] = None
self.cart_session['customer_mode'] = 'guest'
return redirect_to_url(self.get_next_url(request))
return self._proceed_to_next()
else:
return self.render()
@@ -499,7 +515,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
self.request = request
# check whether addons are applicable
if get_cart_positions(request).filter(item__addons__isnull=False).exists():
if get_cart(request).filter(item__addons__isnull=False).exists():
return True
# don't re-check whether cross-selling is applicable if we're already past the AddOnsStep
@@ -772,12 +788,18 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
add_to_cart_items = _items_from_post_data(self.request, warn_if_empty=False)
if 'customer_mode' in self.cart_session:
cart_customer = self.cart_customer
else:
cart_customer = getattr(self.request, 'customer', None)
return self.do(self.request.event.id, addons, add_to_cart_items, get_or_create_cart_id(self.request),
invoice_address=self.invoice_address.pk, locale=get_language(),
sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None))
sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None),
customer=cart_customer.pk if cart_customer else None)
class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
class QuestionsStep(CartQuestionsViewMixin, RecomputeMixin, CartMixin, TemplateFlowStep):
priority = 50
identifier = "questions"
template_name = "pretixpresale/event/checkout_questions.html"
@@ -988,6 +1010,7 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
def post(self, request):
self.request = request
failed = not self.save() or not self.contact_form.is_valid()
recomputed = False
if self.address_asked or self.request.event.settings.invoice_name_required:
failed = failed or not self.invoice_form.is_valid()
if failed:
@@ -1032,13 +1055,8 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
self.cart_session['saved_invoice_address'] = saved.pk
try:
cm = CartManager(
event=self.request.event,
cart_id=get_or_create_cart_id(request),
invoice_address=addr,
sales_channel=request.sales_channel,
)
diff = cm.recompute_final_prices_and_taxes()
diff = self.recompute_cart()
recomputed = True
except TaxRule.SaleNotAllowed:
messages.error(request,
_("Unfortunately, based on the invoice address you entered, we're not able to sell you "
@@ -1047,15 +1065,23 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
self.cart_session['invoice_address'] = addr.pk
if abs(diff) > Decimal('0.001'):
messages.info(request, _('Due to the invoice address you entered, we need to apply a different tax '
'rate to your purchase and the price of the products in your cart has '
'changed accordingly.'))
messages.info(request, _('Due to the information you entered, tax rates or discounts on your purchase '
'have been changed and the price of the products in your cart has changed '
'accordingly.'))
return redirect_to_url(self.get_next_url(request) + '?open_cart=true')
elif 'invoice_address' in self.cart_session:
# Invoice address was there, but is no longer asked for
self.invoice_address.delete()
del self.cart_session['invoice_address']
if self.cart_customer and not recomputed and any(cp.item.validity_dynamic_start_choice for cp in self.positions):
# Discounts might have changed
diff = self.recompute_cart()
if abs(diff) > Decimal('0.001'):
messages.info(request, _('Due to the information you entered, tax rates or discounts on your purchase '
'have been changed and the price of the products in your cart has changed '
'accordingly.'))
try:
validate_memberships_in_order(self.cart_customer, self.positions, self.request.event, lock=False,
testmode=self.request.event.testmode, valid_from_not_chosen=False)
@@ -1092,18 +1118,26 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
return False
for cp in self._positions_for_questions:
qc_cache = {
qc.pk: qc for qq in cp.item.relevant_questionnaires for qc in qq.childlist
answ = {
aw.question_id: aw for aw in cp.answerlist
}
question_cache = {
q.pk: q for q in cp.item.questions_to_ask
}
def question_is_visible(parentid, qvals):
if parentid not in qc_cache:
if parentid not in question_cache:
return False
parentqc = qc_cache[parentid]
if parentqc.dependency_question_id and not question_is_visible(parentqc.dependency_question_id, parentqc.dependency_values):
parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
return False
answer_values = cp.get_dependency_answer_values(parentqc)
return any(qval in answer_values for qval in qvals)
if parentid not in answ:
return False
return (
('True' in qvals and answ[parentid].answer == 'True')
or ('False' in qvals and answ[parentid].answer == 'False')
or (any(qval in [o.identifier for o in answ[parentid].options.all()] for qval in qvals))
)
def question_is_required(q):
return (
@@ -1112,16 +1146,31 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
)
if not self.all_optional:
for qq in cp.item.relevant_questionnaires:
for qc in qq.childlist:
if qc.user_datafield_id and question_is_required(qc) and qc.user_datafield_id not in cp.answer_cache:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if qc.system_datafield and question_is_required(qc) and not cp.get_system_answer(qc.system_datafield):
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
for q in cp.item.questions_to_ask:
if question_is_required(q) and q.id not in answ:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_names_required', as_type=bool) \
and not cp.attendee_name_parts:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_emails_required', as_type=bool) \
and cp.attendee_email is None:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_company_required', as_type=bool) \
and cp.company is None:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_addresses_required', as_type=bool) \
and (cp.street is None and cp.city is None and cp.country is None):
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
responses = question_form_fields.send(sender=self.request.event, position=cp)
form_data = cp.meta_info_data.get('question_form_data', {})
+131 -32
View File
@@ -21,7 +21,8 @@
#
import sys
from datetime import datetime
from typing import Optional
from decimal import Decimal
from typing import List, Optional, Union
from django.conf import settings
from django.db.models import (
@@ -29,11 +30,13 @@ from django.db.models import (
)
from django.db.models.lookups import Exact
from pretix.base.decimal import round_decimal
from pretix.base.models import (
ItemVariation, Quota, SalesChannel, SeatCategoryMapping,
Discount, Event, Item, ItemVariation, Membership, Quota, SalesChannel,
SeatCategoryMapping, SubEvent, Voucher,
)
from pretix.base.models.items import (
Item, ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timemachine import time_machine_now
@@ -54,10 +57,44 @@ def item_group_by_category(items):
)
def _single_item_discounts(event: Event, sales_channel: Union[str, SalesChannel],
subevent: SubEvent=None, voucher: Voucher=None, is_addons=False,
memberships: List[Membership]=None) -> List[Discount]:
if memberships is not None:
prefetch_membership_types = ['require_membership_types']
else:
prefetch_membership_types = []
discount_qs = event.discounts.filter(
Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()),
Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()),
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=sales_channel),
Q(active=True),
# Only discounts that can be applied before we know the full cart
Q(benefit_only_apply_to_cheapest_n_matches__isnull=True) | Q(benefit_only_apply_to_cheapest_n_matches=1),
benefit_same_products=True,
condition_min_value=Decimal("0.00"),
condition_min_count=1,
).prefetch_related('condition_limit_products', *prefetch_membership_types).order_by('position', 'pk')
if subevent:
discount_qs = discount_qs.filter(
Q(subevent_date_from__isnull=True) | Q(subevent_date_from__lte=subevent.date_from),
Q(subevent_date_until__isnull=True) | Q(subevent_date_until__gte=subevent.date_from),
)
if is_addons:
discount_qs = discount_qs.filter(condition_apply_to_addons=True, benefit_apply_to_addons=True)
if voucher and voucher.price_mode != "none":
discount_qs = discount_qs.filter(condition_ignore_voucher_discounted=False, benefit_ignore_voucher_discounted=False)
return list(discount_qs)
def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, voucher=None, require_seat=0, base_qs=None,
allow_addons=False, allow_cross_sell=False,
quota_cache=None, filter_items=None, filter_categories=None, memberships=None,
ignore_hide_sold_out_for_item_ids=None):
ignore_hide_sold_out_for_item_ids=None, _discount_cache=None):
base_qs_set = base_qs is not None
base_qs = base_qs if base_qs is not None else event.items
@@ -190,6 +227,24 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
if filter_categories:
items = items.filter(category_id__in=[a for a in filter_categories if a.isdigit()])
# We pre-computate discounts that do not rely on specific combinations in the cart.
# This is not the same order of operations that is applied in the cart, so there could be some differences
# when it comes to tax rate handling, but we don't have that information in the product list anyway, so
# that is acceptable.
cache_key = (event, channel, voucher, subevent, allow_addons)
if _discount_cache is None:
_discount_cache = {}
if cache_key not in _discount_cache:
_discount_cache[cache_key] = list(_single_item_discounts(
event=event,
sales_channel=channel,
voucher=voucher,
subevent=subevent,
is_addons=allow_addons,
memberships=memberships,
))
discounts = _discount_cache[cache_key]
display_add_to_cart = False
quota_cache_key = f'item_quota_cache:{subevent.id if subevent else 0}:{channel.identifier}:{bool(require_seat)}'
quota_cache = quota_cache or event.cache.get(quota_cache_key) or {}
@@ -272,6 +327,11 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
if resp:
item.description += ("<br/>" if item.description else "") + resp
matching_discounts = [
d for d in discounts
if d.condition_matches_product(item) and d.valid_for_memberships(memberships, event, subevent, item)
]
if not item.has_variations:
item._remove = False
if not bool(item._subevent_quotas):
@@ -299,30 +359,51 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
max_per_order
)
original_price = item_price_override.get(item.pk, item.default_price)
configured_price = item_price_override.get(item.pk, item.default_price)
voucher_reduced = False
if voucher:
price = voucher.calculate_price(original_price)
voucher_reduced = price < original_price
price = voucher.calculate_price(configured_price)
voucher_reduced = price < configured_price
include_bundled = not voucher.all_bundles_included
else:
price = original_price
price = configured_price
include_bundled = True
item.display_price = item.tax(price, currency=event.currency, include_bundled=include_bundled)
if matching_discounts and not item.free_price:
# Discounts and free prices are a non-recommended combination that behaves unintuitively, so we can
# accept it not being handled here.
discount = matching_discounts[0] # First matching discount rule always wins
# First handle taxes because discount is always computed on gross
taxed_price = item.tax(price, currency=event.currency, include_bundled=include_bundled)
price_without_bundles = taxed_price.gross - sum(b.count * b.designated_price for b in item.bundles.all())
discounted_price = round_decimal(
taxed_price.gross - price_without_bundles * discount.benefit_discount_matching_percent / Decimal('100.00'),
event.currency,
)
item.display_price = item.tax(discounted_price, currency=event.currency, include_bundled=include_bundled,
base_price_is='gross')
else:
discount = None
item.display_price = item.tax(price, currency=event.currency, include_bundled=include_bundled)
if item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
item.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, include_bundled=include_bundled)
else:
item.suggested_price = item.display_price
if price != original_price:
item.original_price = item.tax(original_price, currency=event.currency, include_bundled=True)
else:
item.original_price = (
item.tax(item.original_price, currency=event.currency, include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
if item.original_price else None
if voucher_reduced or (discount and not item.original_price):
# If a voucher is used, we use the non-voucher price as the "original price", even if a different
# original price is set, to highlight the voucher's impact. We also use the configured price as
# original price if a discount is applied and no explicit original price is set
item.original_price = item.tax(configured_price, currency=event.currency, include_bundled=True)
elif item.original_price:
item.original_price = item.tax(
item.original_price, currency=event.currency, include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross' # backwards-compat
)
else:
item.original_price = None
if not display_add_to_cart:
display_add_to_cart = not item.requires_seat and item.order_max > 0
else:
@@ -352,35 +433,53 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
max_per_order
)
original_price = var_price_override.get(var.pk, var.price)
configured_price = var_price_override.get(var.pk, var.price)
voucher_reduced = False
if voucher:
price = voucher.calculate_price(original_price)
voucher_reduced = price < original_price
price = voucher.calculate_price(configured_price)
voucher_reduced = price < configured_price
include_bundled = not voucher.all_bundles_included
else:
price = original_price
price = configured_price
include_bundled = True
var.display_price = var.tax(price, currency=event.currency, include_bundled=include_bundled)
if matching_discounts and not item.free_price:
# Discounts and free prices are a non-recommended combination that behaves unintuitively, so we can
# accept it not being handled here.
discount = matching_discounts[0] # First matching discount rule always wins
# First handle taxes because discount is always computed on gross
taxed_price = var.tax(price, currency=event.currency, include_bundled=include_bundled)
price_without_bundles = taxed_price.gross - sum(b.count * b.designated_price for b in item.bundles.all())
discounted_price = round_decimal(
taxed_price.gross - price_without_bundles * discount.benefit_discount_matching_percent / Decimal('100.00'),
event.currency,
)
var.display_price = var.tax(discounted_price, currency=event.currency,
include_bundled=include_bundled,
base_price_is='gross')
else:
discount = None
var.display_price = var.tax(price, currency=event.currency, include_bundled=include_bundled)
if item.free_price and var.free_price_suggestion is not None and not voucher_reduced:
var.suggested_price = item.tax(max(price, var.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
var.suggested_price = var.tax(max(price, var.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
elif item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
var.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
var.suggested_price = var.tax(max(price, item.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
else:
var.suggested_price = var.display_price
if price != original_price:
var.original_price = var.tax(original_price, currency=event.currency, include_bundled=True)
if voucher_reduced or (discount and not item.original_price and not var.original_price):
var.original_price = var.tax(configured_price, currency=event.currency, include_bundled=True)
elif item.original_price or var.original_price:
var.original_price = var.tax(
var.original_price or item.original_price, currency=event.currency,
include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross' # backwards-compat
)
else:
var.original_price = (
var.tax(var.original_price or item.original_price, currency=event.currency,
include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
) if var.original_price or item.original_price else None
var.original_price = None
var.current_unavailability_reason = _get_variant_unavailability_reason(var, has_voucher=voucher, subevent=subevent)
@@ -204,7 +204,7 @@
{% endif %}
{% endif %}
{% for q in line.questions %}
<dt>{{ q.label }}</dt>
<dt>{{ q.question }}</dt>
<dd>
{% include "pretixpresale/event/fragment_question_answer.html" with request=request question=q answer=q.answer %}
</dd>
@@ -3,7 +3,7 @@
{% load escapejson %}
{% if payment_qr_codes %}
<div class="tabcontainer col-md-6 col-sm-6 col-xs-12 text-center js-only blank-after">
<div class="tabcontainer col-md-6 col-sm-6 hidden-xs text-center js-only blank-after">
<div id="banktransfer_qrcodes_tabs_content" class="tabpanels blank-after">
{% for code_info in payment_qr_codes %}
<div id="banktransfer_qrcodes_{{ code_info.id }}"
+17 -28
View File
@@ -41,7 +41,7 @@ from itertools import groupby
from django.conf import settings
from django.contrib import messages
from django.db.models import Exists, OuterRef, Prefetch, Q, Sum
from django.db.models import Exists, OuterRef, Prefetch, Sum
from django.utils import translation
from django.utils.functional import cached_property
from django.utils.timezone import now
@@ -54,7 +54,6 @@ from pretix.base.models import (
CartPosition, Customer, InvoiceAddress, ItemAddOn, OrderFee, Question,
QuestionAnswer, QuestionOption, TaxRule,
)
from pretix.base.models.items import QuestionnaireChild
from pretix.base.models.orders import CheckoutSession
from pretix.base.services.cart import get_fees
from pretix.base.services.pricing import apply_rounding
@@ -96,7 +95,7 @@ class CartMixin:
"""
A list of this users cart position
"""
return list(get_cart_positions(self.request))
return list(get_cart(self.request))
@cached_property
def cart_session(self):
@@ -400,7 +399,7 @@ def cart_exists(request):
return bool(request._cart_cache)
def get_cart_positions(request):
def get_cart(request):
from pretix.presale.views.cart import get_or_create_cart_id
if not hasattr(request, '_cart_cache'):
@@ -408,11 +407,8 @@ def get_cart_positions(request):
if not cart_id:
request._cart_cache = CartPosition.objects.none()
else:
qqs = request.event.questionnaires.all()
qqs = qqs.filter(
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=request.sales_channel.identifier),
type='PS'
)
qqs = request.event.questions.all()
qqs = qqs.filter(ask_during_checkin=False, hidden=False, container_type=Question.ContainerType.ORDERPOSITION)
request._cart_cache = CartPosition.objects.filter(
cart_id=cart_id, event=request.event
).annotate(
@@ -433,23 +429,18 @@ def get_cart_positions(request):
Prefetch('answers',
QuestionAnswer.objects.prefetch_related('options'),
to_attr='answerlist'),
Prefetch('item__questionnaires',
Prefetch('item__questions',
qqs.prefetch_related(
Prefetch('children', QuestionnaireChild.objects.prefetch_related(
Prefetch('user_datafield', Question.objects.prefetch_related(
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
'question',
Question.objects.none(),
to_attr='dummy'
)))
))
),
to_attr='childlist')
),
to_attr='relevant_questionnaires')
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
'question',
Question.objects.none(),
to_attr='dummy'
)))
).select_related('dependency_question'),
to_attr='questions_to_ask')
)
by_id = {cp.pk: cp for cp in request._cart_cache}
for cp in request._cart_cache:
@@ -459,8 +450,6 @@ def get_cart_positions(request):
cp.addon_to = by_id[cp.addon_to_id]
return request._cart_cache
get_cart = get_cart_positions # legacy compatibility
def get_cart_total(request):
"""
@@ -512,7 +501,7 @@ def get_cart_is_free(request):
if not hasattr(request, '_cart_free_cache'):
cs = cart_session(request)
pos = get_cart_positions(request)
pos = get_cart(request)
ia = get_cart_invoice_address(request)
try:
fees = get_fees(event=request.event, request=request, invoice_address=ia,
+18 -6
View File
@@ -58,7 +58,8 @@ from django.views.generic import TemplateView, View
from django_scopes import scopes_disabled
from pretix.base.models import (
CartPosition, GiftCard, InvoiceAddress, QuestionAnswer, SubEvent, Voucher,
CartPosition, Customer, GiftCard, InvoiceAddress, QuestionAnswer, SubEvent,
Voucher,
)
from pretix.base.services.cart import (
CartError, add_items_to_cart, apply_voucher, clear_cart, error_messages,
@@ -153,6 +154,17 @@ class CartActionMixin:
except InvoiceAddress.DoesNotExist:
return InvoiceAddress()
@cached_property
def cart_customer(self):
if 'customer_mode' in self.cart_session:
if self.cart_session['customer_mode'] == 'login':
try:
return self.request.organizer.customers.get(pk=self.cart_session.get('customer', -1))
except Customer.DoesNotExist:
return
else:
return getattr(self.request, 'customer', None)
def _item_from_post_value(request, key, value, voucher=None, voucher_ignore_if_redeemed=False):
if value.strip() == '' or '_' not in key:
@@ -505,7 +517,7 @@ class CartApplyVoucher(EventViewMixin, CartActionMixin, AsyncAction, View):
return self.do(self.request.event.id, code, get_or_create_cart_id(self.request),
translation.get_language(), request.sales_channel.identifier,
time_machine_now(default=None))
time_machine_now(default=None), self.cart_customer.pk if self.cart_customer else None)
else:
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
return JsonResponse({
@@ -532,7 +544,7 @@ class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View):
try:
return self.do(self.request.event.id, int(request.POST.get('id')), get_or_create_cart_id(self.request),
translation.get_language(), request.sales_channel.identifier,
time_machine_now(default=None))
time_machine_now(default=None), self.cart_customer.pk if self.cart_customer else None)
except ValueError:
return redirect_to_url(self.get_error_url())
else:
@@ -555,7 +567,7 @@ class CartClear(EventViewMixin, CartActionMixin, AsyncAction, View):
def post(self, request, *args, **kwargs):
return self.do(self.request.event.id, get_or_create_cart_id(self.request), translation.get_language(),
request.sales_channel.identifier, time_machine_now(default=None))
request.sales_channel.identifier, time_machine_now(default=None), self.cart_customer.pk if self.cart_customer else None)
@method_decorator(allow_cors_if_namespaced, 'dispatch')
@@ -591,7 +603,7 @@ class CartExtendReservation(EventViewMixin, CartActionMixin, AsyncAction, View):
def post(self, request, *args, **kwargs):
return self.do(self.request.event.id, get_or_create_cart_id(self.request), translation.get_language(),
request.sales_channel.identifier, time_machine_now(default=None))
request.sales_channel.identifier, time_machine_now(default=None), self.cart_customer.pk if self.cart_customer else None)
@method_decorator(allow_cors_if_namespaced, 'dispatch')
@@ -648,7 +660,7 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
if items:
return self.do(self.request.event.id, items, cart_id, translation.get_language(),
self.invoice_address.pk, widget_data, self.request.sales_channel.identifier,
time_machine_now(default=None))
time_machine_now(default=None), self.cart_customer.pk if self.cart_customer else None)
else:
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
return JsonResponse({
+8 -9
View File
@@ -122,20 +122,19 @@ def widget_css_etag(request, version, **kwargs):
return f'{_get_source_cache_key(version)}-{request.organizer.cache.get_or_set("css_version", default=lambda: int(time.time()))}'
# use vite by default, serve old vue2-based widget only for widget_vue2_origins
def _use_vite(request):
if getattr(settings, 'PRETIX_WIDGET_VUE', False) or "legacy" in request.GET:
return False
if getattr(settings, 'PRETIX_WIDGET_VITE', False) or "beta" in request.GET:
return True
origin = request.META.get('HTTP_ORIGIN', '')
gs = GlobalSettingsObject()
vue_origins = gs.settings.get('widget_vue2_origins', as_type=str, default='')
if vue_origins and not origin:
vite_origins = gs.settings.get('widget_vite_origins', as_type=str, default='')
if vite_origins and not origin:
referer = request.META.get('HTTP_REFERER', '')
origin = '/'.join(referer.split('/', 3)[:3])
if origin and vue_origins:
origins_list = [o.strip() for o in vue_origins.strip().splitlines() if o.strip()]
return origin not in origins_list
return True
if origin and vite_origins:
origins_list = [o.strip() for o in vite_origins.strip().splitlines() if o.strip()]
return origin in origins_list
return False
def widget_js_etag(request, version, lang, **kwargs):
+1 -1
View File
@@ -913,7 +913,7 @@ VITE_DEV_SERVER_PORT = 5173
VITE_DEV_SERVER = f"http://localhost:{VITE_DEV_SERVER_PORT}"
VITE_DEV_MODE = DEBUG
VITE_IGNORE = False # Used to ignore `collectstatic`/`rebuild`
PRETIX_WIDGET_VUE = os.environ.get('PRETIX_WIDGET_VUE', '') not in ('', '0')
PRETIX_WIDGET_VITE = os.environ.get('PRETIX_WIDGET_VITE', '') not in ('', '0')
if DEBUG:
# Reload if settings file changes
@@ -1,7 +1,7 @@
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('a[href^="mailto:"]').forEach(function (link) {
// Replace [at] with @ and the [dot] with . in both the href and the displayed text (if needed)
link.href = link.href.replace('[at]', '@').replaceAll('[dot]', '.')
link.textContent = link.textContent.replace('[at]', '@').replaceAll('[dot]', '.')
link.href = link.href.replace('[at]', '@').replace('[dot]', '.')
link.textContent = link.textContent.replace('[at]', '@').replace('[dot]', '.')
})
})
@@ -1,146 +0,0 @@
<script lang="ts">
import QuestionnaireElement from './QuestionnaireElement.vue';
import * as api from './api';
import { Questionnaire } from './model';
import { i18n_any, QUESTION_TYPE, sort, localeComp, numericComp, groupBy, _ } from './helper';
import {Ref, ref} from 'vue';
import { SlickList, SlickItem } from 'vue-slicksort';
const items_list = await api.getItems();
const categories_list = await api.getCategories();
const categories = Object.fromEntries(categories_list.map(cat => [cat.id, cat]));
categories['null'] = { position: -1, internal_name: _('Uncategorized') };
sort(items_list, numericComp(item => categories[item.category]?.position), numericComp(item => item.position));
console.log("items_list", items_list);
const grouped_items = [...groupBy(items_list, item => categories[item.category])];
console.log("grouped_items", grouped_items);
const all_questionnaires: (Omit<Questionnaire, 'id'> & { _new_id?: number, id?: number })[] = await api.getQuestionnaires();
const order_questionnaires = ref(all_questionnaires.filter(q => q.type.startsWith('O')));
const position_questionnaires = ref(all_questionnaires.filter(q => q.type.startsWith('P')));
const datafields = ref(await api.getDatafields());
function saveQuestionnaire(questionnaire) {
if (questionnaire.id) {
api.updateQuestionnaire(questionnaire.id, questionnaire);
} else {
api.createQuestionnaire(questionnaire);
}
}
export default {
components: {
QuestionnaireElement, SlickList, SlickItem,
},
methods: {
i18n_any,
addPositionQuestionnaire() {
position_questionnaires.value.push({
all_sales_channels: false, children: [], limit_sales_channels: [], position: 0,
items: [], internal_name: "Unnamed questionnaire", type: "PS",
_new_id: Date.now(),
});
},
addOrderQuestionnaire() {
order_questionnaires.value.push({
all_sales_channels: false, children: [], limit_sales_channels: [], position: 0,
items: [], internal_name: "Unnamed questionnaire", type: "OS",
_new_id: Date.now(),
});
},
saveData() {
for (const questionnaire of order_questionnaires.value) {
saveQuestionnaire(questionnaire);
}
for (const questionnaire of position_questionnaires.value) {
saveQuestionnaire(questionnaire);
}
},
},
data() {
return {
order_questionnaires,
position_questionnaires,
datafields,
items: items_list,
selected_product: ref(""),
grouped_items,
categories,
}
}
}
</script>
<style>
.hidden-questionnaire { opacity: 0.3; }
.questionnaires-list { margin-right: 180px; }
.question-edit-buttons { float:right; }
.question-edit-buttons div { position: absolute; margin-left: 10px; min-width: 100px; }
.question-edit-buttons button { }
.form-group { margin-bottom: 30px }
.questionnaires-editor.product-selected .panel .panel-heading {}
.questionnaires-editor.product-selected .panel { margin-bottom: 0; border: 0 none; border-bottom: 1px solid #ddd; box-shadow: none; border-radius: 0; }
.questionnaires-editor.product-selected .panel .panel-heading { font-style: italic; background: white; border: 0 none; }
.filter-row { background: #f8e6ff; border: 1px solid #e3cbed; padding: 10px; }
.debuginfo { font-size: 70%; background: rgba(200, 200, 200, 0.5); }
.dependency-info { position: absolute; }
.dependency-info > span { }
.category-header { margin: 8px 0 -5px 0; font-weight: bold; color: #737373; }
</style>
<template>
<div class="questionnaires-editor">
<p class="filter-row">
Order questionnaires
</p>
<div class="questionnaires-list">
<SlickList axis="y" v-model:list="order_questionnaires" useDragHandle appendTo="#orderQuestionnaireListParent" id="orderQuestionnaireListParent">
<SlickItem v-for="(questionnaire, index) in order_questionnaires" :key="questionnaire.id || questionnaire._new_id" :index="index">
<QuestionnaireElement
:questionnaire="questionnaire"
:datafields="datafields"
:grouped_items="null"
:selected_product="null" />
</SlickItem>
</SlickList>
</div>
<p>
<button class="btn btn-default" @click="addOrderQuestionnaire()"><i class="fa fa-plus"></i> Neuen Fragebogen erstellen</button>
<button class="btn btn-default" @click="saveData()"><i class="fa fa-save"></i> Speichern</button>
</p>
</div>
<div :class="`questionnaires-editor ${selected_product ? 'product-selected':''}`">
<p class="filter-row">
Questionnaires for product:
<select v-model="selected_product">
<option value="">(all)</option>
<optgroup v-for="[category, items] in grouped_items" :label="category.internal_name || i18n_any(category.name)">
<option v-for="item in items" :value="item.id">
{{ item.internal_name || i18n_any(item.name) }}
</option>
</optgroup>
</select>
</p>
<div class="questionnaires-list">
<SlickList axis="y" v-model:list="position_questionnaires" useDragHandle appendTo="#questionnaireListParent" id="questionnaireListParent">
<SlickItem v-for="(questionnaire, index) in position_questionnaires" :key="questionnaire.id || questionnaire._new_id" :index="index">
<QuestionnaireElement
:questionnaire="questionnaire"
:datafields="datafields"
:grouped_items="grouped_items"
:selected_product="selected_product" />
</SlickItem>
</SlickList>
</div>
<p>
<button class="btn btn-default" @click="addPositionQuestionnaire()"><i class="fa fa-plus"></i> Neuen Fragebogen erstellen</button>
<button class="btn btn-default" @click="saveData()"><i class="fa fa-save"></i> Speichern</button>
</p>
</div>
</template>
@@ -1,16 +0,0 @@
<script setup lang="ts">
import { ref, useId, defineProps } from 'vue';
import {getEventLocales} from "./api";
const locales = getEventLocales();
const props = defineProps(['value', 'id']);
if (!props.value) props.value = {};
</script>
<template>
<div class="i18n-form-group" :id="id">
<textarea v-for="locale in locales" cols="40" rows="2" :lang="locale" dir="ltr" class="form-control" title="Englisch" :id="`${id}_${locale}`" :placeholder="locale" v-model="value[locale]"></textarea>
</div>
</template>
@@ -1,49 +0,0 @@
<script setup lang="ts">
import { ref, useId } from 'vue';
const dialog = ref<HTMLDialogElement>();
const props = defineProps({
classes: {
type: String,
default: "",
},
title: '',
});
const visible = ref(false);
const showModal = () => {
dialog.value?.showModal();
visible.value = true;
};
defineExpose({
show: showModal,
close: (returnVal?: string): void => dialog.value?.close(returnVal),
visible,
});
const id = useId();
</script>
<template>
<dialog
ref="dialog" class="modal-card"
@close="visible = false"
closedby="any"
:aria-labelledby="`${id}-title`"
>
<form
v-if="visible"
method="dialog" class="modal-card-inner form-horizontal"
:class="{
[props.classes]: props.classes,
}"
>
<div class="modal-card-content">
<h2 :id="`${id}-title`" class="modal-card-title h3">{{ title }}</h2>
<slot />
</div>
</form>
</dialog>
</template>
@@ -1,154 +0,0 @@
<script setup lang="ts">
import { i18n_any, QUESTION_TYPE, QUESTION_TYPE_LABEL, SYSTEM_DATAFIELDS } from './helper';
import NativeDialog from './NativeDialog.vue';
import I18nTextField from './I18nTextField.vue';
import {useId, ref, computed} from 'vue'
import { DragHandle } from 'vue-slicksort';
import {getDatafieldEditUrl} from "./api";
const id = useId();
const props = defineProps(['question', 'datafields', 'editable', 'possible_dependencies'])
const emit = defineEmits(['removeSelf']);
const gettext = (window as any).gettext;
const question = ref(props.question);
const df = typeof question.value.question === 'number' ?
props.datafields.find(el => el.id === question.value.question) :
typeof question.value.question === 'string' ?
SYSTEM_DATAFIELDS[question.value.question] :
null;
const dependency_values_options = computed(() => props.datafields.find(el => el.id === question.value.dependency_question)?.options);
const dependency_values_resolved = computed(() => question.value.dependency_values.map(ident => i18n_any(dependency_values_options.value.find(opt => opt.identifier === ident)?.answer) ?? ident));
if (!question.value.label) question.value.label = {};
if (!question.value.help_text) question.value.help_text = {};
const editor = ref();
</script>
<template>
<div class="form-group">
<div class="question-edit-buttons" v-if="editable"><div class="btn-group">
<DragHandle tag="button" class="btn btn-default"><i class="fa fa-arrows"></i></DragHandle>
<button class="btn btn-default" @click="editor.show()"><i class="fa fa-edit"></i></button>
</div></div>
<template v-if="df">
<div v-if="question.dependency_question" class="dependency-info debuginfo">
<span><span class="fa fa-link"></span> {{ question.dependency_question }} = {{ dependency_values_resolved }}</span>
</div>
<div class="col-md-3 control-label label-empty" v-if="df.type === QUESTION_TYPE.BOOLEAN"></div>
<label class="col-md-3 control-label" :for="id" v-else>
{{ i18n_any(question.label) }}
</label>
<div class="col-md-9">
<input :id="id" type="text" v-if="df.type === QUESTION_TYPE.STRING || df.type === QUESTION_TYPE.PHONENUMBER" class="form-control">
<textarea :id="id" v-if="df.type === QUESTION_TYPE.TEXT" class="form-control"></textarea>
<div class="checkbox" v-if="df.type === QUESTION_TYPE.BOOLEAN">
<label :for="id">
<input :id="id" type="checkbox"> {{ i18n_any(question.label) }}
</label>
</div>
<input :id="id" type="number" v-if="df.type === QUESTION_TYPE.NUMBER" class="form-control">
<input :id="id" type="file" v-if="df.type === QUESTION_TYPE.FILE" class="form-control">
<select :id="id" class="form-control"
v-if="df.type === QUESTION_TYPE.CHOICE || df.type === QUESTION_TYPE.COUNTRYCODE">
<option></option>
<option v-for="opt in df.options">{{ i18n_any(opt.answer) }}</option>
</select>
<div class="checkbox" v-if="df.type === QUESTION_TYPE.CHOICE_MULTIPLE" v-for="(opt, index) in df.options">
<label :for="`${id}-${index}`">
<input :id="`${id}-${index}`" type="checkbox"> {{ i18n_any(opt.answer) }}
</label>
</div>
<div class="help-block">{{ i18n_any(question.help_text) }}</div>
</div>
</template>
<div v-else class="col-md-12">
<h3>{{ i18n_any(question.label) }}</h3>
<p>{{ i18n_any(question.help_text) }}</p>
</div>
</div>
<Teleport to="body">
<NativeDialog ref="editor" class="modal-card"
title="Edit question">
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Question') }}
</label>
<div class="col-md-9">
<I18nTextField :value="question.label"/>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Help text') }}
</label>
<div class="col-md-9">
<I18nTextField :value="question.help_text"/>
<div class="help-block">Wenn diese Frage noch weitere Erklärung braucht, können Sie sie hier eintragen.</div>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
Data field
</label>
<div class="col-md-9">
<p class="form-control-static">
<template v-if="typeof question.question === 'number'">
{{ df.internal_name }}
<a :href="getDatafieldEditUrl(df.id)" target="_blank">Manage data field details</a>
</template>
<template v-else>
{{ question.question }}
</template>
</p>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
Data field type
</label>
<div class="col-md-9">
<select v-model="df.type" class="form-control" disabled>
<option v-for="(label, type) in QUESTION_TYPE_LABEL" :value="QUESTION_TYPE[type]">{{ label }}</option>
</select>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label label-empty">&nbsp;</label>
<div class="col-md-9">
<div class="checkbox">
<label>
<input type="checkbox" v-model="question.required">
{{ gettext('Required question') }}
</label>
</div>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Only visible if...') }}
</label>
<div class="col-md-9">
<select v-model="question.dependency_question" class="form-control">
<option :value="null">{{ gettext('(none)') }}</option>
<option v-for="(qc, index) in possible_dependencies" :value="qc.question">{{ i18n_any(qc.label) }}</option>
</select>
<select v-model="question.dependency_values" class="form-control" multiple>
<option v-for="(qc, index) in dependency_values_options" :value="qc.identifier">{{ i18n_any(qc.answer) }}</option>
</select>
<div v-if="question.dependency_question">
<p><span class="debuginfo">depends on {{ question.dependency_question }} = {{ dependency_values_resolved }}</span></p>
</div>
</div>
</div>
<button @click="editor.close()" class="btn btn-primary pull-right"><span class="fa fa-check"></span> Save and close</button>
<button @click="emit('removeSelf')" class="btn btn-default">Remove from questionnaire</button>
</NativeDialog>
</Teleport>
</template>
@@ -1,163 +0,0 @@
<script setup lang="ts">
import {useId, ref, computed} from 'vue'
import QuestionElement from "./QuestionElement.vue";
import {i18n_any, QUESTION_TYPE, QUESTION_TYPE_LABEL} from "./helper";
import I18nTextField from "./I18nTextField.vue";
import NativeDialog from "./NativeDialog.vue";
import { SlickList, SlickItem, DragHandle } from 'vue-slicksort';
const dlgEditor = ref();
const dlgAddExisting = ref();
const dlgAddTextblock = ref();
const newTextblockTitle = ref();
const newTextblockText = ref();
const id = useId();
const props = defineProps(['questionnaire', 'datafields', 'selected_product', 'grouped_items'])
const gettext = (window as any).gettext
function toggleItem() {
const i = props.questionnaire.items.indexOf(props.selected_product);
if (i === -1) {
props.questionnaire.items.push(props.selected_product);
} else {
props.questionnaire.items.splice(i, 1);
}
}
function addExistingDatafield(field) {
props.questionnaire.children.push({
question: field.id,
required: false,
label: {},
help_text: {},
dependency_question: null,
dependency_values: [],
});
dlgAddExisting.value.close();
}
function showAddTextblockDialog() {
newTextblockTitle.value = {};
newTextblockText.value = {};
dlgAddTextblock.value.show();
}
function addTextblock() {
props.questionnaire.children.push({
question: null,
required: false,
label: newTextblockTitle.value,
help_text: newTextblockText.value,
dependency_question: null,
dependency_values: [],
});
dlgAddTextblock.value.close();
}
const isHidden = computed(() => props.selected_product && props.questionnaire.items.indexOf(props.selected_product) === -1);
const isEditable = computed(() => props.selected_product && props.questionnaire.items.indexOf(props.selected_product) !== -1);
</script>
<template>
<div class="question-edit-buttons"><div class="btn-group">
<DragHandle tag="button" class="btn btn-default"><i class="fa fa-arrows"></i></DragHandle>
<button class="btn btn-default" @click="dlgEditor.show()"><i class="fa fa-edit"></i></button>
</div></div>
<details class="panel panel-default " :open="!!isEditable"
:class="{ 'hidden-questionnaire': isHidden }">
<summary class="panel-heading">
<input type="checkbox" @click="toggleItem()" v-if="selected_product" :checked="!isHidden">
{{ props.questionnaire.internal_name }}
</summary>
<div class="panel-body" v-if="!isHidden">
<div class="form-horizontal" :id="`questionListParent${props.questionnaire.id}`">
<SlickList axis="y" v-model:list="props.questionnaire.children" useDragHandle :appendTo="`#questionListParent${props.questionnaire.id}`">
<SlickItem v-for="(child, index) in props.questionnaire.children" :key="child.id" :index="index">
<QuestionElement
:datafields="props.datafields"
:question="child"
:editable="true"
:possible_dependencies="props.questionnaire.children.slice(0, index)"
@remove-self="questionnaire.children.splice(index, 1)" />
</SlickItem>
</SlickList>
</div>
<p v-if="true" class="btn-group" role="group">
<button class="btn btn-default" @click="dlgAddExisting.show()"><i class="fa fa-plus"></i> {{ gettext('Existing data field') }}</button>
<button class="btn btn-default" @click="newDatafield()"><i class="fa fa-plus"></i> {{ gettext('New data field') }}</button>
<button class="btn btn-default" @click="showAddTextblockDialog()"><i class="fa fa-plus"></i> {{ gettext('Text') }}</button>
</p>
</div>
</details>
<Teleport to="body">
<NativeDialog ref="dlgEditor" class="modal-card"
:title="gettext('Edit questionnaire')">
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Internal name') }}
</label>
<div class="col-md-9">
<input type="text" class="form-control" v-model="questionnaire.internal_name"/>
</div>
</div>
<div class="form-group" v-if="grouped_items">
<label class="col-md-3 control-label">
{{ gettext('Visible on products') }}
</label>
<div class="col-md-9">
<div v-for="[category, items] in grouped_items">
<div class="category-header">{{ category.internal_name || i18n_any(category.name) }}</div>
<div class="checkbox" v-for="item in items">
<label :for="id + '_' + item.id">
<input :id="id + '_' + item.id" type="checkbox" :checked="questionnaire.items.indexOf(item.id) !== -1"> {{ item.internal_name || i18n_any(item.name) }}
</label>
</div>
</div>
</div>
</div>
<button @click="dlgEditor.close()" class="btn btn-primary pull-right"><span class="fa fa-check"></span> {{ gettext('Save and close') }}</button>
<button class="btn btn-default">{{ gettext('Delete') }}</button>
</NativeDialog>
<NativeDialog ref="dlgAddExisting" class="modal-card"
:title="gettext('Add existing data field')">
<div class="list-group">
<a href="javascript:" @click="addExistingDatafield(field)" v-for="field in datafields" class="list-group-item">{{ field.internal_name }}</a>
</div>
<button @click="dlgAddExisting.close()" class="btn btn-default pull-right">{{ gettext('Cancel') }}</button>
</NativeDialog>
<NativeDialog ref="dlgAddTextblock" class="modal-card"
:title="gettext('Add sub heading')">
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Title') }}
</label>
<div class="col-md-9">
<I18nTextField :value="newTextblockTitle"/>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Text') }}
</label>
<div class="col-md-9">
<I18nTextField :value="newTextblockText"/>
</div>
</div>
<button @click="addTextblock()" class="btn btn-default pull-right">{{ gettext('OK') }}</button>
<button @click="dlgAddTextblock.close()" class="btn btn-default pull-right">{{ gettext('Cancel') }}</button>
</NativeDialog>
</Teleport>
</template>
@@ -1,67 +0,0 @@
import {ApiListResponse, Datafield, Questionnaire, Item} from "./model";
const organizer_slug = document.body.getAttribute('data-organizer'),
event_slug = document.body.getAttribute('data-event');
async function api_get(resource) {
return await $.getJSON(`/api/v1/${resource}?_nocache=${+new Date()}`);
}
async function api_get_all<T>(resource): Promise<T[]> {
let next = `/api/v1/${resource}?_nocache=${+new Date()}`;
const result: T[] = [];
while (next) {
const response: ApiListResponse<T> = await $.getJSON(next);
result.push(...response.results);
next = response.next;
console.log('api_get_all: '+ resource, next, response, result)
}
return result;
}
async function api_json_request(resource, method, json_body) {
return await (await fetch(`/api/v1/${resource}`, {
body: JSON.stringify(json_body),
method: method,
headers: {
"Content-Type": "application/json",
"X-CSRFToken": $('[name=csrfmiddlewaretoken]').val() as string,
},
})).json();
}
export async function getDatafields() {
return await api_get_all<Datafield>(`organizers/${organizer_slug}/events/${event_slug}/datafields/`);
}
export async function getQuestionnaires() {
return await api_get_all<Questionnaire>(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/`);
}
export async function updateQuestionnaire(id, data) {
return await api_json_request(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/${id}/`, 'PATCH', data);
}
export async function createQuestionnaire(data) {
return await api_json_request(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/`, 'POST', data);
}
export async function getItems() {
return await api_get_all<Item>(`organizers/${organizer_slug}/events/${event_slug}/items/`);
}
export async function getCategories() {
return await api_get_all<Item>(`organizers/${organizer_slug}/events/${event_slug}/categories/`);
}
function get_json_script_value(id) {
return JSON.parse(document.getElementById(id).innerText);
}
export function getEventLocales() {
return get_json_script_value('event_locales');
}
export function getDatafieldEditUrl(datafield_id) {
return get_json_script_value('datafield_edit_url').replace('/0/', `/${datafield_id}/`);
}
@@ -1,97 +0,0 @@
export function i18n_any(data) {
if (!data) return null;
const preferred = document.body.getAttribute("data-pretixlocale");
if (data[preferred]) return data[preferred];
return Object.values(data)[0];
}
function freezeRec(o) {
return Object.freeze(Object.fromEntries(Object.entries(o).map(([k, v]) => [k, v && Object.getPrototypeOf(v) === Object.prototype ? freezeRec(v) : v])))
}
export function localeComp(fn) {
return function(a, b) {
return fn(a).localeCompare(fn(b));
}
}
export function numericComp(fn) {
return function(a, b) {
return fn(a) - fn(b);
}
}
export function pick(key) {
return function(obj) {
return obj[key];
}
}
export function sort(array, ...orderBy) {
array.sort(function(a, b) {
for(let comp of orderBy) {
const result = comp(a, b);
if (result !== 0) {
return result;
}
}
return 0;
});
}
export function *groupBy(array, key) {
let lastKey, lastArray;
for(const x of array){
const k = key(x);
if (lastKey !== k || !lastArray) {
if (lastArray) {
yield [lastKey, lastArray];
}
lastKey = k; lastArray = [x];
} else {
lastArray.push(x);
}
}
if (lastArray) {
yield [lastKey, lastArray];
}
}
export const QUESTION_TYPE = {
NUMBER: "N",
STRING: "S",
TEXT: "T",
BOOLEAN: "B",
CHOICE: "C",
CHOICE_MULTIPLE: "M",
FILE: "F",
DATE: "D",
TIME: "H",
DATETIME: "W",
COUNTRYCODE: "CC",
PHONENUMBER: "TEL",
};
export const _ = x => x;
export const QUESTION_TYPE_LABEL = {
NUMBER: _("Number"),
STRING: _("Text (one line)"),
TEXT: _("Multiline text"),
BOOLEAN: _("Yes/No"),
CHOICE: _("Choose one from a list"),
CHOICE_MULTIPLE: _("Choose multiple from a list"),
FILE: _("File upload"),
DATE: _("Date"),
TIME: _("Time"),
DATETIME: _("Date and time"),
COUNTRYCODE: _("Country code (ISO 3166-1 alpha-2)"),
PHONENUMBER: _("Phone number"),
};
export const SYSTEM_DATAFIELDS = freezeRec({
'attendee_name_parts': { label: _('Attendee name'), type: QUESTION_TYPE.STRING },
'attendee_email': { label: _('Attendee email'), type: QUESTION_TYPE.STRING },
'company': { label: _('Company'), type: QUESTION_TYPE.STRING },
'street': { label: _('Street'), type: QUESTION_TYPE.STRING },
'zipcode': { label: _('ZIP code'), type: QUESTION_TYPE.STRING },
'city': { label: _('City'), type: QUESTION_TYPE.STRING },
'country': { label: _('Country'), type: QUESTION_TYPE.COUNTRYCODE },
});
@@ -1,4 +0,0 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#questionnaires-editor')
@@ -1,65 +0,0 @@
export type ApiListResponse<T> = {
count: number,
next: string | null,
previous: string | null,
results: T[],
};
// from webcheckin/i18n.ts
export type I18nString = string | Record<string, string> | null | undefined;
export type Datafield = {
id: number,
// question: I18nString,
type: string,
required: boolean,
//items: number[],
options: any[],
// position: number,
//ask_during_checkin: boolean,
show_during_checkin: boolean,
identifier: string,
// dependency_question: string | null,
// dependency_values []
// hidden: boolean,
// dependency_value null
print_on_invoice: boolean,
// help_text: I18nString,
valid_number_min: null | number,
valid_number_max: null | number,
valid_date_min: null | string,
valid_date_max: null | string,
valid_datetime_min: null | string,
valid_datetime_max: null | string,
valid_string_length_max: null | number,
valid_file_portrait: boolean,
internal_name: string,
};
export type Questionnaire = {
id: number,
internal_name: string,
type: string,
items: number[],
position: number,
all_sales_channels: boolean,
limit_sales_channels: string[],
children: QuestionnaireChild[],
};
export type QuestionnaireChild = {
question: string | number,
required: boolean,
label: I18nString,
help_text: I18nString,
dependency_question: number | null,
dependency_values: null | string[],
};
export type Item = {
id: number,
category: number,
name: I18nString,
internal_name: string | null,
};
@@ -780,8 +780,6 @@ fieldset.accordion-panel > legend {
}
}
/* These classes are not only used for tax rules, but also for event meta properties */
.tax-rules-formset {
margin-left: -15px;
margin-right: -15px;
@@ -804,7 +802,6 @@ fieldset.accordion-panel > legend {
padding-bottom: 5px;
}
.batch-select-label {
display: block;
width: 100%;
@@ -233,7 +233,6 @@ let form_handlers = function (el) {
function setup_basics (el) {
el.find('form').attr('novalidate', true).on('submit', function (e) {
if (e.originalEvent && e.originalEvent.submitter && e.originalEvent.submitter.formNoValidate) return;
if (!this.checkValidity()) {
let input = this.querySelector(':invalid:not(fieldset)');
(input.labels[0] || input).scrollIntoView()
@@ -1,2 +0,0 @@
- modernize the sometimes native form submitting?
- destructure props?
@@ -514,6 +514,10 @@ export function createWidgetStore (config: {
// button with subevent but no items
redirectUrl += `${this.subevent}/`
}
if (this.subevent && !this.cartId) {
// button with subevent but no items
redirectUrl += `${this.subevent}/`
}
redirectUrl += `?iframe=1&locale=${LANG}`
if (this.cartId) {
// ajax to make sure the cart-id is used, even if the cart is currently empty
+2
View File
@@ -46,6 +46,8 @@ TEST_DISCOUNT_RES = {
"sales_channels": ["bar", "baz", "web"],
"available_from": None,
"available_until": None,
"require_membership_hidden": False,
"require_membership_types": [],
"subevent_mode": "mixed",
"subevent_date_from": None,
"subevent_date_until": None,
+9 -9
View File
@@ -749,7 +749,7 @@ def test_query_count_many_items(event, itemcount):
''',
recommendations=''' Price Discounted Price Max Count Prefix
''',
expect_num_queries=8,
expect_num_queries=9,
)
check_cart_behaviour(
event,
@@ -763,7 +763,7 @@ def test_query_count_many_items(event, itemcount):
recommendations=''' Price Discounted Price Max Count Prefix
Tickets Ticket 2 42.00 0.00 1 -
''',
expect_num_queries=9,
expect_num_queries=10,
)
check_cart_behaviour(
event,
@@ -779,7 +779,7 @@ def test_query_count_many_items(event, itemcount):
recommendations=''' Price Discounted Price Max Count Prefix
Tickets Ticket 2 42.00 0.00 1 -
''',
expect_num_queries=9,
expect_num_queries=10,
)
@@ -803,7 +803,7 @@ def test_query_count_many_categories_and_discounts(event, catcount):
''',
recommendations=''' Price Discounted Price Max Count Prefix
''',
expect_num_queries=8,
expect_num_queries=9,
)
check_cart_behaviour(
event,
@@ -817,7 +817,7 @@ def test_query_count_many_categories_and_discounts(event, catcount):
recommendations=''' Price Discounted Price Max Count Prefix
Category 1 Ticket 1-B 42.00 0.00 1 -
''',
expect_num_queries=9,
expect_num_queries=10,
)
check_cart_behaviour(
event,
@@ -833,7 +833,7 @@ def test_query_count_many_categories_and_discounts(event, catcount):
recommendations=''' Price Discounted Price Max Count Prefix
Category 1 Ticket 1-B 42.00 0.00 1 -
''',
expect_num_queries=9,
expect_num_queries=10,
)
@@ -857,7 +857,7 @@ def test_query_count_many_cartpos(event, catcount):
''',
recommendations=''' Price Discounted Price Max Count Prefix
''',
expect_num_queries=8,
expect_num_queries=9,
)
check_cart_behaviour(
event,
@@ -871,7 +871,7 @@ def test_query_count_many_cartpos(event, catcount):
recommendations=''' Price Discounted Price Max Count Prefix
Category 1 Ticket 1-B 42.00 0.00 1 -
''',
expect_num_queries=9,
expect_num_queries=10,
)
check_cart_behaviour(
event,
@@ -893,5 +893,5 @@ def test_query_count_many_cartpos(event, catcount):
Category 1 Ticket 1-B 42.00 0.00 1 -
Category 2 Ticket 2-B 42.00 0.00 1 -
''',
expect_num_queries=13,
expect_num_queries=14,
)
+2 -2
View File
@@ -128,9 +128,9 @@ class VoucherFormTest(SoupTestMixin, TransactionTestCase):
doc = self.client.get('/control/event/%s/%s/vouchers/?download=yes' % (self.orga.slug, self.event.slug))
assert doc.content.decode().strip() == '"Voucher code","Valid until","Product","Reserve quota",' \
'"Bypass quota","Price effect","Value","Tag","Redeemed",' \
'"Maximum usages","Seat","Comment","Budget","Budget used"' \
'"Maximum usages","Seat","Comment"' \
'\r\n"ABCDEFG","","Early-bird ticket","No","No","No effect","","","0",' \
'"1","","","",""'
'"1","",""'
def test_filter_status_valid(self):
with scopes_disabled():
+7 -3
View File
@@ -89,11 +89,11 @@ def _widget_assets():
f'Start it with: npm run dev:widget'
)
yield
elif os.environ.get("PRETIX_WIDGET_VUE"):
yield # Old widget, no build needed
else:
elif os.environ.get("PRETIX_WIDGET_VITE"):
subprocess.check_call(['npm', 'run', 'build:widget'], cwd=PROJECT_ROOT)
yield
else:
yield # Old widget, no build needed
# ============================================================================
@@ -155,6 +155,10 @@ def live_server_url(live_server, settings):
settings.SITE_URL = live_server.url
# Enable Vite widget if requested via env var
if os.environ.get("PRETIX_WIDGET_VITE") or os.environ.get("PRETIX_WIDGET_VITE_DEV"):
settings.PRETIX_WIDGET_VITE = True
return live_server.url
+283
View File
@@ -0,0 +1,283 @@
#
# 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/>.
#
from datetime import UTC, datetime, timedelta
from decimal import Decimal
import pytest
from django.utils.timezone import now
from django_scopes import scope
from pretix.base.models import Event, Organizer
from pretix.presale.productlist import prepare_item_list_for_shop
# Tests for prepare_item_list_for_shop are really incomplete since historically, most features are
# tested on the test_event or test_widget layer. We'll slowly add new tests here to test closer to the
# source.
@pytest.fixture
def event():
o = Organizer.objects.create(name='MRMCD', slug='mrmcd')
e = Event.objects.create(
organizer=o, name='MRMCD2015', slug='2015',
date_from=now(), live=True
)
with scope(organizer=o):
yield e
@pytest.fixture
def quota(event):
return event.quotas.create(name="Tickets", size=500)
@pytest.fixture
def item(event, quota):
i = event.items.create(name="Ticket", default_price=Decimal("42.00"))
quota.items.add(i)
return i
@pytest.fixture
def variation(event, quota):
i = event.items.create(name="Ticket with variants", default_price=Decimal("99.00"))
v = i.variations.create(value="Default", default_price=Decimal("42.00"))
quota.items.add(i)
quota.variations.add(v)
return v
@pytest.fixture
def discount(event):
return event.discounts.create(
internal_name="Early-Bird-Discount",
all_sales_channels=True,
available_from=now() - timedelta(days=2),
available_until=now() + timedelta(days=2),
condition_all_products=True,
condition_min_count=1,
benefit_discount_matching_percent=Decimal("10.00"),
)
@pytest.fixture
def channel(event):
return event.organizer.sales_channels.get(identifier="web")
@pytest.mark.django_db
def test_default_price(event, item, variation, channel):
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("42")
assert items[1].available_variations[0].display_price.gross == Decimal("42")
def _test_no_discount(event, channel):
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("42")
assert items[1].available_variations[0].display_price.gross == Decimal("42")
@pytest.mark.django_db
def test_discount_applied(event, item, variation, channel, discount):
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("37.80")
assert items[0].original_price.gross == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
assert items[1].available_variations[0].original_price.gross == Decimal("42.00")
@pytest.mark.django_db
def test_discount_for_groups_ignored(event, item, variation, channel, discount):
discount.condition_min_count = 2
discount.save()
_test_no_discount(event, channel)
@pytest.mark.django_db
def test_discount_original_price_kept(event, item, variation, channel, discount):
item.original_price = Decimal("46.00")
item.save()
variation.original_price = Decimal("46.00")
variation.save()
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("37.80")
assert items[0].original_price.gross == Decimal("46.00")
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
assert items[1].available_variations[0].original_price.gross == Decimal("46.00")
@pytest.mark.django_db
def test_discount_out_of_timeframe(event, item, variation, channel, discount):
discount.available_from = now() + timedelta(days=2)
discount.save()
_test_no_discount(event, channel)
@pytest.mark.django_db
def test_discount_wrong_channel(event, item, variation, channel, discount):
discount.all_sales_channels = False
discount.save()
_test_no_discount(event, channel)
@pytest.mark.django_db
def test_discount_benefits_other_products_ignored(event, item, variation, channel, discount):
discount.benefit_same_products = False
discount.save()
_test_no_discount(event, channel)
@pytest.mark.django_db
def test_discounts_for_addons(event, item, variation, channel, discount):
discount.condition_apply_to_addons = False
discount.save()
items, _ = prepare_item_list_for_shop(event, channel=channel, allow_addons=True)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("42.00")
discount.condition_apply_to_addons = True
discount.save()
items, _ = prepare_item_list_for_shop(event, channel=channel, allow_addons=True)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("37.80")
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
@pytest.mark.django_db
def test_discounts_with_voucher(event, item, variation, channel, discount):
voucher = event.vouchers.create(code="FOO", price_mode="subtract", value=Decimal("10.00"))
voucher2 = event.vouchers.create(code="BAR")
discount.condition_ignore_voucher_discounted = True
discount.save()
item.original_price = Decimal("46.00")
item.save()
variation.item.original_price = Decimal("46.00")
variation.item.save()
items, _ = prepare_item_list_for_shop(event, channel=channel, voucher=voucher)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("32.00")
assert items[0].original_price.gross == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("32.00")
assert items[1].available_variations[0].original_price.gross == Decimal("42.00")
items, _ = prepare_item_list_for_shop(event, channel=channel, voucher=voucher2)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("37.80")
assert items[0].original_price.gross == Decimal("46.00")
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
assert items[1].available_variations[0].original_price.gross == Decimal("46.00")
discount.condition_ignore_voucher_discounted = False
discount.save()
items, _ = prepare_item_list_for_shop(event, channel=channel, voucher=voucher)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("28.80")
assert items[0].original_price.gross == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("28.80")
assert items[1].available_variations[0].original_price.gross == Decimal("42.00")
@pytest.mark.django_db
def test_discounts_for_subevent_timeframe(event, quota, item, variation, channel, discount):
event.has_subevents = True
event.save()
se = event.subevents.create(
name="Foobar", date_from=datetime(2028, 12, 27, 10, 0, 0, tzinfo=UTC)
)
quota.subevent = se
quota.save()
discount.subevent_date_from = se.date_from + timedelta(days=1)
discount.save()
items, _ = prepare_item_list_for_shop(event, channel=channel, subevent=se)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("42.00")
discount.subevent_date_from = se.date_from - timedelta(days=1)
discount.save()
items, _ = prepare_item_list_for_shop(event, channel=channel, subevent=se)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("37.80")
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
@pytest.mark.django_db
def test_discounts_for_products(event, quota, item, variation, channel, discount):
discount.condition_all_products = False
discount.save()
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("42.00")
discount.condition_limit_products.add(item)
discount.condition_limit_products.add(variation.item)
discount.save()
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("37.80")
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
@pytest.mark.django_db
def test_discounts_for_products_tax_additive(event, quota, item, variation, channel, discount):
tr = event.tax_rules.create(rate=Decimal("19.00"), price_includes_tax=False)
item.tax_rule = tr
item.save()
variation.item.tax_rule = tr
variation.item.save()
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("44.98")
assert items[0].original_price.gross == Decimal("49.98")
assert items[0].original_price.net == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("44.98")
assert items[1].available_variations[0].original_price.gross == Decimal("49.98")
assert items[1].available_variations[0].original_price.net == Decimal("42.00")
@pytest.mark.django_db
def test_discounts_for_products_tax_additive_bundle_included(event, quota, item, variation, channel, discount):
tr = event.tax_rules.create(rate=Decimal("19.00"), price_includes_tax=False)
b = event.items.create(name="Bundled product", default_price=Decimal("10.00"), tax_rule=tr, require_bundling=True)
quota.items.add(b)
item.tax_rule = tr
item.save()
variation.item.tax_rule = tr
variation.item.save()
item.bundles.create(bundled_item=b, count=2, designated_price=Decimal("5.00"))
variation.item.bundles.create(bundled_item=b, count=2, designated_price=Decimal("5.00"))
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("45.98")
assert items[0].original_price.gross == Decimal("49.98")
assert items[0].original_price.net == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("45.98")
assert items[1].available_variations[0].original_price.gross == Decimal("49.98")
assert items[1].available_variations[0].original_price.net == Decimal("42.00")
+1 -1
View File
@@ -50,7 +50,7 @@ export default defineConfig({
},
optimizeDeps: {
exclude: ['moment', 'jquery']
},
}
})
// Virtual module plugin: generates re-export entries for each shared dep