mirror of
https://github.com/pretix/pretix.git
synced 2026-09-20 17:24:41 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6f488d73c | ||
|
|
5fd6759474 | ||
|
|
50c2c60a9b | ||
|
|
dc689c2263 | ||
|
|
6e66c179a7 | ||
|
|
9f6ce762e7 | ||
|
|
5faf487d13 | ||
|
|
5f341b7f6e | ||
|
|
f6c678ed6c | ||
|
|
3ef35df421 | ||
|
|
d49093b1d3 | ||
|
|
97e241dc1d | ||
|
|
bc0c23aa11 | ||
|
|
eb7936634b | ||
|
|
e3a3768a6c | ||
|
|
3787cbf7ce | ||
|
|
ab1427e3ff | ||
|
|
20f2950c2b | ||
|
|
bbb5fc008b | ||
|
|
e74dcbb01a | ||
|
|
317df5a031 | ||
|
|
0785ca049f | ||
|
|
583d464d6f | ||
|
|
e6f1ee1126 | ||
|
|
d8f444d51d | ||
|
|
9ace35069c | ||
|
|
b0a619392c |
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ from pretix.api.serializers.event import SubEventSerializer
|
||||
from pretix.api.serializers.forms import form_field_to_serializer_field
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.item import (
|
||||
InlineItemVariationSerializer, ItemSerializer, QuestionSerializer,
|
||||
InlineItemVariationSerializer, ItemSerializer, DatafieldSerializer,
|
||||
)
|
||||
from pretix.api.signals import order_api_details, orderposition_api_details
|
||||
from pretix.base.decimal import round_decimal
|
||||
@@ -715,7 +715,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
||||
self.fields['variation'] = InlineItemVariationSerializer(read_only=True, context=self.context)
|
||||
|
||||
if 'answers.question' in self.context['expand']:
|
||||
self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True)
|
||||
self.fields['answers'].child.fields['question'] = DatafieldSerializer(read_only=True) # TODO(questionnaires)
|
||||
|
||||
if 'addons' in self.context['expand']:
|
||||
# Experimental feature, undocumented on purpose for now in case we need to remove it again
|
||||
|
||||
@@ -79,7 +79,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)
|
||||
|
||||
@@ -54,7 +54,7 @@ from pretix.api.serializers.checkin import (
|
||||
CheckinListSerializer, CheckinRPCAnnulInputSerializer,
|
||||
CheckinRPCRedeemInputSerializer, MiniCheckinListSerializer,
|
||||
)
|
||||
from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.api.serializers.item import DatafieldSerializer
|
||||
from pretix.api.serializers.order import (
|
||||
CheckinListOrderPositionSerializer, CheckinSerializer,
|
||||
FailedCheckinSerializer,
|
||||
@@ -866,7 +866,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
'checkin_texts': op.checkin_texts,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
|
||||
'questions': [
|
||||
QuestionSerializer(q).data for q in e.questions
|
||||
DatafieldSerializer(q).data for q in e.questions # TODO(questionnaires)
|
||||
],
|
||||
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
||||
}, status=400)
|
||||
|
||||
@@ -48,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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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 = [
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -639,14 +639,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
|
||||
'event_calendar_future_only',
|
||||
'frontpage_text',
|
||||
'event_info_text',
|
||||
'attendee_names_asked',
|
||||
'attendee_names_required',
|
||||
'attendee_emails_asked',
|
||||
'attendee_emails_required',
|
||||
'attendee_company_asked',
|
||||
'attendee_company_required',
|
||||
'attendee_addresses_asked',
|
||||
'attendee_addresses_required',
|
||||
'attendee_data_explanation_text',
|
||||
'order_phone_asked',
|
||||
'order_phone_required',
|
||||
|
||||
@@ -153,53 +153,10 @@ class QuestionForm(I18nModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance.container_type == Question.ContainerType.ORDERPOSITION:
|
||||
self.fields['items'].queryset = self.instance.event.items.all()
|
||||
self.fields['items'].required = True
|
||||
else:
|
||||
del self.fields['items']
|
||||
del self.fields['ask_during_checkin']
|
||||
if self.instance.container_type != Question.ContainerType.ORDERPOSITION:
|
||||
del self.fields['show_during_checkin']
|
||||
del self.fields['print_on_invoice']
|
||||
self.fields['dependency_question'].widget.attrs['data-container-type'] = self.instance.container_type
|
||||
self.fields['dependency_question'].queryset = self.instance.event.questions.filter(
|
||||
type__in=(Question.TYPE_BOOLEAN, Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE),
|
||||
ask_during_checkin=False,
|
||||
container_type=self.instance.container_type,
|
||||
)
|
||||
if self.instance.pk:
|
||||
self.fields['dependency_question'].queryset = self.fields['dependency_question'].queryset.exclude(
|
||||
pk=self.instance.pk
|
||||
)
|
||||
self.fields['identifier'].required = False
|
||||
self.fields['dependency_values'].required = False
|
||||
self.fields['help_text'].widget.attrs['rows'] = 3
|
||||
|
||||
def clean_dependency_values(self):
|
||||
val = self.data.getlist('dependency_values')
|
||||
return val
|
||||
|
||||
def clean_dependency_question(self):
|
||||
dep = val = self.cleaned_data.get('dependency_question')
|
||||
if dep:
|
||||
if dep.ask_during_checkin:
|
||||
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
|
||||
|
||||
seen_ids = {self.instance.pk} if self.instance else set()
|
||||
while dep:
|
||||
if dep.pk in seen_ids:
|
||||
raise ValidationError(_('Circular dependency between questions detected.'))
|
||||
seen_ids.add(dep.pk)
|
||||
dep = dep.dependency_question
|
||||
return val
|
||||
|
||||
def clean_ask_during_checkin(self):
|
||||
val = self.cleaned_data.get('ask_during_checkin')
|
||||
|
||||
if val and self.cleaned_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
|
||||
raise ValidationError(_('This type of question cannot be asked during check-in.'))
|
||||
|
||||
return val
|
||||
|
||||
def clean_show_during_checkin(self):
|
||||
val = self.cleaned_data.get('show_during_checkin')
|
||||
@@ -233,16 +190,10 @@ class QuestionForm(I18nModelForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'question',
|
||||
'help_text',
|
||||
'type',
|
||||
'required',
|
||||
'ask_during_checkin',
|
||||
'show_during_checkin',
|
||||
'hidden',
|
||||
'identifier',
|
||||
'items',
|
||||
'dependency_question',
|
||||
'dependency_values',
|
||||
'print_on_invoice',
|
||||
'valid_number_min',
|
||||
'valid_number_max',
|
||||
@@ -259,17 +210,11 @@ class QuestionForm(I18nModelForm):
|
||||
'valid_datetime_max': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'valid_date_min': DatePickerWidget(),
|
||||
'valid_date_max': DatePickerWidget(),
|
||||
'items': forms.CheckboxSelectMultiple(
|
||||
attrs={'class': 'scrolling-multiple-choice'}
|
||||
),
|
||||
'dependency_values': forms.SelectMultiple,
|
||||
'help_text': I18nMarkdownTextarea,
|
||||
}
|
||||
field_classes = {
|
||||
'valid_datetime_min': SplitDateTimeField,
|
||||
'valid_datetime_max': SplitDateTimeField,
|
||||
'items': ItemMultipleChoiceField,
|
||||
'dependency_question': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
@@ -1197,6 +1142,10 @@ class ItemAddOnForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['addon_category'].queryset = self.event.categories.all()
|
||||
self.fields['addon_category'].help_text = format_html('<a href="javascript:" data-django-dialog="{}?notify_parent=true">{}</a>', reverse('control:event.items.categories.add', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}), _("Create new category"))
|
||||
self.fields['addon_category'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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(),
|
||||
|
||||
@@ -21,10 +21,15 @@
|
||||
#
|
||||
import collections.abc
|
||||
import warnings
|
||||
from errno import EMSGSIZE
|
||||
|
||||
from django.contrib import messages
|
||||
from django.core.paginator import (
|
||||
EmptyPage, PageNotAnInteger, UnorderedObjectListWarning,
|
||||
)
|
||||
from django.http.response import HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import edit
|
||||
|
||||
@@ -38,7 +43,22 @@ class EventBasedFormMixin:
|
||||
return kwargs
|
||||
|
||||
|
||||
class CreateView(EventBasedFormMixin, edit.CreateView):
|
||||
class TellParentFormMixin:
|
||||
|
||||
def form_valid(self, form):
|
||||
result = super().form_valid(form)
|
||||
if self.request.GET.get('notify_parent') and isinstance(result, HttpResponseRedirect):
|
||||
message_store = messages.get_messages(self.request)
|
||||
msgs = [{'level': msg.level_tag, 'message': msg.message} for msg in message_store]
|
||||
#message_store._queued_messages = []
|
||||
return render(self.request, 'pretixcontrol/notify_parent.html', {
|
||||
'notify_info': {'object': self.object.pk, 'redirect_url': result.url, 'messages': msgs},
|
||||
})
|
||||
else:
|
||||
return result
|
||||
|
||||
|
||||
class CreateView(EventBasedFormMixin, TellParentFormMixin, edit.CreateView):
|
||||
"""
|
||||
Like Django's default CreateView, but passes the optional event
|
||||
argument to the form. This is necessary for I18nModelForms to work
|
||||
@@ -47,7 +67,7 @@ class CreateView(EventBasedFormMixin, edit.CreateView):
|
||||
pass
|
||||
|
||||
|
||||
class UpdateView(EventBasedFormMixin, edit.UpdateView):
|
||||
class UpdateView(EventBasedFormMixin, TellParentFormMixin, edit.UpdateView):
|
||||
"""
|
||||
Like Django's default UpdateView, but passes the optional event
|
||||
argument to the form. This is necessary for I18nModelForms to work
|
||||
|
||||
@@ -1670,6 +1670,7 @@ class QuickSetupView(EventPermissionRequiredMixin, FormView):
|
||||
user=self.request.user)
|
||||
|
||||
subevent = self.request.event.subevents.first()
|
||||
questionnaire = self.request.event.questionnaires.first()
|
||||
for i, f in enumerate(self.formset):
|
||||
if f in self.formset.deleted_forms or not f.has_changed():
|
||||
continue
|
||||
@@ -1685,6 +1686,8 @@ class QuickSetupView(EventPermissionRequiredMixin, FormView):
|
||||
position=i,
|
||||
all_sales_channels=True,
|
||||
)
|
||||
if questionnaire:
|
||||
item.questionnaires.add(questionnaire)
|
||||
item.log_action('pretix.event.item.added', user=self.request.user, data=dict(f.cleaned_data))
|
||||
if f.cleaned_data['quota'] or not form.cleaned_data['total_quota']:
|
||||
quota = self.request.event.quotas.create(
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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"> </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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user