mirror of
https://github.com/pretix/pretix.git
synced 2026-09-18 17:04:41 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6f488d73c | ||
|
|
5fd6759474 | ||
|
|
50c2c60a9b | ||
|
|
dc689c2263 | ||
|
|
6e66c179a7 | ||
|
|
9f6ce762e7 | ||
|
|
5faf487d13 | ||
|
|
5f341b7f6e | ||
|
|
f6c678ed6c | ||
|
|
3ef35df421 | ||
|
|
d49093b1d3 | ||
|
|
97e241dc1d | ||
|
|
bc0c23aa11 | ||
|
|
eb7936634b | ||
|
|
e3a3768a6c | ||
|
|
3787cbf7ce | ||
|
|
ab1427e3ff | ||
|
|
20f2950c2b | ||
|
|
bbb5fc008b | ||
|
|
e74dcbb01a | ||
|
|
317df5a031 | ||
|
|
0785ca049f | ||
|
|
583d464d6f | ||
|
|
e6f1ee1126 | ||
|
|
d8f444d51d | ||
|
|
9ace35069c | ||
|
|
b0a619392c | ||
|
|
54726d6fcd | ||
|
|
8e3cc12357 | ||
|
|
53a8a7954d | ||
|
|
59cebf3ee8 |
Generated
+11
-1
@@ -9,7 +9,8 @@
|
||||
"version": "1.0.0",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.30"
|
||||
"vue": "^3.5.30",
|
||||
"vue-slicksort": "^2.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -4635,6 +4636,15 @@
|
||||
"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",
|
||||
|
||||
+2
-1
@@ -27,7 +27,8 @@
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.30"
|
||||
"vue": "^3.5.30",
|
||||
"vue-slicksort": "^2.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
@@ -764,14 +764,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'event_calendar_future_only',
|
||||
'frontpage_text',
|
||||
'event_info_text',
|
||||
'attendee_names_asked',
|
||||
'attendee_names_required',
|
||||
'attendee_emails_asked',
|
||||
'attendee_emails_required',
|
||||
'attendee_addresses_asked',
|
||||
'attendee_addresses_required',
|
||||
'attendee_company_asked',
|
||||
'attendee_company_required',
|
||||
'attendee_data_explanation_text',
|
||||
'confirm_texts',
|
||||
'order_email_asked_twice',
|
||||
@@ -979,7 +971,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
'reusable_media_type_nfc_mf0aes',
|
||||
'reusable_media_type_nfc_mf0aes_random_uid',
|
||||
'reusable_media_usage_enforced',
|
||||
'system_question_order',
|
||||
'system_question_order', # TODO(questionnaires) - remove or replace
|
||||
'tax_rule_payment',
|
||||
'tax_rule_cancellation',
|
||||
]
|
||||
|
||||
@@ -33,7 +33,10 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
import os.path
|
||||
from decimal import Decimal
|
||||
from itertools import zip_longest
|
||||
import logging
|
||||
|
||||
import rest_framework
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
@@ -53,6 +56,10 @@ from pretix.base.models import (
|
||||
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
|
||||
SalesChannel,
|
||||
)
|
||||
from pretix.base.models.items import Questionnaire, QuestionnaireChild
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
@@ -539,18 +546,18 @@ class LegacyDependencyValueField(serializers.CharField):
|
||||
return [data] if data else []
|
||||
|
||||
|
||||
class QuestionSerializer(I18nAwareModelSerializer):
|
||||
class DatafieldSerializer(I18nAwareModelSerializer):
|
||||
options = InlineQuestionOptionSerializer(many=True, required=False)
|
||||
identifier = serializers.CharField(allow_null=True)
|
||||
dependency_value = LegacyDependencyValueField(source='dependency_values', required=False, allow_null=True)
|
||||
internal_name = serializers.CharField(allow_null=True, source='question', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
|
||||
'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
|
||||
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
|
||||
fields = ('id', 'question', 'type', 'options',
|
||||
'show_during_checkin', 'identifier',
|
||||
'hidden', 'print_on_invoice', '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')
|
||||
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait', 'internal_name',)
|
||||
|
||||
def validate_identifier(self, value):
|
||||
Question._clean_identifier(self.context['event'], value, self.instance)
|
||||
@@ -561,14 +568,6 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
self.instance.clean_type_change(self.instance.type, value)
|
||||
return value
|
||||
|
||||
def validate_dependency_question(self, value):
|
||||
if value:
|
||||
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
|
||||
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
|
||||
if value == self.instance:
|
||||
raise ValidationError('A question cannot depend on itself.')
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
if self.instance and 'options' in data:
|
||||
@@ -580,21 +579,6 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
|
||||
if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
|
||||
raise ValidationError('Dependencies are not supported during check-in.')
|
||||
|
||||
dep = full_data.get('dependency_question')
|
||||
if dep:
|
||||
if dep.ask_during_checkin:
|
||||
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
|
||||
|
||||
seen_ids = {self.instance.pk} if self.instance else set()
|
||||
while dep:
|
||||
if dep.pk in seen_ids:
|
||||
raise ValidationError(_('Circular dependency between questions detected.'))
|
||||
seen_ids.add(dep.pk)
|
||||
dep = dep.dependency_question
|
||||
|
||||
if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
|
||||
raise ValidationError(_('This type of question cannot be asked during check-in.'))
|
||||
|
||||
@@ -626,6 +610,160 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
return question
|
||||
|
||||
|
||||
class QuestionRefField(serializers.PrimaryKeyRelatedField):
|
||||
default_error_messages = {
|
||||
'invalid_choice': _('"{input}" is not a valid choice.')
|
||||
}
|
||||
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:
|
||||
if data not in QuestionnaireChild.SystemQuestion.values:
|
||||
self.fail("invalid_choice", input=data)
|
||||
return {'user_datafield': None, 'system_datafield': data}
|
||||
elif data is None:
|
||||
return {'user_datafield': None, 'system_datafield': None}
|
||||
else:
|
||||
self.fail('incorrect_type', data_type=type(data).__name__)
|
||||
|
||||
def use_pk_only_optimization(self):
|
||||
return self.source == '*'
|
||||
|
||||
|
||||
class RenderedMarkdownField(serializers.CharField):
|
||||
def to_representation(self, value):
|
||||
return rich_text(value)
|
||||
|
||||
|
||||
class InlineQuestionnaireChildSerializer(I18nAwareModelSerializer):
|
||||
question = QuestionRefField(allow_null=True, source='*', queryset=Question.objects.none())
|
||||
dependency_question = QuestionRefField(allow_null=True, required=False, queryset=Question.objects.none())
|
||||
rendered_help_text = RenderedMarkdownField(read_only=True, source='help_text')
|
||||
|
||||
class Meta:
|
||||
model = QuestionnaireChild
|
||||
fields = ('question', 'required', 'label', 'help_text', 'dependency_question', 'dependency_values', 'rendered_help_text')
|
||||
|
||||
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_dependency_question(self, value):
|
||||
if value:
|
||||
if value['user_datafield']:
|
||||
if value['user_datafield'].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.')
|
||||
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)
|
||||
self.fields['limit_sales_channels'].child_relation.queryset = kwargs['context']['event'].organizer.sales_channels.all()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
try:
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
except rest_framework.exceptions.ValidationError as e:
|
||||
# we already have invalid state in the database, what should we do? hope it gets better after saving?
|
||||
logger.exception("Invalid state in database, ignoring some validations!")
|
||||
else:
|
||||
#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 [])
|
||||
|
||||
if (full_data.get('type') == Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN
|
||||
and any(c['dependency_question'] for c in full_data['children'])):
|
||||
raise ValidationError('Dependencies are not supported during check-in.')
|
||||
|
||||
if (not full_data.get('type').startswith('P')
|
||||
and any(c['system_datafield'] for c in full_data['children'])):
|
||||
raise ValidationError('System data fields are only supported on order positions.')
|
||||
|
||||
system_fields = set(c['system_datafield'] for c in full_data['children'])
|
||||
if ('zipcode' in system_fields or 'state' in system_fields or 'street' in system_fields or 'city' in system_fields) and 'country' not in system_fields:
|
||||
raise ValidationError('The system data fields "street", "zip code", "city", and "state" can only be used in combination with the "country" field.')
|
||||
|
||||
return data
|
||||
|
||||
@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_children = questionnaire.children.all()
|
||||
def q(qc):
|
||||
return qc['user_datafield'] or qc['system_datafield']
|
||||
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)
|
||||
for existing, update_data in zip_longest(existing_children, new_data):
|
||||
if update_data:
|
||||
if update_data.get('dependency_question'):
|
||||
try:
|
||||
update_data['dependency_question'] = result[q(update_data['dependency_question'])]
|
||||
except KeyError:
|
||||
raise ValidationError('A question can only depend on a previous question from the same questionnaire.')
|
||||
if existing:
|
||||
result[q(update_data)] = child_serializer.update(existing, update_data)
|
||||
else:
|
||||
result[q(update_data)] = child_serializer.create(update_data)
|
||||
else:
|
||||
existing.delete()
|
||||
return result
|
||||
|
||||
|
||||
class QuotaSerializer(I18nAwareModelSerializer):
|
||||
available = serializers.BooleanField(read_only=True)
|
||||
available_number = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -46,7 +46,7 @@ from pretix.api.serializers.event import SubEventSerializer
|
||||
from pretix.api.serializers.forms import form_field_to_serializer_field
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.item import (
|
||||
InlineItemVariationSerializer, ItemSerializer, QuestionSerializer,
|
||||
InlineItemVariationSerializer, ItemSerializer, DatafieldSerializer,
|
||||
)
|
||||
from pretix.api.signals import order_api_details, orderposition_api_details
|
||||
from pretix.base.decimal import round_decimal
|
||||
@@ -715,7 +715,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
||||
self.fields['variation'] = InlineItemVariationSerializer(read_only=True, context=self.context)
|
||||
|
||||
if 'answers.question' in self.context['expand']:
|
||||
self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True)
|
||||
self.fields['answers'].child.fields['question'] = DatafieldSerializer(read_only=True) # TODO(questionnaires)
|
||||
|
||||
if 'addons' in self.context['expand']:
|
||||
# Experimental feature, undocumented on purpose for now in case we need to remove it again
|
||||
|
||||
@@ -79,7 +79,8 @@ 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'questions', item.QuestionViewSet)
|
||||
event_router.register(r'datafields', item.DatafieldViewSet)
|
||||
event_router.register(r'questionnaires', item.QuestionnaireViewSet)
|
||||
event_router.register(r'discounts', discount.DiscountViewSet)
|
||||
event_router.register(r'quotas', item.QuotaViewSet)
|
||||
event_router.register(r'vouchers', voucher.VoucherViewSet)
|
||||
|
||||
@@ -54,7 +54,7 @@ from pretix.api.serializers.checkin import (
|
||||
CheckinListSerializer, CheckinRPCAnnulInputSerializer,
|
||||
CheckinRPCRedeemInputSerializer, MiniCheckinListSerializer,
|
||||
)
|
||||
from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.api.serializers.item import DatafieldSerializer
|
||||
from pretix.api.serializers.order import (
|
||||
CheckinListOrderPositionSerializer, CheckinSerializer,
|
||||
FailedCheckinSerializer,
|
||||
@@ -866,7 +866,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
'checkin_texts': op.checkin_texts,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
|
||||
'questions': [
|
||||
QuestionSerializer(q).data for q in e.questions
|
||||
DatafieldSerializer(q).data for q in e.questions # TODO(questionnaires)
|
||||
],
|
||||
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
||||
}, status=400)
|
||||
|
||||
@@ -48,13 +48,15 @@ from pretix.api.pagination import TotalOrderingFilter
|
||||
from pretix.api.serializers.item import (
|
||||
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
|
||||
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
|
||||
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
|
||||
QuestionnaireSerializer, QuestionOptionSerializer, DatafieldSerializer,
|
||||
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
|
||||
@@ -461,16 +463,16 @@ with scopes_disabled():
|
||||
class QuestionFilter(FilterSet):
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ['ask_during_checkin', 'required', 'identifier']
|
||||
fields = ['identifier']
|
||||
|
||||
|
||||
class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = QuestionSerializer
|
||||
class DatafieldViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = DatafieldSerializer
|
||||
queryset = Question.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||
filterset_class = QuestionFilter
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position', 'id')
|
||||
ordering_fields = ('id')
|
||||
ordering = ('id')
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
|
||||
@@ -566,6 +568,58 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
with scopes_disabled():
|
||||
class QuestionnaireFilter(FilterSet):
|
||||
class Meta:
|
||||
model = Questionnaire
|
||||
fields = ['type']
|
||||
|
||||
|
||||
class QuestionnaireViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = QuestionnaireSerializer
|
||||
queryset = Questionnaire.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||
filterset_class = QuestionnaireFilter
|
||||
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
|
||||
|
||||
|
||||
+151
-148
@@ -36,7 +36,6 @@ import copy
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import namedtuple
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
@@ -50,7 +49,8 @@ from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db.models import QuerySet
|
||||
from django.db import ProgrammingError
|
||||
from django.db.models import Prefetch, Q, QuerySet
|
||||
from django.forms import Select, widgets
|
||||
from django.forms.widgets import FILE_INPUT_CONTRADICTION
|
||||
from django.utils.formats import date_format
|
||||
@@ -82,7 +82,7 @@ from pretix.base.i18n import (
|
||||
from pretix.base.invoicing.transmission import (
|
||||
get_transmission_types, transmission_types,
|
||||
)
|
||||
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
|
||||
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption, Questionnaire, QuestionnaireChild
|
||||
from pretix.base.models.tax import ask_for_vat_id
|
||||
from pretix.base.services.tax import (
|
||||
VATIDFinalError, VATIDTemporaryError, normalize_vat_id, validate_vat_id,
|
||||
@@ -643,49 +643,22 @@ 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, q):
|
||||
def build_user_question_field(self, request, event, answerlist, qc, datafield):
|
||||
# Do we already have an answer? Provide it as the initial value
|
||||
answers = [a for a in answerlist if a.question_id == q.id]
|
||||
answers = [a for a in answerlist if a.question_id == datafield.id]
|
||||
if answers:
|
||||
initial = answers[0]
|
||||
else:
|
||||
initial = None
|
||||
tz = ZoneInfo(event.settings.timezone)
|
||||
required = q.required and not self.all_optional
|
||||
if q.type == Question.TYPE_BOOLEAN:
|
||||
required = qc.required and not self.all_optional
|
||||
if datafield.type == Question.TYPE_BOOLEAN:
|
||||
if required:
|
||||
# For some reason, django-bootstrap3 does not set the required attribute
|
||||
# itself.
|
||||
@@ -699,107 +672,105 @@ class BaseQuestionsForm(forms.Form):
|
||||
initialbool = False
|
||||
|
||||
field = forms.BooleanField(
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
label=escape(qc.label), required=required,
|
||||
help_text=rich_text(qc.help_text),
|
||||
initial=initialbool, widget=widget,
|
||||
)
|
||||
elif q.type == Question.TYPE_NUMBER:
|
||||
elif datafield.type == Question.TYPE_NUMBER:
|
||||
field = forms.DecimalField(
|
||||
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),
|
||||
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),
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
elif datafield.type == Question.TYPE_STRING:
|
||||
field = forms.CharField(
|
||||
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),
|
||||
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),
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_TEXT:
|
||||
elif datafield.type == Question.TYPE_TEXT:
|
||||
field = forms.CharField(
|
||||
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),
|
||||
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),
|
||||
widget=forms.Textarea,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_COUNTRYCODE:
|
||||
elif datafield.type == Question.TYPE_COUNTRYCODE:
|
||||
field = CountryField(
|
||||
countries=CachedCountries,
|
||||
blank=True, null=True, blank_label=' ',
|
||||
).formfield(
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
label=escape(qc.label), required=required,
|
||||
help_text=rich_text(qc.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 q.type == Question.TYPE_CHOICE:
|
||||
elif datafield.type == Question.TYPE_CHOICE:
|
||||
field = forms.ModelChoiceField(
|
||||
queryset=q.options,
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
queryset=datafield.options,
|
||||
label=escape(qc.label), required=required,
|
||||
help_text=rich_text(qc.help_text),
|
||||
widget=forms.Select,
|
||||
to_field_name='identifier',
|
||||
empty_label='',
|
||||
initial=initial.options.first() if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
elif datafield.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
field = forms.ModelMultipleChoiceField(
|
||||
queryset=q.options,
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
queryset=datafield.options,
|
||||
label=escape(qc.label), required=required,
|
||||
help_text=rich_text(qc.help_text),
|
||||
to_field_name='identifier',
|
||||
widget=QuestionCheckboxSelectMultiple,
|
||||
initial=initial.options.all() if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_FILE:
|
||||
if q.valid_file_portrait:
|
||||
elif datafield.type == Question.TYPE_FILE:
|
||||
if datafield.valid_file_portrait:
|
||||
field = PortraitImageField(
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
label=escape(qc.label), required=required,
|
||||
help_text=rich_text(qc.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(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
label=escape(qc.label), required=required,
|
||||
help_text=rich_text(qc.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 q.type == Question.TYPE_DATE:
|
||||
elif datafield.type == Question.TYPE_DATE:
|
||||
attrs = {}
|
||||
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 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 not help_text:
|
||||
if q.valid_date_min and q.valid_date_max:
|
||||
if datafield.valid_date_min and datafield.valid_date_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date between {min} and {max}.'),
|
||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
min=date_format(datafield.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
max=date_format(datafield.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
elif q.valid_date_min:
|
||||
elif datafield.valid_date_min:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date no earlier than {min}.'),
|
||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
min=date_format(datafield.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
elif q.valid_date_max:
|
||||
elif datafield.valid_date_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date no later than {max}.'),
|
||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
max=date_format(datafield.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
@@ -809,16 +780,16 @@ class BaseQuestionsForm(forms.Form):
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.DateField(
|
||||
label=escape(q.question), required=required,
|
||||
label=escape(qc.label), required=required,
|
||||
help_text=rich_text(help_text),
|
||||
initial=_initial,
|
||||
widget=DatePickerWidget(attrs),
|
||||
)
|
||||
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 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 initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).time()
|
||||
@@ -827,29 +798,29 @@ class BaseQuestionsForm(forms.Form):
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.TimeField(
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
label=escape(qc.label), required=required,
|
||||
help_text=rich_text(qc.help_text),
|
||||
initial=_initial,
|
||||
widget=TimePickerWidget(without_seconds=True),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
help_text = q.help_text
|
||||
elif datafield.type == Question.TYPE_DATETIME:
|
||||
help_text = qc.help_text
|
||||
if not help_text:
|
||||
if q.valid_datetime_min and q.valid_datetime_max:
|
||||
if datafield.valid_datetime_min and datafield.valid_datetime_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date and time between {min} and {max}.'),
|
||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
min=date_format(datafield.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
max=date_format(datafield.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
elif q.valid_datetime_min:
|
||||
elif datafield.valid_datetime_min:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date and time no earlier than {min}.'),
|
||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
min=date_format(datafield.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
elif q.valid_datetime_max:
|
||||
elif datafield.valid_datetime_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date and time no later than {max}.'),
|
||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
max=date_format(datafield.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
|
||||
if initial and initial.answer:
|
||||
@@ -861,20 +832,20 @@ class BaseQuestionsForm(forms.Form):
|
||||
_initial = None
|
||||
|
||||
field = SplitDateTimeField(
|
||||
label=escape(q.question), required=required,
|
||||
label=escape(qc.label), required=required,
|
||||
help_text=rich_text(help_text),
|
||||
initial=_initial,
|
||||
widget=SplitDateTimePickerWidget(
|
||||
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
||||
min_date=q.valid_datetime_min,
|
||||
max_date=q.valid_datetime_max
|
||||
min_date=datafield.valid_datetime_min,
|
||||
max_date=datafield.valid_datetime_max
|
||||
),
|
||||
)
|
||||
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 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 initial:
|
||||
try:
|
||||
initial = PhoneNumber().from_string(initial.answer)
|
||||
@@ -887,28 +858,40 @@ class BaseQuestionsForm(forms.Form):
|
||||
initial = "+{}.".format(phone_prefix)
|
||||
|
||||
field = PhoneNumberField(
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
label=escape(qc.label), required=required,
|
||||
help_text=rich_text(qc.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.question = q
|
||||
else:
|
||||
raise ProgrammingError('Invalid question type')
|
||||
field.datafield = datafield
|
||||
if answers:
|
||||
# Cache the answer object for later use
|
||||
field.answer = answers[0]
|
||||
|
||||
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
|
||||
if qc.dependency_question_id:
|
||||
field.widget.attrs['data-question-dependency'] = f"question_{qc.dependency_question.user_datafield_id}" if qc.dependency_question.user_datafield_id else qc.dependency_question.system_datafield
|
||||
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
|
||||
field.required = False
|
||||
|
||||
return field
|
||||
|
||||
def build_text_block(self, request, event, qc):
|
||||
return forms.CharField( # TODO(questionnaires): use a less hacky way to format this field
|
||||
required=False,
|
||||
label="",
|
||||
help_text=rich_text((f"#### {qc.label}\n" if qc.label else "") + str(qc.help_text)),
|
||||
disabled=True,
|
||||
widget=QuestionCheckboxSelectMultiple,
|
||||
)
|
||||
|
||||
def check_user_questions(self, d):
|
||||
question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
|
||||
|
||||
@@ -960,8 +943,7 @@ class OrderLevelQuestionsForm(BaseQuestionsForm):
|
||||
"""
|
||||
Takes two additional keyword arguments:
|
||||
|
||||
:param checkoutsession: The checkout session the form should be for
|
||||
:param order: The order the form should be for
|
||||
:param container: The checkout session or order the form should be for
|
||||
:param event: The event this belongs to
|
||||
"""
|
||||
request = kwargs.pop('request', None)
|
||||
@@ -970,14 +952,35 @@ class OrderLevelQuestionsForm(BaseQuestionsForm):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
questions = Question.objects.filter(
|
||||
event=event, container_type=Question.ContainerType.ORDER,
|
||||
ask_during_checkin=False, hidden=False,
|
||||
).order_by('position')
|
||||
questionnaires = Questionnaire.objects.filter(
|
||||
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=container.sales_channel.identifier),
|
||||
event=event, type=Questionnaire.QuestionnaireType.ORDER_SALE,
|
||||
).order_by('position').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')
|
||||
)
|
||||
answerlist = container.answers.prefetch_related('options')
|
||||
|
||||
for q in questions:
|
||||
self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, answerlist, q)
|
||||
idx = 0
|
||||
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, answerlist, child, df)
|
||||
else:
|
||||
self.fields['text_%d' % idx] = self.build_text_block(request, event, child)
|
||||
idx += 1
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
@@ -1004,6 +1007,7 @@ 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
|
||||
@@ -1013,18 +1017,17 @@ 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)
|
||||
|
||||
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)
|
||||
idx = 0
|
||||
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)
|
||||
else:
|
||||
self.fields['text_%d' % idx] = self.build_text_block(request, event, child)
|
||||
idx += 1
|
||||
|
||||
responses = question_form_fields.send(sender=event, position=pos)
|
||||
data = pos.meta_info_data
|
||||
@@ -1089,21 +1092,21 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
)
|
||||
|
||||
def build_system_question_field(self, request, event, pos, qc):
|
||||
field_name = qc.id
|
||||
field_name = qc.system_datafield
|
||||
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.question),
|
||||
label=escape(qc.label),
|
||||
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.question),
|
||||
label=escape(qc.label),
|
||||
help_text=rich_text(qc.help_text),
|
||||
initial=pos.attendee_email,
|
||||
widget=forms.EmailInput(
|
||||
@@ -1115,7 +1118,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
if field_name == 'company':
|
||||
return forms.CharField(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=escape(qc.question),
|
||||
label=escape(qc.label),
|
||||
help_text=rich_text(qc.help_text),
|
||||
max_length=255,
|
||||
initial=pos.company,
|
||||
@@ -1124,7 +1127,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
if field_name == 'street':
|
||||
return forms.CharField(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=escape(qc.question),
|
||||
label=escape(qc.label),
|
||||
help_text=rich_text(qc.help_text),
|
||||
widget=forms.Textarea(attrs={
|
||||
'rows': 2,
|
||||
@@ -1137,7 +1140,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
return forms.CharField(
|
||||
required=False,
|
||||
max_length=30,
|
||||
label=escape(qc.question),
|
||||
label=escape(qc.label),
|
||||
help_text=rich_text(qc.help_text),
|
||||
initial=pos.zipcode,
|
||||
widget=forms.TextInput(attrs={
|
||||
@@ -1147,7 +1150,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
if field_name == 'city':
|
||||
return forms.CharField(
|
||||
required=False,
|
||||
label=escape(qc.question),
|
||||
label=escape(qc.label),
|
||||
help_text=rich_text(qc.help_text),
|
||||
max_length=255,
|
||||
initial=pos.city,
|
||||
@@ -1161,7 +1164,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
countries=CachedCountries
|
||||
).formfield(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=escape(qc.question),
|
||||
label=escape(qc.label),
|
||||
help_text=rich_text(qc.help_text),
|
||||
initial=country,
|
||||
widget=forms.Select(attrs={
|
||||
@@ -1189,7 +1192,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
del self.data[fprefix + 'state']
|
||||
|
||||
field = forms.ChoiceField(
|
||||
label=escape(qc.question),
|
||||
label=escape(qc.label),
|
||||
help_text=rich_text(qc.help_text),
|
||||
required=False,
|
||||
choices=c,
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# 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('state', _('State'), sqo.get('country', 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', '0310_question_valid_string_length_min'),
|
||||
]
|
||||
|
||||
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
|
||||
]
|
||||
@@ -37,8 +37,8 @@ from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
|
||||
ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question,
|
||||
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
|
||||
itempicture_upload_to,
|
||||
Questionnaire, QuestionnaireChild, QuestionOption, Quota, SubEventItem,
|
||||
SubEventItemVariation, itempicture_upload_to,
|
||||
)
|
||||
from .log import LogEntry
|
||||
from .mail import OutgoingMail
|
||||
|
||||
@@ -737,6 +737,9 @@ class Event(EventMixin, LoggedModel):
|
||||
self.settings.mail_send_order_approved_attendee = True
|
||||
self.settings.mail_send_order_approved_free_attendee = True
|
||||
self.settings.mail_send_download_reminder_attendee = True
|
||||
from . import Questionnaire
|
||||
q = self.questionnaires.create(internal_name=str(_('Attendee data')), type=Questionnaire.QuestionnaireType.ORDER_POSITION_SALE, position=0)
|
||||
q.children.create(label=str(_('Attendee name')), system_datafield='attendee_name_parts', position=0)
|
||||
|
||||
@property
|
||||
def social_image(self):
|
||||
|
||||
+160
-54
@@ -1569,10 +1569,12 @@ class ItemBundle(models.Model):
|
||||
|
||||
class Question(LoggedModel):
|
||||
"""
|
||||
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 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 number (``TYPE_NUMBER``)
|
||||
* a one-line string (``TYPE_STRING``)
|
||||
@@ -1592,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: A set of ``Items`` objects that this question should be applied to
|
||||
:param items: TO BE REMOVED
|
||||
: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.
|
||||
@@ -1609,6 +1611,22 @@ class Question(LoggedModel):
|
||||
class ContainerType(models.TextChoices):
|
||||
ORDER = "O", _("Order")
|
||||
ORDERPOSITION = "P", _("Order position")
|
||||
|
||||
class FieldType(models.TextChoices):
|
||||
NUMBER = "N", _("Number")
|
||||
STRING = "S", _("Text (one line)")
|
||||
TEXT = "T", _("Multiline text")
|
||||
BOOLEAN = "B", _("Yes/No")
|
||||
CHOICE = "C", _("Choose one from a list")
|
||||
CHOICE_MULTIPLE = "M", _("Choose multiple from a list")
|
||||
FILE = "F", _("File upload")
|
||||
DATE = "D", _("Date")
|
||||
TIME = "H", _("Time")
|
||||
DATETIME = "W", _("Date and time")
|
||||
COUNTRYCODE = "CC", _("Country code (ISO 3166-1 alpha-2)")
|
||||
PHONENUMBER = "TEL", _("Phone number")
|
||||
|
||||
# compat
|
||||
TYPE_NUMBER = "N"
|
||||
TYPE_STRING = "S"
|
||||
TYPE_TEXT = "T"
|
||||
@@ -1621,20 +1639,8 @@ class Question(LoggedModel):
|
||||
TYPE_DATETIME = "W"
|
||||
TYPE_COUNTRYCODE = "CC"
|
||||
TYPE_PHONENUMBER = "TEL"
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_NUMBER, _("Number")),
|
||||
(TYPE_STRING, _("Text (one line)")),
|
||||
(TYPE_TEXT, _("Multiline text")),
|
||||
(TYPE_BOOLEAN, _("Yes/No")),
|
||||
(TYPE_CHOICE, _("Choose one from a list")),
|
||||
(TYPE_CHOICE_MULTIPLE, _("Choose multiple from a list")),
|
||||
(TYPE_FILE, _("File upload")),
|
||||
(TYPE_DATE, _("Date")),
|
||||
(TYPE_TIME, _("Time")),
|
||||
(TYPE_DATETIME, _("Date and time")),
|
||||
(TYPE_COUNTRYCODE, _("Country code (ISO 3166-1 alpha-2)")),
|
||||
(TYPE_PHONENUMBER, _("Phone number")),
|
||||
)
|
||||
TYPE_CHOICES = FieldType.choices
|
||||
|
||||
UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME]
|
||||
ASK_DURING_CHECKIN_UNSUPPORTED = []
|
||||
SHOW_DURING_CHECKIN_UNSUPPORTED = [TYPE_FILE]
|
||||
@@ -1650,8 +1656,8 @@ class Question(LoggedModel):
|
||||
verbose_name=_("Asked on"),
|
||||
default=ContainerType.ORDERPOSITION,
|
||||
)
|
||||
question = I18nTextField(
|
||||
verbose_name=_("Question")
|
||||
question = I18nTextField( # TODO(questionnaires) : to be renamed to 'internal_name'
|
||||
verbose_name=_("Internal name"),
|
||||
)
|
||||
identifier = models.CharField(
|
||||
max_length=190,
|
||||
@@ -1665,42 +1671,43 @@ class Question(LoggedModel):
|
||||
),
|
||||
],
|
||||
)
|
||||
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,
|
||||
)
|
||||
#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,
|
||||
#)
|
||||
type = models.CharField(
|
||||
max_length=5,
|
||||
choices=TYPE_CHOICES,
|
||||
choices=FieldType.choices,
|
||||
verbose_name=_("Question type")
|
||||
)
|
||||
required = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Required question")
|
||||
)
|
||||
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(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
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
|
||||
)
|
||||
#required = models.BooleanField( # TODO(questionnaires) : to be removed, -> QuestionnaireChild
|
||||
# default=False,
|
||||
# verbose_name=_("Required question")
|
||||
#)
|
||||
#items = models.ManyToManyField( # TODO(questionnaires) : to be removed, -> Questionnaire
|
||||
# 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
|
||||
# default=0,
|
||||
# verbose_name=_("Position")
|
||||
#)
|
||||
#ask_during_checkin = models.BooleanField( # TODO(questionnaires) : to be removed
|
||||
# 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
|
||||
#)
|
||||
show_during_checkin = models.BooleanField(
|
||||
verbose_name=_('Show answer during check-in'),
|
||||
help_text=_('Not supported by all check-in apps for all question types.'),
|
||||
default=False
|
||||
)
|
||||
hidden = models.BooleanField(
|
||||
hidden = models.BooleanField( # to be removed
|
||||
verbose_name=_('Hidden question'),
|
||||
help_text=_('This question will only show up in the backend.'),
|
||||
default=False
|
||||
@@ -1709,10 +1716,10 @@ class Question(LoggedModel):
|
||||
verbose_name=_('Print answer on invoices'),
|
||||
default=False
|
||||
)
|
||||
dependency_question = models.ForeignKey(
|
||||
dependency_question = models.ForeignKey( # TODO(questionnaires) : to be removed, -> QuestionnaireChild
|
||||
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
||||
)
|
||||
dependency_values = MultiStringField(default=[])
|
||||
dependency_values = MultiStringField(default=[]) # TODO(questionnaires) : to be removed, -> QuestionnaireChild
|
||||
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'))
|
||||
@@ -1751,9 +1758,9 @@ class Question(LoggedModel):
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Question")
|
||||
verbose_name_plural = _("Questions")
|
||||
ordering = ('position', 'id')
|
||||
verbose_name = _("Data field")
|
||||
verbose_name_plural = _("Data fields")
|
||||
ordering = ('question', 'id')
|
||||
unique_together = (('event', 'identifier'),)
|
||||
|
||||
def __str__(self):
|
||||
@@ -1904,7 +1911,7 @@ class Question(LoggedModel):
|
||||
return answer
|
||||
|
||||
@staticmethod
|
||||
def clean_items(event, items):
|
||||
def clean_items(event, items): # TODO(questionnaires) : remove method / move to qc
|
||||
for item in items:
|
||||
if event != item.event:
|
||||
raise ValidationError(_('One or more items do not belong to this event.'))
|
||||
@@ -1985,6 +1992,105 @@ class QuestionOption(models.Model):
|
||||
ordering = ('position', 'id')
|
||||
|
||||
|
||||
class Questionnaire(LoggedModel):
|
||||
class QuestionnaireType(models.TextChoices):
|
||||
ORDER_SALE = "OS", _("Order-wide, before purchase")
|
||||
ORDER_POSITION_SALE = "PS", _("Per product, before purchase")
|
||||
ORDER_POSITION_ATTENDEE_ONLY = "PA", _("Per product, via attendee link")
|
||||
ORDER_POSITION_CHECKIN = "PC", _("Per product, at check-in")
|
||||
ORDER_POSITION_HIDDEN = "PH", _("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=QuestionnaireType.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 Meta:
|
||||
ordering = ('position', 'id')
|
||||
|
||||
|
||||
class QuestionnaireChild(LoggedModel):
|
||||
class SystemQuestion(models.TextChoices):
|
||||
ATTENDEE_NAME_PARTS = 'attendee_name_parts', _('Attendee name')
|
||||
ATTENDEE_EMAIL = 'attendee_email', _('Attendee email')
|
||||
COMPANY = 'company', _('Company')
|
||||
STREET = 'street', _('Street')
|
||||
ZIPCODE = 'zipcode', _('ZIP code')
|
||||
CITY = 'city', _('City')
|
||||
COUNTRY = '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=SystemQuestion.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 Meta:
|
||||
ordering = ('position', 'id')
|
||||
|
||||
|
||||
class Quota(LoggedModel):
|
||||
"""
|
||||
A quota is a "pool of tickets". It is there to limit the number of items
|
||||
|
||||
@@ -1449,6 +1449,12 @@ 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.')
|
||||
@@ -1593,53 +1599,85 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
|
||||
|
||||
def cache_answers(self, all=True):
|
||||
"""
|
||||
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
|
||||
Creates a new property on the object:
|
||||
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, 'questions_to_ask'):
|
||||
questions = list(copy.copy(q) for q in self.item.questions_to_ask)
|
||||
if hasattr(self.item, 'relevant_questionnaires'):
|
||||
children = list(copy.copy(qc) for qq in self.item.relevant_questionnaires for qc in qq.childlist)
|
||||
else:
|
||||
questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False,
|
||||
hidden=False))
|
||||
children = list(copy.copy(qc) for qq in self.item.questionnaires.filter(type='PS') for qc in qq.children.all())
|
||||
else:
|
||||
questions = list(copy.copy(q) for q in self.item.questions.all())
|
||||
children = list(copy.copy(qc) for qq in self.item.questionnaires.filter(type__startswith='P') for qc in qq.children.all())
|
||||
|
||||
question_cache = {
|
||||
q.pk: q for q in questions
|
||||
qc_cache = {
|
||||
q.pk: q for q in children
|
||||
}
|
||||
|
||||
def question_is_visible(parentid, qvals):
|
||||
if parentid not in question_cache:
|
||||
def qc_is_visible(parentid, qvals):
|
||||
if parentid not in qc_cache:
|
||||
return False
|
||||
parentq = question_cache[parentid]
|
||||
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
|
||||
parentqc = qc_cache[parentid]
|
||||
if parentqc.dependency_question_id and not qc_is_visible(parentqc.dependency_question_id, parentqc.dependency_values):
|
||||
return False
|
||||
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))
|
||||
)
|
||||
answer_values = self.get_dependency_answer_values(parentqc)
|
||||
return any(qval in answer_values for qval in qvals)
|
||||
|
||||
self.questions = []
|
||||
for q in questions:
|
||||
if q.id in self.answ:
|
||||
q.answer = self.answ[q.id]
|
||||
q.answer.question = q # cache object
|
||||
for qc in children:
|
||||
if qc.user_datafield_id:
|
||||
if qc.user_datafield_id in self.answer_cache:
|
||||
qc.answer = self.answer_cache[qc.user_datafield_id]
|
||||
else:
|
||||
qc.answer = ""
|
||||
#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
|
||||
else:
|
||||
q.answer = ""
|
||||
if not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values):
|
||||
self.questions.append(q)
|
||||
continue
|
||||
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
|
||||
elif system_datafield_name == 'attendee_email':
|
||||
return self.attendee_email
|
||||
elif system_datafield_name == 'company':
|
||||
return self.company
|
||||
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')
|
||||
|
||||
@property
|
||||
def net_price(self):
|
||||
@@ -1652,20 +1690,21 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
|
||||
else self.variation.quotas.filter(subevent=self.subevent))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get('update_fields', set())
|
||||
if 'attendee_name_parts' in update_fields:
|
||||
kwargs['update_fields'] = {'attendee_name_cached'}.union(kwargs['update_fields'])
|
||||
def ensure_updated(fieldname):
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {fieldname}.union(kwargs['update_fields'])
|
||||
|
||||
if 'attendee_name_parts' in kwargs.get('update_fields', set()):
|
||||
ensure_updated('attendee_name_cached')
|
||||
|
||||
name = self.attendee_name
|
||||
if name != self.attendee_name_cached:
|
||||
self.attendee_name_cached = name
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {'attendee_name_cached'}.union(kwargs['update_fields'])
|
||||
ensure_updated('attendee_name_cached')
|
||||
|
||||
if self.attendee_name_parts is None:
|
||||
self.attendee_name_parts = {}
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {'attendee_name_parts'}.union(kwargs['update_fields'])
|
||||
ensure_updated('attendee_name_parts')
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
|
||||
@@ -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
|
||||
from pretix.presale.views import get_cart_positions
|
||||
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(request)
|
||||
cart = get_cart_positions(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(request)
|
||||
positions = get_cart_positions(request)
|
||||
testmode = self.event.testmode
|
||||
else:
|
||||
used_cards = []
|
||||
|
||||
@@ -379,99 +379,12 @@ DEFAULTS = {
|
||||
|
||||
)
|
||||
},
|
||||
'system_question_order': {
|
||||
'system_question_order': { # TODO(questionnaires) - remove this
|
||||
'default': {},
|
||||
'type': dict,
|
||||
'serializer_class': serializers.DictField,
|
||||
'serializer_kwargs': lambda: dict(read_only=True, allow_empty=True),
|
||||
},
|
||||
'attendee_names_asked': {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Ask for attendee names"),
|
||||
help_text=_("Ask for a name for all personalized tickets."),
|
||||
)
|
||||
},
|
||||
'attendee_names_required': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Require attendee names"),
|
||||
help_text=_("Require customers to fill in the names of all attendees."),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_names_asked'}),
|
||||
)
|
||||
},
|
||||
'attendee_emails_asked': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Ask for email addresses per ticket"),
|
||||
help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be sent "
|
||||
"only to that email address. If you enable this option, the system will additionally ask for "
|
||||
"individual email addresses for every personalized ticket. This might be useful if you want to "
|
||||
"obtain individual addresses for every attendee even in case of group orders. However, "
|
||||
"pretix will send the order confirmation by default only to the one primary email address, not to "
|
||||
"the per-attendee addresses. You can however enable this in the email settings."),
|
||||
)
|
||||
},
|
||||
'attendee_emails_required': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Require email addresses per ticket"),
|
||||
help_text=_("Require customers to fill in individual email addresses for all personalized tickets. See the "
|
||||
"above option for more details. One email address for the order confirmation will always be "
|
||||
"required regardless of this setting."),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}),
|
||||
)
|
||||
},
|
||||
'attendee_company_asked': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Ask for company per ticket"),
|
||||
)
|
||||
},
|
||||
'attendee_company_required': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Require company per ticket"),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_company_asked'}),
|
||||
)
|
||||
},
|
||||
'attendee_addresses_asked': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Ask for postal addresses per ticket"),
|
||||
)
|
||||
},
|
||||
'attendee_addresses_required': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Require postal addresses per ticket"),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_addresses_asked'}),
|
||||
)
|
||||
},
|
||||
'order_email_asked_twice': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
|
||||
@@ -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, QuerySet
|
||||
from django.db.models import Prefetch, Q, 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,
|
||||
QuestionOption,
|
||||
Questionnaire, QuestionnaireChild, QuestionOption,
|
||||
)
|
||||
from pretix.base.models.customers import AttendeeProfile
|
||||
from pretix.base.models.orders import CheckoutSession, Order
|
||||
@@ -119,8 +119,8 @@ class BaseQuestionsViewMixin:
|
||||
override_sets = self.get_question_override_sets(cr, idx)
|
||||
for overrides in override_sets:
|
||||
for question_name, question_field in form.fields.items():
|
||||
if hasattr(question_field, 'question'):
|
||||
src = overrides.get(question_field.question.identifier)
|
||||
if hasattr(question_field, 'datafield'):
|
||||
src = overrides.get(question_field.datafield.identifier)
|
||||
else:
|
||||
src = overrides.get(question_name)
|
||||
if not src:
|
||||
@@ -184,7 +184,7 @@ class BaseQuestionsViewMixin:
|
||||
field, v,
|
||||
checkoutsession=checkoutsession,
|
||||
order=order,
|
||||
question=field.question,
|
||||
question=field.datafield,
|
||||
)
|
||||
|
||||
for form in self.forms:
|
||||
@@ -246,7 +246,7 @@ class BaseQuestionsViewMixin:
|
||||
field, v,
|
||||
cartposition=cartposition,
|
||||
orderposition=orderposition,
|
||||
question=field.question,
|
||||
question=field.datafield,
|
||||
)
|
||||
|
||||
answer_dict = self._build_answer_dict(field, answer, k)
|
||||
@@ -331,8 +331,8 @@ class BaseQuestionsViewMixin:
|
||||
'field_name': k,
|
||||
'field_label': str(field.label),
|
||||
'value': answer_value,
|
||||
'question_type': field.question.type,
|
||||
'question_identifier': field.question.identifier,
|
||||
'question_type': field.datafield.type,
|
||||
'question_identifier': field.datafield.identifier,
|
||||
}
|
||||
|
||||
|
||||
@@ -348,27 +348,37 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
||||
|
||||
@cached_property
|
||||
def positions(self):
|
||||
qqs = self.request.event.questions.all()
|
||||
qqs = self.request.event.questionnaires.all()
|
||||
if self.only_user_visible:
|
||||
qqs = qqs.filter(ask_during_checkin=False, hidden=False, container_type=Question.ContainerType.ORDERPOSITION)
|
||||
qqs = qqs.filter(type=Questionnaire.QuestionnaireType.ORDER_POSITION_SALE)
|
||||
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)
|
||||
)
|
||||
return list(self.order.positions.select_related(
|
||||
'item', 'variation'
|
||||
).prefetch_related(
|
||||
Prefetch('answers',
|
||||
QuestionAnswer.objects.prefetch_related('options'),
|
||||
to_attr='answerlist'),
|
||||
Prefetch('item__questions',
|
||||
Prefetch('item__questionnaires',
|
||||
qqs.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'
|
||||
)))
|
||||
).select_related('dependency_question'),
|
||||
to_attr='questions_to_ask')
|
||||
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')
|
||||
))
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -26,6 +26,7 @@ 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__)
|
||||
|
||||
@@ -60,6 +61,7 @@ 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,
|
||||
})
|
||||
|
||||
|
||||
@@ -639,14 +639,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
|
||||
'event_calendar_future_only',
|
||||
'frontpage_text',
|
||||
'event_info_text',
|
||||
'attendee_names_asked',
|
||||
'attendee_names_required',
|
||||
'attendee_emails_asked',
|
||||
'attendee_emails_required',
|
||||
'attendee_company_asked',
|
||||
'attendee_company_required',
|
||||
'attendee_addresses_asked',
|
||||
'attendee_addresses_required',
|
||||
'attendee_data_explanation_text',
|
||||
'order_phone_asked',
|
||||
'order_phone_required',
|
||||
|
||||
@@ -153,53 +153,10 @@ class QuestionForm(I18nModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance.container_type == Question.ContainerType.ORDERPOSITION:
|
||||
self.fields['items'].queryset = self.instance.event.items.all()
|
||||
self.fields['items'].required = True
|
||||
else:
|
||||
del self.fields['items']
|
||||
del self.fields['ask_during_checkin']
|
||||
if self.instance.container_type != Question.ContainerType.ORDERPOSITION:
|
||||
del self.fields['show_during_checkin']
|
||||
del self.fields['print_on_invoice']
|
||||
self.fields['dependency_question'].widget.attrs['data-container-type'] = self.instance.container_type
|
||||
self.fields['dependency_question'].queryset = self.instance.event.questions.filter(
|
||||
type__in=(Question.TYPE_BOOLEAN, Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE),
|
||||
ask_during_checkin=False,
|
||||
container_type=self.instance.container_type,
|
||||
)
|
||||
if self.instance.pk:
|
||||
self.fields['dependency_question'].queryset = self.fields['dependency_question'].queryset.exclude(
|
||||
pk=self.instance.pk
|
||||
)
|
||||
self.fields['identifier'].required = False
|
||||
self.fields['dependency_values'].required = False
|
||||
self.fields['help_text'].widget.attrs['rows'] = 3
|
||||
|
||||
def clean_dependency_values(self):
|
||||
val = self.data.getlist('dependency_values')
|
||||
return val
|
||||
|
||||
def clean_dependency_question(self):
|
||||
dep = val = self.cleaned_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 val
|
||||
|
||||
def clean_ask_during_checkin(self):
|
||||
val = self.cleaned_data.get('ask_during_checkin')
|
||||
|
||||
if val and self.cleaned_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
|
||||
raise ValidationError(_('This type of question cannot be asked during check-in.'))
|
||||
|
||||
return val
|
||||
|
||||
def clean_show_during_checkin(self):
|
||||
val = self.cleaned_data.get('show_during_checkin')
|
||||
@@ -233,16 +190,10 @@ class QuestionForm(I18nModelForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'question',
|
||||
'help_text',
|
||||
'type',
|
||||
'required',
|
||||
'ask_during_checkin',
|
||||
'show_during_checkin',
|
||||
'hidden',
|
||||
'identifier',
|
||||
'items',
|
||||
'dependency_question',
|
||||
'dependency_values',
|
||||
'print_on_invoice',
|
||||
'valid_number_min',
|
||||
'valid_number_max',
|
||||
@@ -259,17 +210,11 @@ class QuestionForm(I18nModelForm):
|
||||
'valid_datetime_max': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'valid_date_min': DatePickerWidget(),
|
||||
'valid_date_max': DatePickerWidget(),
|
||||
'items': forms.CheckboxSelectMultiple(
|
||||
attrs={'class': 'scrolling-multiple-choice'}
|
||||
),
|
||||
'dependency_values': forms.SelectMultiple,
|
||||
'help_text': I18nMarkdownTextarea,
|
||||
}
|
||||
field_classes = {
|
||||
'valid_datetime_min': SplitDateTimeField,
|
||||
'valid_datetime_max': SplitDateTimeField,
|
||||
'items': ItemMultipleChoiceField,
|
||||
'dependency_question': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
@@ -1197,6 +1142,10 @@ class ItemAddOnForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['addon_category'].queryset = self.event.categories.all()
|
||||
self.fields['addon_category'].help_text = format_html('<a href="javascript:" data-django-dialog="{}?notify_parent=true">{}</a>', reverse('control:event.items.categories.add', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}), _("Create new category"))
|
||||
self.fields['addon_category'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
|
||||
@@ -861,6 +861,9 @@ 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.'),
|
||||
|
||||
@@ -182,12 +182,12 @@ def get_event_navigation(request: HttpRequest):
|
||||
'active': 'event.items.categories' in url.url_name,
|
||||
},
|
||||
{
|
||||
'label': _('Questions'),
|
||||
'url': reverse('control:event.items.questions', kwargs={
|
||||
'label': _('Questionnaires'),
|
||||
'url': reverse('control:event.items.questionnaires', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug,
|
||||
}),
|
||||
'active': 'event.items.questions' in url.url_name,
|
||||
'active': 'event.items.questionnaires' in url.url_name or 'event.items.questions' in url.url_name,
|
||||
},
|
||||
{
|
||||
'label': _('Discounts'),
|
||||
|
||||
@@ -94,13 +94,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>{% trans "Attendee data (once per personalized ticket)" %}</h4>
|
||||
|
||||
{% bootstrap_field sform.attendee_names_asked_required layout="control" %}
|
||||
{% bootstrap_field sform.attendee_emails_asked_required layout="control" %}
|
||||
{% bootstrap_field sform.attendee_company_asked_required layout="control" %}
|
||||
{% bootstrap_field sform.attendee_addresses_asked_required layout="control" %}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-md-3">
|
||||
{% trans "Custom fields" %}
|
||||
@@ -109,11 +102,27 @@
|
||||
<p>
|
||||
<a href="{% url "control:event.items.questions" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
target="_blank">
|
||||
{% trans "Manage questions" %}
|
||||
{% trans "Manage questionnaires" %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>{% trans "Attendee data (once per personalized ticket)" %}</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-md-3">
|
||||
</label>
|
||||
<div class="col-md-9 static-form-row">
|
||||
<p>
|
||||
<a href="{% url "control:event.items.questions" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
target="_blank">
|
||||
{% trans "Manage questionnaires" %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% bootstrap_field sform.attendee_data_explanation_text layout="control" %}
|
||||
|
||||
<h4>{% trans "Form settings" %}</h4>
|
||||
|
||||
@@ -5,158 +5,124 @@
|
||||
{% load escapejson %}
|
||||
{% block title %}
|
||||
{% if question %}
|
||||
{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}
|
||||
{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}
|
||||
{% else %}
|
||||
{% trans "Question" %}
|
||||
{% trans "Create new data field" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block inside %}
|
||||
{% if question %}
|
||||
<h1>{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}</h1>
|
||||
<h1>{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}</h1>
|
||||
{% else %}
|
||||
<h1>{% trans "Question" %}</h1>
|
||||
<h1>{% trans "Create new data field" %}</h1>
|
||||
{% endif %}
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<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>
|
||||
<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> </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>
|
||||
<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> </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>
|
||||
{% 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> </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>
|
||||
<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> </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>
|
||||
{% bootstrap_field form.identifier layout="control" %}
|
||||
{% 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" %}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{% 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" }}
|
||||
{{ question_type_choices|json_script:"question_type_choices" }}
|
||||
{{ system_question_choices|json_script:"system_question_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" }}
|
||||
|
||||
{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=0 as datafield_view_url %}
|
||||
{{ datafield_view_url|json_script:"datafield_view_url" }}
|
||||
|
||||
{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug as datafield_create_url %}
|
||||
{{ datafield_create_url|json_script:"datafield_create_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 "Questions" %}{% endblock %}
|
||||
{% block title %}{% trans "Data fields" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Questions" %}</h1>
|
||||
<h1>{% trans "Data fields" %}</h1>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Questions allow your attendees to fill in additional data about their ticket. If you provide food, one
|
||||
@@ -11,63 +11,40 @@
|
||||
</p>
|
||||
{% csrf_token %}
|
||||
|
||||
{% 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 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 question" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<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 question" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% 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=O" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new order-level data field" %}
|
||||
</a>
|
||||
<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>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h2>{% trans "Per-order data fields" %}</h2>
|
||||
<p>{% trans "These data fields can be used on orders." %}</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Question" %}</th>
|
||||
<th>{% trans "Internal name" %}</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 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 data-dnd-id="{{ q.id }}">
|
||||
<tbody>
|
||||
{% for q in questions %}{% if q.container_type == "O" %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>
|
||||
{% 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>
|
||||
{% if q.pk %}
|
||||
{{ q.get_type_display }}
|
||||
{% else %}
|
||||
{% trans "System question" %}
|
||||
{% endif %}
|
||||
{{ q.get_type_display }}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.required %}
|
||||
@@ -84,26 +61,8 @@
|
||||
<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">
|
||||
{% 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>
|
||||
@@ -121,85 +80,61 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if request.event.settings.feature_flag_order_level_questions %}
|
||||
<h2>
|
||||
{% 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 "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 questions are asked once per order." %}</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<h2>{% trans "Per-ticket data fields" %}</h2>
|
||||
<p>{% trans "These data field can be used on individual tickets." %}</p>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Internal name" %}</th>
|
||||
<th>{% trans "Type" %}</th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for q in questions %}{% if q.container_type == "P" %}
|
||||
<tr>
|
||||
<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 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 data-dnd-id="{{ q.id }}">
|
||||
<td>
|
||||
<strong>
|
||||
<td>
|
||||
<strong>
|
||||
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
|
||||
{{ q.question }}
|
||||
</strong><br>
|
||||
<small class="text-muted">{{ q.identifier }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk %}
|
||||
{{ q.get_type_display }}
|
||||
{% else %}
|
||||
{% trans "System question" %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.required %}
|
||||
<span class="fa fa-exclamation-circle text-muted" data-toggle="tooltip" title="{% trans "Required question" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk and q.ask_during_checkin %}
|
||||
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk and q.hidden %}
|
||||
<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>
|
||||
</a>
|
||||
</strong><br>
|
||||
<small class="text-muted">{{ q.identifier }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{{ q.get_type_display }}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.required %}
|
||||
<span class="fa fa-exclamation-circle text-muted" data-toggle="tooltip" title="{% trans "Required question" %}"></span>
|
||||
{% endif %}
|
||||
<td class="text-right flip">
|
||||
{% if q.pk %}
|
||||
{% 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>
|
||||
{% endif %}{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk and q.ask_during_checkin %}
|
||||
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk and q.hidden %}
|
||||
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<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>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "error.html" %}
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% load urlreplace %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans "Please wait..." %}</h1>
|
||||
{{ notify_info|json_script:"notify_info" }}
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/notify_parent.js" %}"></script>
|
||||
{% endblock %}
|
||||
@@ -20,8 +20,10 @@
|
||||
{% endif %}
|
||||
{% elif question.type == "M" %}
|
||||
{{ answer.to_string_i18n|rich_text_snippet }}
|
||||
{% elif question.type %}
|
||||
{{ answer.to_string_i18n|rich_text_snippet }}
|
||||
{% else %}
|
||||
{{ answer.to_string_i18n|linebreaksbr }}
|
||||
{{ answer|linebreaksbr }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<em>{% trans "not answered" %}</em>
|
||||
|
||||
@@ -610,60 +610,9 @@
|
||||
{% 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.question }}
|
||||
{{ q.label }}
|
||||
{% if q.ask_during_checkin %}
|
||||
<span class="fa fa-qrcode text-muted"
|
||||
data-toggle="tooltip"
|
||||
@@ -672,7 +621,49 @@
|
||||
{% 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 %}
|
||||
|
||||
@@ -342,7 +342,6 @@ urlpatterns = [
|
||||
name='event.items.categories.edit'),
|
||||
re_path(r'^categories/add$', item.CategoryCreate.as_view(), name='event.items.categories.add'),
|
||||
re_path(r'^questions/$', item.QuestionList.as_view(), name='event.items.questions'),
|
||||
re_path(r'^questions/reorder$', item.reorder_questions, name='event.items.questions.reorder'),
|
||||
re_path(r'^questions/(?P<question>\d+)/delete$', item.QuestionDelete.as_view(),
|
||||
name='event.items.questions.delete'),
|
||||
re_path(r'^questions/(?P<question>\d+)/$', item.QuestionView.as_view(),
|
||||
@@ -350,6 +349,7 @@ 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'),
|
||||
|
||||
@@ -21,10 +21,15 @@
|
||||
#
|
||||
import collections.abc
|
||||
import warnings
|
||||
from errno import EMSGSIZE
|
||||
|
||||
from django.contrib import messages
|
||||
from django.core.paginator import (
|
||||
EmptyPage, PageNotAnInteger, UnorderedObjectListWarning,
|
||||
)
|
||||
from django.http.response import HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import edit
|
||||
|
||||
@@ -38,7 +43,22 @@ class EventBasedFormMixin:
|
||||
return kwargs
|
||||
|
||||
|
||||
class CreateView(EventBasedFormMixin, edit.CreateView):
|
||||
class TellParentFormMixin:
|
||||
|
||||
def form_valid(self, form):
|
||||
result = super().form_valid(form)
|
||||
if self.request.GET.get('notify_parent') and isinstance(result, HttpResponseRedirect):
|
||||
message_store = messages.get_messages(self.request)
|
||||
msgs = [{'level': msg.level_tag, 'message': msg.message} for msg in message_store]
|
||||
#message_store._queued_messages = []
|
||||
return render(self.request, 'pretixcontrol/notify_parent.html', {
|
||||
'notify_info': {'object': self.object.pk, 'redirect_url': result.url, 'messages': msgs},
|
||||
})
|
||||
else:
|
||||
return result
|
||||
|
||||
|
||||
class CreateView(EventBasedFormMixin, TellParentFormMixin, edit.CreateView):
|
||||
"""
|
||||
Like Django's default CreateView, but passes the optional event
|
||||
argument to the form. This is necessary for I18nModelForms to work
|
||||
@@ -47,7 +67,7 @@ class CreateView(EventBasedFormMixin, edit.CreateView):
|
||||
pass
|
||||
|
||||
|
||||
class UpdateView(EventBasedFormMixin, edit.UpdateView):
|
||||
class UpdateView(EventBasedFormMixin, TellParentFormMixin, edit.UpdateView):
|
||||
"""
|
||||
Like Django's default UpdateView, but passes the optional event
|
||||
argument to the form. This is necessary for I18nModelForms to work
|
||||
|
||||
@@ -1670,6 +1670,7 @@ class QuickSetupView(EventPermissionRequiredMixin, FormView):
|
||||
user=self.request.user)
|
||||
|
||||
subevent = self.request.event.subevents.first()
|
||||
questionnaire = self.request.event.questionnaires.first()
|
||||
for i, f in enumerate(self.formset):
|
||||
if f in self.formset.deleted_forms or not f.has_changed():
|
||||
continue
|
||||
@@ -1685,6 +1686,8 @@ class QuickSetupView(EventPermissionRequiredMixin, FormView):
|
||||
position=i,
|
||||
all_sales_channels=True,
|
||||
)
|
||||
if questionnaire:
|
||||
item.questionnaires.add(questionnaire)
|
||||
item.log_action('pretix.event.item.added', user=self.request.user, data=dict(f.cleaned_data))
|
||||
if f.cleaned_data['quota'] or not form.cleaned_data['total_quota']:
|
||||
quota = self.request.event.quotas.create(
|
||||
|
||||
@@ -56,23 +56,23 @@ 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, View
|
||||
from django.views.generic import FormView, ListView, TemplateView, View
|
||||
from django.views.generic.detail import DetailView, SingleObjectMixin
|
||||
from django_countries.fields import Country
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.api.serializers.item import (
|
||||
ItemAddOnSerializer, ItemBundleSerializer, ItemProgramTimeSerializer,
|
||||
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,
|
||||
OrderPosition, Question, QuestionAnswer, QuestionOption, QuestionnaireChild, Quota,
|
||||
SeatCategoryMapping, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
|
||||
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue, Questionnaire
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.services.tickets import invalidate_cache
|
||||
from pretix.base.signals import quota_availability
|
||||
@@ -437,66 +437,7 @@ class QuestionList(ListView):
|
||||
template_name = 'pretixcontrol/items/questions.html'
|
||||
|
||||
def get_queryset(self):
|
||||
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()
|
||||
return self.request.event.questions.all()
|
||||
|
||||
|
||||
class QuestionDelete(EventPermissionRequiredMixin, CompatDeleteView):
|
||||
@@ -614,7 +555,7 @@ class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView
|
||||
question=self.object, orderposition__isnull=False,
|
||||
)
|
||||
qs = qs.filter(orderposition__in=opqs)
|
||||
op_cnt = opqs.filter(item__in=self.object.items.all()).count()
|
||||
op_cnt = 0 # TODO opqs.filter(item__in=self.object.items.all()).count()
|
||||
|
||||
if self.object.type == Question.TYPE_FILE:
|
||||
qs = [
|
||||
@@ -659,7 +600,7 @@ class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data()
|
||||
ctx['items'] = self.object.items.exists()
|
||||
#ctx['items'] = self.object.items.exists()
|
||||
ctx['has_subevents'] = self.request.event.has_subevents
|
||||
stats = self.get_answer_statistics()
|
||||
ctx['stats'], ctx['total'] = stats
|
||||
@@ -766,6 +707,29 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
|
||||
return ret
|
||||
|
||||
|
||||
def textchoices_to_json(choices, event):
|
||||
return [(c.name, c.value, i18n_all(event.settings.locales, LazyI18nString.from_gettext(c.label).data)) for c in choices]
|
||||
|
||||
|
||||
def i18n_all(locales, data):
|
||||
out = {}
|
||||
for locale in locales:
|
||||
out[locale] = data[locale]
|
||||
return out
|
||||
|
||||
|
||||
class QuestionnairesEditor(EventPermissionRequiredMixin, TemplateView):
|
||||
permission = 'can_change_items'
|
||||
template_name = 'pretixcontrol/items/questionnaires.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['questionnaire_type_choices'] = textchoices_to_json(Questionnaire.QuestionnaireType, self.request.event)
|
||||
ctx['system_question_choices'] = textchoices_to_json(QuestionnaireChild.SystemQuestion, self.request.event)
|
||||
ctx['question_type_choices'] = textchoices_to_json(Question.FieldType, self.request.event)
|
||||
return ctx
|
||||
|
||||
|
||||
class QuotaQueryMixin:
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -98,7 +98,9 @@ from pretix.presale.signals import (
|
||||
question_form_fields_overrides,
|
||||
)
|
||||
from pretix.presale.utils import customer_login
|
||||
from pretix.presale.views import CartMixin, get_cart, get_cart_is_free
|
||||
from pretix.presale.views import (
|
||||
CartMixin, get_cart_is_free, get_cart_positions,
|
||||
)
|
||||
from pretix.presale.views.cart import (
|
||||
_items_from_post_data, cart_session, create_empty_cart_id,
|
||||
get_or_create_cart_id,
|
||||
@@ -497,7 +499,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
self.request = request
|
||||
|
||||
# check whether addons are applicable
|
||||
if get_cart(request).filter(item__addons__isnull=False).exists():
|
||||
if get_cart_positions(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
|
||||
@@ -523,7 +525,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
def is_completed(self, request, warn=False):
|
||||
if getattr(self, '_completed', None) is not None:
|
||||
return self._completed
|
||||
for cartpos in get_cart(request).filter(addon_to__isnull=True).prefetch_related(
|
||||
for cartpos in get_cart_positions(request).filter(addon_to__isnull=True).prefetch_related(
|
||||
'item__addons', 'item__addons__addon_category', 'addons', 'addons__item'
|
||||
):
|
||||
a = cartpos.addons.all()
|
||||
@@ -586,7 +588,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
formset = []
|
||||
quota_cache = {}
|
||||
item_cache = {}
|
||||
for cartpos in sorted(get_cart(self.request).filter(addon_to__isnull=True).prefetch_related(
|
||||
for cartpos in sorted(get_cart_positions(self.request).filter(addon_to__isnull=True).prefetch_related(
|
||||
'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation',
|
||||
), key=lambda c: c.sort_key):
|
||||
formsetentry = {
|
||||
@@ -1090,26 +1092,18 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
|
||||
return False
|
||||
|
||||
for cp in self._positions_for_questions:
|
||||
answ = {
|
||||
aw.question_id: aw for aw in cp.answerlist
|
||||
}
|
||||
question_cache = {
|
||||
q.pk: q for q in cp.item.questions_to_ask
|
||||
qc_cache = {
|
||||
qc.pk: qc for qq in cp.item.relevant_questionnaires for qc in qq.childlist
|
||||
}
|
||||
|
||||
def question_is_visible(parentid, qvals):
|
||||
if parentid not in question_cache:
|
||||
if parentid not in qc_cache:
|
||||
return False
|
||||
parentq = question_cache[parentid]
|
||||
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
|
||||
parentqc = qc_cache[parentid]
|
||||
if parentqc.dependency_question_id and not question_is_visible(parentqc.dependency_question_id, parentqc.dependency_values):
|
||||
return False
|
||||
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))
|
||||
)
|
||||
answer_values = cp.get_dependency_answer_values(parentqc)
|
||||
return any(qval in answer_values for qval in qvals)
|
||||
|
||||
def question_is_required(q):
|
||||
return (
|
||||
@@ -1118,31 +1112,16 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
|
||||
)
|
||||
|
||||
if not self.all_optional:
|
||||
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
|
||||
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
|
||||
|
||||
responses = question_form_fields.send(sender=self.request.event, position=cp)
|
||||
form_data = cp.meta_info_data.get('question_form_data', {})
|
||||
@@ -1310,7 +1289,7 @@ class PaymentStep(CartMixin, TemplateFlowStep):
|
||||
|
||||
@cached_property
|
||||
def _total_order_value(self):
|
||||
cart = get_cart(self.request)
|
||||
cart = get_cart_positions(self.request)
|
||||
try:
|
||||
fees = get_fees(
|
||||
event=self.request.event, request=self.request, invoice_address=self.invoice_address,
|
||||
@@ -1502,7 +1481,7 @@ class PaymentStep(CartMixin, TemplateFlowStep):
|
||||
messages.error(request, _('Please select a payment method to proceed.'))
|
||||
return False
|
||||
|
||||
cart = get_cart(self.request)
|
||||
cart = get_cart_positions(self.request)
|
||||
try:
|
||||
fees = get_fees(
|
||||
event=self.request.event,
|
||||
@@ -1542,7 +1521,7 @@ class PaymentStep(CartMixin, TemplateFlowStep):
|
||||
def is_applicable(self, request):
|
||||
self.request = request
|
||||
|
||||
for cartpos in get_cart(self.request):
|
||||
for cartpos in get_cart_positions(self.request):
|
||||
if cartpos.requires_approval(invoice_address=self.invoice_address):
|
||||
if 'payments' in self.cart_session:
|
||||
del self.cart_session['payments']
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% for q in line.questions %}
|
||||
<dt>{{ q.question }}</dt>
|
||||
<dt>{{ q.label }}</dt>
|
||||
<dd>
|
||||
{% include "pretixpresale/event/fragment_question_answer.html" with request=request question=q answer=q.answer %}
|
||||
</dd>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{% load safelink %}
|
||||
{% load i18n %}
|
||||
{% load rich_text %}
|
||||
{% if answer %}
|
||||
{% if not answer and not question %}
|
||||
{% elif answer %}
|
||||
{% if answer.file %}
|
||||
<span class="fa fa-file" aria-hidden="true"></span>
|
||||
<a href="{{ answer.frontend_file_url }}?token={% answer_token request answer %}">
|
||||
|
||||
@@ -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, Sum
|
||||
from django.db.models import Exists, OuterRef, Prefetch, Q, Sum
|
||||
from django.utils import translation
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
@@ -54,6 +54,7 @@ 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
|
||||
@@ -95,7 +96,7 @@ class CartMixin:
|
||||
"""
|
||||
A list of this users cart position
|
||||
"""
|
||||
return list(get_cart(self.request))
|
||||
return list(get_cart_positions(self.request))
|
||||
|
||||
@cached_property
|
||||
def cart_session(self):
|
||||
@@ -399,7 +400,7 @@ def cart_exists(request):
|
||||
return bool(request._cart_cache)
|
||||
|
||||
|
||||
def get_cart(request):
|
||||
def get_cart_positions(request):
|
||||
from pretix.presale.views.cart import get_or_create_cart_id
|
||||
|
||||
if not hasattr(request, '_cart_cache'):
|
||||
@@ -407,8 +408,11 @@ def get_cart(request):
|
||||
if not cart_id:
|
||||
request._cart_cache = CartPosition.objects.none()
|
||||
else:
|
||||
qqs = request.event.questions.all()
|
||||
qqs = qqs.filter(ask_during_checkin=False, hidden=False, container_type=Question.ContainerType.ORDERPOSITION)
|
||||
qqs = request.event.questionnaires.all()
|
||||
qqs = qqs.filter(
|
||||
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=request.sales_channel.identifier),
|
||||
type='PS'
|
||||
)
|
||||
request._cart_cache = CartPosition.objects.filter(
|
||||
cart_id=cart_id, event=request.event
|
||||
).annotate(
|
||||
@@ -429,18 +433,23 @@ def get_cart(request):
|
||||
Prefetch('answers',
|
||||
QuestionAnswer.objects.prefetch_related('options'),
|
||||
to_attr='answerlist'),
|
||||
Prefetch('item__questions',
|
||||
Prefetch('item__questionnaires',
|
||||
qqs.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'
|
||||
)))
|
||||
).select_related('dependency_question'),
|
||||
to_attr='questions_to_ask')
|
||||
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')
|
||||
)
|
||||
by_id = {cp.pk: cp for cp in request._cart_cache}
|
||||
for cp in request._cart_cache:
|
||||
@@ -450,6 +459,8 @@ def get_cart(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):
|
||||
"""
|
||||
@@ -501,7 +512,7 @@ def get_cart_is_free(request):
|
||||
|
||||
if not hasattr(request, '_cart_free_cache'):
|
||||
cs = cart_session(request)
|
||||
pos = get_cart(request)
|
||||
pos = get_cart_positions(request)
|
||||
ia = get_cart_invoice_address(request)
|
||||
try:
|
||||
fees = get_fees(event=request.event, request=request, invoice_address=ia,
|
||||
|
||||
@@ -87,6 +87,8 @@ dialog::backdrop {
|
||||
100% { transform: skewX(0deg); }
|
||||
}
|
||||
|
||||
.modal-card.no-padding, .modal-card.no-padding .modal-card-content { padding: 0; }
|
||||
.modal-card.no-scroll { overflow: hidden; }
|
||||
|
||||
|
||||
/* Legacy dialogs (still used for #ajaxerr and #popupmodal) */
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
try {
|
||||
window.parent.postMessage({
|
||||
type: 'pretix:notify-parent',
|
||||
data: JSON.parse(document.getElementById('notify_info').textContent),
|
||||
}, location.origin)
|
||||
} catch (e) {
|
||||
console.error('Could not post message to parent.', e)
|
||||
}
|
||||
@@ -1118,3 +1118,65 @@ $(function () {
|
||||
return $(this).find('button:not([type=button]), input[type=submit]').length > 0
|
||||
}).areYouSure({ message: gettext('You have unsaved changes!') })
|
||||
})
|
||||
|
||||
function show_django_dialog(url, callback) {
|
||||
function messageEvent(e) {
|
||||
console.log('messageEvent', e.origin, e.source, e.data)
|
||||
if (e.origin === location.origin && e.data.type === 'pretix:dialog-loaded') {
|
||||
$dlg.find("iframe").attr("height", Math.min(window.innerHeight - 120, e.data.contentHeight|0)).css("visibility", "visible")
|
||||
$dlg.find("center").remove()
|
||||
}
|
||||
if (e.origin === location.origin && e.data.type === 'pretix:notify-parent') {
|
||||
$dlg[0].close()
|
||||
if (!callback(e.data.data)) {
|
||||
if (e.data.data.messages?.length) {
|
||||
alert(e.data.data.messages.map(m => m.message).join('\n\n'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var $dlg = $('<dialog class="modal-card no-padding no-scroll" closedby="any"><center><i class="fa fa-cog big-rotating-icon"></i></center><iframe height="400" width="100%"></iframe></div>')
|
||||
.css('max-width', '60em')
|
||||
$dlg.find("iframe").attr("src", url).css("visibility", "hidden").css("border", "0")
|
||||
window.addEventListener('message', messageEvent)
|
||||
$dlg.appendTo("body")
|
||||
$dlg.on('close', function() {
|
||||
window.removeEventListener('message', messageEvent)
|
||||
$dlg.remove()
|
||||
})
|
||||
$dlg[0].showModal()
|
||||
}
|
||||
$(function() {
|
||||
$("[data-django-dialog]").on("click", function(e) {
|
||||
show_django_dialog(this.getAttribute("data-django-dialog"), function() {})
|
||||
})
|
||||
})
|
||||
function notify_parent_frame() {
|
||||
window.addEventListener('message', function(e) {
|
||||
if (e.source === window) return
|
||||
if (e.origin === location.origin && e.data.type === 'pretix:dialog-handshake') {
|
||||
if (!window.isInDialog) {
|
||||
window.isInDialog = true
|
||||
window.document.documentElement.classList.add('in-iframe')
|
||||
}
|
||||
}
|
||||
if (e.origin === location.origin && e.data.type === 'pretix:dialog-loading') {
|
||||
e.source.postMessage({ type: 'pretix:dialog-handshake' })
|
||||
}
|
||||
})
|
||||
try {
|
||||
window.parent.postMessage({
|
||||
type: 'pretix:dialog-loading',
|
||||
title: document.title,
|
||||
}, location.origin)
|
||||
} catch {}
|
||||
$(function () {
|
||||
setTimeout(() => {
|
||||
window.parent.postMessage({
|
||||
type: 'pretix:dialog-loaded',
|
||||
contentHeight: $('#page-wrapper > .container-fluid').outerHeight() + 20,
|
||||
}, location.origin)
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
notify_parent_frame()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import Editor from './Editor.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Suspense>
|
||||
<Editor/>
|
||||
</Suspense>
|
||||
</template>
|
||||
<style>
|
||||
.progressBar { position: fixed; top: 0; left: 0; right: 0; z-index:1000000; pointer-events: none; }
|
||||
.progressBar.local { position: absolute; }
|
||||
.progressBar .progressBar_progress { height: 5px; background: #0091EA; border-bottom: #026099; }
|
||||
.progressBar .progressBar_progress.indeterminate {
|
||||
width: 30%; animation: slide 5s forwards;
|
||||
}
|
||||
.progressBarText { width: 100%; color: white; text-shadow: 1px 1px 1px black, -1px -1px 1px black;
|
||||
font-weight: bold; padding-left: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@keyframes slide {
|
||||
0% { width: 20%; }
|
||||
30% { width: 50%; }
|
||||
100% { width: 60%; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {gettext} from "./gettextstub";
|
||||
import NativeDialog from "./NativeDialog.vue";
|
||||
import {computed, onMounted, onUnmounted, ref, watch} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
'defaultUrl': {type: String, default: null},
|
||||
'maxWidth': {type: String, default: '43em'},
|
||||
})
|
||||
const emit = defineEmits(['confirm'])
|
||||
const dlgDjangoDialog = ref()
|
||||
const url = ref(props.defaultUrl)
|
||||
const frameHeight = ref(400)
|
||||
const frameLoading = ref(true)
|
||||
|
||||
function messageEvent(e: MessageEvent) {
|
||||
console.log('messageEvent', e.origin, e.source, e.data)
|
||||
if (!dlgDjangoDialog.value.visible) return
|
||||
if (e.origin === location.origin && e.data.type === 'pretix:dialog-loaded') {
|
||||
frameHeight.value = Math.min(window.innerHeight - 120, e.data.contentHeight|0)
|
||||
frameLoading.value = false
|
||||
}
|
||||
if (e.origin === location.origin && e.data.type === 'pretix:notify-parent') {
|
||||
dlgDjangoDialog.value.close()
|
||||
emit('confirm', e.data.data)
|
||||
if (e.data.data.messages) {
|
||||
alert(e.data.data.messages.map(m => m.message).join('\n\n'))
|
||||
}
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', messageEvent)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('message', messageEvent)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
dialog: dlgDjangoDialog,
|
||||
open: (newUrl) => {
|
||||
frameLoading.value = true
|
||||
frameHeight.value = 400
|
||||
url.value = newUrl
|
||||
dlgDjangoDialog.value.show()
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NativeDialog ref="dlgDjangoDialog" class="modal-card" :no-padding="true" :no-scroll="true" :max-width="maxWidth">
|
||||
<div :style="{'height': frameHeight + 'px'}">
|
||||
<div class="frame-load-indicator" v-if="frameLoading"><i class="fa fa-cog big-rotating-icon"></i></div>
|
||||
<iframe :src="url" v-if="dlgDjangoDialog.visible" :height="frameHeight" :style="{'opacity': frameLoading ? '0' : '1'}"></iframe>
|
||||
</div>
|
||||
</NativeDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.frame-load-indicator { text-align: center; }
|
||||
iframe { width: 100%; border: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<script setup lang="ts">
|
||||
import QuestionnaireElement from './QuestionnaireElement.vue';
|
||||
import * as api from './api';
|
||||
import {Datafield, Questionnaire} from './model';
|
||||
import {i18n_any, sort, numericComp, groupBy, SYSTEM_DATAFIELDS} from './helper';
|
||||
import { gettext } from './gettextstub';
|
||||
import {onMounted, onUnmounted, ref} from 'vue';
|
||||
import { SlickList, SlickItem } from 'vue-slicksort';
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
|
||||
const sales_channels_list = await api.getSalesChannels();
|
||||
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: gettext('Uncategorized'), name: null, id: null };
|
||||
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)
|
||||
|
||||
type QuestionnaireMaybeUnsaved = Omit<Questionnaire, 'id'> & { _new_id?: number, id?: number };
|
||||
const order_questionnaires = ref<QuestionnaireMaybeUnsaved[]>();
|
||||
const position_questionnaires = ref<QuestionnaireMaybeUnsaved[]>();
|
||||
const order_datafields = ref<Datafield[]>();
|
||||
const position_datafields = ref<Datafield[]>();
|
||||
|
||||
let lastDataRefresh = 0
|
||||
async function refreshQuestionnaireList () {
|
||||
const all_questionnaires: QuestionnaireMaybeUnsaved[] = await api.getQuestionnaires();
|
||||
|
||||
order_questionnaires.value = all_questionnaires.filter(q => q.type.startsWith('O'))
|
||||
position_questionnaires.value = all_questionnaires.filter(q => q.type.startsWith('P'))
|
||||
lastDataRefresh = Date.now()
|
||||
}
|
||||
async function refreshDatafieldList () {
|
||||
order_datafields.value = await api.getDatafields('O')
|
||||
position_datafields.value = (await api.getDatafields('P')).concat(Object.values(SYSTEM_DATAFIELDS))
|
||||
console.log('datafield list refreshed')
|
||||
}
|
||||
await Promise.all([refreshQuestionnaireList(), refreshDatafieldList()])
|
||||
|
||||
function saveQuestionnaire(questionnaire) {
|
||||
let result;
|
||||
questionnaire._loading = true
|
||||
if (questionnaire.id) {
|
||||
result = api.updateQuestionnaire(questionnaire.id, questionnaire)
|
||||
} else {
|
||||
result = api.createQuestionnaire(questionnaire)
|
||||
}
|
||||
result = result.then(d => {
|
||||
questionnaire.id = d.id
|
||||
questionnaire.children = d.children
|
||||
questionnaire._err_mes = null
|
||||
questionnaire._loading = false
|
||||
return d;
|
||||
}, err => {
|
||||
questionnaire._err_mes = err
|
||||
questionnaire._loading = false
|
||||
return err;
|
||||
})
|
||||
return result;
|
||||
}
|
||||
function addPositionQuestionnaire () {
|
||||
position_questionnaires.value.push({
|
||||
all_sales_channels: true, children: [], limit_sales_channels: [], position: 0,
|
||||
items: [], internal_name: "Unnamed questionnaire", type: "PS",
|
||||
_new_id: Date.now(),
|
||||
});
|
||||
}
|
||||
function addOrderQuestionnaire () {
|
||||
order_questionnaires.value.push({
|
||||
all_sales_channels: true, children: [], limit_sales_channels: [], position: 0,
|
||||
items: [], internal_name: "Unnamed questionnaire", type: "OS",
|
||||
_new_id: Date.now(),
|
||||
});
|
||||
}
|
||||
async function saveData () {
|
||||
using pb = ProgressBar.show('saving questionnaires')
|
||||
let promises = [];
|
||||
let i = 0;
|
||||
for (const questionnaire of order_questionnaires.value) {
|
||||
questionnaire.position = i++;
|
||||
promises.push(saveQuestionnaire(questionnaire))
|
||||
}
|
||||
i = 0;
|
||||
for (const questionnaire of position_questionnaires.value) {
|
||||
questionnaire.position = i++;
|
||||
promises.push(saveQuestionnaire(questionnaire))
|
||||
}
|
||||
await Promise.all(promises)
|
||||
}
|
||||
/*
|
||||
export default {
|
||||
components: {
|
||||
QuestionnaireElement, SlickList, SlickItem,
|
||||
},
|
||||
methods: {
|
||||
i18n_any,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
order_questionnaires,
|
||||
position_questionnaires,
|
||||
order_datafields,
|
||||
position_datafields,
|
||||
items: items_list,
|
||||
grouped_items,
|
||||
categories,
|
||||
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
function windowFocused() {
|
||||
if (Date.now() - lastDataRefresh > 30000) {
|
||||
console.log('refreshing to avoid overwriting with older data on edit')
|
||||
refreshQuestionnaireList()
|
||||
}
|
||||
}
|
||||
onMounted(() => window.addEventListener('focus', windowFocused))
|
||||
onUnmounted(() => window.removeEventListener('focus', windowFocused))
|
||||
|
||||
const selected_product = ref("")
|
||||
const preview_mode = ref(false)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.hidden-questionnaire { opacity: 0.3; }
|
||||
|
||||
.questionnaires-list {
|
||||
padding: 20px 190px 20px 0;
|
||||
background: linear-gradient(to right, #fff 0, #fff calc(100% - 172px), #ddd calc(100% - 171px), #f6f2f8 calc(100% - 171px), #fff calc(100% - 100px), #fff 100%);
|
||||
}
|
||||
.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 .questionnaire-panel .questionnaire-panel-heading {}
|
||||
|
||||
.questionnaires-editor:not(.preview-mode) .questionnaire-panel .questionnaire-panel-heading .editor-row { border-top: 1px solid rgb(175 175 175 / 0.3); }
|
||||
.questionnaires-editor:not(.preview-mode) .questionnaire-panel[open] .questionnaire-panel-heading .editor-row { border-bottom: 1px solid rgb(216 216 216 / 0.3); }
|
||||
|
||||
.questionnaires-editor.preview-mode .questionnaire-panel .questionnaire-panel-heading { color: #888; margin-top: 5px; border-top: 1px dashed rgb(175 175 175 / 0.3); border-bottom: 1px dashed rgb(216 216 216 / 0.3); font-style: italic; padding: 0; }
|
||||
|
||||
.questionnaires-editor .editor-row:not(:hover) .btn.btn-default,
|
||||
.questionnaires-editor .editor-action-row:not(:hover) .btn.btn-default{ box-shadow: 0 0 0 0 #eeeeee; background: transparent; color: #555; }
|
||||
|
||||
.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; }
|
||||
|
||||
.editor-row { display: flex; width: calc(100% + 190px); background: transparent; }
|
||||
.editor-row:hover { background: rgba(255,233,244,0.3); }
|
||||
.editor-preview-area { flex: 1; padding-left: 10px; padding-right: 40px; }
|
||||
.editor-action-area { width: 160px; padding-left: 20px; }
|
||||
.questionnaire-panel-heading .editor-preview-area { padding-block: 11px; }
|
||||
.questionnaire-panel-heading .editor-action-area { padding-block: 5px; }
|
||||
.questionnaire-panel-body .editor-preview-area, .questionnaire-panel-body .editor-action-area { padding-block: 10px; }
|
||||
.preview-mode .editor-preview-area { padding-right: 50px; }
|
||||
|
||||
.questionnaires-editor.questionnaires-editor .form-group { margin-bottom: 0px; }
|
||||
|
||||
.questionnaire-panel .questionnaire-panel-heading .editor-preview-area::before {
|
||||
margin-top: -.5em;
|
||||
content: "";
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
font: normal normal normal 14px/1 FontAwesome;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 150ms ease-in 0s;
|
||||
}
|
||||
.questionnaire-panel[open] .questionnaire-panel-heading .editor-preview-area::before {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.editor-action-row { padding-top: 10px; width: calc(100% + 190px); padding-right: 170px }
|
||||
.questionnaires-list > .editor-action-row { border-top: 1px solid rgb(175 175 175 / 0.3); padding-top: 25px; padding-bottom: 10px }
|
||||
</style>
|
||||
<template>
|
||||
<div class="questionnaires-editor" v-if="!preview_mode">
|
||||
<div class="filter-row">
|
||||
Order questionnaires
|
||||
</div>
|
||||
<div class="questionnaires-list">
|
||||
<SlickList axis="y" v-model:list="order_questionnaires" useDragHandle appendTo="#orderQuestionnaireListParent" id="orderQuestionnaireListParent" @update:list="saveData()">
|
||||
<SlickItem v-for="(questionnaire, index) in order_questionnaires" :key="questionnaire.id || questionnaire._new_id" :index="index">
|
||||
<QuestionnaireElement
|
||||
:questionnaire="questionnaire"
|
||||
:datafields="order_datafields"
|
||||
:sales_channels="sales_channels_list"
|
||||
:grouped_items="null"
|
||||
:selected_product="null"
|
||||
:preview_mode="false"
|
||||
@update="saveQuestionnaire(questionnaire)"
|
||||
@invalidate:datafields="refreshDatafieldList()" />
|
||||
</SlickItem>
|
||||
</SlickList>
|
||||
<div class="editor-action-row form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="col-md-9 col-md-push-3">
|
||||
<button class="btn btn-default" @click="addOrderQuestionnaire()"><i class="fa fa-plus"></i> {{ gettext('New questionnaire') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="`questionnaires-editor ${selected_product ? 'product-selected':''} ${preview_mode ? 'preview-mode':''}`">
|
||||
<div class="filter-row" v-if="preview_mode">
|
||||
<button class="btn btn-default" @click="preview_mode = false"><span class="fa fa-chevron-left"></span> {{ gettext('Back') }}</button>
|
||||
Previewing questionnaires for product {{ selected_product }}
|
||||
</div>
|
||||
<div class="filter-row" v-else>
|
||||
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>
|
||||
|
||||
<button class="btn btn-default" @click="preview_mode = true" :disabled="!selected_product"> {{ gettext('Preview') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="questionnaires-list" v-if="preview_mode && selected_product">
|
||||
<details class="panel panel-default details-open" open>
|
||||
<summary class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<strong>Product {{ selected_product }}</strong>
|
||||
</h4>
|
||||
</summary>
|
||||
|
||||
<QuestionnaireElement v-for="(questionnaire, index) in position_questionnaires.filter(q => q.items.indexOf(selected_product as any) !== -1)"
|
||||
:questionnaire="questionnaire"
|
||||
:datafields="position_datafields"
|
||||
:sales_channels="sales_channels_list"
|
||||
:grouped_items="grouped_items"
|
||||
:selected_product="selected_product"
|
||||
:preview_mode="true"
|
||||
@update="saveQuestionnaire(questionnaire)"
|
||||
@invalidate:datafields="refreshDatafieldList()"/>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="questionnaires-list" v-else>
|
||||
<SlickList axis="y" v-model:list="position_questionnaires" useDragHandle appendTo="#questionnaireListParent" id="questionnaireListParent" @update:list="saveData()">
|
||||
<SlickItem v-for="(questionnaire, index) in position_questionnaires" :key="questionnaire.id || questionnaire._new_id" :index="index">
|
||||
<QuestionnaireElement
|
||||
:questionnaire="questionnaire"
|
||||
:datafields="position_datafields"
|
||||
:sales_channels="sales_channels_list"
|
||||
:grouped_items="grouped_items"
|
||||
:selected_product="selected_product"
|
||||
:preview_mode="false"
|
||||
@update="saveQuestionnaire(questionnaire)"
|
||||
@invalidate:datafields="refreshDatafieldList()"/>
|
||||
</SlickItem>
|
||||
</SlickList>
|
||||
<div v-if="!preview_mode" class="editor-action-row form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="col-md-9 col-md-push-3">
|
||||
<button class="btn btn-default" @click="addPositionQuestionnaire()"><i class="fa fa-plus"></i> {{ gettext('New questionnaire') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import {getEventLocales} from "./api"
|
||||
|
||||
const locales = getEventLocales();
|
||||
|
||||
const props = defineProps<{ value: any, id?: string }>();
|
||||
</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>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import {getEventLocales} from "./api"
|
||||
|
||||
const locales = getEventLocales();
|
||||
|
||||
const props = defineProps<{ value: any, id?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="i18n-form-group" :id="id">
|
||||
<input 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]">
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useId } from 'vue';
|
||||
|
||||
const dialog = ref<HTMLDialogElement>();
|
||||
|
||||
const props = defineProps({
|
||||
classes: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
title: {type: String, default: ''},
|
||||
noPadding: {type: Boolean, default: false},
|
||||
noScroll: {type: Boolean, default: true},
|
||||
maxWidth: {type: String, default: '43em'},
|
||||
});
|
||||
|
||||
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 ${props.noPadding ? 'no-padding' : ''} ${props.noPadding ? 'no-scroll' : ''}`"
|
||||
@close="visible = false"
|
||||
closedby="any"
|
||||
:aria-labelledby="`${id}-title`"
|
||||
:style="{maxWidth: props.maxWidth}"
|
||||
>
|
||||
<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" v-if="title">{{ title }}</h2>
|
||||
<slot />
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.modal-card.no-padding, .modal-card.no-padding .modal-card-content { padding: 0; }
|
||||
.modal-card.no-scroll { overflow: hidden; }
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
|
||||
type MyAttrs = {
|
||||
[key: `on${any}`]: EventListener, style?: {[key:string]: string} | string, innerHTML?: string,
|
||||
appendTo?: HTMLElement | ShadowRoot, prependTo?: HTMLElement | ShadowRoot, insertBefore?: HTMLElement, insertAfter?: HTMLElement,
|
||||
[key: Exclude<string, 'component' | 'args'>]: any,
|
||||
};
|
||||
|
||||
export function EL<K extends keyof HTMLElementTagNameMap>(tagName: K, attrs:MyAttrs, ...children:(string|HTMLElement)[]): HTMLElementTagNameMap[K];
|
||||
export function EL(tagName:string, attrs:MyAttrs, ...children:(string|HTMLElement)[]) : HTMLElement;
|
||||
export function EL(tagName:string, attrs:MyAttrs, ...children:(string|HTMLElement)[]) : HTMLElement {
|
||||
if (attrs?.idGetOrCreate) {
|
||||
const el = document.getElementById(attrs.idGetOrCreate);
|
||||
if (el) {
|
||||
return el;
|
||||
} else {
|
||||
attrs.id = attrs.idGetOrCreate; delete attrs.idGetOrCreate;
|
||||
}
|
||||
}
|
||||
var el = document.createElement(tagName);
|
||||
if (attrs) for(var key in attrs)
|
||||
if (key === 'style' && typeof attrs[key] === 'object') Object.assign(el.style, attrs[key]);
|
||||
else if (key === 'component' && 'args' in attrs) el['Component'] = new attrs.component(el, ...attrs.args);
|
||||
else if (key === 'innerHTML') el.innerHTML = attrs[key];
|
||||
else if (key === 'appendTo' && (attrs.appendTo instanceof HTMLElement || attrs.appendTo instanceof ShadowRoot)) attrs.appendTo.append(el);
|
||||
else if (key === 'prependTo' && (attrs.prependTo instanceof HTMLElement || attrs.prependTo instanceof ShadowRoot)) attrs.prependTo.prepend(el);
|
||||
else if (key === 'insertBefore' && attrs.insertBefore instanceof HTMLElement) attrs.insertBefore.before(el);
|
||||
else if (key === 'insertAfter' && attrs.insertAfter instanceof HTMLElement) attrs.insertAfter.after(el);
|
||||
else if (key.startsWith("on")) el.addEventListener(key.substring(2), attrs[key] as unknown as EventListener, false);
|
||||
else if (key.startsWith(":")) el[key.substring(1)] = attrs[key];
|
||||
else if (key === 'checked' && 'checked' in el) el.checked = attrs.checked;
|
||||
else if (key === 'disabled' && 'disabled' in el) el.disabled = attrs.disabled;
|
||||
else if (key === 'selected' && 'selected' in el) el.selected = attrs.selected;
|
||||
else if (key === 'multiple' && 'multiple' in el) el.multiple = attrs.multiple;
|
||||
else el.setAttribute(key, attrs[key]);
|
||||
|
||||
for(var i=0;i<children.length;i++){
|
||||
if (children[i] instanceof HTMLElement) el.appendChild(<HTMLElement>children[i]);
|
||||
else if (children[i]) el.appendChild(document.createTextNode(""+children[i]));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
export class ProgressBar {
|
||||
private static container : HTMLDivElement = EL('div', { appendTo: document.body, class: 'progressBar' }, EL('div', {class:'progressBarText'}));
|
||||
|
||||
static show(message?: string, max?: number) {
|
||||
const texts = ProgressBar.container.lastElementChild;
|
||||
const bar = EL('div', {class:'progressBar_progress'+(max ? '' : ' indeterminate')});
|
||||
let text : HTMLElement;
|
||||
if (message) {
|
||||
text = EL('span', {}, message);
|
||||
texts.append(text);
|
||||
}
|
||||
texts.before(bar);
|
||||
return {
|
||||
disposed: false,
|
||||
[Symbol.dispose]() {
|
||||
if (this.disposed) return;
|
||||
bar.remove();
|
||||
if (text) text.remove();
|
||||
this.disposed = true;
|
||||
},
|
||||
set message(val: string) {
|
||||
text.innerText = val;
|
||||
},
|
||||
set progress(val: number) {
|
||||
if (max) bar.style.width = `${val / max * 100}%`;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<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 {getDatafieldCreateUrl, getDatafieldEditUrl, getDatafieldViewUrl} from "./api";
|
||||
import I18nTextArea from "./I18nTextArea.vue";
|
||||
import DjangoDialog from "./DjangoDialog.vue";
|
||||
|
||||
const id = useId();
|
||||
const props = defineProps(['question', 'datafields', 'editable', 'possible_dependencies'])
|
||||
const emit = defineEmits(['removeSelf', 'update', 'invalidate:datafields']);
|
||||
const gettext = (window as any).gettext;
|
||||
const question = ref(props.question);
|
||||
|
||||
const dlgEditDatafield = ref()
|
||||
|
||||
const df = computed(() => 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="editor-row">
|
||||
<div class="editor-preview-area">
|
||||
<div class="form-group">
|
||||
|
||||
<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" v-html="question.rendered_help_text" v-if="question.rendered_help_text"></div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="col-md-9 col-md-push-3">
|
||||
<h4>{{ i18n_any(question.label) }}</h4>
|
||||
<p v-html="question.rendered_help_text" v-if="question.rendered_help_text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="editor-action-area">
|
||||
<div class="btn-group" v-if="editable">
|
||||
<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>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<NativeDialog ref="editor" class="modal-card"
|
||||
:title="df ? gettext('Edit question') : gettext('Edit text block')">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{{ df ? gettext('Question') : gettext('Headline') }}
|
||||
</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">
|
||||
<I18nTextArea :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" v-if="df">
|
||||
<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 }}
|
||||
<div>
|
||||
<a class="btn btn-sm btn-default" href="javascript:" @click="dlgEditDatafield.open(getDatafieldEditUrl(df.id))"><span class="fa fa-wrench"></span> Manage data field details</a>
|
||||
<a class="btn btn-sm btn-default" :href="getDatafieldViewUrl(df.id)" target="_blank"><span class="fa fa-external-link"></span> View answers</a>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ question.question }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" v-if="df">
|
||||
<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" v-if="df">
|
||||
<label class="col-md-3 control-label label-empty"> </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(); emit('update')" 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>
|
||||
|
||||
<DjangoDialog ref="dlgEditDatafield" @confirm="emit('invalidate:datafields')" max-width="60em"></DjangoDialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import {useId, ref, computed, onMounted, watch} from 'vue'
|
||||
import QuestionElement from "./QuestionElement.vue";
|
||||
import {
|
||||
i18n_any,
|
||||
QUESTION_TYPE,
|
||||
QUESTION_TYPE_LABEL,
|
||||
QUESTIONNAIRE_TYPE,
|
||||
QUESTIONNAIRE_TYPE_LABEL,
|
||||
SYSTEM_DATAFIELDS
|
||||
} from "./helper";
|
||||
import { gettext } from './gettextstub';
|
||||
import I18nTextField from "./I18nTextField.vue";
|
||||
import NativeDialog from "./NativeDialog.vue";
|
||||
import { SlickList, SlickItem, DragHandle } from 'vue-slicksort';
|
||||
import {getDatafieldCreateUrl} from "./api";
|
||||
import DjangoDialog from "./DjangoDialog.vue";
|
||||
|
||||
const dlgEditor = ref()
|
||||
const dlgAddExisting = ref()
|
||||
const dlgAddTextblock = ref()
|
||||
const dlgNewDatafield = ref()
|
||||
|
||||
const newTextblockTitle = ref()
|
||||
const newTextblockText = ref()
|
||||
|
||||
const id = useId();
|
||||
const props = defineProps(['questionnaire', 'datafields', 'sales_channels', 'selected_product', 'grouped_items', 'preview_mode', 'err_mes'])
|
||||
const emit = defineEmits(['update', 'invalidate:datafields'])
|
||||
|
||||
let nextId = 1;
|
||||
watch(() => props.questionnaire.children, () => {
|
||||
for (let qc of props.questionnaire.children) {
|
||||
if (!qc._cid) qc._cid = id + (++nextId);
|
||||
}
|
||||
})
|
||||
|
||||
function setVisibleOnItem (checked, itemId) {
|
||||
const i = props.questionnaire.items.indexOf(itemId)
|
||||
if (i === -1) {
|
||||
props.questionnaire.items.push(itemId)
|
||||
} else {
|
||||
props.questionnaire.items.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function addExistingDatafield (field) {
|
||||
props.questionnaire.children.push({
|
||||
_cid: useId(),
|
||||
question: field.id,
|
||||
required: false,
|
||||
label: field.question,
|
||||
help_text: {},
|
||||
dependency_question: null,
|
||||
dependency_values: [],
|
||||
})
|
||||
dlgAddExisting.value.close()
|
||||
emit('update')
|
||||
}
|
||||
|
||||
function showAddTextblockDialog () {
|
||||
newTextblockTitle.value = {}
|
||||
newTextblockText.value = {}
|
||||
dlgAddTextblock.value.show()
|
||||
}
|
||||
|
||||
function addTextblock () {
|
||||
props.questionnaire.children.push({
|
||||
_cid: useId(),
|
||||
question: null,
|
||||
required: false,
|
||||
label: newTextblockTitle.value,
|
||||
help_text: newTextblockText.value,
|
||||
dependency_question: null,
|
||||
dependency_values: [],
|
||||
})
|
||||
dlgAddTextblock.value.close()
|
||||
emit('update')
|
||||
}
|
||||
|
||||
function newDatafield (container_type) {
|
||||
dlgNewDatafield.value.open(getDatafieldCreateUrl(props.questionnaire.type[0]))
|
||||
}
|
||||
|
||||
async function onNewDatafieldCreated (data) {
|
||||
watch(
|
||||
() => props.datafields,
|
||||
(newValue, oldValue) => {
|
||||
console.log('datafields changed watcher called')
|
||||
addExistingDatafield(newValue.find(f => f.id === data.object))
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
emit('invalidate:datafields')
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<details class="questionnaire-panel" :open="preview_mode"
|
||||
:class="{ 'hidden-questionnaire': isHidden }">
|
||||
<summary class="questionnaire-panel-heading">
|
||||
<div class=" editor-row">
|
||||
<div class="editor-preview-area">
|
||||
<input type="checkbox" @change="e => {setVisibleOnItem(e.target.checked, selected_product); emit('update')}" v-if="selected_product && !preview_mode" :checked="!isHidden">
|
||||
{{ props.questionnaire.internal_name }}
|
||||
<span class="fa fa-warning" v-if="questionnaire._err_mes"></span>
|
||||
<span class="fa fa-cog fa-spin" v-if="questionnaire._loading"></span>
|
||||
</div>
|
||||
|
||||
<aside class="editor-action-area"><div class="btn-group">
|
||||
<DragHandle tag="button" class="btn btn-default" v-if="!preview_mode"><i class="fa fa-arrows"></i></DragHandle>
|
||||
<button class="btn btn-default" @click="dlgEditor.show()"><i class="fa fa-wrench"></i></button>
|
||||
</div></aside>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="questionnaire-panel-body" v-if="!isHidden">
|
||||
<div class="alert alert-warning" v-if="questionnaire._err_mes">{{ questionnaire._err_mes }}</div>
|
||||
<div class="form-horizontal" :id="`questionListParent${props.questionnaire.id}`">
|
||||
<SlickList axis="y" v-model:list="props.questionnaire.children" useDragHandle :appendTo="`#questionListParent${props.questionnaire.id}`" @update:list="emit('update')">
|
||||
<SlickItem v-for="(child, index) in props.questionnaire.children" :key="child._cid" :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); emit('update')"
|
||||
@update="emit('update')"
|
||||
@invalidate:datafields="emit('invalidate:datafields')"/>
|
||||
</SlickItem>
|
||||
</SlickList>
|
||||
</div>
|
||||
<div class="editor-action-row form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="col-md-9 col-md-push-3">
|
||||
<p 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(questionnaire.type[0])"><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>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<label class="col-md-3 control-label">
|
||||
{{ gettext('Where to ask') }}
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<select v-model="questionnaire.type" class="form-control">
|
||||
<option v-for="(label, type) in QUESTIONNAIRE_TYPE_LABEL" :value="QUESTIONNAIRE_TYPE[type]">{{ label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{{ gettext('Sales channels') }}
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" v-model="questionnaire.all_sales_channels"> {{ gettext('All sales channels') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox" v-for="channel in sales_channels">
|
||||
<label>
|
||||
<input type="checkbox" :checked="questionnaire.all_sales_channels || questionnaire.limit_sales_channels.indexOf(channel.identifier) !== -1"
|
||||
@change="e => e.target.checked ? questionnaire.limit_sales_channels.push(channel.identifier) : questionnaire.limit_sales_channels.splice(questionnaire.limit_sales_channels.indexOf(channel.identifier), 1)"
|
||||
:disabled="questionnaire.all_sales_channels">
|
||||
{{ i18n_any(channel.label) }}
|
||||
</label>
|
||||
</div>
|
||||
</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" @change="e => setVisibleOnItem(e.target.checked, item.id)"> {{ item.internal_name || i18n_any(item.name) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="dlgEditor.close(); emit('update')" 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">{{ i18n_any(field.question) }}</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>
|
||||
|
||||
<DjangoDialog ref="dlgNewDatafield" @confirm="onNewDatafieldCreated" max-width="60em"></DjangoDialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ApiListResponse, Datafield, Questionnaire, Item, Category } from './model'
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import {fromJsonScript} from "./helper";
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
class APIError extends Error {
|
||||
api_error: string;
|
||||
constructor(json) {
|
||||
super('' + Object.values(json)[0]);
|
||||
this.api_error = json;
|
||||
}
|
||||
}
|
||||
async function api_json_request(resource, method, json_body) {
|
||||
const response = await fetch(`/api/v1/${resource}`, {
|
||||
body: JSON.stringify(json_body),
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': $('[name=csrfmiddlewaretoken]').val() as string,
|
||||
},
|
||||
});
|
||||
if (response.status >= 400)
|
||||
throw new APIError(await response.json());
|
||||
else
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export async function getDatafields(container_type) {
|
||||
using pb = ProgressBar.show('loading data fields')
|
||||
return await api_get_all<Datafield>(`organizers/${organizer_slug}/events/${event_slug}/datafields/?container_type=${container_type}&`);
|
||||
}
|
||||
|
||||
export async function getQuestionnaires() {
|
||||
using pb = ProgressBar.show('loading questionnaires')
|
||||
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() {
|
||||
using pb = ProgressBar.show('loading product list')
|
||||
return await api_get_all<Item>(`organizers/${organizer_slug}/events/${event_slug}/items/`);
|
||||
}
|
||||
|
||||
export async function getCategories() {
|
||||
using pb = ProgressBar.show('loading category list')
|
||||
return await api_get_all<Category>(`organizers/${organizer_slug}/events/${event_slug}/categories/`);
|
||||
}
|
||||
|
||||
export async function getSalesChannels() {
|
||||
return await api_get_all<Category>(`organizers/${organizer_slug}/saleschannels/`);
|
||||
}
|
||||
|
||||
export function getEventLocales() {
|
||||
return fromJsonScript('event_locales');
|
||||
}
|
||||
|
||||
export function getDatafieldViewUrl(datafield_id) {
|
||||
return fromJsonScript('datafield_view_url').replace('/0/', `/${datafield_id}/`);
|
||||
}
|
||||
export function getDatafieldEditUrl(datafield_id) {
|
||||
return fromJsonScript('datafield_edit_url').replace('/0/', `/${datafield_id}/`) + '?notify_parent=true&';
|
||||
}
|
||||
|
||||
export function getDatafieldCreateUrl(container_type) {
|
||||
return fromJsonScript('datafield_create_url') + '?notify_parent=true&container_type=' + container_type;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// The actual gettext implementation is loaded asynchronously with the translation
|
||||
export function gettext (msgid) {
|
||||
if (typeof django !== 'undefined' && typeof django.gettext !== 'undefined') {
|
||||
return django.gettext(msgid)
|
||||
}
|
||||
return msgid
|
||||
}
|
||||
|
||||
export function ngettext (singular, plural, count) {
|
||||
if (typeof django !== 'undefined' && typeof django.ngettext !== 'undefined') {
|
||||
return django.ngettext(singular, plural, count)
|
||||
}
|
||||
return plural
|
||||
}
|
||||
|
||||
export function pgettext (context, msgid) {
|
||||
if (typeof django !== 'undefined' && typeof django.pgettext !== 'undefined') {
|
||||
return django.pgettext(context, msgid)
|
||||
}
|
||||
return msgid
|
||||
}
|
||||
|
||||
export function interpolate (fmt, object, named) {
|
||||
if (named) {
|
||||
return fmt.replace(/%\(\w+\)s/g, function (match) { return String(object[match.slice(2, -2)]) })
|
||||
} else {
|
||||
return fmt.replace(/%s/g, function (match) { return String(object.shift()) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/* global gettext, pgettext */
|
||||
|
||||
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 function fromJsonScript (id) {
|
||||
return JSON.parse(document.getElementById(id).textContent)
|
||||
}
|
||||
|
||||
export const QUESTION_TYPE = Object.fromEntries(fromJsonScript('question_type_choices').map(([name, value, label]) => [name, value]))
|
||||
export const QUESTION_TYPE_LABEL = Object.fromEntries(fromJsonScript('question_type_choices').map(([name, value, label]) => [name, i18n_any(label)]))
|
||||
|
||||
export const SYSTEM_DATAFIELDS = freezeRec(Object.fromEntries(fromJsonScript('system_question_choices').map(([name, value, label]) => [
|
||||
value, { id: value, question: label, type: value === 'country' ? QUESTION_TYPE.COUNTRYCODE : QUESTION_TYPE.STRING }
|
||||
])))
|
||||
|
||||
export const QUESTIONNAIRE_TYPE = Object.fromEntries(fromJsonScript('questionnaire_type_choices').map(([name, value, label]) => [name, value]))
|
||||
export const QUESTIONNAIRE_TYPE_LABEL = Object.fromEntries(fromJsonScript('questionnaire_type_choices').map(([name, value, label]) => [name, i18n_any(label)]))
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#questionnaires-editor')
|
||||
@@ -0,0 +1,72 @@
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
export type Category = {
|
||||
id: number,
|
||||
position: number,
|
||||
name: I18nString,
|
||||
internal_name: string | null,
|
||||
}
|
||||
@@ -780,6 +780,8 @@ 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;
|
||||
@@ -802,6 +804,7 @@ fieldset.accordion-panel > legend {
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
|
||||
.batch-select-label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -898,6 +898,10 @@ tbody th {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
html.in-iframe body { background: white }
|
||||
.in-iframe #wrapper > nav.navbar, .in-iframe #page-wrapper > div > footer { display: none; }
|
||||
.in-iframe #page-wrapper { padding: 0 15px; min-height: 568px; border-left: 0; margin: 0; }
|
||||
|
||||
@import "../../pretixbase/scss/_rtl.scss";
|
||||
@import "../../bootstrap/scss/_rtl.scss";
|
||||
@import "_rtl.scss";
|
||||
|
||||
@@ -4,7 +4,7 @@ function questions_toggle_dependent (ev) {
|
||||
return true
|
||||
}
|
||||
|
||||
let dependency_name = $el.attr('name').split('_')[0] + '_' + $el.attr('data-question-dependency')
|
||||
let dependency_name = $el.attr('name').split('-')[0] + '-' + $el.attr('data-question-dependency')
|
||||
let dependency_values = JSON.parse($el.attr('data-question-dependency-values'))
|
||||
let $dependency_el
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- modernize the sometimes native form submitting?
|
||||
- destructure props?
|
||||
|
||||
@@ -134,14 +134,13 @@ class CategoriesTest(ItemFormTest):
|
||||
assert not ItemCategory.objects.filter(id=c.id).exists()
|
||||
|
||||
|
||||
class QuestionsTest(ItemFormTest):
|
||||
class DatafieldsTest(ItemFormTest):
|
||||
|
||||
def test_create(self):
|
||||
doc = self.get_doc('/control/event/%s/%s/questions/add' % (self.orga1.slug, self.event1.slug))
|
||||
form_data = extract_form_fields(doc.select('.container-fluid form')[0])
|
||||
form_data['question_0'] = 'What is your shoe size?'
|
||||
form_data['type'] = 'N'
|
||||
form_data['items'] = self.item1.id
|
||||
doc = self.post_doc('/control/event/%s/%s/questions/add' % (self.orga1.slug, self.event1.slug), form_data)
|
||||
assert doc.select(".alert-success")
|
||||
self.assertIn("shoe size", doc.select("#page-wrapper table")[0].text)
|
||||
@@ -157,7 +156,6 @@ class QuestionsTest(ItemFormTest):
|
||||
form_data['form-MIN_NUM_FORMS'] = '0'
|
||||
form_data['form-MAX_NUM_FORMS'] = '1'
|
||||
form_data['form-0-id'] = o1.pk
|
||||
form_data['items'] = self.item1.id
|
||||
form_data['form-0-answer_0'] = 'England'
|
||||
self.post_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, c.id),
|
||||
form_data)
|
||||
@@ -176,7 +174,6 @@ class QuestionsTest(ItemFormTest):
|
||||
form_data['form-INITIAL_FORMS'] = '1'
|
||||
form_data['form-MIN_NUM_FORMS'] = '0'
|
||||
form_data['form-MAX_NUM_FORMS'] = '1'
|
||||
form_data['items'] = self.item1.id
|
||||
form_data['form-0-id'] = o1.pk
|
||||
form_data['form-0-answer_0'] = 'England'
|
||||
form_data['form-0-DELETE'] = 'yes'
|
||||
@@ -196,7 +193,6 @@ class QuestionsTest(ItemFormTest):
|
||||
form_data['form-INITIAL_FORMS'] = '0'
|
||||
form_data['form-MIN_NUM_FORMS'] = '0'
|
||||
form_data['form-MAX_NUM_FORMS'] = '1'
|
||||
form_data['items'] = self.item1.id
|
||||
form_data['form-0-id'] = ''
|
||||
form_data['form-0-answer_0'] = 'Germany'
|
||||
self.post_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, c.id),
|
||||
@@ -211,7 +207,6 @@ class QuestionsTest(ItemFormTest):
|
||||
c = Question.objects.create(event=self.event1, question="What is your shoe size?", type="N", required=True)
|
||||
doc = self.get_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, c.id))
|
||||
form_data = extract_form_fields(doc.select('.container-fluid form')[0])
|
||||
form_data['items'] = self.item1.id
|
||||
form_data['question_0'] = 'How old are you?'
|
||||
doc = self.post_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, c.id),
|
||||
form_data)
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ export default defineConfig({
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['moment', 'jquery']
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Virtual module plugin: generates re-export entries for each shared dep
|
||||
|
||||
Reference in New Issue
Block a user