mirror of
https://github.com/pretix/pretix.git
synced 2026-07-31 09:15:08 +00:00
Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ea7ea31b6 | ||
|
|
5b34a25fb5 | ||
|
|
9467f1d08d | ||
|
|
85344a7690 | ||
|
|
3b47f4aaa7 | ||
|
|
0309b05121 | ||
|
|
ad092ae0d1 | ||
|
|
9f42d679cc | ||
|
|
9eae9c6de7 | ||
|
|
84c51e031e | ||
|
|
902ee9242e | ||
|
|
b758efd4a9 | ||
|
|
c68fe00502 | ||
|
|
b7da9bb6c1 | ||
|
|
694b915d89 | ||
|
|
ea928ea7d4 | ||
|
|
36d49fbd77 | ||
|
|
f0cb451c34 | ||
|
|
2c7fcd0599 | ||
|
|
034722fa39 | ||
|
|
897120d3da | ||
|
|
09abc5725e | ||
|
|
9a7d5abb1f | ||
|
|
3cee5f524b | ||
|
|
3a1a6b988e | ||
|
|
bdc720f5a2 | ||
|
|
583184af38 | ||
|
|
63d9f7cea8 | ||
|
|
c3d6fb1bd6 | ||
|
|
bb43acd257 | ||
|
|
e0744951e8 | ||
|
|
fef1e356f7 | ||
|
|
81775f5d2d | ||
|
|
4bbcc398cc | ||
|
|
a924d0a266 | ||
|
|
0754e9d049 | ||
|
|
0ebdab5389 | ||
|
|
35c5f02b59 | ||
|
|
11c332dfd6 | ||
|
|
b95847c395 | ||
|
|
8fc2e78a58 | ||
|
|
21bd00e7e6 | ||
|
|
3df9d86216 | ||
|
|
c850a35235 | ||
|
|
d794738b8f | ||
|
|
c62a6359e1 | ||
|
|
e62bed7092 | ||
|
|
477601e9f9 | ||
|
|
6024fad056 | ||
|
|
8945e55900 | ||
|
|
7251ae953d | ||
|
|
3c1b806c8e | ||
|
|
2dade31f23 | ||
|
|
504191c005 | ||
|
|
00cb77de8f | ||
|
|
99bd78f2c6 | ||
|
|
d9b691690e | ||
|
|
b1b2a688a8 | ||
|
|
3f92868dba | ||
|
|
961253bac4 | ||
|
|
f3eb2fba6d | ||
|
|
4d8c22a839 | ||
|
|
d544df098c | ||
|
|
6cfdfc2cd6 | ||
|
|
55299b1eae | ||
|
|
97857e7a67 | ||
|
|
469b777dcf | ||
|
|
333dc56ef7 | ||
|
|
2898f06f56 | ||
|
|
0d522c0eed | ||
|
|
ee44d4c968 | ||
|
|
3d37f62c51 | ||
|
|
94dd5b0350 |
+1
-2
@@ -10,8 +10,7 @@ tests:
|
||||
- cd src
|
||||
- python manage.py check
|
||||
- make all compress
|
||||
- playwright install
|
||||
- PRETIX_CONFIG_FILE=tests/ci_sqlite.cfg py.test -n 3 tests --maxfail=100
|
||||
- PRETIX_CONFIG_FILE=tests/ci_sqlite.cfg py.test -n 3 tests --ignore=tests/e2e --maxfail=100
|
||||
except:
|
||||
- '/^v.*$/'
|
||||
pypi:
|
||||
|
||||
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",
|
||||
@@ -4617,6 +4618,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",
|
||||
|
||||
@@ -701,7 +701,7 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||
return super().save(**kwargs)
|
||||
|
||||
def validate_default(self, value):
|
||||
if not value and self.instance.default:
|
||||
if not value and (not self.instance or self.instance.default):
|
||||
raise ValidationError("You can't remove the default property, instead set it on another tax rule.")
|
||||
return value
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ from pretix.base.models import (
|
||||
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
|
||||
SalesChannel,
|
||||
)
|
||||
from pretix.base.models.items import Questionnaire, QuestionnaireChild
|
||||
|
||||
|
||||
class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
@@ -540,6 +541,7 @@ class LegacyDependencyValueField(serializers.CharField):
|
||||
class QuestionSerializer(I18nAwareModelSerializer):
|
||||
options = InlineQuestionOptionSerializer(many=True, required=False)
|
||||
identifier = serializers.CharField(allow_null=True)
|
||||
internal_name = serializers.CharField(allow_null=True, source='question', read_only=True)
|
||||
dependency_value = LegacyDependencyValueField(source='dependency_values', required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
@@ -548,7 +550,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
|
||||
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
|
||||
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
|
||||
'valid_string_length_max', 'valid_file_portrait')
|
||||
'valid_string_length_max', 'valid_file_portrait', 'internal_name',)
|
||||
|
||||
def validate_identifier(self, value):
|
||||
Question._clean_identifier(self.context['event'], value, self.instance)
|
||||
@@ -624,6 +626,160 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
return question
|
||||
|
||||
|
||||
class QuestionRefField(serializers.PrimaryKeyRelatedField):
|
||||
def to_representation(self, qc):
|
||||
if not qc:
|
||||
return None
|
||||
elif qc.system_question:
|
||||
return qc.system_question
|
||||
elif qc.user_question_id:
|
||||
return qc.user_question_id
|
||||
else:
|
||||
return None
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if type(data) == int:
|
||||
return {'user_question': super().to_internal_value(data), 'system_question': None}
|
||||
elif type(data) == str or data is None:
|
||||
return {'user_question': None, 'system_question': data}
|
||||
else:
|
||||
self.fail('incorrect_type', data_type=type(data).__name__)
|
||||
|
||||
def use_pk_only_optimization(self):
|
||||
return self.source == '*'
|
||||
|
||||
|
||||
class InlineQuestionnaireChildSerializer(I18nAwareModelSerializer):
|
||||
question = QuestionRefField(source='*', queryset=Question.objects.none())
|
||||
dependency_question = QuestionRefField(allow_null=True, required=False, queryset=Question.objects.none())
|
||||
|
||||
class Meta:
|
||||
model = QuestionnaireChild
|
||||
fields = ('question', 'required', 'label', 'help_text', 'dependency_question', 'dependency_values')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["question"].queryset = self.context["event"].questions.all()
|
||||
self.fields["dependency_question"].queryset = self.context["event"].questions.all()
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
event = self.context['event']
|
||||
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
|
||||
if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
|
||||
raise ValidationError('Dependencies are not supported during check-in.')
|
||||
|
||||
dep = full_data.get('dependency_question')
|
||||
if dep:
|
||||
if dep.ask_during_checkin:
|
||||
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
|
||||
|
||||
seen_ids = {self.instance.pk} if self.instance else set()
|
||||
while dep:
|
||||
if dep.pk in seen_ids:
|
||||
raise ValidationError(_('Circular dependency between questions detected.'))
|
||||
seen_ids.add(dep.pk)
|
||||
dep = dep.dependency_question
|
||||
|
||||
return data
|
||||
|
||||
def validate_dependency_question(self, value):
|
||||
if value:
|
||||
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
|
||||
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
|
||||
if value == self.instance:
|
||||
raise ValidationError('A question cannot depend on itself.')
|
||||
return value
|
||||
|
||||
|
||||
class QuestionnaireSerializer(I18nAwareModelSerializer):
|
||||
limit_sales_channels = serializers.SlugRelatedField(
|
||||
slug_field="identifier",
|
||||
queryset=SalesChannel.objects.none(),
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
many=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Questionnaire
|
||||
fields = ('id', 'type', 'internal_name', 'items', 'position', 'all_sales_channels', 'limit_sales_channels', 'children')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.fields['children'] = InlineQuestionnaireChildSerializer(many=True, required=True, context=kwargs['context'], partial=False)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
event = self.context['event']
|
||||
|
||||
#full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
#full_data.update(data)
|
||||
|
||||
#if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
|
||||
# raise ValidationError('Dependencies are not supported during check-in.')
|
||||
|
||||
#if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
|
||||
# raise ValidationError(_('This type of question cannot be asked during check-in.'))
|
||||
|
||||
#if full_data.get('show_during_checkin') and full_data.get('type') in Question.SHOW_DURING_CHECKIN_UNSUPPORTED:
|
||||
# raise ValidationError(_('This type of question cannot be shown during check-in.'))
|
||||
|
||||
#Question.clean_items(event, full_data.get('items') or [])
|
||||
return data
|
||||
|
||||
def validate_children(self, value):
|
||||
prev_questions = {}
|
||||
for child in value:
|
||||
if child.get('dependency_question'):
|
||||
if (child['dependency_question']['user_question'] or child['dependency_question']['system_question']) not in prev_questions:
|
||||
raise ValidationError('A question can only depend on a previous question from the same questionnaire.')
|
||||
|
||||
if child['user_question']:
|
||||
prev_questions[child['user_question']] = child
|
||||
if child['system_question']:
|
||||
prev_questions[child['system_question']] = child
|
||||
return value
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
children_data = validated_data.pop('children') if 'children' in validated_data else []
|
||||
questionnaire = super().create(validated_data)
|
||||
self.set_children(questionnaire, children_data)
|
||||
return questionnaire
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
children_data = validated_data.pop('children', None)
|
||||
questionnaire = super().update(instance, validated_data)
|
||||
if children_data is not None:
|
||||
self.set_children(questionnaire, children_data)
|
||||
return questionnaire
|
||||
|
||||
def set_children(self, questionnaire, new_data):
|
||||
result = []
|
||||
child_serializer = self.fields['children'].child
|
||||
existing = questionnaire.children.all()
|
||||
for i, d in enumerate(new_data):
|
||||
d['questionnaire'] = questionnaire
|
||||
d['position'] = i + 1
|
||||
d.setdefault('required', False)
|
||||
d.setdefault('help_text', None)
|
||||
d.setdefault('dependency_question', None)
|
||||
d.setdefault('dependency_values', None)
|
||||
updatable = min(len(existing), len(new_data))
|
||||
for i in range(0, updatable):
|
||||
result.append(child_serializer.update(existing[i], new_data[i]))
|
||||
for i in range(updatable, len(new_data)):
|
||||
result.append(child_serializer.create(new_data[i]))
|
||||
for i in range(updatable, len(existing)):
|
||||
existing[i].delete()
|
||||
return result
|
||||
|
||||
|
||||
class QuotaSerializer(I18nAwareModelSerializer):
|
||||
available = serializers.BooleanField(read_only=True)
|
||||
available_number = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -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.QuestionViewSet)
|
||||
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)
|
||||
|
||||
@@ -47,13 +47,14 @@ from pretix.api.pagination import TotalOrderingFilter
|
||||
from pretix.api.serializers.item import (
|
||||
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
|
||||
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
|
||||
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
|
||||
QuestionOptionSerializer, QuestionSerializer, QuestionnaireSerializer, 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
|
||||
@@ -538,6 +539,51 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class QuestionnaireViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = QuestionnaireSerializer
|
||||
queryset = Questionnaire.objects.none()
|
||||
#filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||
#filterset_class = QuestionFilter
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position', 'id')
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.questionnaires.prefetch_related('children').all()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.questionnaire.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.questionnaire.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
instance.log_action(
|
||||
'pretix.event.questionnaire.deleted',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
|
||||
pass
|
||||
|
||||
|
||||
+351
-341
@@ -650,7 +650,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
orderpos = self.orderpos = kwargs.pop('orderpos', None)
|
||||
pos = cartpos or orderpos
|
||||
item = pos.item
|
||||
questions = pos.item.questions_to_ask
|
||||
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
|
||||
@@ -696,346 +696,13 @@ class BaseQuestionsForm(forms.Form):
|
||||
validators=([MaxDateTimeValidator(max_date)] if max_date else []) + [MinDateTimeValidator(min_date)]
|
||||
)
|
||||
|
||||
add_fields = {}
|
||||
|
||||
if item.ask_attendee_data and event.settings.attendee_names_asked:
|
||||
add_fields['attendee_name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=event.settings.attendee_names_required and not self.all_optional,
|
||||
scheme=event.settings.name_scheme,
|
||||
titles=event.settings.name_scheme_titles,
|
||||
label=_('Attendee name'),
|
||||
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
|
||||
)
|
||||
if item.ask_attendee_data and event.settings.attendee_emails_asked:
|
||||
add_fields['attendee_email'] = forms.EmailField(
|
||||
required=event.settings.attendee_emails_required and not self.all_optional,
|
||||
label=_('Attendee email'),
|
||||
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email),
|
||||
widget=forms.EmailInput(
|
||||
attrs={
|
||||
'autocomplete': 'email'
|
||||
}
|
||||
)
|
||||
)
|
||||
if item.ask_attendee_data and event.settings.attendee_company_asked:
|
||||
add_fields['company'] = forms.CharField(
|
||||
required=event.settings.attendee_company_required and not self.all_optional,
|
||||
label=_('Company'),
|
||||
max_length=255,
|
||||
initial=(cartpos.company if cartpos else orderpos.company),
|
||||
)
|
||||
|
||||
if item.ask_attendee_data and event.settings.attendee_addresses_asked:
|
||||
add_fields['street'] = forms.CharField(
|
||||
required=self.attendee_addresses_required,
|
||||
label=_('Address'),
|
||||
widget=forms.Textarea(attrs={
|
||||
'rows': 2,
|
||||
'placeholder': _('Street and Number'),
|
||||
'autocomplete': 'street-address'
|
||||
}),
|
||||
initial=(cartpos.street if cartpos else orderpos.street),
|
||||
)
|
||||
add_fields['zipcode'] = forms.CharField(
|
||||
required=False,
|
||||
max_length=30,
|
||||
label=_('ZIP code'),
|
||||
initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'postal-code',
|
||||
}),
|
||||
)
|
||||
add_fields['city'] = forms.CharField(
|
||||
required=False,
|
||||
label=_('City'),
|
||||
max_length=255,
|
||||
initial=(cartpos.city if cartpos else orderpos.city),
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'address-level2',
|
||||
}),
|
||||
)
|
||||
country = (cartpos.country if cartpos else orderpos.country) or guess_country_from_request(request, event)
|
||||
add_fields['country'] = CountryField(
|
||||
countries=CachedCountries
|
||||
).formfield(
|
||||
required=self.attendee_addresses_required,
|
||||
label=_('Country'),
|
||||
initial=country,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'country',
|
||||
'data-trigger-address-info': 'on',
|
||||
}),
|
||||
)
|
||||
c = [('', '---')]
|
||||
fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
|
||||
cc = None
|
||||
state = None
|
||||
if fprefix + 'country' in self.data:
|
||||
cc = str(self.data[fprefix + 'country'])
|
||||
elif country:
|
||||
cc = str(country)
|
||||
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
|
||||
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
|
||||
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
|
||||
state = (cartpos.state if cartpos else orderpos.state)
|
||||
elif fprefix + 'state' in self.data:
|
||||
self.data = self.data.copy()
|
||||
del self.data[fprefix + 'state']
|
||||
|
||||
add_fields['state'] = forms.ChoiceField(
|
||||
label=pgettext_lazy('address', 'State'),
|
||||
required=False,
|
||||
choices=c,
|
||||
initial=state,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'address-level1',
|
||||
}),
|
||||
)
|
||||
add_fields['state'].widget.is_required = True
|
||||
|
||||
field_positions = list(
|
||||
[
|
||||
(n, event.settings.system_question_order.get(n if n != 'state' else 'country', 0))
|
||||
for n in add_fields.keys()
|
||||
]
|
||||
)
|
||||
|
||||
for q in questions:
|
||||
# Do we already have an answer? Provide it as the initial value
|
||||
answers = [a for a in pos.answerlist if a.question_id == q.id]
|
||||
if answers:
|
||||
initial = answers[0]
|
||||
else:
|
||||
initial = None
|
||||
tz = ZoneInfo(event.settings.timezone)
|
||||
help_text = rich_text(q.help_text)
|
||||
label = escape(q.question) # django-bootstrap3 calls mark_safe
|
||||
required = q.required and not self.all_optional
|
||||
if q.type == Question.TYPE_BOOLEAN:
|
||||
if required:
|
||||
# For some reason, django-bootstrap3 does not set the required attribute
|
||||
# itself.
|
||||
widget = forms.CheckboxInput(attrs={'required': 'required'})
|
||||
else:
|
||||
widget = forms.CheckboxInput()
|
||||
|
||||
if initial:
|
||||
initialbool = (initial.answer == "True")
|
||||
else:
|
||||
initialbool = False
|
||||
|
||||
field = forms.BooleanField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initialbool, widget=widget,
|
||||
)
|
||||
elif q.type == Question.TYPE_NUMBER:
|
||||
field = forms.DecimalField(
|
||||
label=label, required=required,
|
||||
min_value=q.valid_number_min or Decimal('0.00'),
|
||||
max_value=q.valid_number_max,
|
||||
help_text=help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
field = forms.CharField(
|
||||
label=label, required=required,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_TEXT:
|
||||
field = forms.CharField(
|
||||
label=label, required=required,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=help_text,
|
||||
widget=forms.Textarea,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_COUNTRYCODE:
|
||||
field = CountryField(
|
||||
countries=CachedCountries,
|
||||
blank=True, null=True, blank_label=' ',
|
||||
).formfield(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
widget=forms.Select,
|
||||
empty_label=' ',
|
||||
initial=initial.answer if initial else (guess_country_from_request(request, event) if required else None),
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE:
|
||||
field = forms.ModelChoiceField(
|
||||
queryset=q.options,
|
||||
label=label, required=required,
|
||||
help_text=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:
|
||||
field = forms.ModelMultipleChoiceField(
|
||||
queryset=q.options,
|
||||
label=label, required=required,
|
||||
help_text=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:
|
||||
field = PortraitImageField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=PortraitImageWidget(position=pos, event=event, answer=initial, attrs={'data-portrait-photo': 'true'}),
|
||||
)
|
||||
else:
|
||||
field = ExtFileField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
|
||||
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
|
||||
)
|
||||
elif q.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()
|
||||
if not help_text:
|
||||
if q.valid_date_min and q.valid_date_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date between {min} and {max}.'),
|
||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).date()
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.DateField(
|
||||
label=label, required=required,
|
||||
help_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 initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).time()
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.TimeField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=_initial,
|
||||
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
if not help_text:
|
||||
if q.valid_datetime_min and q.valid_datetime_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date and time between {min} and {max}.'),
|
||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).astimezone(tz)
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
|
||||
field = SplitDateTimeField(
|
||||
label=label, required=required,
|
||||
help_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
|
||||
),
|
||||
)
|
||||
if q.valid_datetime_min:
|
||||
field.validators.append(MinDateTimeValidator(q.valid_datetime_min))
|
||||
if q.valid_datetime_max:
|
||||
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
|
||||
elif q.type == Question.TYPE_PHONENUMBER:
|
||||
if initial:
|
||||
try:
|
||||
initial = PhoneNumber().from_string(initial.answer)
|
||||
except NumberParseException:
|
||||
initial = None
|
||||
|
||||
if not initial:
|
||||
phone_prefix = guess_phone_prefix_from_request(request, event)
|
||||
if phone_prefix:
|
||||
initial = "+{}.".format(phone_prefix)
|
||||
|
||||
field = PhoneNumberField(
|
||||
label=label, required=required,
|
||||
help_text=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
|
||||
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
|
||||
field.required = False
|
||||
|
||||
add_fields['question_%s' % q.id] = field
|
||||
field_positions.append(('question_%s' % q.id, q.position))
|
||||
|
||||
field_positions.sort(key=lambda e: e[1])
|
||||
for fname, p in field_positions:
|
||||
self.fields[fname] = add_fields[fname]
|
||||
for questionnaire in questionnaires:
|
||||
for child in getattr(questionnaire, 'childlist', questionnaire.children.all()):
|
||||
if child.user_question:
|
||||
q = child.user_question
|
||||
self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, pos, q)
|
||||
elif child.system_question:
|
||||
self.fields[child.system_question] = self.build_system_question_field(request, event, pos, child)
|
||||
|
||||
responses = question_form_fields.send(sender=event, position=pos)
|
||||
data = pos.meta_info_data
|
||||
@@ -1060,6 +727,349 @@ class BaseQuestionsForm(forms.Form):
|
||||
else:
|
||||
v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
|
||||
|
||||
def build_system_question_field(self, request, event, pos, qc):
|
||||
field_name = qc.system_question
|
||||
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=qc.label,
|
||||
help_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=qc.label,
|
||||
help_text=qc.help_text,
|
||||
initial=pos.attendee_email,
|
||||
widget=forms.EmailInput(
|
||||
attrs={
|
||||
'autocomplete': 'email'
|
||||
}
|
||||
)
|
||||
)
|
||||
if field_name == 'company':
|
||||
return forms.CharField(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=qc.label,
|
||||
help_text=qc.help_text,
|
||||
max_length=255,
|
||||
initial=pos.company,
|
||||
)
|
||||
|
||||
if field_name == 'street':
|
||||
return forms.CharField(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=qc.label,
|
||||
help_text=qc.help_text,
|
||||
widget=forms.Textarea(attrs={
|
||||
'rows': 2,
|
||||
'placeholder': _('Street and Number'),
|
||||
'autocomplete': 'street-address'
|
||||
}),
|
||||
initial=pos.street,
|
||||
)
|
||||
if field_name == 'zipcode':
|
||||
return forms.CharField(
|
||||
required=False,
|
||||
max_length=30,
|
||||
label=qc.label,
|
||||
help_text=qc.help_text,
|
||||
initial=pos.zipcode,
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'postal-code',
|
||||
}),
|
||||
)
|
||||
if field_name == 'city':
|
||||
return forms.CharField(
|
||||
required=False,
|
||||
label=qc.label,
|
||||
help_text=qc.help_text,
|
||||
max_length=255,
|
||||
initial=pos.city,
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'address-level2',
|
||||
}),
|
||||
)
|
||||
if field_name == 'country':
|
||||
country = pos.country or guess_country_from_request(request, event)
|
||||
return CountryField(
|
||||
countries=CachedCountries
|
||||
).formfield(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=qc.label,
|
||||
help_text=qc.help_text,
|
||||
initial=country,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'country',
|
||||
'data-trigger-address-info': 'on',
|
||||
}),
|
||||
)
|
||||
if field_name == 'state':
|
||||
country = pos.country or guess_country_from_request(request, event)
|
||||
c = [('', '---')]
|
||||
fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
|
||||
cc = None
|
||||
state = None
|
||||
if fprefix + 'country' in self.data:
|
||||
cc = str(self.data[fprefix + 'country'])
|
||||
elif country:
|
||||
cc = str(country)
|
||||
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
|
||||
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
|
||||
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
|
||||
state = (pos.state)
|
||||
elif fprefix + 'state' in self.data:
|
||||
self.data = self.data.copy()
|
||||
del self.data[fprefix + 'state']
|
||||
|
||||
field = forms.ChoiceField(
|
||||
label=qc.label,
|
||||
help_text=qc.help_text,
|
||||
required=False,
|
||||
choices=c,
|
||||
initial=state,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'address-level1',
|
||||
}),
|
||||
)
|
||||
field.widget.is_required = True
|
||||
return field
|
||||
|
||||
def build_user_question_field(self, request, event, pos, q):
|
||||
# Do we already have an answer? Provide it as the initial value
|
||||
answers = [a for a in pos.answerlist if a.question_id == q.id]
|
||||
if answers:
|
||||
initial = answers[0]
|
||||
else:
|
||||
initial = None
|
||||
tz = ZoneInfo(event.settings.timezone)
|
||||
help_text = rich_text(q.help_text)
|
||||
label = escape(q.question) # django-bootstrap3 calls mark_safe
|
||||
required = q.required and not self.all_optional
|
||||
if q.type == Question.TYPE_BOOLEAN:
|
||||
if required:
|
||||
# For some reason, django-bootstrap3 does not set the required attribute
|
||||
# itself.
|
||||
widget = forms.CheckboxInput(attrs={'required': 'required'})
|
||||
else:
|
||||
widget = forms.CheckboxInput()
|
||||
|
||||
if initial:
|
||||
initialbool = (initial.answer == "True")
|
||||
else:
|
||||
initialbool = False
|
||||
|
||||
field = forms.BooleanField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initialbool, widget=widget,
|
||||
)
|
||||
elif q.type == Question.TYPE_NUMBER:
|
||||
field = forms.DecimalField(
|
||||
label=label, required=required,
|
||||
min_value=q.valid_number_min or Decimal('0.00'),
|
||||
max_value=q.valid_number_max,
|
||||
help_text=help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
field = forms.CharField(
|
||||
label=label, required=required,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_TEXT:
|
||||
field = forms.CharField(
|
||||
label=label, required=required,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=help_text,
|
||||
widget=forms.Textarea,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_COUNTRYCODE:
|
||||
field = CountryField(
|
||||
countries=CachedCountries,
|
||||
blank=True, null=True, blank_label=' ',
|
||||
).formfield(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
widget=forms.Select,
|
||||
empty_label=' ',
|
||||
initial=initial.answer if initial else (guess_country_from_request(request, event) if required else None),
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE:
|
||||
field = forms.ModelChoiceField(
|
||||
queryset=q.options,
|
||||
label=label, required=required,
|
||||
help_text=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:
|
||||
field = forms.ModelMultipleChoiceField(
|
||||
queryset=q.options,
|
||||
label=label, required=required,
|
||||
help_text=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:
|
||||
field = PortraitImageField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=PortraitImageWidget(position=pos, event=event, answer=initial, attrs={'data-portrait-photo': 'true'}),
|
||||
)
|
||||
else:
|
||||
field = ExtFileField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
|
||||
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
|
||||
)
|
||||
elif q.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()
|
||||
if not help_text:
|
||||
if q.valid_date_min and q.valid_date_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date between {min} and {max}.'),
|
||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).date()
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.DateField(
|
||||
label=label, required=required,
|
||||
help_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 initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).time()
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.TimeField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=_initial,
|
||||
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
if not help_text:
|
||||
if q.valid_datetime_min and q.valid_datetime_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date and time between {min} and {max}.'),
|
||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).astimezone(tz)
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
|
||||
field = SplitDateTimeField(
|
||||
label=label, required=required,
|
||||
help_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
|
||||
),
|
||||
)
|
||||
if q.valid_datetime_min:
|
||||
field.validators.append(MinDateTimeValidator(q.valid_datetime_min))
|
||||
if q.valid_datetime_max:
|
||||
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
|
||||
elif q.type == Question.TYPE_PHONENUMBER:
|
||||
if initial:
|
||||
try:
|
||||
initial = PhoneNumber().from_string(initial.answer)
|
||||
except NumberParseException:
|
||||
initial = None
|
||||
|
||||
if not initial:
|
||||
phone_prefix = guess_phone_prefix_from_request(request, event)
|
||||
if phone_prefix:
|
||||
initial = "+{}.".format(phone_prefix)
|
||||
|
||||
field = PhoneNumberField(
|
||||
label=label, required=required,
|
||||
help_text=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
|
||||
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
|
||||
field.required = False
|
||||
|
||||
return field
|
||||
|
||||
def clean(self):
|
||||
from pretix.base.addressvalidation import \
|
||||
validate_address # local import to prevent impact on startup time
|
||||
|
||||
@@ -74,6 +74,7 @@ class LocaleMiddleware(MiddlewareMixin):
|
||||
|
||||
def process_request(self, request: HttpRequest):
|
||||
language = get_language_from_request(request)
|
||||
region = None
|
||||
# Normally, this middleware runs *before* the event is set. However, on event frontend pages it
|
||||
# might be run a second time by pretix.presale.EventMiddleware and in this case the event is already
|
||||
# set and can be taken into account for the decision.
|
||||
@@ -94,15 +95,16 @@ class LocaleMiddleware(MiddlewareMixin):
|
||||
if '-' not in language and settings_holder.settings.region:
|
||||
language += '-' + settings_holder.settings.region
|
||||
if settings_holder.settings.region:
|
||||
set_region(settings_holder.settings.region)
|
||||
region = settings_holder.settings.region
|
||||
else:
|
||||
gs = global_settings_object(request)
|
||||
if '-' not in language and gs.settings.region:
|
||||
language += '-' + gs.settings.region
|
||||
if gs.settings.region:
|
||||
set_region(gs.settings.region)
|
||||
region = gs.settings.region
|
||||
|
||||
translation.activate(language)
|
||||
set_region(region)
|
||||
request.LANGUAGE_CODE = get_language_without_region()
|
||||
|
||||
tzname = None
|
||||
@@ -284,8 +286,8 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
'script-src': ["{static}"],
|
||||
'object-src': ["'none'"],
|
||||
'frame-src': ['{static}'],
|
||||
'style-src': ["{static}", "{media}"],
|
||||
'connect-src': ["{dynamic}", "{media}"],
|
||||
'style-src': ["{static}", "{media}"] + (["'unsafe-inline'"] if settings.VITE_DEV_MODE else []),
|
||||
'connect-src': ["{dynamic}", "{media}"] + (["http://localhost:5173", "ws://localhost:5173"] if settings.VITE_DEV_MODE else []),
|
||||
'img-src': ["{static}", "{media}", "data:"] + img_src,
|
||||
'font-src': ["{static}"] + list(font_src),
|
||||
'media-src': ["{static}", "data:"],
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-19 14:24
|
||||
import json
|
||||
from collections import namedtuple
|
||||
from itertools import chain, groupby
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
import pretix.base.models.base
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
FakeQuestion = namedtuple(
|
||||
'FakeQuestion', 'id question position required'
|
||||
)
|
||||
|
||||
|
||||
def get_fake_questions(settings):
|
||||
def b(s):
|
||||
return s == 'True'
|
||||
fq = []
|
||||
sqo = json.loads(settings.get('system_question_order', '{}'))
|
||||
_ = LazyI18nString.from_gettext
|
||||
|
||||
if b(settings.get('attendee_names_asked', 'True')):
|
||||
fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), b(settings.get('attendee_names_required'))))
|
||||
|
||||
if b(settings.get('attendee_emails_asked')):
|
||||
fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), b(settings.get('attendee_emails_required'))))
|
||||
|
||||
if b(settings.get('attendee_company_asked')):
|
||||
fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), b(settings.get('attendee_company_required'))))
|
||||
|
||||
if b(settings.get('attendee_addresses_asked')):
|
||||
fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), b(settings.get('attendee_addresses_required'))))
|
||||
fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), b(settings.get('attendee_addresses_required'))))
|
||||
fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), b(settings.get('attendee_addresses_required'))))
|
||||
fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), b(settings.get('attendee_addresses_required'))))
|
||||
return fq
|
||||
|
||||
|
||||
def migrate_questions_forward(apps, schema_editor):
|
||||
Event = apps.get_model("pretixbase", "Event")
|
||||
Item = apps.get_model("pretixbase", "Item")
|
||||
Question = apps.get_model("pretixbase", "Question")
|
||||
Questionnaire = apps.get_model("pretixbase", "Questionnaire")
|
||||
QuestionnaireChild = apps.get_model("pretixbase", "QuestionnaireChild")
|
||||
EventSettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
|
||||
|
||||
def create_grouped_item_questionnaires(event, children, label_prefix, questionnaire_type):
|
||||
# group by item, creating a unique questionnaire per item
|
||||
item_questionnaires = (([t[3] for t in children], item_id) for item_id, children in
|
||||
groupby(children, key=lambda t: t[0]))
|
||||
|
||||
# group again, merging all questionnaires with identical children
|
||||
merged_questionnaires = groupby(sorted(item_questionnaires, key=lambda t: [q.id for q in t[0]]),
|
||||
key=lambda t: t[0])
|
||||
for children, iterator in merged_questionnaires:
|
||||
items = [item for _c, item in iterator]
|
||||
|
||||
# create questionnaires and children
|
||||
questionnaire = Questionnaire.objects.create(
|
||||
event=event, type=questionnaire_type, position=0, all_sales_channels=True,
|
||||
internal_name=label_prefix + ', '.join(str(iname or name) for (id, iname, name) in items)
|
||||
)
|
||||
questionnaire.items.set([id for (id, iname, name) in items])
|
||||
deps = {}
|
||||
for position, child in enumerate(children):
|
||||
if isinstance(child, FakeQuestion):
|
||||
QuestionnaireChild.objects.create(
|
||||
questionnaire=questionnaire,
|
||||
position=position + 1,
|
||||
system_question=child.id,
|
||||
required=child.required,
|
||||
label=child.question,
|
||||
)
|
||||
else:
|
||||
deps[child.id] = QuestionnaireChild.objects.create(
|
||||
questionnaire=questionnaire,
|
||||
position=position + 1,
|
||||
user_question=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 questions (user-defined and system provided), along with the products for which they're asked
|
||||
questions = event.questions.all()
|
||||
children = sorted(chain((
|
||||
(item, q.position, q.id, q)
|
||||
for q in questions.filter(hidden=False)
|
||||
for item in q.items.values_list('id', 'internal_name', 'name')
|
||||
), (
|
||||
(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')
|
||||
)), key=lambda t: (t[0], t[1], t[2]))
|
||||
|
||||
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], t[2]))
|
||||
|
||||
create_grouped_item_questionnaires(event, children, 'Hidden questions for ', 'PH')
|
||||
|
||||
|
||||
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', '0301_reusablemedium_remove_orderposition'),
|
||||
]
|
||||
|
||||
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_question', 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_question', 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 remove old columns from Question model
|
||||
]
|
||||
@@ -37,7 +37,7 @@ from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
|
||||
ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question,
|
||||
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
|
||||
QuestionOption, Questionnaire, QuestionnaireChild, Quota, SubEventItem, SubEventItemVariation,
|
||||
itempicture_upload_to,
|
||||
)
|
||||
from .log import LogEntry
|
||||
|
||||
+117
-16
@@ -1605,10 +1605,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``)
|
||||
@@ -1677,7 +1679,7 @@ class Question(LoggedModel):
|
||||
related_name="questions",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
question = I18nTextField(
|
||||
question = I18nTextField( # to be renamed to 'internal_name'
|
||||
verbose_name=_("Question")
|
||||
)
|
||||
identifier = models.CharField(
|
||||
@@ -1692,7 +1694,7 @@ class Question(LoggedModel):
|
||||
),
|
||||
],
|
||||
)
|
||||
help_text = I18nTextField(
|
||||
help_text = I18nTextField( # 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,
|
||||
@@ -1702,22 +1704,22 @@ class Question(LoggedModel):
|
||||
choices=TYPE_CHOICES,
|
||||
verbose_name=_("Question type")
|
||||
)
|
||||
required = models.BooleanField(
|
||||
required = models.BooleanField( # to be removed, -> QuestionnaireChild
|
||||
default=False,
|
||||
verbose_name=_("Required question")
|
||||
)
|
||||
items = models.ManyToManyField(
|
||||
items = models.ManyToManyField( # 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(
|
||||
position = models.PositiveIntegerField( # to be removed, -> Questionnaire + QuestionnaireChild
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
ask_during_checkin = models.BooleanField(
|
||||
ask_during_checkin = models.BooleanField( # 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
|
||||
@@ -1727,7 +1729,7 @@ class Question(LoggedModel):
|
||||
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
|
||||
@@ -1736,10 +1738,10 @@ class Question(LoggedModel):
|
||||
verbose_name=_('Print answer on invoices'),
|
||||
default=False
|
||||
)
|
||||
dependency_question = models.ForeignKey(
|
||||
dependency_question = models.ForeignKey( # 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=[]) # 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'))
|
||||
@@ -1773,9 +1775,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):
|
||||
@@ -2000,6 +2002,105 @@ class QuestionOption(models.Model):
|
||||
ordering = ('position', 'id')
|
||||
|
||||
|
||||
class Questionnaire(LoggedModel):
|
||||
TYPE_ORDER_SALE = "OS"
|
||||
TYPE_ORDER_POSITION_SALE = "PS"
|
||||
TYPE_ORDER_POSITION_ATTENDEE_ONLY = "PA"
|
||||
TYPE_ORDER_POSITION_CHECKIN = "PC"
|
||||
TYPE_ORDER_POSITION_HIDDEN = "PH"
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_ORDER_SALE, _("Order-wide, before purchase")),
|
||||
(TYPE_ORDER_POSITION_SALE, _("Per product, before purchase")),
|
||||
(TYPE_ORDER_POSITION_ATTENDEE_ONLY, _("Per product, via attendee link")),
|
||||
(TYPE_ORDER_POSITION_CHECKIN, _("Per product, at check-in")),
|
||||
(TYPE_ORDER_POSITION_HIDDEN, _("Per product, hidden")),
|
||||
)
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
related_name="questionnaires",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
internal_name = models.CharField(
|
||||
verbose_name=_("Internal name"),
|
||||
max_length=255,
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=5,
|
||||
choices=TYPE_CHOICES,
|
||||
verbose_name=_("Questionnaire type")
|
||||
)
|
||||
items = models.ManyToManyField(
|
||||
Item,
|
||||
related_name='questionnaires',
|
||||
verbose_name=_("Products"),
|
||||
blank=True,
|
||||
help_text=_('This questionnaire will be asked to buyers of the selected products')
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
all_sales_channels = models.BooleanField(
|
||||
verbose_name=_("Sell on all sales channels the product is sold on"),
|
||||
default=True,
|
||||
)
|
||||
limit_sales_channels = models.ManyToManyField(
|
||||
"SalesChannel",
|
||||
verbose_name=_("Restrict to specific sales channels"),
|
||||
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
|
||||
'selected here but not on product level, the variation will not be available.'),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
|
||||
class QuestionnaireChild(LoggedModel):
|
||||
SYSTEM_QUESTION_CHOICES = (
|
||||
('attendee_name_parts', _('Attendee name')),
|
||||
('attendee_email', _('Attendee email')),
|
||||
('company', _('Company')),
|
||||
('street', _('Street')),
|
||||
('zipcode', _('ZIP code')),
|
||||
('city', _('City')),
|
||||
('country', _('Country')),
|
||||
)
|
||||
questionnaire = models.ForeignKey(
|
||||
Questionnaire,
|
||||
related_name="children",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
user_question = models.ForeignKey(
|
||||
Question,
|
||||
related_name="references",
|
||||
on_delete=models.CASCADE,
|
||||
null=True, blank=True,
|
||||
)
|
||||
system_question = models.CharField(
|
||||
max_length=25,
|
||||
choices=SYSTEM_QUESTION_CHOICES,
|
||||
null=True, blank=True,
|
||||
)
|
||||
required = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Required question")
|
||||
)
|
||||
label = I18nTextField(
|
||||
verbose_name=_("Question")
|
||||
)
|
||||
help_text = I18nTextField(
|
||||
verbose_name=_("Help text"),
|
||||
help_text=_("If the question needs to be explained or clarified, do it here!"),
|
||||
null=True, blank=True,
|
||||
)
|
||||
dependency_question = models.ForeignKey(
|
||||
'QuestionnaireChild', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
||||
)
|
||||
dependency_values = MultiStringField(default=[])
|
||||
|
||||
|
||||
class Quota(LoggedModel):
|
||||
"""
|
||||
A quota is a "pool of tickets". It is there to limit the number of items
|
||||
|
||||
@@ -1426,6 +1426,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.')
|
||||
@@ -1570,53 +1576,80 @@ 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_question_id and qc.user_question_id in self.answer_cache:
|
||||
qc.answer = self.answer_cache[qc.user_question_id]
|
||||
#qc.answer.question = qc # cache object
|
||||
elif qc.system_question:
|
||||
qc.answer = self.get_system_answer(qc.system_question)
|
||||
#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)
|
||||
qc.answer = ""
|
||||
if not qc.dependency_question_id or qc_is_visible(qc.dependency_question_id, qc.dependency_values):
|
||||
self.questions.append(qc)
|
||||
|
||||
@cached_property
|
||||
def answer_cache(self):
|
||||
return {
|
||||
aw.question_id: aw for aw in getattr(self, 'answerlist', self.answers.all())
|
||||
}
|
||||
|
||||
def get_dependency_answer_values(self, qc):
|
||||
if qc.user_question_id:
|
||||
if qc.user_question_id not in self.answer_cache:
|
||||
return None
|
||||
answer = self.answer_cache[qc.user_question_id]
|
||||
return answer.to_dependency_values()
|
||||
elif qc.system_question:
|
||||
return [self.get_system_answer(qc.system_question)]
|
||||
else:
|
||||
raise ValueError('Questionnaire child without question has no answer')
|
||||
|
||||
def get_system_answer(self, system_question_name):
|
||||
if system_question_name == 'attendee_name_parts':
|
||||
return self.attendee_name_parts
|
||||
elif system_question_name == 'attendee_email':
|
||||
return self.attendee_email
|
||||
elif system_question_name == 'street':
|
||||
return self.street
|
||||
elif system_question_name == 'zipcode':
|
||||
return self.zipcode
|
||||
elif system_question_name == 'city':
|
||||
return self.city
|
||||
elif system_question_name == 'state':
|
||||
return self.state
|
||||
elif system_question_name == 'country':
|
||||
return self.country
|
||||
else:
|
||||
raise ValueError('Unknown system question name')
|
||||
|
||||
@property
|
||||
def net_price(self):
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="utf-8">
|
||||
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/errors.js" %}"></script>
|
||||
{% block custom_header %}{% endblock %}
|
||||
{% if css_theme %}
|
||||
<link rel="stylesheet" type="text/css" href="{{ css_theme }}" />
|
||||
@@ -20,6 +21,5 @@
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<script src="{% static "pretixbase/js/errors.js" %}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="error-details">
|
||||
<h1>{% trans "Redirect" %}</h1>
|
||||
<h3>
|
||||
{% blocktrans trimmed with host="<strong>"|add:hostname|add:"</strong>"|safe %}
|
||||
{% blocktrans trimmed with host=bold_hostname %}
|
||||
The link you clicked on wants to redirect you to a destination on the website {{ host }}.
|
||||
{% endblocktrans %}
|
||||
{% blocktrans trimmed %}
|
||||
|
||||
@@ -36,7 +36,7 @@ from pretix.base.forms.questions import (
|
||||
)
|
||||
from pretix.base.models import (
|
||||
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
|
||||
QuestionOption,
|
||||
QuestionOption, QuestionnaireChild,
|
||||
)
|
||||
from pretix.base.models.customers import AttendeeProfile
|
||||
from pretix.presale.signals import contact_form_fields_overrides
|
||||
@@ -307,27 +307,34 @@ 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)
|
||||
qqs = qqs.filter(type='PS')
|
||||
else:
|
||||
qqs = qqs.filter(type__startswith='P')
|
||||
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_question', 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
|
||||
|
||||
@@ -25,6 +25,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
|
||||
|
||||
|
||||
def _is_samesite_referer(request):
|
||||
@@ -52,6 +53,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,
|
||||
})
|
||||
|
||||
|
||||
@@ -1342,7 +1342,13 @@ class QuestionAnswerFilterForm(forms.Form):
|
||||
opqs = opqs.filter(canceled=False)
|
||||
if fdata.get("item", "") != "":
|
||||
i = fdata.get("item", "")
|
||||
opqs = opqs.filter(item_id__in=(i,))
|
||||
if '-' in i:
|
||||
opqs = opqs.filter(
|
||||
item_id=i.split('-')[0],
|
||||
variation_id=i.split('-')[1],
|
||||
)
|
||||
else:
|
||||
opqs = opqs.filter(item_id=i)
|
||||
|
||||
return opqs
|
||||
|
||||
|
||||
@@ -853,6 +853,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'),
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<script type="text/javascript" src="{% static "lightbox/js/lightbox.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "are-you-sure/jquery.are-you-sure.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/addressform.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/errors.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{{ html_head|safe }}
|
||||
|
||||
|
||||
@@ -12,142 +12,113 @@
|
||||
{% 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 "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" %}
|
||||
{% bootstrap_field form.items layout="control" %}
|
||||
{% 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_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" %}
|
||||
{% bootstrap_field form.ask_during_checkin layout="control" %}
|
||||
{% bootstrap_field form.show_during_checkin layout="control" %}
|
||||
{% bootstrap_field form.hidden layout="control" %}
|
||||
{% bootstrap_field form.print_on_invoice layout="control" %}
|
||||
|
||||
<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_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" %}
|
||||
{% bootstrap_field form.ask_during_checkin layout="control" %}
|
||||
{% bootstrap_field form.show_during_checkin layout="control" %}
|
||||
{% bootstrap_field form.hidden layout="control" %}
|
||||
{% bootstrap_field form.print_on_invoice layout="control" %}
|
||||
|
||||
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{% 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 %}
|
||||
Questions 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>
|
||||
|
||||
{{ request.event.settings.locales|json_script:"event_locales" }}
|
||||
{{ questionnaire_type_choices|json_script:"questionnaire_type_choices" }}
|
||||
|
||||
{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=0 as datafield_edit_url %}
|
||||
{{ datafield_edit_url|json_script:"datafield_edit_url" }}
|
||||
|
||||
<div id="questionnaires-editor">
|
||||
<!-- Vue app mount point -->
|
||||
</div>
|
||||
|
||||
{% vite_hmr %}
|
||||
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/questionnaires/index.ts" %}
|
||||
{% endblock %}
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "pretixcontrol/items/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "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
|
||||
@@ -12,7 +12,7 @@
|
||||
{% csrf_token %}
|
||||
{% 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 %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new question" %}
|
||||
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new data field" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -20,39 +20,27 @@
|
||||
<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 %}">
|
||||
<tbody>
|
||||
{% for q in questions %}
|
||||
<tr data-dnd-id="{{ q.id }}">
|
||||
<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 %}
|
||||
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
|
||||
{{ q.question }}
|
||||
</a>
|
||||
</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 %}
|
||||
@@ -63,42 +51,17 @@
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
<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>
|
||||
|
||||
@@ -602,60 +602,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"
|
||||
@@ -683,12 +632,27 @@
|
||||
{% endif %}
|
||||
{% elif q.type == "M" %}
|
||||
{{ q.answer.to_string_i18n|rich_text_snippet }}
|
||||
{% else %}
|
||||
{% elif q.type %}
|
||||
{{ q.answer.to_string_i18n|linebreaksbr }}
|
||||
{% else %}{# TODO: proper separation of QuestionAnswer objects and system answers...... #}
|
||||
{{ q.answer|linebreaksbr }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<em>{% trans "not answered" %}</em>
|
||||
{% endif %}
|
||||
{% if q.system_question == "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 %}
|
||||
|
||||
@@ -349,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'),
|
||||
|
||||
@@ -56,7 +56,7 @@ from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.generic import FormView, ListView, View
|
||||
from django.views.generic import FormView, ListView, TemplateView, View
|
||||
from django.views.generic.detail import DetailView, SingleObjectMixin
|
||||
from django_countries.fields import Country
|
||||
|
||||
@@ -440,87 +440,7 @@ class QuestionList(ListView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
questions = []
|
||||
|
||||
if self.request.event.settings.attendee_names_asked:
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='attendee_name_parts',
|
||||
question=_('Attendee name'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'attendee_name_parts', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_names_required,
|
||||
)
|
||||
)
|
||||
|
||||
if self.request.event.settings.attendee_emails_asked:
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='attendee_email',
|
||||
question=_('Attendee email'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'attendee_email', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_emails_required,
|
||||
)
|
||||
)
|
||||
|
||||
if self.request.event.settings.attendee_company_asked:
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='company',
|
||||
question=_('Company'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'company', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_company_required,
|
||||
)
|
||||
)
|
||||
|
||||
if self.request.event.settings.attendee_addresses_asked:
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='street',
|
||||
question=_('Street'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'street', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_addresses_required,
|
||||
)
|
||||
)
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='zipcode',
|
||||
question=_('ZIP code'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'zipcode', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_addresses_required,
|
||||
)
|
||||
)
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='city',
|
||||
question=_('City'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'city', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_addresses_required,
|
||||
)
|
||||
)
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='country',
|
||||
question=_('Country'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'country', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_addresses_required,
|
||||
)
|
||||
)
|
||||
|
||||
questions += list(ctx['questions'])
|
||||
questions = list(ctx['questions'])
|
||||
questions.sort(key=lambda q: q.position)
|
||||
ctx['questions'] = questions
|
||||
return ctx
|
||||
@@ -836,6 +756,11 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
|
||||
return ret
|
||||
|
||||
|
||||
class QuestionnairesEditor(EventPermissionRequiredMixin, TemplateView):
|
||||
permission = 'can_change_items'
|
||||
template_name = 'pretixcontrol/items/questionnaires.html'
|
||||
|
||||
|
||||
class QuotaQueryMixin:
|
||||
|
||||
@cached_property
|
||||
@@ -861,7 +786,7 @@ class QuotaQueryMixin:
|
||||
return QuotaFilterForm(data=self.request_data, prefix='filter', event=self.request.event)
|
||||
|
||||
|
||||
class QuotaList(PaginationMixin, QuotaQueryMixin, ListView):
|
||||
class QuotaList(PaginationMixin, ListView):
|
||||
model = Quota
|
||||
context_object_name = 'quotas'
|
||||
template_name = 'pretixcontrol/items/quotas.html'
|
||||
|
||||
@@ -270,18 +270,18 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
|
||||
def _transaction_group_header_label(self):
|
||||
return _("Event") + " / " + _("Product")
|
||||
|
||||
def _transaction_group_label(self, form_data, r):
|
||||
def _transaction_group(self, form_data, r):
|
||||
if not self.is_multievent and not form_data.get("split_subevents"):
|
||||
return None
|
||||
return None, None
|
||||
if r.get("subevent_id"):
|
||||
return "{} - {} ({}) [{}]".format(
|
||||
r["order__event__name"],
|
||||
r["subevent__name"],
|
||||
date_format(r["subevent__date_from"]),
|
||||
r["order__event__slug"]
|
||||
)
|
||||
), f"subevent-{r['subevent_id']}"
|
||||
else:
|
||||
return "{} [{}]".format(r["order__event__name"], r["order__event__slug"])
|
||||
return "{} [{}]".format(r["order__event__name"], r["order__event__slug"]), f"event-{r['order__event__slug']}"
|
||||
|
||||
def _transaction_row_label(self, r):
|
||||
if r["item_id"]:
|
||||
@@ -343,17 +343,17 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
|
||||
last_group = None
|
||||
last_group_head_idx = 0
|
||||
for r in qs:
|
||||
e = self._transaction_group_label(form_data, r)
|
||||
group_label, group_id = self._transaction_group(form_data, r)
|
||||
|
||||
if e != last_group:
|
||||
if last_group_head_idx > 0 and e is not None:
|
||||
if group_id != last_group:
|
||||
if last_group_head_idx > 0 and group_id is not None:
|
||||
tdata[last_group_head_idx][4] = Paragraph(money_filter(sum_price_by_group - sum_tax_by_group, currency), tstyle_bold_right),
|
||||
tdata[last_group_head_idx][5] = Paragraph(money_filter(sum_tax_by_group, currency), tstyle_bold_right),
|
||||
tdata[last_group_head_idx][6] = Paragraph(money_filter(sum_price_by_group, currency), tstyle_bold_right),
|
||||
tdata.append(
|
||||
[
|
||||
FontFallbackParagraph(
|
||||
e,
|
||||
group_label,
|
||||
tstyle_bold,
|
||||
),
|
||||
"",
|
||||
@@ -367,7 +367,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
|
||||
tstyledata.append(
|
||||
("SPAN", (0, len(tdata) - 1), (3, len(tdata) - 1)),
|
||||
)
|
||||
last_group = e
|
||||
last_group = group_id
|
||||
last_group_head_idx = len(tdata) - 1
|
||||
sum_price_by_group = Decimal("0.00")
|
||||
sum_tax_by_group = Decimal("0.00")
|
||||
|
||||
@@ -1060,26 +1060,18 @@ class QuestionsStep(QuestionsViewMixin, 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 (
|
||||
@@ -1088,31 +1080,16 @@ class QuestionsStep(QuestionsViewMixin, 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_question_id and question_is_required(qc) and qc.user_question_id not in cp.answer_cache:
|
||||
if warn:
|
||||
messages.warning(request, _('Please fill in answers to all required questions.'))
|
||||
return False
|
||||
if qc.system_question and question_is_required(qc) and not cp.get_system_answer(qc.system_question):
|
||||
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', {})
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% for q in line.questions %}
|
||||
<dt>{{ q.question }}</dt>
|
||||
<dt>{{ q.label }}</dt>
|
||||
<dd>
|
||||
{% if q.answer %}
|
||||
{% if q.answer.file %}
|
||||
|
||||
@@ -22,4 +22,5 @@
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/ui/iframe.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/addressform.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/deanonymize_email.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/errors.js" %}"></script>
|
||||
{% endcompress %}
|
||||
|
||||
@@ -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.services.cart import get_fees
|
||||
from pretix.base.services.pricing import apply_rounding
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
@@ -397,8 +398,8 @@ 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)
|
||||
qqs = request.event.questionnaires.all()
|
||||
qqs = qqs.filter(type='PS')
|
||||
request._cart_cache = CartPosition.objects.filter(
|
||||
cart_id=cart_id, event=request.event
|
||||
).annotate(
|
||||
@@ -419,18 +420,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_question', 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:
|
||||
|
||||
@@ -174,15 +174,16 @@ function async_task_error(jqXHR, textStatus, errorThrown) {
|
||||
var respdom = $(jqXHR.responseText);
|
||||
var c = respdom.filter('.container');
|
||||
if (respdom.filter('form') && (respdom.filter('.has-error') || respdom.filter('.alert-danger'))) {
|
||||
// This is a failed form validation, let's just use it
|
||||
|
||||
if (respdom.filter('#page-wrapper') && $('#page-wrapper').length) {
|
||||
// This is a failed form validation, let's just use it
|
||||
async_task_replace_page("#page-wrapper", respdom.find("#page-wrapper").html());
|
||||
} else {
|
||||
async_task_replace_page("body", jqXHR.responseText.substring(
|
||||
jqXHR.responseText.indexOf("<body"),
|
||||
jqXHR.responseText.indexOf("</body")
|
||||
));
|
||||
document.dispatchEvent(new Event("pretix:async-task-error"))
|
||||
|
||||
}
|
||||
|
||||
} else if (c.length > 0) {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
document.getElementById('goback').onclick =
|
||||
function() {window.history.back()};
|
||||
|
||||
document.getElementById('reload').onclick =
|
||||
function() {window.location.reload(true)};
|
||||
['DOMContentLoaded', 'pretix:async-task-error'].forEach(function (ev) {
|
||||
document.addEventListener(ev, function () {
|
||||
document.querySelectorAll('#goback, #reload').forEach(function (element) {
|
||||
const regularLoad = ev === 'DOMContentLoaded' && element.id === 'goback';
|
||||
element.addEventListener('click', regularLoad
|
||||
? () => window.history.back()
|
||||
: () => window.location.reload()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import QuestionnaireElement from './QuestionnaireElement.vue';
|
||||
import * as api from './api';
|
||||
import { Questionnaire } from './model';
|
||||
import { i18n_any, QUESTION_TYPE, sort, localeComp, numericComp, groupBy, _ } from './helper';
|
||||
import {Ref, ref} from 'vue';
|
||||
import { SlickList, SlickItem } from 'vue-slicksort';
|
||||
|
||||
const items_list = await api.getItems();
|
||||
const categories_list = await api.getCategories();
|
||||
const categories = Object.fromEntries(categories_list.map(cat => [cat.id, cat]));
|
||||
categories['null'] = { position: -1, internal_name: _('Uncategorized') };
|
||||
sort(items_list, numericComp(item => categories[item.category]?.position), numericComp(item => item.position));
|
||||
console.log("items_list", items_list);
|
||||
const grouped_items = [...groupBy(items_list, item => categories[item.category])];
|
||||
console.log("grouped_items", grouped_items);
|
||||
|
||||
const all_questionnaires: (Omit<Questionnaire, 'id'> & { _new_id?: number, id?: number })[] = await api.getQuestionnaires();
|
||||
const order_questionnaires = ref(all_questionnaires.filter(q => q.type.startsWith('O')));
|
||||
const position_questionnaires = ref(all_questionnaires.filter(q => q.type.startsWith('P')));
|
||||
const datafields = ref(await api.getDatafields());
|
||||
|
||||
function saveQuestionnaire(questionnaire) {
|
||||
if (questionnaire.id) {
|
||||
api.updateQuestionnaire(questionnaire.id, questionnaire);
|
||||
} else {
|
||||
api.createQuestionnaire(questionnaire);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
QuestionnaireElement, SlickList, SlickItem,
|
||||
},
|
||||
methods: {
|
||||
i18n_any,
|
||||
addPositionQuestionnaire() {
|
||||
position_questionnaires.value.push({
|
||||
all_sales_channels: false, children: [], limit_sales_channels: [], position: 0,
|
||||
items: [], internal_name: "Unnamed questionnaire", type: "PS",
|
||||
_new_id: Date.now(),
|
||||
});
|
||||
},
|
||||
addOrderQuestionnaire() {
|
||||
order_questionnaires.value.push({
|
||||
all_sales_channels: false, children: [], limit_sales_channels: [], position: 0,
|
||||
items: [], internal_name: "Unnamed questionnaire", type: "OS",
|
||||
_new_id: Date.now(),
|
||||
});
|
||||
},
|
||||
saveData() {
|
||||
for (const questionnaire of order_questionnaires.value) {
|
||||
saveQuestionnaire(questionnaire);
|
||||
}
|
||||
for (const questionnaire of position_questionnaires.value) {
|
||||
saveQuestionnaire(questionnaire);
|
||||
}
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
order_questionnaires,
|
||||
position_questionnaires,
|
||||
datafields,
|
||||
items: items_list,
|
||||
selected_product: ref(""),
|
||||
grouped_items,
|
||||
categories,
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.hidden-question { opacity: 0.3; }
|
||||
.hidden-question label { text-decoration: line-through; }
|
||||
|
||||
.question-editor { margin-right: 180px; }
|
||||
.question-edit-buttons { float:right; }
|
||||
.question-edit-buttons div { position: absolute; margin-left: 10px; min-width: 100px; }
|
||||
.question-edit-buttons button { }
|
||||
.form-group { margin-bottom: 30px }
|
||||
|
||||
.filter-row { background: #fffee6; border: 1px solid #ecead5; padding: 10px; }
|
||||
|
||||
.debuginfo { font-size: 70%; background: rgba(200, 200, 200, 0.5); }
|
||||
.dependency-info { position: absolute; }
|
||||
.dependency-info > span { }
|
||||
|
||||
.category-header { margin: 8px 0 -5px 0; font-weight: bold; color: #737373; }
|
||||
</style>
|
||||
<template>
|
||||
<p class="filter-row">
|
||||
Order questionnaires
|
||||
</p>
|
||||
<div class="question-editor">
|
||||
<SlickList axis="y" v-model:list="order_questionnaires" useDragHandle appendTo="#orderQuestionnaireListParent" id="orderQuestionnaireListParent">
|
||||
<SlickItem v-for="(questionnaire, index) in order_questionnaires" :key="questionnaire.id || questionnaire._new_id" :index="index">
|
||||
<QuestionnaireElement
|
||||
:questionnaire="questionnaire"
|
||||
:datafields="datafields"
|
||||
:grouped_items="null"
|
||||
:selected_product="null" />
|
||||
</SlickItem>
|
||||
</SlickList>
|
||||
</div>
|
||||
<p>
|
||||
<button class="btn btn-default" @click="addOrderQuestionnaire()"><i class="fa fa-plus"></i> Neuen Fragebogen erstellen</button>
|
||||
<button class="btn btn-default" @click="saveData()"><i class="fa fa-save"></i> Speichern</button>
|
||||
</p>
|
||||
|
||||
<p class="filter-row">
|
||||
Questionnaires for product:
|
||||
<select v-model="selected_product">
|
||||
<option value="">(all)</option>
|
||||
<optgroup v-for="[category, items] in grouped_items" :label="category.internal_name || i18n_any(category.name)">
|
||||
<option v-for="item in items" :value="item.id">
|
||||
{{ item.internal_name || i18n_any(item.name) }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</p>
|
||||
<div class="question-editor">
|
||||
<SlickList axis="y" v-model:list="position_questionnaires" useDragHandle appendTo="#questionnaireListParent" id="questionnaireListParent">
|
||||
<SlickItem v-for="(questionnaire, index) in position_questionnaires" :key="questionnaire.id || questionnaire._new_id" :index="index">
|
||||
<QuestionnaireElement
|
||||
:questionnaire="questionnaire"
|
||||
:datafields="datafields"
|
||||
:grouped_items="grouped_items"
|
||||
:selected_product="selected_product" />
|
||||
</SlickItem>
|
||||
</SlickList>
|
||||
</div>
|
||||
<p>
|
||||
<button class="btn btn-default" @click="addPositionQuestionnaire()"><i class="fa fa-plus"></i> Neuen Fragebogen erstellen</button>
|
||||
<button class="btn btn-default" @click="saveData()"><i class="fa fa-save"></i> Speichern</button>
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useId, defineProps } from 'vue';
|
||||
import {getEventLocales} from "./api";
|
||||
|
||||
const locales = getEventLocales();
|
||||
|
||||
const props = defineProps(['value', 'id']);
|
||||
|
||||
if (!props.value) props.value = {};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="i18n-form-group" :id="id">
|
||||
<textarea v-for="locale in locales" cols="40" rows="2" :lang="locale" dir="ltr" class="form-control" title="Englisch" :id="`${id}_${locale}`" :placeholder="locale" v-model="value[locale]"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useId } from 'vue';
|
||||
|
||||
const dialog = ref<HTMLDialogElement>();
|
||||
|
||||
const props = defineProps({
|
||||
classes: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
title: '',
|
||||
});
|
||||
|
||||
const visible = ref(false);
|
||||
|
||||
const showModal = () => {
|
||||
dialog.value?.showModal();
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: showModal,
|
||||
close: (returnVal?: string): void => dialog.value?.close(returnVal),
|
||||
visible,
|
||||
});
|
||||
const id = useId();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialog" class="modal-card"
|
||||
@close="visible = false"
|
||||
closedby="any"
|
||||
:aria-labelledby="`${id}-title`"
|
||||
>
|
||||
<form
|
||||
v-if="visible"
|
||||
method="dialog" class="modal-card-inner form-horizontal"
|
||||
:class="{
|
||||
[props.classes]: props.classes,
|
||||
}"
|
||||
>
|
||||
<div class="modal-card-content">
|
||||
<h2 :id="`${id}-title`" class="modal-card-title h3">{{ title }}</h2>
|
||||
<slot />
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n_any, QUESTION_TYPE, QUESTION_TYPE_LABEL, SYSTEM_DATAFIELDS } from './helper';
|
||||
import NativeDialog from './NativeDialog.vue';
|
||||
import I18nTextField from './I18nTextField.vue';
|
||||
import {useId, ref, computed} from 'vue'
|
||||
import { DragHandle } from 'vue-slicksort';
|
||||
import {getDatafieldEditUrl} from "./api";
|
||||
|
||||
const id = useId();
|
||||
const props = defineProps(['question', 'datafields', 'editable', 'possible_dependencies'])
|
||||
const emit = defineEmits(['removeSelf']);
|
||||
const gettext = (window as any).gettext;
|
||||
const question = ref(props.question);
|
||||
|
||||
const df = typeof question.value.question === 'number' ?
|
||||
props.datafields.find(el => el.id === question.value.question) :
|
||||
typeof question.value.question === 'string' ?
|
||||
SYSTEM_DATAFIELDS[question.value.question] :
|
||||
null;
|
||||
|
||||
const dependency_values_options = computed(() => props.datafields.find(el => el.id === question.value.dependency_question)?.options);
|
||||
const dependency_values_resolved = computed(() => question.value.dependency_values.map(ident => i18n_any(dependency_values_options.value.find(opt => opt.identifier === ident)?.answer) ?? ident));
|
||||
|
||||
if (!question.value.label) question.value.label = {};
|
||||
if (!question.value.help_text) question.value.help_text = {};
|
||||
|
||||
const editor = ref();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-group">
|
||||
<div class="question-edit-buttons" v-if="editable"><div class="btn-group">
|
||||
<DragHandle tag="button" class="btn btn-default"><i class="fa fa-arrows"></i></DragHandle>
|
||||
<button class="btn btn-default" @click="editor.show()"><i class="fa fa-edit"></i></button>
|
||||
</div></div>
|
||||
|
||||
<template v-if="df">
|
||||
<div v-if="question.dependency_question" class="dependency-info debuginfo">
|
||||
<span><span class="fa fa-link"></span> {{ question.dependency_question }} = {{ dependency_values_resolved }}</span>
|
||||
</div>
|
||||
<div class="col-md-3 control-label label-empty" v-if="df.type === QUESTION_TYPE.BOOLEAN"></div>
|
||||
<label class="col-md-3 control-label" :for="id" v-else>
|
||||
{{ i18n_any(question.label) }}
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<input :id="id" type="text" v-if="df.type === QUESTION_TYPE.STRING || df.type === QUESTION_TYPE.PHONENUMBER" class="form-control">
|
||||
<textarea :id="id" v-if="df.type === QUESTION_TYPE.TEXT" class="form-control"></textarea>
|
||||
<div class="checkbox" v-if="df.type === QUESTION_TYPE.BOOLEAN">
|
||||
<label :for="id">
|
||||
<input :id="id" type="checkbox"> {{ i18n_any(question.label) }}
|
||||
</label>
|
||||
</div>
|
||||
<input :id="id" type="number" v-if="df.type === QUESTION_TYPE.NUMBER" class="form-control">
|
||||
<input :id="id" type="file" v-if="df.type === QUESTION_TYPE.FILE" class="form-control">
|
||||
<select :id="id" class="form-control"
|
||||
v-if="df.type === QUESTION_TYPE.CHOICE || df.type === QUESTION_TYPE.COUNTRYCODE">
|
||||
<option></option>
|
||||
<option v-for="opt in df.options">{{ i18n_any(opt.answer) }}</option>
|
||||
</select>
|
||||
<div class="checkbox" v-if="df.type === QUESTION_TYPE.CHOICE_MULTIPLE" v-for="(opt, index) in df.options">
|
||||
<label :for="`${id}-${index}`">
|
||||
<input :id="`${id}-${index}`" type="checkbox"> {{ i18n_any(opt.answer) }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="help-block">{{ i18n_any(question.help_text) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="col-md-12">
|
||||
<h3>{{ i18n_any(question.label) }}</h3>
|
||||
<p>{{ i18n_any(question.help_text) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<NativeDialog ref="editor" class="modal-card"
|
||||
title="Edit question">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{{ gettext('Question') }}
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<I18nTextField :value="question.label"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{{ gettext('Help text') }}
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<I18nTextField :value="question.help_text"/>
|
||||
<div class="help-block">Wenn diese Frage noch weitere Erklärung braucht, können Sie sie hier eintragen.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
Data field
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">
|
||||
<template v-if="typeof question.question === 'number'">
|
||||
{{ df.internal_name }}
|
||||
<a :href="getDatafieldEditUrl(df.id)" target="_blank">Manage data field details</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ question.question }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
Data field type
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<select v-model="df.type" class="form-control" disabled>
|
||||
<option v-for="(label, type) in QUESTION_TYPE_LABEL" :value="QUESTION_TYPE[type]">{{ label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label label-empty"> </label>
|
||||
<div class="col-md-9">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" v-model="question.required">
|
||||
{{ gettext('Required question') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{{ gettext('Only visible if...') }}
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<select v-model="question.dependency_question" class="form-control">
|
||||
<option :value="null">{{ gettext('(none)') }}</option>
|
||||
<option v-for="(qc, index) in possible_dependencies" :value="qc.question">{{ i18n_any(qc.label) }}</option>
|
||||
</select>
|
||||
<select v-model="question.dependency_values" class="form-control" multiple>
|
||||
<option v-for="(qc, index) in dependency_values_options" :value="qc.identifier">{{ i18n_any(qc.answer) }}</option>
|
||||
</select>
|
||||
<div v-if="question.dependency_question">
|
||||
<p><span class="debuginfo">depends on {{ question.dependency_question }} = {{ dependency_values_resolved }}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="editor.close()" class="btn btn-primary pull-right"><span class="fa fa-check"></span> Save and close</button>
|
||||
<button @click="emit('removeSelf')" class="btn btn-default">Remove from questionnaire</button>
|
||||
</NativeDialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import {useId, ref, computed} from 'vue'
|
||||
import QuestionElement from "./QuestionElement.vue";
|
||||
import {i18n_any, QUESTION_TYPE, QUESTION_TYPE_LABEL} from "./helper";
|
||||
import I18nTextField from "./I18nTextField.vue";
|
||||
import NativeDialog from "./NativeDialog.vue";
|
||||
import { SlickList, SlickItem, DragHandle } from 'vue-slicksort';
|
||||
|
||||
const dlgEditor = ref();
|
||||
const dlgAddExisting = ref();
|
||||
const dlgAddTextblock = ref();
|
||||
|
||||
const newTextblockTitle = ref();
|
||||
const newTextblockText = ref();
|
||||
|
||||
const id = useId();
|
||||
const props = defineProps(['questionnaire', 'datafields', 'selected_product', 'grouped_items'])
|
||||
const gettext = (window as any).gettext
|
||||
|
||||
function toggleItem() {
|
||||
const i = props.questionnaire.items.indexOf(props.selected_product);
|
||||
if (i === -1) {
|
||||
props.questionnaire.items.push(props.selected_product);
|
||||
} else {
|
||||
props.questionnaire.items.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function addExistingDatafield(field) {
|
||||
props.questionnaire.children.push({
|
||||
question: field.id,
|
||||
required: false,
|
||||
label: {},
|
||||
help_text: {},
|
||||
dependency_question: null,
|
||||
dependency_values: [],
|
||||
});
|
||||
dlgAddExisting.value.close();
|
||||
}
|
||||
|
||||
function showAddTextblockDialog() {
|
||||
newTextblockTitle.value = {};
|
||||
newTextblockText.value = {};
|
||||
dlgAddTextblock.value.show();
|
||||
}
|
||||
|
||||
function addTextblock() {
|
||||
props.questionnaire.children.push({
|
||||
question: null,
|
||||
required: false,
|
||||
label: newTextblockTitle.value,
|
||||
help_text: newTextblockText.value,
|
||||
dependency_question: null,
|
||||
dependency_values: [],
|
||||
});
|
||||
dlgAddTextblock.value.close();
|
||||
}
|
||||
|
||||
const isHidden = computed(() => props.selected_product && props.questionnaire.items.indexOf(props.selected_product) === -1);
|
||||
const isEditable = computed(() => props.selected_product && props.questionnaire.items.indexOf(props.selected_product) !== -1);
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="question-edit-buttons"><div class="btn-group">
|
||||
<DragHandle tag="button" class="btn btn-default"><i class="fa fa-arrows"></i></DragHandle>
|
||||
<button class="btn btn-default" @click="dlgEditor.show()"><i class="fa fa-edit"></i></button>
|
||||
<button class="btn btn-default" @click="toggleItem()" v-if="selected_product"><i :class="`fa fa-eye${isHidden ? '-slash':''}`"></i></button>
|
||||
</div></div>
|
||||
|
||||
<details class="panel panel-default " :open="!!isEditable"
|
||||
:class="{ 'hidden-question': isHidden }">
|
||||
<summary class="panel-heading">
|
||||
{{ props.questionnaire.internal_name }}
|
||||
</summary>
|
||||
<div class="panel-body" v-if="!isHidden">
|
||||
<div class="form-horizontal" :id="`questionListParent${props.questionnaire.id}`">
|
||||
<SlickList axis="y" v-model:list="props.questionnaire.children" useDragHandle :appendTo="`#questionListParent${props.questionnaire.id}`">
|
||||
<SlickItem v-for="(child, index) in props.questionnaire.children" :key="child.id" :index="index">
|
||||
<QuestionElement
|
||||
:datafields="props.datafields"
|
||||
:question="child"
|
||||
:editable="true"
|
||||
:possible_dependencies="props.questionnaire.children.slice(0, index)"
|
||||
@remove-self="questionnaire.children.splice(index, 1)" />
|
||||
</SlickItem>
|
||||
</SlickList>
|
||||
</div>
|
||||
<p v-if="true" class="btn-group" role="group">
|
||||
<button class="btn btn-default" @click="dlgAddExisting.show()"><i class="fa fa-plus"></i> {{ gettext('Existing data field') }}</button>
|
||||
<button class="btn btn-default" @click="newDatafield()"><i class="fa fa-plus"></i> {{ gettext('New data field') }}</button>
|
||||
<button class="btn btn-default" @click="showAddTextblockDialog()"><i class="fa fa-plus"></i> {{ gettext('Text') }}</button>
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<Teleport to="body">
|
||||
<NativeDialog ref="dlgEditor" class="modal-card"
|
||||
:title="gettext('Edit questionnaire')">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{{ gettext('Internal name') }}
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<input type="text" class="form-control" v-model="questionnaire.internal_name"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" v-if="grouped_items">
|
||||
<label class="col-md-3 control-label">
|
||||
{{ gettext('Visible on products') }}
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<div v-for="[category, items] in grouped_items">
|
||||
<div class="category-header">{{ category.internal_name || i18n_any(category.name) }}</div>
|
||||
<div class="checkbox" v-for="item in items">
|
||||
<label :for="id + '_' + item.id">
|
||||
<input :id="id + '_' + item.id" type="checkbox" :checked="questionnaire.items.indexOf(item.id) !== -1"> {{ item.internal_name || i18n_any(item.name) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="dlgEditor.close()" class="btn btn-primary pull-right"><span class="fa fa-check"></span> {{ gettext('Save and close') }}</button>
|
||||
<button class="btn btn-default">{{ gettext('Delete') }}</button>
|
||||
</NativeDialog>
|
||||
|
||||
<NativeDialog ref="dlgAddExisting" class="modal-card"
|
||||
:title="gettext('Add existing data field')">
|
||||
|
||||
<div class="list-group">
|
||||
<a href="javascript:" @click="addExistingDatafield(field)" v-for="field in datafields" class="list-group-item">{{ field.internal_name }}</a>
|
||||
</div>
|
||||
|
||||
<button @click="dlgAddExisting.close()" class="btn btn-default pull-right">{{ gettext('Cancel') }}</button>
|
||||
</NativeDialog>
|
||||
|
||||
<NativeDialog ref="dlgAddTextblock" class="modal-card"
|
||||
:title="gettext('Add sub heading')">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{{ gettext('Title') }}
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<I18nTextField :value="newTextblockTitle"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{{ gettext('Text') }}
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<I18nTextField :value="newTextblockText"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="addTextblock()" class="btn btn-default pull-right">{{ gettext('OK') }}</button>
|
||||
<button @click="dlgAddTextblock.close()" class="btn btn-default pull-right">{{ gettext('Cancel') }}</button>
|
||||
|
||||
</NativeDialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
import {ApiListResponse, Datafield, Questionnaire, Item} from "./model";
|
||||
|
||||
const organizer_slug = document.body.getAttribute('data-organizer'),
|
||||
event_slug = document.body.getAttribute('data-event');
|
||||
|
||||
async function api_get(resource) {
|
||||
return await $.getJSON(`/api/v1/${resource}?_nocache=${+new Date()}`);
|
||||
}
|
||||
|
||||
async function api_get_all<T>(resource): Promise<T[]> {
|
||||
let next = `/api/v1/${resource}?_nocache=${+new Date()}`;
|
||||
const result: T[] = [];
|
||||
while (next) {
|
||||
const response: ApiListResponse<T> = await $.getJSON(next);
|
||||
result.push(...response.results);
|
||||
next = response.next;
|
||||
console.log('api_get_all: '+ resource, next, response, result)
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function api_json_request(resource, method, json_body) {
|
||||
return await (await fetch(`/api/v1/${resource}`, {
|
||||
body: JSON.stringify(json_body),
|
||||
method: method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRFToken": $('[name=csrfmiddlewaretoken]').val() as string,
|
||||
},
|
||||
})).json();
|
||||
}
|
||||
|
||||
export async function getDatafields() {
|
||||
return await api_get_all<Datafield>(`organizers/${organizer_slug}/events/${event_slug}/datafields/`);
|
||||
}
|
||||
|
||||
export async function getQuestionnaires() {
|
||||
return await api_get_all<Questionnaire>(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/`);
|
||||
}
|
||||
|
||||
export async function updateQuestionnaire(id, data) {
|
||||
return await api_json_request(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/${id}/`, 'PATCH', data);
|
||||
}
|
||||
|
||||
export async function createQuestionnaire(data) {
|
||||
return await api_json_request(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/`, 'POST', data);
|
||||
}
|
||||
|
||||
export async function getItems() {
|
||||
return await api_get_all<Item>(`organizers/${organizer_slug}/events/${event_slug}/items/`);
|
||||
}
|
||||
|
||||
export async function getCategories() {
|
||||
return await api_get_all<Item>(`organizers/${organizer_slug}/events/${event_slug}/categories/`);
|
||||
}
|
||||
|
||||
function get_json_script_value(id) {
|
||||
return JSON.parse(document.getElementById(id).innerText);
|
||||
}
|
||||
|
||||
export function getEventLocales() {
|
||||
return get_json_script_value('event_locales');
|
||||
}
|
||||
|
||||
export function getDatafieldEditUrl(datafield_id) {
|
||||
return get_json_script_value('datafield_edit_url').replace('/0/', `/${datafield_id}/`);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
|
||||
export function i18n_any(data) {
|
||||
if (!data) return null;
|
||||
const preferred = document.body.getAttribute("data-pretixlocale");
|
||||
if (data[preferred]) return data[preferred];
|
||||
return Object.values(data)[0];
|
||||
}
|
||||
|
||||
function freezeRec(o) {
|
||||
return Object.freeze(Object.fromEntries(Object.entries(o).map(([k, v]) => [k, v && Object.getPrototypeOf(v) === Object.prototype ? freezeRec(v) : v])))
|
||||
}
|
||||
|
||||
export function localeComp(fn) {
|
||||
return function(a, b) {
|
||||
return fn(a).localeCompare(fn(b));
|
||||
}
|
||||
}
|
||||
export function numericComp(fn) {
|
||||
return function(a, b) {
|
||||
return fn(a) - fn(b);
|
||||
}
|
||||
}
|
||||
export function pick(key) {
|
||||
return function(obj) {
|
||||
return obj[key];
|
||||
}
|
||||
}
|
||||
export function sort(array, ...orderBy) {
|
||||
array.sort(function(a, b) {
|
||||
for(let comp of orderBy) {
|
||||
const result = comp(a, b);
|
||||
if (result !== 0) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
export function *groupBy(array, key) {
|
||||
let lastKey, lastArray;
|
||||
for(const x of array){
|
||||
const k = key(x);
|
||||
if (lastKey !== k || !lastArray) {
|
||||
if (lastArray) {
|
||||
yield [lastKey, lastArray];
|
||||
}
|
||||
lastKey = k; lastArray = [x];
|
||||
} else {
|
||||
lastArray.push(x);
|
||||
}
|
||||
}
|
||||
if (lastArray) {
|
||||
yield [lastKey, lastArray];
|
||||
}
|
||||
}
|
||||
|
||||
export const QUESTION_TYPE = {
|
||||
NUMBER: "N",
|
||||
STRING: "S",
|
||||
TEXT: "T",
|
||||
BOOLEAN: "B",
|
||||
CHOICE: "C",
|
||||
CHOICE_MULTIPLE: "M",
|
||||
FILE: "F",
|
||||
DATE: "D",
|
||||
TIME: "H",
|
||||
DATETIME: "W",
|
||||
COUNTRYCODE: "CC",
|
||||
PHONENUMBER: "TEL",
|
||||
};
|
||||
|
||||
export const _ = x => x;
|
||||
|
||||
export const QUESTION_TYPE_LABEL = {
|
||||
NUMBER: _("Number"),
|
||||
STRING: _("Text (one line)"),
|
||||
TEXT: _("Multiline text"),
|
||||
BOOLEAN: _("Yes/No"),
|
||||
CHOICE: _("Choose one from a list"),
|
||||
CHOICE_MULTIPLE: _("Choose multiple from a list"),
|
||||
FILE: _("File upload"),
|
||||
DATE: _("Date"),
|
||||
TIME: _("Time"),
|
||||
DATETIME: _("Date and time"),
|
||||
COUNTRYCODE: _("Country code (ISO 3166-1 alpha-2)"),
|
||||
PHONENUMBER: _("Phone number"),
|
||||
};
|
||||
|
||||
export const SYSTEM_DATAFIELDS = freezeRec({
|
||||
'attendee_name_parts': { label: _('Attendee name'), type: QUESTION_TYPE.STRING },
|
||||
'attendee_email': { label: _('Attendee email'), type: QUESTION_TYPE.STRING },
|
||||
'company': { label: _('Company'), type: QUESTION_TYPE.STRING },
|
||||
'street': { label: _('Street'), type: QUESTION_TYPE.STRING },
|
||||
'zipcode': { label: _('ZIP code'), type: QUESTION_TYPE.STRING },
|
||||
'city': { label: _('City'), type: QUESTION_TYPE.STRING },
|
||||
'country': { label: _('Country'), type: QUESTION_TYPE.COUNTRYCODE },
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#questionnaires-editor')
|
||||
@@ -0,0 +1,65 @@
|
||||
|
||||
export type ApiListResponse<T> = {
|
||||
count: number,
|
||||
next: string | null,
|
||||
previous: string | null,
|
||||
results: T[],
|
||||
};
|
||||
|
||||
// from webcheckin/i18n.ts
|
||||
export type I18nString = string | Record<string, string> | null | undefined;
|
||||
|
||||
export type Datafield = {
|
||||
id: number,
|
||||
// question: I18nString,
|
||||
type: string,
|
||||
required: boolean,
|
||||
//items: number[],
|
||||
options: any[],
|
||||
// position: number,
|
||||
//ask_during_checkin: boolean,
|
||||
show_during_checkin: boolean,
|
||||
identifier: string,
|
||||
// dependency_question: string | null,
|
||||
// dependency_values []
|
||||
// hidden: boolean,
|
||||
// dependency_value null
|
||||
print_on_invoice: boolean,
|
||||
// help_text: I18nString,
|
||||
valid_number_min: null | number,
|
||||
valid_number_max: null | number,
|
||||
valid_date_min: null | string,
|
||||
valid_date_max: null | string,
|
||||
valid_datetime_min: null | string,
|
||||
valid_datetime_max: null | string,
|
||||
valid_string_length_max: null | number,
|
||||
valid_file_portrait: boolean,
|
||||
internal_name: string,
|
||||
};
|
||||
|
||||
export type Questionnaire = {
|
||||
id: number,
|
||||
internal_name: string,
|
||||
type: string,
|
||||
items: number[],
|
||||
position: number,
|
||||
all_sales_channels: boolean,
|
||||
limit_sales_channels: string[],
|
||||
children: QuestionnaireChild[],
|
||||
};
|
||||
|
||||
export type QuestionnaireChild = {
|
||||
question: string | number,
|
||||
required: boolean,
|
||||
label: I18nString,
|
||||
help_text: I18nString,
|
||||
dependency_question: number | null,
|
||||
dependency_values: null | string[],
|
||||
};
|
||||
|
||||
export type Item = {
|
||||
id: number,
|
||||
category: number,
|
||||
name: I18nString,
|
||||
internal_name: string | null,
|
||||
};
|
||||
@@ -780,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%;
|
||||
|
||||
@@ -2051,9 +2051,16 @@ var shared_root_methods = {
|
||||
this.$root.set_cart_id(data.cart_id);
|
||||
this.$root.overlay.frame_loading = false;
|
||||
callback()
|
||||
}, () => {
|
||||
}, (xhr, data) => {
|
||||
if (xhr.status === 429 && typeof xhr.responseURL !== "undefined") {
|
||||
this.$root.overlay.error_message = strings['cart_error_429'];
|
||||
this.$root.overlay.frame_loading = false;
|
||||
this.$root.overlay.error_url_after = this.$root.newTabTarget;
|
||||
this.$root.overlay.error_url_after_new_tab = true;
|
||||
} else {
|
||||
this.$root.overlay.error_message = strings['cart_error'];
|
||||
this.$root.overlay.frame_loading = false;
|
||||
}
|
||||
})
|
||||
},
|
||||
get_cart_id: function() {
|
||||
@@ -2142,7 +2149,14 @@ var shared_root_computed = {
|
||||
if (this.subevent) {
|
||||
target = this.target_url + this.subevent + '/';
|
||||
}
|
||||
return target;
|
||||
var parameters = this.$root.consent_parameter
|
||||
if (this.$root.additionalURLParams) {
|
||||
parameters += `&${this.$root.additionalURLParams}`
|
||||
}
|
||||
if (parameters) {
|
||||
target += '?' + parameters.replace(/^&/, '')
|
||||
}
|
||||
return target
|
||||
},
|
||||
useIframe: function () {
|
||||
if (window.crossOriginIsolated === true) {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- modernize the sometimes native form submitting?
|
||||
- destructure props?
|
||||
|
||||
@@ -162,7 +162,15 @@ export function createWidgetStore (config: {
|
||||
return params.toString()
|
||||
},
|
||||
newTabTarget (): string {
|
||||
return this.subevent ? `${this.targetUrl}${this.subevent}/` : this.targetUrl
|
||||
let url = this.subevent ? `${this.targetUrl}${this.subevent}/` : this.targetUrl
|
||||
let parameters = this.consentParameter
|
||||
if (this.additionalURLParams) {
|
||||
parameters += `&${this.additionalURLParams}`
|
||||
}
|
||||
if (parameters) {
|
||||
url += '?' + parameters.replace(/^&/, '')
|
||||
}
|
||||
return url
|
||||
},
|
||||
formTarget (): string {
|
||||
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox')
|
||||
@@ -441,6 +449,7 @@ export function createWidgetStore (config: {
|
||||
this.overlay.frameLoading = false
|
||||
this.overlay.errorUrlAfter = this.newTabTarget
|
||||
this.overlay.errorUrlAfterNewTab = true
|
||||
return
|
||||
} else if (e.status === 405) {
|
||||
// Likely a redirect!
|
||||
this.targetUrl = e.responseUrl.substring(0, e.responseUrl.indexOf('/cart/add') - 18)
|
||||
@@ -460,11 +469,18 @@ export function createWidgetStore (config: {
|
||||
this.overlay.frameLoading = true
|
||||
const data = await createCart(url)
|
||||
this.setCartId(data.cart_id)
|
||||
return true
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && (e.status === 200 || (e.status >= 400 && e.status < 500))) {
|
||||
if (e instanceof ApiError && e.status === 429) {
|
||||
this.overlay.errorMessage = STRINGS.cart_error_429
|
||||
this.overlay.frameLoading = false
|
||||
this.overlay.errorUrlAfter = this.newTabTarget
|
||||
this.overlay.errorUrlAfterNewTab = true
|
||||
} else if (e instanceof ApiError && (e.status === 200 || (e.status >= 400 && e.status < 500))) {
|
||||
this.overlay.errorMessage = STRINGS.cart_error
|
||||
this.overlay.frameLoading = false
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
redeem (voucherCode: string, event?: Event) {
|
||||
@@ -484,7 +500,7 @@ export function createWidgetStore (config: {
|
||||
async resume () {
|
||||
if (!this.cartId && this.keepCart) {
|
||||
// create an empty cart whose id we can persist
|
||||
await this.createCart()
|
||||
if (!await this.createCart()) return
|
||||
}
|
||||
let redirectUrl = `${this.targetUrl}w/${globalWidgetId}/`
|
||||
if (this.subevent && this.isButton && this.items.length === 0) {
|
||||
|
||||
+2
-1
@@ -31,6 +31,7 @@ export default defineConfig({
|
||||
// Allow serving source files from sibling plugin directories
|
||||
allow: ['src', ...pluginDirs],
|
||||
},
|
||||
cors: { origin: /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\]|[^:]+\.pretix\.work)(?::\d+)?$/ },
|
||||
},
|
||||
build: {
|
||||
manifest: true,
|
||||
@@ -47,7 +48,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