forked from CGM_Public/pretix_original
add Questionnaire and QuestionnaireChild models and their API; migrate existing data
This commit is contained in:
@@ -51,6 +51,7 @@ from pretix.base.models import (
|
|||||||
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
|
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
|
||||||
SalesChannel,
|
SalesChannel,
|
||||||
)
|
)
|
||||||
|
from pretix.base.models.items import Questionnaire, QuestionnaireChild
|
||||||
|
|
||||||
|
|
||||||
class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||||
@@ -624,6 +625,160 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
|||||||
return question
|
return question
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionRefField(serializers.PrimaryKeyRelatedField):
|
||||||
|
def to_representation(self, qc):
|
||||||
|
if not qc:
|
||||||
|
return None
|
||||||
|
elif qc.system_question:
|
||||||
|
return qc.system_question
|
||||||
|
elif qc.user_question_id:
|
||||||
|
return qc.user_question_id
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
if type(data) == int:
|
||||||
|
return {'user_question': super().to_internal_value(data), 'system_question': None}
|
||||||
|
elif type(data) == str or data is None:
|
||||||
|
return {'user_question': None, 'system_question': data}
|
||||||
|
else:
|
||||||
|
self.fail('incorrect_type', data_type=type(data).__name__)
|
||||||
|
|
||||||
|
def use_pk_only_optimization(self):
|
||||||
|
return self.source == '*'
|
||||||
|
|
||||||
|
|
||||||
|
class InlineQuestionnaireChildSerializer(I18nAwareModelSerializer):
|
||||||
|
question = QuestionRefField(source='*', queryset=Question.objects.none())
|
||||||
|
dependency_question = QuestionRefField(allow_null=True, required=False, queryset=Question.objects.none())
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = QuestionnaireChild
|
||||||
|
fields = ('question', 'required', 'label', 'help_text', 'dependency_question', 'dependency_values')
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["question"].queryset = self.context["event"].questions.all()
|
||||||
|
self.fields["dependency_question"].queryset = self.context["event"].questions.all()
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
data = super().validate(data)
|
||||||
|
event = self.context['event']
|
||||||
|
|
||||||
|
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||||
|
full_data.update(data)
|
||||||
|
|
||||||
|
if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
|
||||||
|
raise ValidationError('Dependencies are not supported during check-in.')
|
||||||
|
|
||||||
|
dep = full_data.get('dependency_question')
|
||||||
|
if dep:
|
||||||
|
if dep.ask_during_checkin:
|
||||||
|
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
|
||||||
|
|
||||||
|
seen_ids = {self.instance.pk} if self.instance else set()
|
||||||
|
while dep:
|
||||||
|
if dep.pk in seen_ids:
|
||||||
|
raise ValidationError(_('Circular dependency between questions detected.'))
|
||||||
|
seen_ids.add(dep.pk)
|
||||||
|
dep = dep.dependency_question
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def validate_dependency_question(self, value):
|
||||||
|
if value:
|
||||||
|
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
|
||||||
|
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
|
||||||
|
if value == self.instance:
|
||||||
|
raise ValidationError('A question cannot depend on itself.')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionnaireSerializer(I18nAwareModelSerializer):
|
||||||
|
limit_sales_channels = serializers.SlugRelatedField(
|
||||||
|
slug_field="identifier",
|
||||||
|
queryset=SalesChannel.objects.none(),
|
||||||
|
required=False,
|
||||||
|
allow_empty=True,
|
||||||
|
many=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Questionnaire
|
||||||
|
fields = ('id', 'type', 'internal_name', 'items', 'position', 'all_sales_channels', 'limit_sales_channels', 'children')
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.fields['children'] = InlineQuestionnaireChildSerializer(many=True, required=True, context=kwargs['context'], partial=False)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
data = super().validate(data)
|
||||||
|
event = self.context['event']
|
||||||
|
|
||||||
|
#full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||||
|
#full_data.update(data)
|
||||||
|
|
||||||
|
#if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
|
||||||
|
# raise ValidationError('Dependencies are not supported during check-in.')
|
||||||
|
|
||||||
|
#if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
|
||||||
|
# raise ValidationError(_('This type of question cannot be asked during check-in.'))
|
||||||
|
|
||||||
|
#if full_data.get('show_during_checkin') and full_data.get('type') in Question.SHOW_DURING_CHECKIN_UNSUPPORTED:
|
||||||
|
# raise ValidationError(_('This type of question cannot be shown during check-in.'))
|
||||||
|
|
||||||
|
#Question.clean_items(event, full_data.get('items') or [])
|
||||||
|
return data
|
||||||
|
|
||||||
|
def validate_children(self, value):
|
||||||
|
prev_questions = {}
|
||||||
|
for child in value:
|
||||||
|
if child.get('dependency_question'):
|
||||||
|
if (child['dependency_question']['user_question'] or child['dependency_question']['system_question']) not in prev_questions:
|
||||||
|
raise ValidationError('A question can only depend on a previous question from the same questionnaire.')
|
||||||
|
|
||||||
|
if child['user_question']:
|
||||||
|
prev_questions[child['user_question']] = child
|
||||||
|
if child['system_question']:
|
||||||
|
prev_questions[child['system_question']] = child
|
||||||
|
return value
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def create(self, validated_data):
|
||||||
|
children_data = validated_data.pop('children') if 'children' in validated_data else []
|
||||||
|
questionnaire = super().create(validated_data)
|
||||||
|
self.set_children(questionnaire, children_data)
|
||||||
|
return questionnaire
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
children_data = validated_data.pop('children', None)
|
||||||
|
questionnaire = super().update(instance, validated_data)
|
||||||
|
if children_data is not None:
|
||||||
|
self.set_children(questionnaire, children_data)
|
||||||
|
return questionnaire
|
||||||
|
|
||||||
|
def set_children(self, questionnaire, new_data):
|
||||||
|
result = []
|
||||||
|
child_serializer = self.fields['children'].child
|
||||||
|
existing = questionnaire.children.all()
|
||||||
|
for i, d in enumerate(new_data):
|
||||||
|
d['questionnaire'] = questionnaire
|
||||||
|
d['position'] = i + 1
|
||||||
|
d.setdefault('required', False)
|
||||||
|
d.setdefault('help_text', None)
|
||||||
|
d.setdefault('dependency_question', None)
|
||||||
|
d.setdefault('dependency_values', None)
|
||||||
|
updatable = min(len(existing), len(new_data))
|
||||||
|
for i in range(0, updatable):
|
||||||
|
result.append(child_serializer.update(existing[i], new_data[i]))
|
||||||
|
for i in range(updatable, len(new_data)):
|
||||||
|
result.append(child_serializer.create(new_data[i]))
|
||||||
|
for i in range(updatable, len(existing)):
|
||||||
|
existing[i].delete()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class QuotaSerializer(I18nAwareModelSerializer):
|
class QuotaSerializer(I18nAwareModelSerializer):
|
||||||
available = serializers.BooleanField(read_only=True)
|
available = serializers.BooleanField(read_only=True)
|
||||||
available_number = serializers.IntegerField(read_only=True)
|
available_number = serializers.IntegerField(read_only=True)
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ event_router.register(r'subevents', event.SubEventViewSet)
|
|||||||
event_router.register(r'clone', event.CloneEventViewSet)
|
event_router.register(r'clone', event.CloneEventViewSet)
|
||||||
event_router.register(r'items', item.ItemViewSet)
|
event_router.register(r'items', item.ItemViewSet)
|
||||||
event_router.register(r'categories', item.ItemCategoryViewSet)
|
event_router.register(r'categories', item.ItemCategoryViewSet)
|
||||||
event_router.register(r'questions', item.QuestionViewSet)
|
event_router.register(r'datafields', item.QuestionViewSet)
|
||||||
|
event_router.register(r'questionnaires', item.QuestionnaireViewSet)
|
||||||
event_router.register(r'discounts', discount.DiscountViewSet)
|
event_router.register(r'discounts', discount.DiscountViewSet)
|
||||||
event_router.register(r'quotas', item.QuotaViewSet)
|
event_router.register(r'quotas', item.QuotaViewSet)
|
||||||
event_router.register(r'vouchers', voucher.VoucherViewSet)
|
event_router.register(r'vouchers', voucher.VoucherViewSet)
|
||||||
|
|||||||
@@ -47,13 +47,14 @@ from pretix.api.pagination import TotalOrderingFilter
|
|||||||
from pretix.api.serializers.item import (
|
from pretix.api.serializers.item import (
|
||||||
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
|
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
|
||||||
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
|
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
|
||||||
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
|
QuestionOptionSerializer, QuestionSerializer, QuestionnaireSerializer, QuotaSerializer,
|
||||||
)
|
)
|
||||||
from pretix.api.views import ConditionalListView
|
from pretix.api.views import ConditionalListView
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
|
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
|
||||||
ItemVariation, Question, QuestionOption, Quota,
|
ItemVariation, Question, QuestionOption, Quota,
|
||||||
)
|
)
|
||||||
|
from pretix.base.models.items import Questionnaire
|
||||||
from pretix.base.services.quotas import QuotaAvailability
|
from pretix.base.services.quotas import QuotaAvailability
|
||||||
from pretix.helpers.dicts import merge_dicts
|
from pretix.helpers.dicts import merge_dicts
|
||||||
from pretix.helpers.i18n import i18ncomp
|
from pretix.helpers.i18n import i18ncomp
|
||||||
@@ -538,6 +539,51 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
|||||||
super().perform_destroy(instance)
|
super().perform_destroy(instance)
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionnaireViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||||
|
serializer_class = QuestionnaireSerializer
|
||||||
|
queryset = Questionnaire.objects.none()
|
||||||
|
#filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||||
|
#filterset_class = QuestionFilter
|
||||||
|
ordering_fields = ('id', 'position')
|
||||||
|
ordering = ('position', 'id')
|
||||||
|
permission = None
|
||||||
|
write_permission = 'event.items:write'
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.request.event.questionnaires.prefetch_related('children').all()
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(event=self.request.event)
|
||||||
|
serializer.instance.log_action(
|
||||||
|
'pretix.event.questionnaire.added',
|
||||||
|
user=self.request.user,
|
||||||
|
auth=self.request.auth,
|
||||||
|
data=self.request.data
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
ctx = super().get_serializer_context()
|
||||||
|
ctx['event'] = self.request.event
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
serializer.save(event=self.request.event)
|
||||||
|
serializer.instance.log_action(
|
||||||
|
'pretix.event.questionnaire.changed',
|
||||||
|
user=self.request.user,
|
||||||
|
auth=self.request.auth,
|
||||||
|
data=self.request.data
|
||||||
|
)
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
instance.log_action(
|
||||||
|
'pretix.event.questionnaire.deleted',
|
||||||
|
user=self.request.user,
|
||||||
|
auth=self.request.auth,
|
||||||
|
)
|
||||||
|
super().perform_destroy(instance)
|
||||||
|
|
||||||
|
|
||||||
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
|
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-03-19 14:24
|
||||||
|
import json
|
||||||
|
from collections import namedtuple
|
||||||
|
from itertools import chain, groupby
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import i18nfield.fields
|
||||||
|
from i18nfield.strings import LazyI18nString
|
||||||
|
|
||||||
|
import pretix.base.models.base
|
||||||
|
import pretix.base.models.fields
|
||||||
|
|
||||||
|
|
||||||
|
FakeQuestion = namedtuple(
|
||||||
|
'FakeQuestion', 'id question position required'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_fake_questions(settings):
|
||||||
|
def b(s):
|
||||||
|
return s == 'True'
|
||||||
|
fq = []
|
||||||
|
sqo = json.loads(settings.get('system_question_order', '{}'))
|
||||||
|
_ = LazyI18nString.from_gettext
|
||||||
|
|
||||||
|
if b(settings.get('attendee_names_asked', 'True')):
|
||||||
|
fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), b(settings.get('attendee_names_required'))))
|
||||||
|
|
||||||
|
if b(settings.get('attendee_emails_asked')):
|
||||||
|
fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), b(settings.get('attendee_emails_required'))))
|
||||||
|
|
||||||
|
if b(settings.get('attendee_company_asked')):
|
||||||
|
fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), b(settings.get('attendee_company_required'))))
|
||||||
|
|
||||||
|
if b(settings.get('attendee_addresses_asked')):
|
||||||
|
fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), b(settings.get('attendee_addresses_required'))))
|
||||||
|
fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), b(settings.get('attendee_addresses_required'))))
|
||||||
|
fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), b(settings.get('attendee_addresses_required'))))
|
||||||
|
fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), b(settings.get('attendee_addresses_required'))))
|
||||||
|
return fq
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_questions_forward(apps, schema_editor):
|
||||||
|
Event = apps.get_model("pretixbase", "Event")
|
||||||
|
Item = apps.get_model("pretixbase", "Item")
|
||||||
|
Question = apps.get_model("pretixbase", "Question")
|
||||||
|
Questionnaire = apps.get_model("pretixbase", "Questionnaire")
|
||||||
|
QuestionnaireChild = apps.get_model("pretixbase", "QuestionnaireChild")
|
||||||
|
EventSettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
|
||||||
|
|
||||||
|
for event in Event.objects.iterator():
|
||||||
|
# get relevant settings
|
||||||
|
settings = {
|
||||||
|
setting.key: setting.value for setting in EventSettingsStore.objects.filter(object_id=event.id, key__in=(
|
||||||
|
'system_question_order', 'attendee_names_asked', 'attendee_names_required', 'attendee_emails_asked', 'attendee_emails_required',
|
||||||
|
'attendee_company_asked', 'attendee_company_required', 'attendee_addresses_asked', 'attendee_addresses_required',
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
# get all questions (user-defined and system provided), along with the products for which they're asked
|
||||||
|
questions = event.questions.all()
|
||||||
|
children = sorted(chain((
|
||||||
|
(item, q.position, q.id, q)
|
||||||
|
for q in questions
|
||||||
|
for item in q.items.values_list('id', 'internal_name', 'name')
|
||||||
|
), (
|
||||||
|
(item, q.position, None, q)
|
||||||
|
for q in get_fake_questions(settings)
|
||||||
|
for item in event.items.filter(personalized=True).values_list('id', 'internal_name', 'name')
|
||||||
|
)), key=lambda t: (t[0], t[1], t[2]))
|
||||||
|
|
||||||
|
# group by item, creating a unique questionnaire per item
|
||||||
|
item_questionnaires = (([t[3] for t in children], item_id) for item_id, children in groupby(children, key=lambda t: t[0]))
|
||||||
|
|
||||||
|
# group again, merging all questionnaires with identical children
|
||||||
|
merged_questionnaires = groupby(sorted(item_questionnaires, key=lambda t: [q.id for q in t[0]]), key=lambda t: t[0])
|
||||||
|
for children, iterator in merged_questionnaires:
|
||||||
|
items = [item for _c, item in iterator]
|
||||||
|
|
||||||
|
# create questionnaires and children
|
||||||
|
questionnaire = Questionnaire.objects.create(
|
||||||
|
event=event, type='PS', position=0, all_sales_channels=True,
|
||||||
|
internal_name=', '.join(str(iname or name) for (id, iname, name) in items)
|
||||||
|
)
|
||||||
|
questionnaire.items.set([id for (id, iname, name) in items])
|
||||||
|
deps = {}
|
||||||
|
for position, child in enumerate(children):
|
||||||
|
if isinstance(child, FakeQuestion):
|
||||||
|
QuestionnaireChild.objects.create(
|
||||||
|
questionnaire=questionnaire,
|
||||||
|
position=position + 1,
|
||||||
|
system_question=child.id,
|
||||||
|
required=child.required,
|
||||||
|
label=child.question,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
deps[child.id] = QuestionnaireChild.objects.create(
|
||||||
|
questionnaire=questionnaire,
|
||||||
|
position=position + 1,
|
||||||
|
user_question=child,
|
||||||
|
required=child.required,
|
||||||
|
label=child.question,
|
||||||
|
help_text=child.help_text,
|
||||||
|
dependency_question=deps[child.dependency_question.id] if child.dependency_question else None,
|
||||||
|
dependency_values=child.dependency_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_questions_backward(apps, schema_editor):
|
||||||
|
pass # as long as we don't delete the old columns, this is a no op. after that, it gets complicated...
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0298_pluggable_permissions'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Questionnaire',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||||
|
('internal_name', models.CharField(max_length=255)),
|
||||||
|
('type', models.CharField(max_length=5)),
|
||||||
|
('position', models.PositiveIntegerField(default=0)),
|
||||||
|
('all_sales_channels', models.BooleanField(default=True)),
|
||||||
|
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questionnaires', to='pretixbase.event')),
|
||||||
|
('items', models.ManyToManyField(related_name='questionnaires', to='pretixbase.item')),
|
||||||
|
('limit_sales_channels', models.ManyToManyField(to='pretixbase.saleschannel')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='QuestionnaireChild',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||||
|
('position', models.PositiveIntegerField(default=0)),
|
||||||
|
('system_question', models.CharField(max_length=25, null=True)),
|
||||||
|
('required', models.BooleanField(default=False)),
|
||||||
|
('label', i18nfield.fields.I18nTextField()),
|
||||||
|
('help_text', i18nfield.fields.I18nTextField(null=True)),
|
||||||
|
('dependency_values', pretix.base.models.fields.MultiStringField(default=[])),
|
||||||
|
('dependency_question', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dependent_questions', to='pretixbase.questionnairechild')),
|
||||||
|
('questionnaire', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='children', to='pretixbase.questionnaire')),
|
||||||
|
('user_question', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='references', to='pretixbase.question')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
migrate_questions_forward,
|
||||||
|
migrate_questions_backward,
|
||||||
|
),
|
||||||
|
# TODO remove old columns from Question model
|
||||||
|
]
|
||||||
@@ -1595,10 +1595,12 @@ class ItemBundle(models.Model):
|
|||||||
|
|
||||||
class Question(LoggedModel):
|
class Question(LoggedModel):
|
||||||
"""
|
"""
|
||||||
A question is an input field that can be used to extend a ticket by custom information,
|
A question is a data field that can be used to extend an order or a ticket by custom
|
||||||
e.g. "Attendee age". The answers are found next to the position. The answers may be found
|
information, e.g. "Attendee age". To be actually useful, questions need to be added to
|
||||||
in QuestionAnswers, attached to OrderPositions/CartPositions. A question can allow one of
|
one or multiple Questionnaires. The answers may be found in QuestionAnswers, attached
|
||||||
several input types, currently:
|
to Orders, OrderPositions or CartPositions.
|
||||||
|
|
||||||
|
A question can allow one of several input types, currently:
|
||||||
|
|
||||||
* a number (``TYPE_NUMBER``)
|
* a number (``TYPE_NUMBER``)
|
||||||
* a one-line string (``TYPE_STRING``)
|
* a one-line string (``TYPE_STRING``)
|
||||||
@@ -1667,7 +1669,7 @@ class Question(LoggedModel):
|
|||||||
related_name="questions",
|
related_name="questions",
|
||||||
on_delete=models.CASCADE
|
on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
question = I18nTextField(
|
question = I18nTextField( # to be renamed to 'internal_name'
|
||||||
verbose_name=_("Question")
|
verbose_name=_("Question")
|
||||||
)
|
)
|
||||||
identifier = models.CharField(
|
identifier = models.CharField(
|
||||||
@@ -1682,7 +1684,7 @@ class Question(LoggedModel):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
help_text = I18nTextField(
|
help_text = I18nTextField( # to be removed
|
||||||
verbose_name=_("Help text"),
|
verbose_name=_("Help text"),
|
||||||
help_text=_("If the question needs to be explained or clarified, do it here!"),
|
help_text=_("If the question needs to be explained or clarified, do it here!"),
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
@@ -1692,22 +1694,22 @@ class Question(LoggedModel):
|
|||||||
choices=TYPE_CHOICES,
|
choices=TYPE_CHOICES,
|
||||||
verbose_name=_("Question type")
|
verbose_name=_("Question type")
|
||||||
)
|
)
|
||||||
required = models.BooleanField(
|
required = models.BooleanField( # to be removed, -> QuestionnaireChild
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name=_("Required question")
|
verbose_name=_("Required question")
|
||||||
)
|
)
|
||||||
items = models.ManyToManyField(
|
items = models.ManyToManyField( # to be removed, -> Questionnaire
|
||||||
Item,
|
Item,
|
||||||
related_name='questions',
|
related_name='questions',
|
||||||
verbose_name=_("Products"),
|
verbose_name=_("Products"),
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text=_('This question will be asked to buyers of the selected products')
|
help_text=_('This question will be asked to buyers of the selected products')
|
||||||
)
|
)
|
||||||
position = models.PositiveIntegerField(
|
position = models.PositiveIntegerField( # to be removed, -> Questionnaire + QuestionnaireChild
|
||||||
default=0,
|
default=0,
|
||||||
verbose_name=_("Position")
|
verbose_name=_("Position")
|
||||||
)
|
)
|
||||||
ask_during_checkin = models.BooleanField(
|
ask_during_checkin = models.BooleanField( # to be removed
|
||||||
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
|
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.'),
|
help_text=_('Not supported by all check-in apps for all question types.'),
|
||||||
default=False
|
default=False
|
||||||
@@ -1717,7 +1719,7 @@ class Question(LoggedModel):
|
|||||||
help_text=_('Not supported by all check-in apps for all question types.'),
|
help_text=_('Not supported by all check-in apps for all question types.'),
|
||||||
default=False
|
default=False
|
||||||
)
|
)
|
||||||
hidden = models.BooleanField(
|
hidden = models.BooleanField( # to be removed
|
||||||
verbose_name=_('Hidden question'),
|
verbose_name=_('Hidden question'),
|
||||||
help_text=_('This question will only show up in the backend.'),
|
help_text=_('This question will only show up in the backend.'),
|
||||||
default=False
|
default=False
|
||||||
@@ -1726,10 +1728,10 @@ class Question(LoggedModel):
|
|||||||
verbose_name=_('Print answer on invoices'),
|
verbose_name=_('Print answer on invoices'),
|
||||||
default=False
|
default=False
|
||||||
)
|
)
|
||||||
dependency_question = models.ForeignKey(
|
dependency_question = models.ForeignKey( # to be removed, -> QuestionnaireChild
|
||||||
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
||||||
)
|
)
|
||||||
dependency_values = MultiStringField(default=[])
|
dependency_values = MultiStringField(default=[]) # to be removed, -> QuestionnaireChild
|
||||||
valid_number_min = models.DecimalField(decimal_places=6, max_digits=30, null=True, blank=True,
|
valid_number_min = models.DecimalField(decimal_places=6, max_digits=30, null=True, blank=True,
|
||||||
verbose_name=_('Minimum value'),
|
verbose_name=_('Minimum value'),
|
||||||
help_text=_('Currently not supported in our apps and during check-in'))
|
help_text=_('Currently not supported in our apps and during check-in'))
|
||||||
@@ -1763,9 +1765,9 @@ class Question(LoggedModel):
|
|||||||
objects = ScopedManager(organizer='event__organizer')
|
objects = ScopedManager(organizer='event__organizer')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Question")
|
verbose_name = _("Data field")
|
||||||
verbose_name_plural = _("Questions")
|
verbose_name_plural = _("Data fields")
|
||||||
ordering = ('position', 'id')
|
ordering = ('question', 'id')
|
||||||
unique_together = (('event', 'identifier'),)
|
unique_together = (('event', 'identifier'),)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -1990,6 +1992,103 @@ class QuestionOption(models.Model):
|
|||||||
ordering = ('position', 'id')
|
ordering = ('position', 'id')
|
||||||
|
|
||||||
|
|
||||||
|
class Questionnaire(LoggedModel):
|
||||||
|
TYPE_ORDER_SALE = "OS"
|
||||||
|
TYPE_ORDER_POSITION_SALE = "PS"
|
||||||
|
TYPE_ORDER_POSITION_ATTENDEE_ONLY = "PA"
|
||||||
|
TYPE_ORDER_POSITION_CHECKIN = "PC"
|
||||||
|
TYPE_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")),
|
||||||
|
)
|
||||||
|
event = models.ForeignKey(
|
||||||
|
Event,
|
||||||
|
related_name="questionnaires",
|
||||||
|
on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
internal_name = models.CharField(
|
||||||
|
verbose_name=_("Internal name"),
|
||||||
|
max_length=255,
|
||||||
|
)
|
||||||
|
type = models.CharField(
|
||||||
|
max_length=5,
|
||||||
|
choices=TYPE_CHOICES,
|
||||||
|
verbose_name=_("Questionnaire type")
|
||||||
|
)
|
||||||
|
items = models.ManyToManyField(
|
||||||
|
Item,
|
||||||
|
related_name='questionnaires',
|
||||||
|
verbose_name=_("Products"),
|
||||||
|
blank=True,
|
||||||
|
help_text=_('This questionnaire will be asked to buyers of the selected products')
|
||||||
|
)
|
||||||
|
position = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
verbose_name=_("Position")
|
||||||
|
)
|
||||||
|
all_sales_channels = models.BooleanField(
|
||||||
|
verbose_name=_("Sell on all sales channels the product is sold on"),
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
limit_sales_channels = models.ManyToManyField(
|
||||||
|
"SalesChannel",
|
||||||
|
verbose_name=_("Restrict to specific sales channels"),
|
||||||
|
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
|
||||||
|
'selected here but not on product level, the variation will not be available.'),
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionnaireChild(LoggedModel):
|
||||||
|
SYSTEM_QUESTION_CHOICES = (
|
||||||
|
('attendee_name_parts', _('Attendee name')),
|
||||||
|
('attendee_email', _('Attendee email')),
|
||||||
|
('company', _('Company')),
|
||||||
|
('street', _('Street')),
|
||||||
|
('zipcode', _('ZIP code')),
|
||||||
|
('city', _('City')),
|
||||||
|
('country', _('Country')),
|
||||||
|
)
|
||||||
|
questionnaire = models.ForeignKey(
|
||||||
|
Questionnaire,
|
||||||
|
related_name="children",
|
||||||
|
on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
position = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
verbose_name=_("Position")
|
||||||
|
)
|
||||||
|
user_question = models.ForeignKey(
|
||||||
|
Question,
|
||||||
|
related_name="references",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
system_question = models.CharField(
|
||||||
|
max_length=25,
|
||||||
|
choices=SYSTEM_QUESTION_CHOICES,
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
required = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_("Required question")
|
||||||
|
)
|
||||||
|
label = I18nTextField(
|
||||||
|
verbose_name=_("Question")
|
||||||
|
)
|
||||||
|
help_text = I18nTextField(
|
||||||
|
verbose_name=_("Help text"),
|
||||||
|
help_text=_("If the question needs to be explained or clarified, do it here!"),
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
dependency_question = models.ForeignKey(
|
||||||
|
'QuestionnaireChild', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
||||||
|
)
|
||||||
|
dependency_values = MultiStringField(default=[])
|
||||||
|
|
||||||
|
|
||||||
class Quota(LoggedModel):
|
class Quota(LoggedModel):
|
||||||
"""
|
"""
|
||||||
A quota is a "pool of tickets". It is there to limit the number of items
|
A quota is a "pool of tickets". It is there to limit the number of items
|
||||||
|
|||||||
@@ -850,6 +850,9 @@ class OrganizerPluginStateLogEntryType(LogEntryType):
|
|||||||
'pretix.event.question.option.added': _('An answer option has been added to the question.'),
|
'pretix.event.question.option.added': _('An answer option has been added to the question.'),
|
||||||
'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'),
|
'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'),
|
||||||
'pretix.event.question.option.changed': _('An answer option has been changed.'),
|
'pretix.event.question.option.changed': _('An answer option has been changed.'),
|
||||||
|
'pretix.event.questionnaire.added': _('A questionnaire has been created.'),
|
||||||
|
'pretix.event.questionnaire.deleted': _('A questionnaire has been deleted.'),
|
||||||
|
'pretix.event.questionnaire.changed': _('A questionnaire has been changed.'),
|
||||||
'pretix.event.permissions.added': _('A user has been added to the event team.'),
|
'pretix.event.permissions.added': _('A user has been added to the event team.'),
|
||||||
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
|
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
|
||||||
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
|
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
|
||||||
|
|||||||
Reference in New Issue
Block a user