Compare commits

..
Author SHA1 Message Date
Mira Weller a6f488d73c experimental: use dialog for adding category 2026-09-18 14:04:07 +02:00
Mira Weller 5fd6759474 wip2 2026-09-18 14:04:07 +02:00
Mira Weller 50c2c60a9b add DjangoDialog / notify_parent - allow to load existing views in iframe
...and some more changes
2026-09-18 14:04:07 +02:00
Mira Weller dc689c2263 Remove print() 2026-09-17 16:24:35 +02:00
Mira Weller 6e66c179a7 Fix checkout 2026-09-17 16:22:59 +02:00
Mira Weller 9f6ce762e7 Add validations in QuestionnaireSerializer 2026-09-17 16:22:49 +02:00
Mira Weller 5faf487d13 Remove old code from DatafieldSerializer 2026-09-17 16:22:32 +02:00
Mira Weller 5f341b7f6e Fix setting question to null in QuestionnaireSerializer.set_children 2026-09-17 16:22:32 +02:00
Mira Weller f6c678ed6c Support order-level questionnaires in OrderLevelQuestionsForm 2026-09-17 16:17:43 +02:00
Mira Weller 3ef35df421 Add (somewhat hacky) support for text blocks in QuestionsForms 2026-09-17 16:15:02 +02:00
Mira Weller d49093b1d3 Refactor attendee_name handling in Order.save 2026-09-17 16:12:26 +02:00
Mira Weller 97e241dc1d Fix some system datafields 2026-09-17 16:12:12 +02:00
Mira Weller bc0c23aa11 Hide text blocks in question answer views 2026-09-17 16:11:58 +02:00
Mira Weller eb7936634b Editor UX rework 2026-09-17 16:11:03 +02:00
Mira Weller e3a3768a6c Flip order-level / ticket-level data field lists 2026-09-16 17:16:26 +02:00
Mira Weller 3787cbf7ce Fix changing visibility, add sales channel checkbox + default 2026-09-16 16:41:01 +02:00
Mira Weller ab1427e3ff Fix sorting questions 2026-09-16 16:39:17 +02:00
Mira Weller 20f2950c2b Fix sorting questionnaires 2026-09-16 16:38:30 +02:00
Mira Weller bbb5fc008b Create a default questionnaire for new events 2026-09-16 16:37:55 +02:00
Mira Weller e74dcbb01a Fix checkoutflow 2026-09-16 16:37:25 +02:00
Mira Weller 317df5a031 Remove old settings 2026-09-16 16:37:11 +02:00
Mira Weller 0785ca049f rename some instances of Question -> Datafield 2026-09-16 15:09:51 +02:00
Mira Weller 583d464d6f progress bar 2026-09-16 15:08:42 +02:00
Mira Weller e6f1ee1126 Move App -> Editor 2026-09-16 15:08:17 +02:00
Mira Weller d8f444d51d Pass choice field options to JS 2026-09-16 15:07:33 +02:00
Mira Weller 9ace35069c Remove feature flag from order-level data fields 2026-09-16 15:03:27 +02:00
Mira Weller b0a619392c fix migration 2026-09-16 12:41:38 +02:00
46 changed files with 1272 additions and 828 deletions
-8
View File
@@ -764,14 +764,6 @@ class EventSettingsSerializer(SettingsSerializer):
'event_calendar_future_only',
'frontpage_text',
'event_info_text',
'attendee_names_asked',
'attendee_names_required',
'attendee_emails_asked',
'attendee_emails_required',
'attendee_addresses_asked',
'attendee_addresses_required',
'attendee_company_asked',
'attendee_company_required',
'attendee_data_explanation_text',
'confirm_texts',
'order_email_asked_twice',
+73 -91
View File
@@ -33,7 +33,10 @@
# License for the specific language governing permissions and limitations under the License.
import os.path
from decimal import Decimal
from itertools import zip_longest
import logging
import rest_framework
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
@@ -54,6 +57,9 @@ from pretix.base.models import (
SalesChannel,
)
from pretix.base.models.items import Questionnaire, QuestionnaireChild
from pretix.base.templatetags.rich_text import rich_text
logger = logging.getLogger(__name__)
class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
@@ -540,17 +546,16 @@ class LegacyDependencyValueField(serializers.CharField):
return [data] if data else []
class QuestionSerializer(I18nAwareModelSerializer):
class DatafieldSerializer(I18nAwareModelSerializer):
options = InlineQuestionOptionSerializer(many=True, required=False)
identifier = serializers.CharField(allow_null=True)
internal_name = serializers.CharField(allow_null=True, source='question', read_only=True)
dependency_value = LegacyDependencyValueField(source='dependency_values', required=False, allow_null=True)
class Meta:
model = Question
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
fields = ('id', 'question', 'type', 'options',
'show_during_checkin', 'identifier',
'hidden', 'print_on_invoice', 'valid_number_min',
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait', 'internal_name',)
@@ -563,14 +568,6 @@ class QuestionSerializer(I18nAwareModelSerializer):
self.instance.clean_type_change(self.instance.type, value)
return value
def validate_dependency_question(self, value):
if value:
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
if value == self.instance:
raise ValidationError('A question cannot depend on itself.')
return value
def validate(self, data):
data = super().validate(data)
if self.instance and 'options' in data:
@@ -582,21 +579,6 @@ class QuestionSerializer(I18nAwareModelSerializer):
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
raise ValidationError('Dependencies are not supported during check-in.')
dep = full_data.get('dependency_question')
if dep:
if dep.ask_during_checkin:
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
seen_ids = {self.instance.pk} if self.instance else set()
while dep:
if dep.pk in seen_ids:
raise ValidationError(_('Circular dependency between questions detected.'))
seen_ids.add(dep.pk)
dep = dep.dependency_question
if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
raise ValidationError(_('This type of question cannot be asked during check-in.'))
@@ -629,6 +611,9 @@ class QuestionSerializer(I18nAwareModelSerializer):
class QuestionRefField(serializers.PrimaryKeyRelatedField):
default_error_messages = {
'invalid_choice': _('"{input}" is not a valid choice.')
}
def to_representation(self, qc):
if not qc:
return None
@@ -642,8 +627,12 @@ class QuestionRefField(serializers.PrimaryKeyRelatedField):
def to_internal_value(self, data):
if type(data) == int:
return {'user_datafield': super().to_internal_value(data), 'system_datafield': None}
elif type(data) == str or data is None:
elif type(data) == str:
if data not in QuestionnaireChild.SystemQuestion.values:
self.fail("invalid_choice", input=data)
return {'user_datafield': None, 'system_datafield': data}
elif data is None:
return {'user_datafield': None, 'system_datafield': None}
else:
self.fail('incorrect_type', data_type=type(data).__name__)
@@ -651,49 +640,30 @@ class QuestionRefField(serializers.PrimaryKeyRelatedField):
return self.source == '*'
class RenderedMarkdownField(serializers.CharField):
def to_representation(self, value):
return rich_text(value)
class InlineQuestionnaireChildSerializer(I18nAwareModelSerializer):
question = QuestionRefField(source='*', queryset=Question.objects.none())
question = QuestionRefField(allow_null=True, source='*', queryset=Question.objects.none())
dependency_question = QuestionRefField(allow_null=True, required=False, queryset=Question.objects.none())
rendered_help_text = RenderedMarkdownField(read_only=True, source='help_text')
class Meta:
model = QuestionnaireChild
fields = ('question', 'required', 'label', 'help_text', 'dependency_question', 'dependency_values')
fields = ('question', 'required', 'label', 'help_text', 'dependency_question', 'dependency_values', 'rendered_help_text')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["question"].queryset = self.context["event"].questions.all()
self.fields["dependency_question"].queryset = self.context["event"].questions.all()
def validate(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.')
if value['user_datafield']:
if value['user_datafield'].type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
return value
@@ -712,40 +682,44 @@ class QuestionnaireSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
self.fields['children'] = InlineQuestionnaireChildSerializer(many=True, required=True, context=kwargs['context'], partial=False)
self.fields['limit_sales_channels'].child_relation.queryset = kwargs['context']['event'].organizer.sales_channels.all()
super().__init__(*args, **kwargs)
def validate(self, data):
data = super().validate(data)
event = self.context['event']
#full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
#full_data.update(data)
try:
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
except rest_framework.exceptions.ValidationError as e:
# we already have invalid state in the database, what should we do? hope it gets better after saving?
logger.exception("Invalid state in database, ignoring some validations!")
else:
#if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
# raise ValidationError('Dependencies are not supported during check-in.')
#if full_data.get('ask_during_checkin') and full_data.get('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('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.'))
#if full_data.get('show_during_checkin') and full_data.get('type') in Question.SHOW_DURING_CHECKIN_UNSUPPORTED:
# raise ValidationError(_('This type of question cannot be shown during check-in.'))
#Question.clean_items(event, full_data.get('items') or [])
if (full_data.get('type') == Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN
and any(c['dependency_question'] for c in full_data['children'])):
raise ValidationError('Dependencies are not supported during check-in.')
if (not full_data.get('type').startswith('P')
and any(c['system_datafield'] for c in full_data['children'])):
raise ValidationError('System data fields are only supported on order positions.')
system_fields = set(c['system_datafield'] for c in full_data['children'])
if ('zipcode' in system_fields or 'state' in system_fields or 'street' in system_fields or 'city' in system_fields) and 'country' not in system_fields:
raise ValidationError('The system data fields "street", "zip code", "city", and "state" can only be used in combination with the "country" field.')
#Question.clean_items(event, full_data.get('items') or [])
return data
def validate_children(self, value):
prev_questions = {}
for child in value:
if child.get('dependency_question'):
if (child['dependency_question']['user_datafield'] or child['dependency_question']['system_datafield']) not in prev_questions:
raise ValidationError('A question can only depend on a previous question from the same questionnaire.')
if child['user_datafield']:
prev_questions[child['user_datafield']] = child
if child['system_datafield']:
prev_questions[child['system_datafield']] = child
return value
@transaction.atomic
def create(self, validated_data):
children_data = validated_data.pop('children') if 'children' in validated_data else []
@@ -762,9 +736,11 @@ class QuestionnaireSerializer(I18nAwareModelSerializer):
return questionnaire
def set_children(self, questionnaire, new_data):
result = []
result = {}
child_serializer = self.fields['children'].child
existing = questionnaire.children.all()
existing_children = questionnaire.children.all()
def q(qc):
return qc['user_datafield'] or qc['system_datafield']
for i, d in enumerate(new_data):
d['questionnaire'] = questionnaire
d['position'] = i + 1
@@ -772,13 +748,19 @@ class QuestionnaireSerializer(I18nAwareModelSerializer):
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()
for existing, update_data in zip_longest(existing_children, new_data):
if update_data:
if update_data.get('dependency_question'):
try:
update_data['dependency_question'] = result[q(update_data['dependency_question'])]
except KeyError:
raise ValidationError('A question can only depend on a previous question from the same questionnaire.')
if existing:
result[q(update_data)] = child_serializer.update(existing, update_data)
else:
result[q(update_data)] = child_serializer.create(update_data)
else:
existing.delete()
return result
+2 -2
View File
@@ -46,7 +46,7 @@ from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.forms import form_field_to_serializer_field
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.item import (
InlineItemVariationSerializer, ItemSerializer, QuestionSerializer,
InlineItemVariationSerializer, ItemSerializer, DatafieldSerializer,
)
from pretix.api.signals import order_api_details, orderposition_api_details
from pretix.base.decimal import round_decimal
@@ -715,7 +715,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
self.fields['variation'] = InlineItemVariationSerializer(read_only=True, context=self.context)
if 'answers.question' in self.context['expand']:
self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True)
self.fields['answers'].child.fields['question'] = DatafieldSerializer(read_only=True) # TODO(questionnaires)
if 'addons' in self.context['expand']:
# Experimental feature, undocumented on purpose for now in case we need to remove it again
+1 -1
View File
@@ -79,7 +79,7 @@ event_router.register(r'subevents', event.SubEventViewSet)
event_router.register(r'clone', event.CloneEventViewSet)
event_router.register(r'items', item.ItemViewSet)
event_router.register(r'categories', item.ItemCategoryViewSet)
event_router.register(r'datafields', item.QuestionViewSet)
event_router.register(r'datafields', item.DatafieldViewSet)
event_router.register(r'questionnaires', item.QuestionnaireViewSet)
event_router.register(r'discounts', discount.DiscountViewSet)
event_router.register(r'quotas', item.QuotaViewSet)
+2 -2
View File
@@ -54,7 +54,7 @@ from pretix.api.serializers.checkin import (
CheckinListSerializer, CheckinRPCAnnulInputSerializer,
CheckinRPCRedeemInputSerializer, MiniCheckinListSerializer,
)
from pretix.api.serializers.item import QuestionSerializer
from pretix.api.serializers.item import DatafieldSerializer
from pretix.api.serializers.order import (
CheckinListOrderPositionSerializer, CheckinSerializer,
FailedCheckinSerializer,
@@ -866,7 +866,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'checkin_texts': op.checkin_texts,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
'questions': [
QuestionSerializer(q).data for q in e.questions
DatafieldSerializer(q).data for q in e.questions # TODO(questionnaires)
],
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=400)
+15 -8
View File
@@ -48,7 +48,7 @@ from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
QuestionnaireSerializer, QuestionOptionSerializer, QuestionSerializer,
QuestionnaireSerializer, QuestionOptionSerializer, DatafieldSerializer,
QuotaSerializer,
)
from pretix.api.views import ConditionalListView
@@ -463,16 +463,16 @@ with scopes_disabled():
class QuestionFilter(FilterSet):
class Meta:
model = Question
fields = ['ask_during_checkin', 'required', 'identifier']
fields = ['identifier']
class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = QuestionSerializer
class DatafieldViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = DatafieldSerializer
queryset = Question.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
filterset_class = QuestionFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
ordering_fields = ('id')
ordering = ('id')
permission = None
write_permission = 'event.items:write'
@@ -568,11 +568,18 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance)
with scopes_disabled():
class QuestionnaireFilter(FilterSet):
class Meta:
model = Questionnaire
fields = ['type']
class QuestionnaireViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = QuestionnaireSerializer
queryset = Questionnaire.objects.none()
#filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
#filterset_class = QuestionFilter
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
filterset_class = QuestionnaireFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
permission = None
+47 -12
View File
@@ -49,7 +49,8 @@ from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db.models import QuerySet
from django.db import ProgrammingError
from django.db.models import Prefetch, Q, QuerySet
from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.utils.formats import date_format
@@ -81,7 +82,7 @@ from pretix.base.i18n import (
from pretix.base.invoicing.transmission import (
get_transmission_types, transmission_types,
)
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption, Questionnaire, QuestionnaireChild
from pretix.base.models.tax import ask_for_vat_id
from pretix.base.services.tax import (
VATIDFinalError, VATIDTemporaryError, normalize_vat_id, validate_vat_id,
@@ -865,13 +866,15 @@ class BaseQuestionsForm(forms.Form):
initial=initial,
widget=WrappedPhoneNumberPrefixWidget()
)
else:
raise ProgrammingError('Invalid question type')
field.datafield = datafield
if answers:
# Cache the answer object for later use
field.answer = answers[0]
if qc.dependency_question_id:
field.widget.attrs['data-question-dependency'] = qc.dependency_question_id
field.widget.attrs['data-question-dependency'] = f"question_{qc.dependency_question.user_datafield_id}" if qc.dependency_question.user_datafield_id else qc.dependency_question.system_datafield
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(qc.dependency_values))
if datafield.type != 'M':
field.widget.attrs['required'] = qc.required and not self.all_optional
@@ -880,6 +883,15 @@ class BaseQuestionsForm(forms.Form):
return field
def build_text_block(self, request, event, qc):
return forms.CharField( # TODO(questionnaires): use a less hacky way to format this field
required=False,
label="",
help_text=rich_text((f"#### {qc.label}\n" if qc.label else "") + str(qc.help_text)),
disabled=True,
widget=QuestionCheckboxSelectMultiple,
)
def check_user_questions(self, d):
question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
@@ -931,8 +943,7 @@ class OrderLevelQuestionsForm(BaseQuestionsForm):
"""
Takes two additional keyword arguments:
:param checkoutsession: The checkout session the form should be for
:param order: The order the form should be for
:param container: The checkout session or order the form should be for
:param event: The event this belongs to
"""
request = kwargs.pop('request', None)
@@ -941,15 +952,35 @@ class OrderLevelQuestionsForm(BaseQuestionsForm):
super().__init__(*args, **kwargs)
# TODO(questionnaires) - switch olq's to questionnaires !
questions = Question.objects.filter(
event=event, container_type=Question.ContainerType.ORDER,
ask_during_checkin=False, hidden=False,
).order_by('position')
questionnaires = Questionnaire.objects.filter(
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=container.sales_channel.identifier),
event=event, type=Questionnaire.QuestionnaireType.ORDER_SALE,
).order_by('position').prefetch_related(
Prefetch('children', QuestionnaireChild.objects.prefetch_related(
Prefetch('user_datafield', Question.objects.prefetch_related(
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
'question',
Question.objects.none(),
to_attr='dummy'
)))
))
),
to_attr='childlist')
)
answerlist = container.answers.prefetch_related('options')
for q in questions:
self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, answerlist, q)
idx = 0
for questionnaire in questionnaires:
for child in getattr(questionnaire, 'childlist', questionnaire.children.all()):
if child.user_datafield:
df = child.user_datafield
self.fields['question_%s' % df.id] = self.build_user_question_field(request, event, answerlist, child, df)
else:
self.fields['text_%d' % idx] = self.build_text_block(request, event, child)
idx += 1
def clean(self):
d = super().clean()
@@ -986,6 +1017,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
self.fields['requested_valid_from'] = self.build_requested_valid_from_field(event, pos, item)
idx = 0
for questionnaire in questionnaires:
for child in getattr(questionnaire, 'childlist', questionnaire.children.all()):
if child.user_datafield:
@@ -993,6 +1025,9 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
self.fields['question_%s' % df.id] = self.build_user_question_field(request, event, pos.answerlist, child, df)
elif child.system_datafield:
self.fields[child.system_datafield] = self.build_system_question_field(request, event, pos, child)
else:
self.fields['text_%d' % idx] = self.build_text_block(request, event, child)
idx += 1
responses = question_form_fields.send(sender=event, position=pos)
data = pos.meta_info_data
@@ -37,6 +37,7 @@ def get_fake_questions(settings):
fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('state', _('State'), sqo.get('country', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), b(settings.get('attendee_addresses_required'))))
return fq
@@ -148,7 +149,7 @@ def migrate_questions_backward(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0309_alter_questionanswer_unique_together_and_more'),
('pretixbase', '0310_question_valid_string_length_min'),
]
operations = [
+3
View File
@@ -737,6 +737,9 @@ class Event(EventMixin, LoggedModel):
self.settings.mail_send_order_approved_attendee = True
self.settings.mail_send_order_approved_free_attendee = True
self.settings.mail_send_download_reminder_attendee = True
from . import Questionnaire
q = self.questionnaires.create(internal_name=str(_('Attendee data')), type=Questionnaire.QuestionnaireType.ORDER_POSITION_SALE, position=0)
q.children.create(label=str(_('Attendee name')), system_datafield='attendee_name_parts', position=0)
@property
def social_image(self):
+70 -67
View File
@@ -1611,6 +1611,22 @@ class Question(LoggedModel):
class ContainerType(models.TextChoices):
ORDER = "O", _("Order")
ORDERPOSITION = "P", _("Order position")
class FieldType(models.TextChoices):
NUMBER = "N", _("Number")
STRING = "S", _("Text (one line)")
TEXT = "T", _("Multiline text")
BOOLEAN = "B", _("Yes/No")
CHOICE = "C", _("Choose one from a list")
CHOICE_MULTIPLE = "M", _("Choose multiple from a list")
FILE = "F", _("File upload")
DATE = "D", _("Date")
TIME = "H", _("Time")
DATETIME = "W", _("Date and time")
COUNTRYCODE = "CC", _("Country code (ISO 3166-1 alpha-2)")
PHONENUMBER = "TEL", _("Phone number")
# compat
TYPE_NUMBER = "N"
TYPE_STRING = "S"
TYPE_TEXT = "T"
@@ -1623,20 +1639,8 @@ class Question(LoggedModel):
TYPE_DATETIME = "W"
TYPE_COUNTRYCODE = "CC"
TYPE_PHONENUMBER = "TEL"
TYPE_CHOICES = (
(TYPE_NUMBER, _("Number")),
(TYPE_STRING, _("Text (one line)")),
(TYPE_TEXT, _("Multiline text")),
(TYPE_BOOLEAN, _("Yes/No")),
(TYPE_CHOICE, _("Choose one from a list")),
(TYPE_CHOICE_MULTIPLE, _("Choose multiple from a list")),
(TYPE_FILE, _("File upload")),
(TYPE_DATE, _("Date")),
(TYPE_TIME, _("Time")),
(TYPE_DATETIME, _("Date and time")),
(TYPE_COUNTRYCODE, _("Country code (ISO 3166-1 alpha-2)")),
(TYPE_PHONENUMBER, _("Phone number")),
)
TYPE_CHOICES = FieldType.choices
UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME]
ASK_DURING_CHECKIN_UNSUPPORTED = []
SHOW_DURING_CHECKIN_UNSUPPORTED = [TYPE_FILE]
@@ -1652,9 +1656,8 @@ class Question(LoggedModel):
verbose_name=_("Asked on"),
default=ContainerType.ORDERPOSITION,
)
question = I18nTextField(
# TODO(questionnaires) : to be renamed to 'internal_name'
verbose_name=_("Question")
question = I18nTextField( # TODO(questionnaires) : to be renamed to 'internal_name'
verbose_name=_("Internal name"),
)
identifier = models.CharField(
max_length=190,
@@ -1668,37 +1671,37 @@ class Question(LoggedModel):
),
],
)
help_text = I18nTextField(
# TODO(questionnaires) : to be removed
verbose_name=_("Help text"),
help_text=_("If the question needs to be explained or clarified, do it here!"),
null=True, blank=True,
)
#help_text = I18nTextField(
# # TODO(questionnaires) : to be removed
# verbose_name=_("Help text"),
# help_text=_("If the question needs to be explained or clarified, do it here!"),
# null=True, blank=True,
#)
type = models.CharField(
max_length=5,
choices=TYPE_CHOICES,
choices=FieldType.choices,
verbose_name=_("Question type")
)
required = models.BooleanField( # TODO(questionnaires) : to be removed, -> QuestionnaireChild
default=False,
verbose_name=_("Required question")
)
items = models.ManyToManyField( # TODO(questionnaires) : to be removed, -> Questionnaire
Item,
related_name='questions',
verbose_name=_("Products"),
blank=True,
help_text=_('This question will be asked to buyers of the selected products')
)
position = models.PositiveIntegerField( # TODO(questionnaires) : to be removed, -> Questionnaire + QuestionnaireChild
default=0,
verbose_name=_("Position")
)
ask_during_checkin = models.BooleanField( # TODO(questionnaires) : to be removed
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
help_text=_('Not supported by all check-in apps for all question types.'),
default=False
)
#required = models.BooleanField( # TODO(questionnaires) : to be removed, -> QuestionnaireChild
# default=False,
# verbose_name=_("Required question")
#)
#items = models.ManyToManyField( # TODO(questionnaires) : to be removed, -> Questionnaire
# Item,
# related_name='questions',
# verbose_name=_("Products"),
# blank=True,
# help_text=_('This question will be asked to buyers of the selected products')
#)
#position = models.PositiveIntegerField( # TODO(questionnaires) : to be removed, -> Questionnaire + QuestionnaireChild
# default=0,
# verbose_name=_("Position")
#)
#ask_during_checkin = models.BooleanField( # TODO(questionnaires) : to be removed
# verbose_name=_('Ask during check-in instead of in the ticket buying process'),
# help_text=_('Not supported by all check-in apps for all question types.'),
# default=False
#)
show_during_checkin = models.BooleanField(
verbose_name=_('Show answer during check-in'),
help_text=_('Not supported by all check-in apps for all question types.'),
@@ -1990,18 +1993,12 @@ class QuestionOption(models.Model):
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")),
)
class QuestionnaireType(models.TextChoices):
ORDER_SALE = "OS", _("Order-wide, before purchase")
ORDER_POSITION_SALE = "PS", _("Per product, before purchase")
ORDER_POSITION_ATTENDEE_ONLY = "PA", _("Per product, via attendee link")
ORDER_POSITION_CHECKIN = "PC", _("Per product, at check-in")
ORDER_POSITION_HIDDEN = "PH", _("Per product, hidden")
event = models.ForeignKey(
Event,
related_name="questionnaires",
@@ -2013,7 +2010,7 @@ class Questionnaire(LoggedModel):
)
type = models.CharField(
max_length=5,
choices=TYPE_CHOICES,
choices=QuestionnaireType.choices,
verbose_name=_("Questionnaire type")
)
items = models.ManyToManyField(
@@ -2039,17 +2036,20 @@ class Questionnaire(LoggedModel):
blank=True,
)
class Meta:
ordering = ('position', 'id')
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')),
)
class SystemQuestion(models.TextChoices):
ATTENDEE_NAME_PARTS = 'attendee_name_parts', _('Attendee name')
ATTENDEE_EMAIL = 'attendee_email', _('Attendee email')
COMPANY = 'company', _('Company')
STREET = 'street', _('Street')
ZIPCODE = 'zipcode', _('ZIP code')
CITY = 'city', _('City')
COUNTRY = 'country', _('Country')
questionnaire = models.ForeignKey(
Questionnaire,
related_name="children",
@@ -2067,7 +2067,7 @@ class QuestionnaireChild(LoggedModel):
)
system_datafield = models.CharField(
max_length=25,
choices=SYSTEM_QUESTION_CHOICES,
choices=SystemQuestion.choices,
null=True, blank=True,
)
required = models.BooleanField(
@@ -2087,6 +2087,9 @@ class QuestionnaireChild(LoggedModel):
)
dependency_values = MultiStringField(default=[])
class Meta:
ordering = ('position', 'id')
class Quota(LoggedModel):
"""
+17 -11
View File
@@ -1628,14 +1628,17 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
self.questions = []
for qc in children:
if qc.user_datafield_id and qc.user_datafield_id in self.answer_cache:
qc.answer = self.answer_cache[qc.user_datafield_id]
if qc.user_datafield_id:
if qc.user_datafield_id in self.answer_cache:
qc.answer = self.answer_cache[qc.user_datafield_id]
else:
qc.answer = ""
#qc.answer.question = qc # cache object
elif qc.system_datafield:
qc.answer = self.get_system_answer(qc.system_datafield)
#qc.answer.question = qc # cache object
else:
qc.answer = ""
continue
if not qc.dependency_question_id or qc_is_visible(qc.dependency_question_id, qc.dependency_values):
self.questions.append(qc)
@@ -1658,9 +1661,11 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
def get_system_answer(self, system_datafield_name):
if system_datafield_name == 'attendee_name_parts':
return self.attendee_name_parts
return self.attendee_name
elif system_datafield_name == 'attendee_email':
return self.attendee_email
elif system_datafield_name == 'company':
return self.company
elif system_datafield_name == 'street':
return self.street
elif system_datafield_name == 'zipcode':
@@ -1685,20 +1690,21 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
else self.variation.quotas.filter(subevent=self.subevent))
def save(self, *args, **kwargs):
update_fields = kwargs.get('update_fields', set())
if 'attendee_name_parts' in update_fields:
kwargs['update_fields'] = {'attendee_name_cached'}.union(kwargs['update_fields'])
def ensure_updated(fieldname):
if 'update_fields' in kwargs:
kwargs['update_fields'] = {fieldname}.union(kwargs['update_fields'])
if 'attendee_name_parts' in kwargs.get('update_fields', set()):
ensure_updated('attendee_name_cached')
name = self.attendee_name
if name != self.attendee_name_cached:
self.attendee_name_cached = name
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'attendee_name_cached'}.union(kwargs['update_fields'])
ensure_updated('attendee_name_cached')
if self.attendee_name_parts is None:
self.attendee_name_parts = {}
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'attendee_name_parts'}.union(kwargs['update_fields'])
ensure_updated('attendee_name_parts')
super().save(*args, **kwargs)
@property
-87
View File
@@ -385,93 +385,6 @@ DEFAULTS = {
'serializer_class': serializers.DictField,
'serializer_kwargs': lambda: dict(read_only=True, allow_empty=True),
},
'attendee_names_asked': {
'default': 'True',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for attendee names"),
help_text=_("Ask for a name for all personalized tickets."),
)
},
'attendee_names_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require attendee names"),
help_text=_("Require customers to fill in the names of all attendees."),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_names_asked'}),
)
},
'attendee_emails_asked': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for email addresses per ticket"),
help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be sent "
"only to that email address. If you enable this option, the system will additionally ask for "
"individual email addresses for every personalized ticket. This might be useful if you want to "
"obtain individual addresses for every attendee even in case of group orders. However, "
"pretix will send the order confirmation by default only to the one primary email address, not to "
"the per-attendee addresses. You can however enable this in the email settings."),
)
},
'attendee_emails_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require email addresses per ticket"),
help_text=_("Require customers to fill in individual email addresses for all personalized tickets. See the "
"above option for more details. One email address for the order confirmation will always be "
"required regardless of this setting."),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}),
)
},
'attendee_company_asked': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for company per ticket"),
)
},
'attendee_company_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require company per ticket"),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_company_asked'}),
)
},
'attendee_addresses_asked': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for postal addresses per ticket"),
)
},
'attendee_addresses_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require postal addresses per ticket"),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_addresses_asked'}),
)
},
'order_email_asked_twice': {
'default': 'False',
'type': bool,
+8 -8
View File
@@ -37,7 +37,7 @@ from pretix.base.forms.questions import (
)
from pretix.base.models import (
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
QuestionnaireChild, QuestionOption,
Questionnaire, QuestionnaireChild, QuestionOption,
)
from pretix.base.models.customers import AttendeeProfile
from pretix.base.models.orders import CheckoutSession, Order
@@ -119,8 +119,8 @@ class BaseQuestionsViewMixin:
override_sets = self.get_question_override_sets(cr, idx)
for overrides in override_sets:
for question_name, question_field in form.fields.items():
if hasattr(question_field, 'question'):
src = overrides.get(question_field.question.identifier)
if hasattr(question_field, 'datafield'):
src = overrides.get(question_field.datafield.identifier)
else:
src = overrides.get(question_name)
if not src:
@@ -184,7 +184,7 @@ class BaseQuestionsViewMixin:
field, v,
checkoutsession=checkoutsession,
order=order,
question=field.question,
question=field.datafield,
)
for form in self.forms:
@@ -246,7 +246,7 @@ class BaseQuestionsViewMixin:
field, v,
cartposition=cartposition,
orderposition=orderposition,
question=field.question,
question=field.datafield,
)
answer_dict = self._build_answer_dict(field, answer, k)
@@ -331,8 +331,8 @@ class BaseQuestionsViewMixin:
'field_name': k,
'field_label': str(field.label),
'value': answer_value,
'question_type': field.question.type,
'question_identifier': field.question.identifier,
'question_type': field.datafield.type,
'question_identifier': field.datafield.identifier,
}
@@ -350,7 +350,7 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
def positions(self):
qqs = self.request.event.questionnaires.all()
if self.only_user_visible:
qqs = qqs.filter(type='PS')
qqs = qqs.filter(type=Questionnaire.QuestionnaireType.ORDER_POSITION_SALE)
else:
qqs = qqs.filter(type__startswith='P')
qqs = qqs.filter(
-8
View File
@@ -639,14 +639,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
'event_calendar_future_only',
'frontpage_text',
'event_info_text',
'attendee_names_asked',
'attendee_names_required',
'attendee_emails_asked',
'attendee_emails_required',
'attendee_company_asked',
'attendee_company_required',
'attendee_addresses_asked',
'attendee_addresses_required',
'attendee_data_explanation_text',
'order_phone_asked',
'order_phone_required',
+5 -56
View File
@@ -153,53 +153,10 @@ class QuestionForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.container_type == Question.ContainerType.ORDERPOSITION:
self.fields['items'].queryset = self.instance.event.items.all()
self.fields['items'].required = True
else:
del self.fields['items']
del self.fields['ask_during_checkin']
if self.instance.container_type != Question.ContainerType.ORDERPOSITION:
del self.fields['show_during_checkin']
del self.fields['print_on_invoice']
self.fields['dependency_question'].widget.attrs['data-container-type'] = self.instance.container_type
self.fields['dependency_question'].queryset = self.instance.event.questions.filter(
type__in=(Question.TYPE_BOOLEAN, Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE),
ask_during_checkin=False,
container_type=self.instance.container_type,
)
if self.instance.pk:
self.fields['dependency_question'].queryset = self.fields['dependency_question'].queryset.exclude(
pk=self.instance.pk
)
self.fields['identifier'].required = False
self.fields['dependency_values'].required = False
self.fields['help_text'].widget.attrs['rows'] = 3
def clean_dependency_values(self):
val = self.data.getlist('dependency_values')
return val
def clean_dependency_question(self):
dep = val = self.cleaned_data.get('dependency_question')
if dep:
if dep.ask_during_checkin:
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
seen_ids = {self.instance.pk} if self.instance else set()
while dep:
if dep.pk in seen_ids:
raise ValidationError(_('Circular dependency between questions detected.'))
seen_ids.add(dep.pk)
dep = dep.dependency_question
return val
def clean_ask_during_checkin(self):
val = self.cleaned_data.get('ask_during_checkin')
if val and self.cleaned_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
raise ValidationError(_('This type of question cannot be asked during check-in.'))
return val
def clean_show_during_checkin(self):
val = self.cleaned_data.get('show_during_checkin')
@@ -233,16 +190,10 @@ class QuestionForm(I18nModelForm):
localized_fields = '__all__'
fields = [
'question',
'help_text',
'type',
'required',
'ask_during_checkin',
'show_during_checkin',
'hidden',
'identifier',
'items',
'dependency_question',
'dependency_values',
'print_on_invoice',
'valid_number_min',
'valid_number_max',
@@ -259,17 +210,11 @@ class QuestionForm(I18nModelForm):
'valid_datetime_max': SplitDateTimePickerWidget(without_seconds=True),
'valid_date_min': DatePickerWidget(),
'valid_date_max': DatePickerWidget(),
'items': forms.CheckboxSelectMultiple(
attrs={'class': 'scrolling-multiple-choice'}
),
'dependency_values': forms.SelectMultiple,
'help_text': I18nMarkdownTextarea,
}
field_classes = {
'valid_datetime_min': SplitDateTimeField,
'valid_datetime_max': SplitDateTimeField,
'items': ItemMultipleChoiceField,
'dependency_question': SafeModelChoiceField,
}
@@ -1197,6 +1142,10 @@ class ItemAddOnForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['addon_category'].queryset = self.event.categories.all()
self.fields['addon_category'].help_text = format_html('<a href="javascript:" data-django-dialog="{}?notify_parent=true">{}</a>', reverse('control:event.items.categories.add', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}), _("Create new category"))
self.fields['addon_category'].widget = Select2(
attrs={
'data-model-select2': 'generic',
@@ -94,13 +94,6 @@
</div>
</div>
<h4>{% trans "Attendee data (once per personalized ticket)" %}</h4>
{% bootstrap_field sform.attendee_names_asked_required layout="control" %}
{% bootstrap_field sform.attendee_emails_asked_required layout="control" %}
{% bootstrap_field sform.attendee_company_asked_required layout="control" %}
{% bootstrap_field sform.attendee_addresses_asked_required layout="control" %}
<div class="form-group">
<label class="control-label col-md-3">
{% trans "Custom fields" %}
@@ -109,11 +102,27 @@
<p>
<a href="{% url "control:event.items.questions" event=request.event.slug organizer=request.organizer.slug %}"
target="_blank">
{% trans "Manage questions" %}
{% trans "Manage questionnaires" %}
</a>
</p>
</div>
</div>
<h4>{% trans "Attendee data (once per personalized ticket)" %}</h4>
<div class="form-group">
<label class="control-label col-md-3">
</label>
<div class="col-md-9 static-form-row">
<p>
<a href="{% url "control:event.items.questions" event=request.event.slug organizer=request.organizer.slug %}"
target="_blank">
{% trans "Manage questionnaires" %}
</a>
</p>
</div>
</div>
{% bootstrap_field sform.attendee_data_explanation_text layout="control" %}
<h4>{% trans "Form settings" %}</h4>
@@ -5,16 +5,16 @@
{% load escapejson %}
{% block title %}
{% if question %}
{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}
{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}
{% else %}
{% trans "Question" %}
{% trans "Create new data field" %}
{% endif %}
{% endblock %}
{% block inside %}
{% if question %}
<h1>{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}</h1>
{% else %}
<h1>{% trans "Data field" %}</h1>
<h1>{% trans "Create new data field" %}</h1>
{% endif %}
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
@@ -114,9 +114,6 @@
</div>
</div>
{% bootstrap_field form.identifier layout="control" %}
{% if form.ask_during_checkin %}
{% bootstrap_field form.ask_during_checkin layout="control" %}
{% endif %}
{% if form.show_during_checkin %}
{% bootstrap_field form.show_during_checkin layout="control" %}
{% endif %}
@@ -28,10 +28,18 @@
{{ request.event.settings.locales|json_script:"event_locales" }}
{{ questionnaire_type_choices|json_script:"questionnaire_type_choices" }}
{{ question_type_choices|json_script:"question_type_choices" }}
{{ system_question_choices|json_script:"system_question_choices" }}
{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=0 as datafield_edit_url %}
{{ datafield_edit_url|json_script:"datafield_edit_url" }}
{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=0 as datafield_view_url %}
{{ datafield_view_url|json_script:"datafield_view_url" }}
{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug as datafield_create_url %}
{{ datafield_create_url|json_script:"datafield_create_url" }}
<div id="questionnaires-editor">
<!-- Vue app mount point -->
</div>
@@ -11,26 +11,78 @@
</p>
{% csrf_token %}
{% if request.event.settings.feature_flag_order_level_questions %}
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new per-ticket data field" %}
</a>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new order-level data field" %}
</a>
</p>
{% endif %}
<h2>{% trans "Per-ticket data fields" %}</h2>
<p>{% trans "These data field can be used on individual tickets." %}</p>
{% else %}
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new data field" %}
</a>
</p>
{% endif %}
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new order-level data field" %}
</a>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new per-ticket data field" %}
</a>
</p>
{% endif %}
<h2>{% trans "Per-order data fields" %}</h2>
<p>{% trans "These data fields can be used on orders." %}</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Internal name" %}</th>
<th>{% trans "Type" %}</th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for q in questions %}{% if q.container_type == "O" %}
<tr>
<td>
<strong>
{{ q.question }}
</strong><br>
<small class="text-muted">{{ q.identifier }}</small>
</td>
<td>
{{ q.get_type_display }}
</td>
<td>
{% if q.required %}
<span class="fa fa-exclamation-circle text-muted" data-toggle="tooltip" title="{% trans "Required question" %}"></span>
{% endif %}
</td>
<td>
{% if q.pk and q.ask_during_checkin %}
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
{% endif %}
</td>
<td>
{% if q.pk and q.hidden %}
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
{% endif %}
</td>
<td class="text-right flip">
{% if q.pk %}
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
{% else %}
{% if 'event.settings.general:write' in request.eventpermset %}
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
class="btn btn-default btn-sm"><i class="fa fa-wrench"></i></a>
{% endif %}
{% endif %}
</td>
</tr>
{% endif %}{% endfor %}
</tbody>
</table>
</div>
<h2>{% trans "Per-ticket data fields" %}</h2>
<p>{% trans "These data field can be used on individual tickets." %}</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
@@ -85,74 +137,4 @@
</table>
</div>
{% if request.event.settings.feature_flag_order_level_questions %}
<h2>
{% trans "Per-order data fields" %}
<small><span class="label label-info" title="
{% trans "This functionality is in active development and expected to change significantly over the coming months." %}
{% trans "In pretixPOS, per-order data fields are currently not supported and will not be displayed." %}
" data-toggle="tooltip">
<span class="fa fa-flask" aria-hidden="true"></span>
{% trans "Experimental feature" %}
</span></small>
</h2>
<p>{% trans "These data fields are asked once per order." %}</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Internal name" %}</th>
<th>{% trans "Type" %}</th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for q in questions %}{% if q.container_type == "O" %}
<tr>
<td>
<strong>
{{ q.question }}
</strong><br>
<small class="text-muted">{{ q.identifier }}</small>
</td>
<td>
{{ q.get_type_display }}
</td>
<td>
{% if q.required %}
<span class="fa fa-exclamation-circle text-muted" data-toggle="tooltip" title="{% trans "Required question" %}"></span>
{% endif %}
</td>
<td>
{% if q.pk and q.ask_during_checkin %}
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
{% endif %}
</td>
<td>
{% if q.pk and q.hidden %}
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
{% endif %}
</td>
<td class="text-right flip">
{% if q.pk %}
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
{% else %}
{% if 'event.settings.general:write' in request.eventpermset %}
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
class="btn btn-default btn-sm"><i class="fa fa-wrench"></i></a>
{% endif %}
{% endif %}
</td>
</tr>
{% endif %}{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "error.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load static %}
{% block content %}
<h1>{% trans "Please wait..." %}</h1>
{{ notify_info|json_script:"notify_info" }}
<script type="text/javascript" src="{% static "pretixcontrol/js/notify_parent.js" %}"></script>
{% endblock %}
-1
View File
@@ -342,7 +342,6 @@ urlpatterns = [
name='event.items.categories.edit'),
re_path(r'^categories/add$', item.CategoryCreate.as_view(), name='event.items.categories.add'),
re_path(r'^questions/$', item.QuestionList.as_view(), name='event.items.questions'),
re_path(r'^questions/reorder$', item.reorder_questions, name='event.items.questions.reorder'),
re_path(r'^questions/(?P<question>\d+)/delete$', item.QuestionDelete.as_view(),
name='event.items.questions.delete'),
re_path(r'^questions/(?P<question>\d+)/$', item.QuestionView.as_view(),
+22 -2
View File
@@ -21,10 +21,15 @@
#
import collections.abc
import warnings
from errno import EMSGSIZE
from django.contrib import messages
from django.core.paginator import (
EmptyPage, PageNotAnInteger, UnorderedObjectListWarning,
)
from django.http.response import HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import edit
@@ -38,7 +43,22 @@ class EventBasedFormMixin:
return kwargs
class CreateView(EventBasedFormMixin, edit.CreateView):
class TellParentFormMixin:
def form_valid(self, form):
result = super().form_valid(form)
if self.request.GET.get('notify_parent') and isinstance(result, HttpResponseRedirect):
message_store = messages.get_messages(self.request)
msgs = [{'level': msg.level_tag, 'message': msg.message} for msg in message_store]
#message_store._queued_messages = []
return render(self.request, 'pretixcontrol/notify_parent.html', {
'notify_info': {'object': self.object.pk, 'redirect_url': result.url, 'messages': msgs},
})
else:
return result
class CreateView(EventBasedFormMixin, TellParentFormMixin, edit.CreateView):
"""
Like Django's default CreateView, but passes the optional event
argument to the form. This is necessary for I18nModelForms to work
@@ -47,7 +67,7 @@ class CreateView(EventBasedFormMixin, edit.CreateView):
pass
class UpdateView(EventBasedFormMixin, edit.UpdateView):
class UpdateView(EventBasedFormMixin, TellParentFormMixin, edit.UpdateView):
"""
Like Django's default UpdateView, but passes the optional event
argument to the form. This is necessary for I18nModelForms to work
+3
View File
@@ -1670,6 +1670,7 @@ class QuickSetupView(EventPermissionRequiredMixin, FormView):
user=self.request.user)
subevent = self.request.event.subevents.first()
questionnaire = self.request.event.questionnaires.first()
for i, f in enumerate(self.formset):
if f in self.formset.deleted_forms or not f.has_changed():
continue
@@ -1685,6 +1686,8 @@ class QuickSetupView(EventPermissionRequiredMixin, FormView):
position=i,
all_sales_channels=True,
)
if questionnaire:
item.questionnaires.add(questionnaire)
item.log_action('pretix.event.item.added', user=self.request.user, data=dict(f.cleaned_data))
if f.cleaned_data['quota'] or not form.cleaned_data['total_quota']:
quota = self.request.event.quotas.create(
+24 -5
View File
@@ -59,6 +59,7 @@ from django.views.decorators.http import require_http_methods
from django.views.generic import FormView, ListView, TemplateView, View
from django.views.generic.detail import DetailView, SingleObjectMixin
from django_countries.fields import Country
from i18nfield.strings import LazyI18nString
from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemBundleSerializer, ItemProgramTimeSerializer,
@@ -67,11 +68,11 @@ from pretix.api.serializers.item import (
from pretix.base.forms import I18nFormSet
from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, LogEntry,
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
OrderPosition, Question, QuestionAnswer, QuestionOption, QuestionnaireChild, Quota,
SeatCategoryMapping, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue, Questionnaire
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tickets import invalidate_cache
from pretix.base.signals import quota_availability
@@ -436,7 +437,7 @@ class QuestionList(ListView):
template_name = 'pretixcontrol/items/questions.html'
def get_queryset(self):
return self.request.event.questions
return self.request.event.questions.all()
class QuestionDelete(EventPermissionRequiredMixin, CompatDeleteView):
@@ -554,7 +555,7 @@ class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView
question=self.object, orderposition__isnull=False,
)
qs = qs.filter(orderposition__in=opqs)
op_cnt = opqs.filter(item__in=self.object.items.all()).count()
op_cnt = 0 # TODO opqs.filter(item__in=self.object.items.all()).count()
if self.object.type == Question.TYPE_FILE:
qs = [
@@ -599,7 +600,7 @@ class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['items'] = self.object.items.exists()
#ctx['items'] = self.object.items.exists()
ctx['has_subevents'] = self.request.event.has_subevents
stats = self.get_answer_statistics()
ctx['stats'], ctx['total'] = stats
@@ -706,10 +707,28 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
return ret
def textchoices_to_json(choices, event):
return [(c.name, c.value, i18n_all(event.settings.locales, LazyI18nString.from_gettext(c.label).data)) for c in choices]
def i18n_all(locales, data):
out = {}
for locale in locales:
out[locale] = data[locale]
return out
class QuestionnairesEditor(EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_items'
template_name = 'pretixcontrol/items/questionnaires.html'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['questionnaire_type_choices'] = textchoices_to_json(Questionnaire.QuestionnaireType, self.request.event)
ctx['system_question_choices'] = textchoices_to_json(QuestionnaireChild.SystemQuestion, self.request.event)
ctx['question_type_choices'] = textchoices_to_json(Question.FieldType, self.request.event)
return ctx
class QuotaQueryMixin:
+5 -5
View File
@@ -525,7 +525,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
def is_completed(self, request, warn=False):
if getattr(self, '_completed', None) is not None:
return self._completed
for cartpos in get_cart(request).filter(addon_to__isnull=True).prefetch_related(
for cartpos in get_cart_positions(request).filter(addon_to__isnull=True).prefetch_related(
'item__addons', 'item__addons__addon_category', 'addons', 'addons__item'
):
a = cartpos.addons.all()
@@ -588,7 +588,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
formset = []
quota_cache = {}
item_cache = {}
for cartpos in sorted(get_cart(self.request).filter(addon_to__isnull=True).prefetch_related(
for cartpos in sorted(get_cart_positions(self.request).filter(addon_to__isnull=True).prefetch_related(
'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation',
), key=lambda c: c.sort_key):
formsetentry = {
@@ -1289,7 +1289,7 @@ class PaymentStep(CartMixin, TemplateFlowStep):
@cached_property
def _total_order_value(self):
cart = get_cart(self.request)
cart = get_cart_positions(self.request)
try:
fees = get_fees(
event=self.request.event, request=self.request, invoice_address=self.invoice_address,
@@ -1481,7 +1481,7 @@ class PaymentStep(CartMixin, TemplateFlowStep):
messages.error(request, _('Please select a payment method to proceed.'))
return False
cart = get_cart(self.request)
cart = get_cart_positions(self.request)
try:
fees = get_fees(
event=self.request.event,
@@ -1521,7 +1521,7 @@ class PaymentStep(CartMixin, TemplateFlowStep):
def is_applicable(self, request):
self.request = request
for cartpos in get_cart(self.request):
for cartpos in get_cart_positions(self.request):
if cartpos.requires_approval(invoice_address=self.invoice_address):
if 'payments' in self.cart_session:
del self.cart_session['payments']
@@ -1,7 +1,8 @@
{% load safelink %}
{% load i18n %}
{% load rich_text %}
{% if answer %}
{% if not answer and not question %}
{% elif answer %}
{% if answer.file %}
<span class="fa fa-file" aria-hidden="true"></span>
<a href="{{ answer.frontend_file_url }}?token={% answer_token request answer %}">
@@ -87,6 +87,8 @@ dialog::backdrop {
100% { transform: skewX(0deg); }
}
.modal-card.no-padding, .modal-card.no-padding .modal-card-content { padding: 0; }
.modal-card.no-scroll { overflow: hidden; }
/* Legacy dialogs (still used for #ajaxerr and #popupmodal) */
@@ -0,0 +1,8 @@
try {
window.parent.postMessage({
type: 'pretix:notify-parent',
data: JSON.parse(document.getElementById('notify_info').textContent),
}, location.origin)
} catch (e) {
console.error('Could not post message to parent.', e)
}
@@ -1118,3 +1118,65 @@ $(function () {
return $(this).find('button:not([type=button]), input[type=submit]').length > 0
}).areYouSure({ message: gettext('You have unsaved changes!') })
})
function show_django_dialog(url, callback) {
function messageEvent(e) {
console.log('messageEvent', e.origin, e.source, e.data)
if (e.origin === location.origin && e.data.type === 'pretix:dialog-loaded') {
$dlg.find("iframe").attr("height", Math.min(window.innerHeight - 120, e.data.contentHeight|0)).css("visibility", "visible")
$dlg.find("center").remove()
}
if (e.origin === location.origin && e.data.type === 'pretix:notify-parent') {
$dlg[0].close()
if (!callback(e.data.data)) {
if (e.data.data.messages?.length) {
alert(e.data.data.messages.map(m => m.message).join('\n\n'))
}
}
}
}
var $dlg = $('<dialog class="modal-card no-padding no-scroll" closedby="any"><center><i class="fa fa-cog big-rotating-icon"></i></center><iframe height="400" width="100%"></iframe></div>')
.css('max-width', '60em')
$dlg.find("iframe").attr("src", url).css("visibility", "hidden").css("border", "0")
window.addEventListener('message', messageEvent)
$dlg.appendTo("body")
$dlg.on('close', function() {
window.removeEventListener('message', messageEvent)
$dlg.remove()
})
$dlg[0].showModal()
}
$(function() {
$("[data-django-dialog]").on("click", function(e) {
show_django_dialog(this.getAttribute("data-django-dialog"), function() {})
})
})
function notify_parent_frame() {
window.addEventListener('message', function(e) {
if (e.source === window) return
if (e.origin === location.origin && e.data.type === 'pretix:dialog-handshake') {
if (!window.isInDialog) {
window.isInDialog = true
window.document.documentElement.classList.add('in-iframe')
}
}
if (e.origin === location.origin && e.data.type === 'pretix:dialog-loading') {
e.source.postMessage({ type: 'pretix:dialog-handshake' })
}
})
try {
window.parent.postMessage({
type: 'pretix:dialog-loading',
title: document.title,
}, location.origin)
} catch {}
$(function () {
setTimeout(() => {
window.parent.postMessage({
type: 'pretix:dialog-loaded',
contentHeight: $('#page-wrapper > .container-fluid').outerHeight() + 20,
}, location.origin)
}, 100)
})
}
notify_parent_frame()
@@ -1,146 +1,24 @@
<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 setup lang="ts">
import Editor from './Editor.vue';
</script>
<style>
.hidden-questionnaire { opacity: 0.3; }
.questionnaires-list { margin-right: 180px; }
.question-edit-buttons { float:right; }
.question-edit-buttons div { position: absolute; margin-left: 10px; min-width: 100px; }
.question-edit-buttons button { }
.form-group { margin-bottom: 30px }
.questionnaires-editor.product-selected .panel .panel-heading {}
.questionnaires-editor.product-selected .panel { margin-bottom: 0; border: 0 none; border-bottom: 1px solid #ddd; box-shadow: none; border-radius: 0; }
.questionnaires-editor.product-selected .panel .panel-heading { font-style: italic; background: white; border: 0 none; }
.filter-row { background: #f8e6ff; border: 1px solid #e3cbed; padding: 10px; }
.debuginfo { font-size: 70%; background: rgba(200, 200, 200, 0.5); }
.dependency-info { position: absolute; }
.dependency-info > span { }
.category-header { margin: 8px 0 -5px 0; font-weight: bold; color: #737373; }
</style>
<template>
<div class="questionnaires-editor">
<p class="filter-row">
Order questionnaires
</p>
<div class="questionnaires-list">
<SlickList axis="y" v-model:list="order_questionnaires" useDragHandle appendTo="#orderQuestionnaireListParent" id="orderQuestionnaireListParent">
<SlickItem v-for="(questionnaire, index) in order_questionnaires" :key="questionnaire.id || questionnaire._new_id" :index="index">
<QuestionnaireElement
:questionnaire="questionnaire"
:datafields="datafields"
:grouped_items="null"
:selected_product="null" />
</SlickItem>
</SlickList>
</div>
<p>
<button class="btn btn-default" @click="addOrderQuestionnaire()"><i class="fa fa-plus"></i> Neuen Fragebogen erstellen</button>
<button class="btn btn-default" @click="saveData()"><i class="fa fa-save"></i> Speichern</button>
</p>
</div>
<div :class="`questionnaires-editor ${selected_product ? 'product-selected':''}`">
<p class="filter-row">
Questionnaires for product:
<select v-model="selected_product">
<option value="">(all)</option>
<optgroup v-for="[category, items] in grouped_items" :label="category.internal_name || i18n_any(category.name)">
<option v-for="item in items" :value="item.id">
{{ item.internal_name || i18n_any(item.name) }}
</option>
</optgroup>
</select>
</p>
<div class="questionnaires-list">
<SlickList axis="y" v-model:list="position_questionnaires" useDragHandle appendTo="#questionnaireListParent" id="questionnaireListParent">
<SlickItem v-for="(questionnaire, index) in position_questionnaires" :key="questionnaire.id || questionnaire._new_id" :index="index">
<QuestionnaireElement
:questionnaire="questionnaire"
:datafields="datafields"
:grouped_items="grouped_items"
:selected_product="selected_product" />
</SlickItem>
</SlickList>
</div>
<p>
<button class="btn btn-default" @click="addPositionQuestionnaire()"><i class="fa fa-plus"></i> Neuen Fragebogen erstellen</button>
<button class="btn btn-default" @click="saveData()"><i class="fa fa-save"></i> Speichern</button>
</p>
</div>
<Suspense>
<Editor/>
</Suspense>
</template>
<style>
.progressBar { position: fixed; top: 0; left: 0; right: 0; z-index:1000000; pointer-events: none; }
.progressBar.local { position: absolute; }
.progressBar .progressBar_progress { height: 5px; background: #0091EA; border-bottom: #026099; }
.progressBar .progressBar_progress.indeterminate {
width: 30%; animation: slide 5s forwards;
}
.progressBarText { width: 100%; color: white; text-shadow: 1px 1px 1px black, -1px -1px 1px black;
font-weight: bold; padding-left: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@keyframes slide {
0% { width: 20%; }
30% { width: 50%; }
100% { width: 60%; }
}
</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import {gettext} from "./gettextstub";
import NativeDialog from "./NativeDialog.vue";
import {computed, onMounted, onUnmounted, ref, watch} from "vue";
const props = defineProps({
'defaultUrl': {type: String, default: null},
'maxWidth': {type: String, default: '43em'},
})
const emit = defineEmits(['confirm'])
const dlgDjangoDialog = ref()
const url = ref(props.defaultUrl)
const frameHeight = ref(400)
const frameLoading = ref(true)
function messageEvent(e: MessageEvent) {
console.log('messageEvent', e.origin, e.source, e.data)
if (!dlgDjangoDialog.value.visible) return
if (e.origin === location.origin && e.data.type === 'pretix:dialog-loaded') {
frameHeight.value = Math.min(window.innerHeight - 120, e.data.contentHeight|0)
frameLoading.value = false
}
if (e.origin === location.origin && e.data.type === 'pretix:notify-parent') {
dlgDjangoDialog.value.close()
emit('confirm', e.data.data)
if (e.data.data.messages) {
alert(e.data.data.messages.map(m => m.message).join('\n\n'))
}
}
}
onMounted(() => {
window.addEventListener('message', messageEvent)
})
onUnmounted(() => {
window.removeEventListener('message', messageEvent)
})
defineExpose({
dialog: dlgDjangoDialog,
open: (newUrl) => {
frameLoading.value = true
frameHeight.value = 400
url.value = newUrl
dlgDjangoDialog.value.show()
},
});
</script>
<template>
<NativeDialog ref="dlgDjangoDialog" class="modal-card" :no-padding="true" :no-scroll="true" :max-width="maxWidth">
<div :style="{'height': frameHeight + 'px'}">
<div class="frame-load-indicator" v-if="frameLoading"><i class="fa fa-cog big-rotating-icon"></i></div>
<iframe :src="url" v-if="dlgDjangoDialog.visible" :height="frameHeight" :style="{'opacity': frameLoading ? '0' : '1'}"></iframe>
</div>
</NativeDialog>
</template>
<style scoped>
.frame-load-indicator { text-align: center; }
iframe { width: 100%; border: 0; }
</style>
@@ -0,0 +1,277 @@
<script setup lang="ts">
import QuestionnaireElement from './QuestionnaireElement.vue';
import * as api from './api';
import {Datafield, Questionnaire} from './model';
import {i18n_any, sort, numericComp, groupBy, SYSTEM_DATAFIELDS} from './helper';
import { gettext } from './gettextstub';
import {onMounted, onUnmounted, ref} from 'vue';
import { SlickList, SlickItem } from 'vue-slicksort';
import { ProgressBar } from "./ProgressBar";
const sales_channels_list = await api.getSalesChannels();
const items_list = await api.getItems();
const categories_list = await api.getCategories();
const categories = Object.fromEntries(categories_list.map(cat => [cat.id, cat]));
categories['null'] = { position: -1, internal_name: gettext('Uncategorized'), name: null, id: null };
sort(items_list, numericComp(item => categories[item.category]?.position), numericComp(item => item.position));
console.log('items_list', items_list)
const grouped_items = [...groupBy(items_list, item => categories[item.category])]
console.log('grouped_items', grouped_items)
type QuestionnaireMaybeUnsaved = Omit<Questionnaire, 'id'> & { _new_id?: number, id?: number };
const order_questionnaires = ref<QuestionnaireMaybeUnsaved[]>();
const position_questionnaires = ref<QuestionnaireMaybeUnsaved[]>();
const order_datafields = ref<Datafield[]>();
const position_datafields = ref<Datafield[]>();
let lastDataRefresh = 0
async function refreshQuestionnaireList () {
const all_questionnaires: QuestionnaireMaybeUnsaved[] = await api.getQuestionnaires();
order_questionnaires.value = all_questionnaires.filter(q => q.type.startsWith('O'))
position_questionnaires.value = all_questionnaires.filter(q => q.type.startsWith('P'))
lastDataRefresh = Date.now()
}
async function refreshDatafieldList () {
order_datafields.value = await api.getDatafields('O')
position_datafields.value = (await api.getDatafields('P')).concat(Object.values(SYSTEM_DATAFIELDS))
console.log('datafield list refreshed')
}
await Promise.all([refreshQuestionnaireList(), refreshDatafieldList()])
function saveQuestionnaire(questionnaire) {
let result;
questionnaire._loading = true
if (questionnaire.id) {
result = api.updateQuestionnaire(questionnaire.id, questionnaire)
} else {
result = api.createQuestionnaire(questionnaire)
}
result = result.then(d => {
questionnaire.id = d.id
questionnaire.children = d.children
questionnaire._err_mes = null
questionnaire._loading = false
return d;
}, err => {
questionnaire._err_mes = err
questionnaire._loading = false
return err;
})
return result;
}
function addPositionQuestionnaire () {
position_questionnaires.value.push({
all_sales_channels: true, children: [], limit_sales_channels: [], position: 0,
items: [], internal_name: "Unnamed questionnaire", type: "PS",
_new_id: Date.now(),
});
}
function addOrderQuestionnaire () {
order_questionnaires.value.push({
all_sales_channels: true, children: [], limit_sales_channels: [], position: 0,
items: [], internal_name: "Unnamed questionnaire", type: "OS",
_new_id: Date.now(),
});
}
async function saveData () {
using pb = ProgressBar.show('saving questionnaires')
let promises = [];
let i = 0;
for (const questionnaire of order_questionnaires.value) {
questionnaire.position = i++;
promises.push(saveQuestionnaire(questionnaire))
}
i = 0;
for (const questionnaire of position_questionnaires.value) {
questionnaire.position = i++;
promises.push(saveQuestionnaire(questionnaire))
}
await Promise.all(promises)
}
/*
export default {
components: {
QuestionnaireElement, SlickList, SlickItem,
},
methods: {
i18n_any,
},
data() {
return {
order_questionnaires,
position_questionnaires,
order_datafields,
position_datafields,
items: items_list,
grouped_items,
categories,
}
}
}*/
function windowFocused() {
if (Date.now() - lastDataRefresh > 30000) {
console.log('refreshing to avoid overwriting with older data on edit')
refreshQuestionnaireList()
}
}
onMounted(() => window.addEventListener('focus', windowFocused))
onUnmounted(() => window.removeEventListener('focus', windowFocused))
const selected_product = ref("")
const preview_mode = ref(false)
</script>
<style>
.hidden-questionnaire { opacity: 0.3; }
.questionnaires-list {
padding: 20px 190px 20px 0;
background: linear-gradient(to right, #fff 0, #fff calc(100% - 172px), #ddd calc(100% - 171px), #f6f2f8 calc(100% - 171px), #fff calc(100% - 100px), #fff 100%);
}
.question-edit-buttons { float:right; }
.question-edit-buttons div { position: absolute; margin-left: 10px; min-width: 100px; }
.question-edit-buttons button { }
.form-group { margin-bottom: 30px }
.questionnaires-editor.product-selected .questionnaire-panel .questionnaire-panel-heading {}
.questionnaires-editor:not(.preview-mode) .questionnaire-panel .questionnaire-panel-heading .editor-row { border-top: 1px solid rgb(175 175 175 / 0.3); }
.questionnaires-editor:not(.preview-mode) .questionnaire-panel[open] .questionnaire-panel-heading .editor-row { border-bottom: 1px solid rgb(216 216 216 / 0.3); }
.questionnaires-editor.preview-mode .questionnaire-panel .questionnaire-panel-heading { color: #888; margin-top: 5px; border-top: 1px dashed rgb(175 175 175 / 0.3); border-bottom: 1px dashed rgb(216 216 216 / 0.3); font-style: italic; padding: 0; }
.questionnaires-editor .editor-row:not(:hover) .btn.btn-default,
.questionnaires-editor .editor-action-row:not(:hover) .btn.btn-default{ box-shadow: 0 0 0 0 #eeeeee; background: transparent; color: #555; }
.filter-row { background: #f8e6ff; border: 1px solid #e3cbed; padding: 10px; }
.debuginfo { font-size: 70%; background: rgba(200, 200, 200, 0.5); }
.dependency-info { position: absolute; }
.dependency-info > span { }
.category-header { margin: 8px 0 -5px 0; font-weight: bold; color: #737373; }
.editor-row { display: flex; width: calc(100% + 190px); background: transparent; }
.editor-row:hover { background: rgba(255,233,244,0.3); }
.editor-preview-area { flex: 1; padding-left: 10px; padding-right: 40px; }
.editor-action-area { width: 160px; padding-left: 20px; }
.questionnaire-panel-heading .editor-preview-area { padding-block: 11px; }
.questionnaire-panel-heading .editor-action-area { padding-block: 5px; }
.questionnaire-panel-body .editor-preview-area, .questionnaire-panel-body .editor-action-area { padding-block: 10px; }
.preview-mode .editor-preview-area { padding-right: 50px; }
.questionnaires-editor.questionnaires-editor .form-group { margin-bottom: 0px; }
.questionnaire-panel .questionnaire-panel-heading .editor-preview-area::before {
margin-top: -.5em;
content: "";
width: 1em;
height: 1em;
font: normal normal normal 14px/1 FontAwesome;
display: inline-block;
text-align: center;
transform: rotate(-90deg);
transition: transform 150ms ease-in 0s;
}
.questionnaire-panel[open] .questionnaire-panel-heading .editor-preview-area::before {
transform: rotate(0deg);
}
.editor-action-row { padding-top: 10px; width: calc(100% + 190px); padding-right: 170px }
.questionnaires-list > .editor-action-row { border-top: 1px solid rgb(175 175 175 / 0.3); padding-top: 25px; padding-bottom: 10px }
</style>
<template>
<div class="questionnaires-editor" v-if="!preview_mode">
<div class="filter-row">
Order questionnaires
</div>
<div class="questionnaires-list">
<SlickList axis="y" v-model:list="order_questionnaires" useDragHandle appendTo="#orderQuestionnaireListParent" id="orderQuestionnaireListParent" @update:list="saveData()">
<SlickItem v-for="(questionnaire, index) in order_questionnaires" :key="questionnaire.id || questionnaire._new_id" :index="index">
<QuestionnaireElement
:questionnaire="questionnaire"
:datafields="order_datafields"
:sales_channels="sales_channels_list"
:grouped_items="null"
:selected_product="null"
:preview_mode="false"
@update="saveQuestionnaire(questionnaire)"
@invalidate:datafields="refreshDatafieldList()" />
</SlickItem>
</SlickList>
<div class="editor-action-row form-horizontal">
<div class="form-group">
<div class="col-md-9 col-md-push-3">
<button class="btn btn-default" @click="addOrderQuestionnaire()"><i class="fa fa-plus"></i> {{ gettext('New questionnaire') }}</button>
</div>
</div>
</div>
</div>
</div>
<div :class="`questionnaires-editor ${selected_product ? 'product-selected':''} ${preview_mode ? 'preview-mode':''}`">
<div class="filter-row" v-if="preview_mode">
<button class="btn btn-default" @click="preview_mode = false"><span class="fa fa-chevron-left"></span> {{ gettext('Back') }}</button>
Previewing questionnaires for product {{ selected_product }}
</div>
<div class="filter-row" v-else>
Questionnaires for product:
<select v-model="selected_product">
<option value="">(all)</option>
<optgroup v-for="[category, items] in grouped_items" :label="category.internal_name || i18n_any(category.name)">
<option v-for="item in items" :value="item.id">
{{ item.internal_name || i18n_any(item.name) }}
</option>
</optgroup>
</select>
&nbsp;
<button class="btn btn-default" @click="preview_mode = true" :disabled="!selected_product"> {{ gettext('Preview') }}</button>
</div>
<div class="questionnaires-list" v-if="preview_mode && selected_product">
<details class="panel panel-default details-open" open>
<summary class="panel-heading">
<h4 class="panel-title">
<strong>Product {{ selected_product }}</strong>
</h4>
</summary>
<QuestionnaireElement v-for="(questionnaire, index) in position_questionnaires.filter(q => q.items.indexOf(selected_product as any) !== -1)"
:questionnaire="questionnaire"
:datafields="position_datafields"
:sales_channels="sales_channels_list"
:grouped_items="grouped_items"
:selected_product="selected_product"
:preview_mode="true"
@update="saveQuestionnaire(questionnaire)"
@invalidate:datafields="refreshDatafieldList()"/>
</details>
</div>
<div class="questionnaires-list" v-else>
<SlickList axis="y" v-model:list="position_questionnaires" useDragHandle appendTo="#questionnaireListParent" id="questionnaireListParent" @update:list="saveData()">
<SlickItem v-for="(questionnaire, index) in position_questionnaires" :key="questionnaire.id || questionnaire._new_id" :index="index">
<QuestionnaireElement
:questionnaire="questionnaire"
:datafields="position_datafields"
:sales_channels="sales_channels_list"
:grouped_items="grouped_items"
:selected_product="selected_product"
:preview_mode="false"
@update="saveQuestionnaire(questionnaire)"
@invalidate:datafields="refreshDatafieldList()"/>
</SlickItem>
</SlickList>
<div v-if="!preview_mode" class="editor-action-row form-horizontal">
<div class="form-group">
<div class="col-md-9 col-md-push-3">
<button class="btn btn-default" @click="addPositionQuestionnaire()"><i class="fa fa-plus"></i> {{ gettext('New questionnaire') }}</button>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import {getEventLocales} from "./api"
const locales = getEventLocales();
const props = defineProps<{ value: any, id?: string }>();
</script>
<template>
<div class="i18n-form-group" :id="id">
<textarea v-for="locale in locales" cols="40" rows="2" :lang="locale" dir="ltr" class="form-control" title="Englisch" :id="`${id}_${locale}`" :placeholder="locale" v-model="value[locale]"></textarea>
</div>
</template>
@@ -1,16 +1,13 @@
<script setup lang="ts">
import { ref, useId, defineProps } from 'vue';
import {getEventLocales} from "./api";
import {getEventLocales} from "./api"
const locales = getEventLocales();
const props = defineProps(['value', 'id']);
if (!props.value) props.value = {};
const props = defineProps<{ value: any, id?: string }>();
</script>
<template>
<div class="i18n-form-group" :id="id">
<textarea v-for="locale in locales" cols="40" rows="2" :lang="locale" dir="ltr" class="form-control" title="Englisch" :id="`${id}_${locale}`" :placeholder="locale" v-model="value[locale]"></textarea>
<input v-for="locale in locales" cols="40" rows="2" :lang="locale" dir="ltr" class="form-control" title="Englisch" :id="`${id}_${locale}`" :placeholder="locale" v-model="value[locale]">
</div>
</template>
@@ -8,7 +8,10 @@ const props = defineProps({
type: String,
default: "",
},
title: '',
title: {type: String, default: ''},
noPadding: {type: Boolean, default: false},
noScroll: {type: Boolean, default: true},
maxWidth: {type: String, default: '43em'},
});
const visible = ref(false);
@@ -28,10 +31,11 @@ const id = useId();
<template>
<dialog
ref="dialog" class="modal-card"
ref="dialog" :class="`modal-card ${props.noPadding ? 'no-padding' : ''} ${props.noPadding ? 'no-scroll' : ''}`"
@close="visible = false"
closedby="any"
:aria-labelledby="`${id}-title`"
:style="{maxWidth: props.maxWidth}"
>
<form
v-if="visible"
@@ -41,9 +45,14 @@ const id = useId();
}"
>
<div class="modal-card-content">
<h2 :id="`${id}-title`" class="modal-card-title h3">{{ title }}</h2>
<h2 :id="`${id}-title`" class="modal-card-title h3" v-if="title">{{ title }}</h2>
<slot />
</div>
</form>
</dialog>
</template>
<style>
.modal-card.no-padding, .modal-card.no-padding .modal-card-content { padding: 0; }
.modal-card.no-scroll { overflow: hidden; }
</style>
@@ -0,0 +1,71 @@
type MyAttrs = {
[key: `on${any}`]: EventListener, style?: {[key:string]: string} | string, innerHTML?: string,
appendTo?: HTMLElement | ShadowRoot, prependTo?: HTMLElement | ShadowRoot, insertBefore?: HTMLElement, insertAfter?: HTMLElement,
[key: Exclude<string, 'component' | 'args'>]: any,
};
export function EL<K extends keyof HTMLElementTagNameMap>(tagName: K, attrs:MyAttrs, ...children:(string|HTMLElement)[]): HTMLElementTagNameMap[K];
export function EL(tagName:string, attrs:MyAttrs, ...children:(string|HTMLElement)[]) : HTMLElement;
export function EL(tagName:string, attrs:MyAttrs, ...children:(string|HTMLElement)[]) : HTMLElement {
if (attrs?.idGetOrCreate) {
const el = document.getElementById(attrs.idGetOrCreate);
if (el) {
return el;
} else {
attrs.id = attrs.idGetOrCreate; delete attrs.idGetOrCreate;
}
}
var el = document.createElement(tagName);
if (attrs) for(var key in attrs)
if (key === 'style' && typeof attrs[key] === 'object') Object.assign(el.style, attrs[key]);
else if (key === 'component' && 'args' in attrs) el['Component'] = new attrs.component(el, ...attrs.args);
else if (key === 'innerHTML') el.innerHTML = attrs[key];
else if (key === 'appendTo' && (attrs.appendTo instanceof HTMLElement || attrs.appendTo instanceof ShadowRoot)) attrs.appendTo.append(el);
else if (key === 'prependTo' && (attrs.prependTo instanceof HTMLElement || attrs.prependTo instanceof ShadowRoot)) attrs.prependTo.prepend(el);
else if (key === 'insertBefore' && attrs.insertBefore instanceof HTMLElement) attrs.insertBefore.before(el);
else if (key === 'insertAfter' && attrs.insertAfter instanceof HTMLElement) attrs.insertAfter.after(el);
else if (key.startsWith("on")) el.addEventListener(key.substring(2), attrs[key] as unknown as EventListener, false);
else if (key.startsWith(":")) el[key.substring(1)] = attrs[key];
else if (key === 'checked' && 'checked' in el) el.checked = attrs.checked;
else if (key === 'disabled' && 'disabled' in el) el.disabled = attrs.disabled;
else if (key === 'selected' && 'selected' in el) el.selected = attrs.selected;
else if (key === 'multiple' && 'multiple' in el) el.multiple = attrs.multiple;
else el.setAttribute(key, attrs[key]);
for(var i=0;i<children.length;i++){
if (children[i] instanceof HTMLElement) el.appendChild(<HTMLElement>children[i]);
else if (children[i]) el.appendChild(document.createTextNode(""+children[i]));
}
return el;
}
export class ProgressBar {
private static container : HTMLDivElement = EL('div', { appendTo: document.body, class: 'progressBar' }, EL('div', {class:'progressBarText'}));
static show(message?: string, max?: number) {
const texts = ProgressBar.container.lastElementChild;
const bar = EL('div', {class:'progressBar_progress'+(max ? '' : ' indeterminate')});
let text : HTMLElement;
if (message) {
text = EL('span', {}, message);
texts.append(text);
}
texts.before(bar);
return {
disposed: false,
[Symbol.dispose]() {
if (this.disposed) return;
bar.remove();
if (text) text.remove();
this.disposed = true;
},
set message(val: string) {
text.innerText = val;
},
set progress(val: number) {
if (max) bar.style.width = `${val / max * 100}%`;
}
};
}
}
@@ -4,19 +4,23 @@ 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";
import {getDatafieldCreateUrl, getDatafieldEditUrl, getDatafieldViewUrl} from "./api";
import I18nTextArea from "./I18nTextArea.vue";
import DjangoDialog from "./DjangoDialog.vue";
const id = useId();
const props = defineProps(['question', 'datafields', 'editable', 'possible_dependencies'])
const emit = defineEmits(['removeSelf']);
const emit = defineEmits(['removeSelf', 'update', 'invalidate:datafields']);
const gettext = (window as any).gettext;
const question = ref(props.question);
const df = typeof question.value.question === 'number' ?
const dlgEditDatafield = ref()
const df = computed(() => typeof question.value.question === 'number' ?
props.datafields.find(el => el.id === question.value.question) :
typeof question.value.question === 'string' ?
SYSTEM_DATAFIELDS[question.value.question] :
null;
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));
@@ -28,55 +32,61 @@ 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>
<div class="editor-row">
<div class="editor-preview-area">
<div class="form-group">
<template v-if="df">
<div v-if="question.dependency_question" class="dependency-info debuginfo">
<span><span class="fa fa-link"></span> {{ question.dependency_question }} = {{ dependency_values_resolved }}</span>
</div>
<div class="col-md-3 control-label label-empty" v-if="df.type === QUESTION_TYPE.BOOLEAN"></div>
<label class="col-md-3 control-label" :for="id" v-else>
{{ i18n_any(question.label) }}
</label>
<div class="col-md-9">
<input :id="id" type="text" v-if="df.type === QUESTION_TYPE.STRING || df.type === QUESTION_TYPE.PHONENUMBER" class="form-control">
<textarea :id="id" v-if="df.type === QUESTION_TYPE.TEXT" class="form-control"></textarea>
<div class="checkbox" v-if="df.type === QUESTION_TYPE.BOOLEAN">
<label :for="id">
<input :id="id" type="checkbox"> {{ i18n_any(question.label) }}
<template v-if="df">
<div v-if="question.dependency_question" class="dependency-info debuginfo">
<span><span class="fa fa-link"></span> {{ question.dependency_question }} = {{ dependency_values_resolved }}</span>
</div>
<div class="col-md-3 control-label label-empty" v-if="df.type === QUESTION_TYPE.BOOLEAN"></div>
<label class="col-md-3 control-label" :for="id" v-else>
{{ i18n_any(question.label) }}
</label>
<div class="col-md-9">
<input :id="id" type="text" v-if="df.type === QUESTION_TYPE.STRING || df.type === QUESTION_TYPE.PHONENUMBER" class="form-control">
<textarea :id="id" v-if="df.type === QUESTION_TYPE.TEXT" class="form-control"></textarea>
<div class="checkbox" v-if="df.type === QUESTION_TYPE.BOOLEAN">
<label :for="id">
<input :id="id" type="checkbox"> {{ i18n_any(question.label) }}
</label>
</div>
<input :id="id" type="number" v-if="df.type === QUESTION_TYPE.NUMBER" class="form-control">
<input :id="id" type="file" v-if="df.type === QUESTION_TYPE.FILE" class="form-control">
<select :id="id" class="form-control"
v-if="df.type === QUESTION_TYPE.CHOICE || df.type === QUESTION_TYPE.COUNTRYCODE">
<option></option>
<option v-for="opt in df.options">{{ i18n_any(opt.answer) }}</option>
</select>
<div class="checkbox" v-if="df.type === QUESTION_TYPE.CHOICE_MULTIPLE" v-for="(opt, index) in df.options">
<label :for="`${id}-${index}`">
<input :id="`${id}-${index}`" type="checkbox"> {{ i18n_any(opt.answer) }}
</label>
</div>
<div class="help-block" v-html="question.rendered_help_text" v-if="question.rendered_help_text"></div>
</div>
</template>
<div v-else class="col-md-9 col-md-push-3">
<h4>{{ i18n_any(question.label) }}</h4>
<p v-html="question.rendered_help_text" v-if="question.rendered_help_text"></p>
</div>
<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>
<aside class="editor-action-area">
<div class="btn-group" v-if="editable">
<DragHandle tag="button" class="btn btn-default"><i class="fa fa-arrows"></i></DragHandle>
<button class="btn btn-default" @click="editor.show()"><i class="fa fa-edit"></i></button>
</div>
</aside>
</div>
<Teleport to="body">
<NativeDialog ref="editor" class="modal-card"
title="Edit question">
:title="df ? gettext('Edit question') : gettext('Edit text block')">
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Question') }}
{{ df ? gettext('Question') : gettext('Headline') }}
</label>
<div class="col-md-9">
<I18nTextField :value="question.label"/>
@@ -87,11 +97,11 @@ const editor = ref();
{{ gettext('Help text') }}
</label>
<div class="col-md-9">
<I18nTextField :value="question.help_text"/>
<I18nTextArea :value="question.help_text"/>
<div class="help-block">Wenn diese Frage noch weitere Erklärung braucht, können Sie sie hier eintragen.</div>
</div>
</div>
<div class="form-group">
<div class="form-group" v-if="df">
<label class="col-md-3 control-label">
Data field
</label>
@@ -99,7 +109,10 @@ const editor = ref();
<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>
<div>
<a class="btn btn-sm btn-default" href="javascript:" @click="dlgEditDatafield.open(getDatafieldEditUrl(df.id))"><span class="fa fa-wrench"></span> Manage data field details</a>
<a class="btn btn-sm btn-default" :href="getDatafieldViewUrl(df.id)" target="_blank"><span class="fa fa-external-link"></span> View answers</a>
</div>
</template>
<template v-else>
{{ question.question }}
@@ -107,7 +120,7 @@ const editor = ref();
</p>
</div>
</div>
<div class="form-group">
<div class="form-group" v-if="df">
<label class="col-md-3 control-label">
Data field type
</label>
@@ -117,7 +130,7 @@ const editor = ref();
</select>
</div>
</div>
<div class="form-group">
<div class="form-group" v-if="df">
<label class="col-md-3 control-label label-empty">&nbsp;</label>
<div class="col-md-9">
<div class="checkbox">
@@ -147,8 +160,10 @@ const editor = ref();
</div>
</div>
<button @click="editor.close()" class="btn btn-primary pull-right"><span class="fa fa-check"></span> Save and close</button>
<button @click="editor.close(); emit('update')" class="btn btn-primary pull-right"><span class="fa fa-check"></span> Save and close</button>
<button @click="emit('removeSelf')" class="btn btn-default">Remove from questionnaire</button>
</NativeDialog>
<DjangoDialog ref="dlgEditDatafield" @confirm="emit('invalidate:datafields')" max-width="60em"></DjangoDialog>
</Teleport>
</template>
@@ -1,97 +1,151 @@
<script setup lang="ts">
import {useId, ref, computed} from 'vue'
import {useId, ref, computed, onMounted, watch} from 'vue'
import QuestionElement from "./QuestionElement.vue";
import {i18n_any, QUESTION_TYPE, QUESTION_TYPE_LABEL} from "./helper";
import {
i18n_any,
QUESTION_TYPE,
QUESTION_TYPE_LABEL,
QUESTIONNAIRE_TYPE,
QUESTIONNAIRE_TYPE_LABEL,
SYSTEM_DATAFIELDS
} from "./helper";
import { gettext } from './gettextstub';
import I18nTextField from "./I18nTextField.vue";
import NativeDialog from "./NativeDialog.vue";
import { SlickList, SlickItem, DragHandle } from 'vue-slicksort';
import {getDatafieldCreateUrl} from "./api";
import DjangoDialog from "./DjangoDialog.vue";
const dlgEditor = ref();
const dlgAddExisting = ref();
const dlgAddTextblock = ref();
const dlgEditor = ref()
const dlgAddExisting = ref()
const dlgAddTextblock = ref()
const dlgNewDatafield = ref()
const newTextblockTitle = ref();
const newTextblockText = 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
const props = defineProps(['questionnaire', 'datafields', 'sales_channels', 'selected_product', 'grouped_items', 'preview_mode', 'err_mes'])
const emit = defineEmits(['update', 'invalidate:datafields'])
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);
}
let nextId = 1;
watch(() => props.questionnaire.children, () => {
for (let qc of props.questionnaire.children) {
if (!qc._cid) qc._cid = id + (++nextId);
}
})
function setVisibleOnItem (checked, itemId) {
const i = props.questionnaire.items.indexOf(itemId)
if (i === -1) {
props.questionnaire.items.push(itemId)
} else {
props.questionnaire.items.splice(i, 1)
}
}
function addExistingDatafield(field) {
function addExistingDatafield (field) {
props.questionnaire.children.push({
_cid: useId(),
question: field.id,
required: false,
label: {},
label: field.question,
help_text: {},
dependency_question: null,
dependency_values: [],
});
dlgAddExisting.value.close();
})
dlgAddExisting.value.close()
emit('update')
}
function showAddTextblockDialog() {
newTextblockTitle.value = {};
newTextblockText.value = {};
dlgAddTextblock.value.show();
function showAddTextblockDialog () {
newTextblockTitle.value = {}
newTextblockText.value = {}
dlgAddTextblock.value.show()
}
function addTextblock() {
function addTextblock () {
props.questionnaire.children.push({
_cid: useId(),
question: null,
required: false,
label: newTextblockTitle.value,
help_text: newTextblockText.value,
dependency_question: null,
dependency_values: [],
});
dlgAddTextblock.value.close();
})
dlgAddTextblock.value.close()
emit('update')
}
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);
function newDatafield (container_type) {
dlgNewDatafield.value.open(getDatafieldCreateUrl(props.questionnaire.type[0]))
}
async function onNewDatafieldCreated (data) {
watch(
() => props.datafields,
(newValue, oldValue) => {
console.log('datafields changed watcher called')
addExistingDatafield(newValue.find(f => f.id === data.object))
},
{ once: true }
)
emit('invalidate:datafields')
}
const isHidden = computed(() => props.selected_product && props.questionnaire.items.indexOf(props.selected_product) === -1)
const isEditable = computed(() => props.selected_product && props.questionnaire.items.indexOf(props.selected_product) !== -1)
</script>
<template>
<div class="question-edit-buttons"><div class="btn-group">
<DragHandle tag="button" class="btn btn-default"><i class="fa fa-arrows"></i></DragHandle>
<button class="btn btn-default" @click="dlgEditor.show()"><i class="fa fa-edit"></i></button>
</div></div>
<details class="panel panel-default " :open="!!isEditable"
<details class="questionnaire-panel" :open="preview_mode"
:class="{ 'hidden-questionnaire': isHidden }">
<summary class="panel-heading">
<input type="checkbox" @click="toggleItem()" v-if="selected_product" :checked="!isHidden">
{{ props.questionnaire.internal_name }}
<summary class="questionnaire-panel-heading">
<div class=" editor-row">
<div class="editor-preview-area">
<input type="checkbox" @change="e => {setVisibleOnItem(e.target.checked, selected_product); emit('update')}" v-if="selected_product && !preview_mode" :checked="!isHidden">
{{ props.questionnaire.internal_name }}
<span class="fa fa-warning" v-if="questionnaire._err_mes"></span>
<span class="fa fa-cog fa-spin" v-if="questionnaire._loading"></span>
</div>
<aside class="editor-action-area"><div class="btn-group">
<DragHandle tag="button" class="btn btn-default" v-if="!preview_mode"><i class="fa fa-arrows"></i></DragHandle>
<button class="btn btn-default" @click="dlgEditor.show()"><i class="fa fa-wrench"></i></button>
</div></aside>
</div>
</summary>
<div class="panel-body" v-if="!isHidden">
<div class="questionnaire-panel-body" v-if="!isHidden">
<div class="alert alert-warning" v-if="questionnaire._err_mes">{{ questionnaire._err_mes }}</div>
<div class="form-horizontal" :id="`questionListParent${props.questionnaire.id}`">
<SlickList axis="y" v-model:list="props.questionnaire.children" useDragHandle :appendTo="`#questionListParent${props.questionnaire.id}`">
<SlickItem v-for="(child, index) in props.questionnaire.children" :key="child.id" :index="index">
<SlickList axis="y" v-model:list="props.questionnaire.children" useDragHandle :appendTo="`#questionListParent${props.questionnaire.id}`" @update:list="emit('update')">
<SlickItem v-for="(child, index) in props.questionnaire.children" :key="child._cid" :index="index">
<QuestionElement
:datafields="props.datafields"
:question="child"
:editable="true"
:possible_dependencies="props.questionnaire.children.slice(0, index)"
@remove-self="questionnaire.children.splice(index, 1)" />
@remove-self="questionnaire.children.splice(index, 1); emit('update')"
@update="emit('update')"
@invalidate:datafields="emit('invalidate:datafields')"/>
</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 class="editor-action-row form-horizontal">
<div class="form-group">
<div class="col-md-9 col-md-push-3">
<p class="btn-group" role="group">
<button class="btn btn-default" @click="dlgAddExisting.show()"><i class="fa fa-plus"></i> {{ gettext('Existing data field') }}</button>
<button class="btn btn-default" @click="newDatafield(questionnaire.type[0])"><i class="fa fa-plus"></i> {{ gettext('New data field') }}</button>
<button class="btn btn-default" @click="showAddTextblockDialog()"><i class="fa fa-plus"></i> {{ gettext('Text') }}</button>
</p>
</div>
</div>
</div>
</div>
</details>
@@ -106,6 +160,36 @@ const isEditable = computed(() => props.selected_product && props.questionnaire.
<input type="text" class="form-control" v-model="questionnaire.internal_name"/>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Where to ask') }}
</label>
<div class="col-md-9">
<select v-model="questionnaire.type" class="form-control">
<option v-for="(label, type) in QUESTIONNAIRE_TYPE_LABEL" :value="QUESTIONNAIRE_TYPE[type]">{{ label }}</option>
</select>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Sales channels') }}
</label>
<div class="col-md-9">
<div class="checkbox">
<label>
<input type="checkbox" v-model="questionnaire.all_sales_channels"> {{ gettext('All sales channels') }}
</label>
</div>
<div class="checkbox" v-for="channel in sales_channels">
<label>
<input type="checkbox" :checked="questionnaire.all_sales_channels || questionnaire.limit_sales_channels.indexOf(channel.identifier) !== -1"
@change="e => e.target.checked ? questionnaire.limit_sales_channels.push(channel.identifier) : questionnaire.limit_sales_channels.splice(questionnaire.limit_sales_channels.indexOf(channel.identifier), 1)"
:disabled="questionnaire.all_sales_channels">
{{ i18n_any(channel.label) }}
</label>
</div>
</div>
</div>
<div class="form-group" v-if="grouped_items">
<label class="col-md-3 control-label">
{{ gettext('Visible on products') }}
@@ -115,13 +199,13 @@ const isEditable = computed(() => props.selected_product && props.questionnaire.
<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) }}
<input :id="id + '_' + item.id" type="checkbox" :checked="questionnaire.items.indexOf(item.id) !== -1" @change="e => setVisibleOnItem(e.target.checked, item.id)"> {{ item.internal_name || i18n_any(item.name) }}
</label>
</div>
</div>
</div>
</div>
<button @click="dlgEditor.close()" class="btn btn-primary pull-right"><span class="fa fa-check"></span> {{ gettext('Save and close') }}</button>
<button @click="dlgEditor.close(); emit('update')" class="btn btn-primary pull-right"><span class="fa fa-check"></span> {{ gettext('Save and close') }}</button>
<button class="btn btn-default">{{ gettext('Delete') }}</button>
</NativeDialog>
@@ -129,7 +213,7 @@ const isEditable = computed(() => props.selected_product && props.questionnaire.
: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>
<a href="javascript:" @click="addExistingDatafield(field)" v-for="field in datafields" class="list-group-item">{{ i18n_any(field.question) }}</a>
</div>
<button @click="dlgAddExisting.close()" class="btn btn-default pull-right">{{ gettext('Cancel') }}</button>
@@ -159,5 +243,7 @@ const isEditable = computed(() => props.selected_product && props.questionnaire.
<button @click="dlgAddTextblock.close()" class="btn btn-default pull-right">{{ gettext('Cancel') }}</button>
</NativeDialog>
<DjangoDialog ref="dlgNewDatafield" @confirm="onNewDatafieldCreated" max-width="60em"></DjangoDialog>
</Teleport>
</template>
@@ -1,40 +1,55 @@
import {ApiListResponse, Datafield, Questionnaire, Item} from "./model";
import { ApiListResponse, Datafield, Questionnaire, Item, Category } from './model'
import { ProgressBar } from "./ProgressBar";
import {fromJsonScript} from "./helper";
const organizer_slug = document.body.getAttribute('data-organizer'),
event_slug = document.body.getAttribute('data-event');
event_slug = document.body.getAttribute('data-event')
async function api_get(resource) {
return await $.getJSON(`/api/v1/${resource}?_nocache=${+new Date()}`);
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()}`;
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)
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;
return result
}
class APIError extends Error {
api_error: string;
constructor(json) {
super('' + Object.values(json)[0]);
this.api_error = json;
}
}
async function api_json_request(resource, method, json_body) {
return await (await fetch(`/api/v1/${resource}`, {
const response = await fetch(`/api/v1/${resource}`, {
body: JSON.stringify(json_body),
method: method,
headers: {
"Content-Type": "application/json",
"X-CSRFToken": $('[name=csrfmiddlewaretoken]').val() as string,
'Content-Type': 'application/json',
'X-CSRFToken': $('[name=csrfmiddlewaretoken]').val() as string,
},
})).json();
});
if (response.status >= 400)
throw new APIError(await response.json());
else
return await response.json()
}
export async function getDatafields() {
return await api_get_all<Datafield>(`organizers/${organizer_slug}/events/${event_slug}/datafields/`);
export async function getDatafields(container_type) {
using pb = ProgressBar.show('loading data fields')
return await api_get_all<Datafield>(`organizers/${organizer_slug}/events/${event_slug}/datafields/?container_type=${container_type}&`);
}
export async function getQuestionnaires() {
using pb = ProgressBar.show('loading questionnaires')
return await api_get_all<Questionnaire>(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/`);
}
@@ -47,21 +62,30 @@ export async function createQuestionnaire(data) {
}
export async function getItems() {
using pb = ProgressBar.show('loading product list')
return await api_get_all<Item>(`organizers/${organizer_slug}/events/${event_slug}/items/`);
}
export async function getCategories() {
return await api_get_all<Item>(`organizers/${organizer_slug}/events/${event_slug}/categories/`);
using pb = ProgressBar.show('loading category list')
return await api_get_all<Category>(`organizers/${organizer_slug}/events/${event_slug}/categories/`);
}
function get_json_script_value(id) {
return JSON.parse(document.getElementById(id).innerText);
export async function getSalesChannels() {
return await api_get_all<Category>(`organizers/${organizer_slug}/saleschannels/`);
}
export function getEventLocales() {
return get_json_script_value('event_locales');
return fromJsonScript('event_locales');
}
export function getDatafieldViewUrl(datafield_id) {
return fromJsonScript('datafield_view_url').replace('/0/', `/${datafield_id}/`);
}
export function getDatafieldEditUrl(datafield_id) {
return get_json_script_value('datafield_edit_url').replace('/0/', `/${datafield_id}/`);
return fromJsonScript('datafield_edit_url').replace('/0/', `/${datafield_id}/`) + '?notify_parent=true&';
}
export function getDatafieldCreateUrl(container_type) {
return fromJsonScript('datafield_create_url') + '?notify_parent=true&container_type=' + container_type;
}
@@ -0,0 +1,29 @@
// The actual gettext implementation is loaded asynchronously with the translation
export function gettext (msgid) {
if (typeof django !== 'undefined' && typeof django.gettext !== 'undefined') {
return django.gettext(msgid)
}
return msgid
}
export function ngettext (singular, plural, count) {
if (typeof django !== 'undefined' && typeof django.ngettext !== 'undefined') {
return django.ngettext(singular, plural, count)
}
return plural
}
export function pgettext (context, msgid) {
if (typeof django !== 'undefined' && typeof django.pgettext !== 'undefined') {
return django.pgettext(context, msgid)
}
return msgid
}
export function interpolate (fmt, object, named) {
if (named) {
return fmt.replace(/%\(\w+\)s/g, function (match) { return String(object[match.slice(2, -2)]) })
} else {
return fmt.replace(/%s/g, function (match) { return String(object.shift()) })
}
}
@@ -1,97 +1,70 @@
/* global gettext, pgettext */
export function i18n_any(data) {
if (!data) return null;
const preferred = document.body.getAttribute("data-pretixlocale");
if (data[preferred]) return data[preferred];
return Object.values(data)[0];
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) {
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 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 numericComp (fn) {
return function (a, b) {
return fn(a) - fn(b)
}
}
export function pick(key) {
return function(obj) {
return obj[key];
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);
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 result
}
}
return 0;
});
return 0
})
}
export function *groupBy(array, key) {
let lastKey, lastArray;
for(const x of array){
const k = key(x);
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];
yield [lastKey, lastArray]
}
lastKey = k; lastArray = [x];
lastKey = k; lastArray = [x]
} else {
lastArray.push(x);
lastArray.push(x)
}
}
if (lastArray) {
yield [lastKey, 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 function fromJsonScript (id) {
return JSON.parse(document.getElementById(id).textContent)
}
export const _ = x => x;
export const QUESTION_TYPE = Object.fromEntries(fromJsonScript('question_type_choices').map(([name, value, label]) => [name, value]))
export const QUESTION_TYPE_LABEL = Object.fromEntries(fromJsonScript('question_type_choices').map(([name, value, label]) => [name, i18n_any(label)]))
export const 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(Object.fromEntries(fromJsonScript('system_question_choices').map(([name, value, label]) => [
value, { id: value, question: label, type: value === 'country' ? QUESTION_TYPE.COUNTRYCODE : QUESTION_TYPE.STRING }
])))
export const 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 },
});
export const QUESTIONNAIRE_TYPE = Object.fromEntries(fromJsonScript('questionnaire_type_choices').map(([name, value, label]) => [name, value]))
export const QUESTIONNAIRE_TYPE_LABEL = Object.fromEntries(fromJsonScript('questionnaire_type_choices').map(([name, value, label]) => [name, i18n_any(label)]))
@@ -4,7 +4,7 @@ export type ApiListResponse<T> = {
next: string | null,
previous: string | null,
results: T[],
};
}
// from webcheckin/i18n.ts
export type I18nString = string | Record<string, string> | null | undefined;
@@ -35,7 +35,7 @@ export type Datafield = {
valid_string_length_max: null | number,
valid_file_portrait: boolean,
internal_name: string,
};
}
export type Questionnaire = {
id: number,
@@ -46,7 +46,7 @@ export type Questionnaire = {
all_sales_channels: boolean,
limit_sales_channels: string[],
children: QuestionnaireChild[],
};
}
export type QuestionnaireChild = {
question: string | number,
@@ -55,11 +55,18 @@ export type QuestionnaireChild = {
help_text: I18nString,
dependency_question: number | null,
dependency_values: null | string[],
};
}
export type Item = {
id: number,
category: number,
name: I18nString,
internal_name: string | null,
};
}
export type Category = {
id: number,
position: number,
name: I18nString,
internal_name: string | null,
}
@@ -898,6 +898,10 @@ tbody th {
height: auto;
}
html.in-iframe body { background: white }
.in-iframe #wrapper > nav.navbar, .in-iframe #page-wrapper > div > footer { display: none; }
.in-iframe #page-wrapper { padding: 0 15px; min-height: 568px; border-left: 0; margin: 0; }
@import "../../pretixbase/scss/_rtl.scss";
@import "../../bootstrap/scss/_rtl.scss";
@import "_rtl.scss";
@@ -4,7 +4,7 @@ function questions_toggle_dependent (ev) {
return true
}
let dependency_name = $el.attr('name').split('_')[0] + '_' + $el.attr('data-question-dependency')
let dependency_name = $el.attr('name').split('-')[0] + '-' + $el.attr('data-question-dependency')
let dependency_values = JSON.parse($el.attr('data-question-dependency-values'))
let $dependency_el
+1 -6
View File
@@ -134,14 +134,13 @@ class CategoriesTest(ItemFormTest):
assert not ItemCategory.objects.filter(id=c.id).exists()
class QuestionsTest(ItemFormTest):
class DatafieldsTest(ItemFormTest):
def test_create(self):
doc = self.get_doc('/control/event/%s/%s/questions/add' % (self.orga1.slug, self.event1.slug))
form_data = extract_form_fields(doc.select('.container-fluid form')[0])
form_data['question_0'] = 'What is your shoe size?'
form_data['type'] = 'N'
form_data['items'] = self.item1.id
doc = self.post_doc('/control/event/%s/%s/questions/add' % (self.orga1.slug, self.event1.slug), form_data)
assert doc.select(".alert-success")
self.assertIn("shoe size", doc.select("#page-wrapper table")[0].text)
@@ -157,7 +156,6 @@ class QuestionsTest(ItemFormTest):
form_data['form-MIN_NUM_FORMS'] = '0'
form_data['form-MAX_NUM_FORMS'] = '1'
form_data['form-0-id'] = o1.pk
form_data['items'] = self.item1.id
form_data['form-0-answer_0'] = 'England'
self.post_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, c.id),
form_data)
@@ -176,7 +174,6 @@ class QuestionsTest(ItemFormTest):
form_data['form-INITIAL_FORMS'] = '1'
form_data['form-MIN_NUM_FORMS'] = '0'
form_data['form-MAX_NUM_FORMS'] = '1'
form_data['items'] = self.item1.id
form_data['form-0-id'] = o1.pk
form_data['form-0-answer_0'] = 'England'
form_data['form-0-DELETE'] = 'yes'
@@ -196,7 +193,6 @@ class QuestionsTest(ItemFormTest):
form_data['form-INITIAL_FORMS'] = '0'
form_data['form-MIN_NUM_FORMS'] = '0'
form_data['form-MAX_NUM_FORMS'] = '1'
form_data['items'] = self.item1.id
form_data['form-0-id'] = ''
form_data['form-0-answer_0'] = 'Germany'
self.post_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, c.id),
@@ -211,7 +207,6 @@ class QuestionsTest(ItemFormTest):
c = Question.objects.create(event=self.event1, question="What is your shoe size?", type="N", required=True)
doc = self.get_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, c.id))
form_data = extract_form_fields(doc.select('.container-fluid form')[0])
form_data['items'] = self.item1.id
form_data['question_0'] = 'How old are you?'
doc = self.post_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, c.id),
form_data)