Compare commits

..
Author SHA1 Message Date
Raphael Michel dd9d59d4bf Fix issues 2026-10-03 10:50:03 +02:00
Raphael Michel 4db39b0b10 Fixup incorrect merge 2026-10-03 10:37:35 +02:00
Raphael Michel 56b6147535 Move default statistics to plugin 2026-10-03 10:33:13 +02:00
Raphael Michel 752db242d8 Max height for timeline 2026-10-03 10:32:05 +02:00
Raphael Michel c43875b22c Move welcome widget 2026-10-03 10:31:46 +02:00
Raphael Michel e2de10f10f Replace checkin widget 2026-10-03 10:31:46 +02:00
Raphael Michel 930b329a62 Replace quota widgets 2026-10-03 10:31:46 +02:00
Raphael Michel 985a51300b Pretty loading state for logs 2026-10-03 10:31:46 +02:00
Raphael Michel 20246ff533 Move event_dashboard_top to partial 2026-10-03 10:31:46 +02:00
Raphael Michel ce433f6c7d Move comment form 2026-10-03 10:31:45 +02:00
Raphael Michel 157bb10f7e Show timeline on subevent detail page 2026-10-03 10:30:59 +02:00
rash 55434445da add new signal so reports can render just where event dashboard is 2026-10-03 10:30:58 +02:00
84858bc048 Allow mails to be sent to addon-attendees (Z#23213551) (#6235)
Remove restrictions that prevent mails to be sent to addon-product-attendees while reducing amount of mails sent to the same email-addresses

Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: Kara Engelhardt <engelhardt@pretix.eu>
2026-10-01 13:13:55 +02:00
Raphael Michel e8c091741b Invoice export: Fix crash PRETIXEU-FGE 2026-10-01 11:43:36 +02:00
Richard Schreiber ebdfd21b0f Fix JavaScript error description passing HTML (#6630) 2026-10-01 11:28:36 +02:00
pajowuandRichard Schreiber ed0b3cab7f Fix addon legend overlapping with profile checkbox (Z#23247075) (#6568)
* Fix addon legend overlapping with profile checkbox (Z#23247075)

* Update src/pretix/static/pretixpresale/scss/_checkout.scss

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-10-01 09:49:23 +02:00
113 changed files with 2242 additions and 4197 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ Dashboards
.. automodule:: pretix.control.signals
:no-index:
:members: event_dashboard_widgets, user_dashboard_widgets, event_dashboard_top
:members: event_dashboard_statistics, user_dashboard_widgets, event_dashboard_top
Ticket designs
""""""""""""""
+1 -11
View File
@@ -9,8 +9,7 @@
"version": "1.0.0",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"vue": "^3.5.30",
"vue-slicksort": "^2.0.5"
"vue": "^3.5.30"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -4650,15 +4649,6 @@
"vue-eslint-parser": "^10.0.0"
}
},
"node_modules/vue-slicksort": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/vue-slicksort/-/vue-slicksort-2.0.5.tgz",
"integrity": "sha512-fXz1YrNjhUbJK7o0tMk27mIr4pMAZYLSYvtmLazCtfpvz+zafPCn34ILDL8B7hT7WLVZKreYs6JVe5VWymqmzA==",
"license": "MIT",
"peerDependencies": {
"vue": ">=3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+1 -2
View File
@@ -27,8 +27,7 @@
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"vue": "^3.5.30",
"vue-slicksort": "^2.0.5"
"vue": "^3.5.30"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
-12
View File
@@ -100,10 +100,6 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:itemcategory-list'),
('GET', 'api-v1:item-list'),
('GET', 'api-v1:question-list'),
('GET', 'api-v1:compatquestion-list'),
('GET', 'api-v1:datafield-list'),
('GET', 'api-v1:questionnaire-list'),
('GET', 'api-v1:questionnairechild-list'),
('GET', 'api-v1:badgelayout-list'),
('GET', 'api-v1:badgeitem-list'),
('GET', 'api-v1:checkinlist-list'),
@@ -145,10 +141,6 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:itemcategory-list'),
('GET', 'api-v1:item-list'),
('GET', 'api-v1:question-list'),
('GET', 'api-v1:compatquestion-list'),
('GET', 'api-v1:datafield-list'),
('GET', 'api-v1:questionnaire-list'),
('GET', 'api-v1:questionnairechild-list'),
('GET', 'api-v1:badgelayout-list'),
('GET', 'api-v1:badgeitem-list'),
('GET', 'api-v1:checkinlist-list'),
@@ -185,10 +177,6 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:itemcategory-list'),
('GET', 'api-v1:item-list'),
('GET', 'api-v1:question-list'),
('GET', 'api-v1:compatquestion-list'),
('GET', 'api-v1:datafield-list'),
('GET', 'api-v1:questionnaire-list'),
('GET', 'api-v1:questionnairechild-list'),
('GET', 'api-v1:badgelayout-list'),
('GET', 'api-v1:badgeitem-list'),
('GET', 'api-v1:checkinlist-list'),
-63
View File
@@ -1,63 +0,0 @@
from functools import reduce
from rest_framework import serializers
from rest_framework.exceptions import APIException
class CompatException(APIException):
status = 410
default_code = 'incompatible_data'
class MergeField(serializers.Field):
"""
Read-only field that iterates over a list of related objects, picks a value from each, and merges the values
in a configurable way (mode parameter).
:param mode: list: returns a list of all picked values
consensus: returns the value only if it is equal on all related objects, otherwise
raises or returns an error object
union: only supported if picked values are iterables - generates union of all sub-items
bool_or: returns a boolean that is true if at least one picked value is truthy
"""
def __init__(self, related_name, inner_field, mode, **kwargs):
self.inner_field = inner_field
self.related_name = related_name
self.merge_mode = mode
assert mode in ('list', 'consensus', 'union', 'bool_or',)
kwargs['source'] = '*'
kwargs['read_only'] = True
super().__init__(**kwargs)
def bind(self, field_name, parent):
self.field_name = field_name
self.parent = parent
self.inner_field.bind(field_name, parent)
def get_attribute(self, instance):
vals = []
for ref in getattr(instance, self.related_name).all():
val = self.inner_field.get_attribute(ref)
if val is not None:
val = self.inner_field.to_representation(val)
vals.append(val)
if self.merge_mode == 'list':
return vals
elif self.merge_mode == 'consensus':
if len(vals) >= 1 and all(val == vals[0] for val in vals):
return vals[0]
else:
errmes = 'Questionnaire configuration incompatible with old data model due to conflicting values of ' + self.field_name
if self.context['ignore_errors']:
return {'_error': errmes, '_values': vals}
else:
raise CompatException({'detail': errmes, '_values': vals})
elif self.merge_mode == 'union':
return list(set(sum(vals, [])))
elif self.merge_mode == 'bool_or':
return reduce(lambda a, b: a or bool(b), vals, False)
else:
raise Exception('Unknown merge mode: ' + self.merge_mode)
def to_representation(self, value):
return value
+2 -3
View File
@@ -32,7 +32,6 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
MixedAnswerListSerializer,
)
from pretix.base.models import SalesChannel, Seat, Voucher
from pretix.base.models.orders import CartPosition
@@ -44,7 +43,7 @@ class TaxIncludedField(serializers.Field):
class CartPositionSerializer(I18nAwareModelSerializer):
answers = MixedAnswerListSerializer(child=AnswerSerializer(), source='*')
answers = AnswerSerializer(many=True)
seat = InlineSeatSerializer()
includes_tax = TaxIncludedField(source='*')
@@ -56,7 +55,7 @@ class CartPositionSerializer(I18nAwareModelSerializer):
class BaseCartPositionCreateSerializer(I18nAwareModelSerializer):
answers = MixedAnswerListSerializer(child=AnswerCreateSerializer(required=False), source='*', required=False)
answers = AnswerCreateSerializer(many=True, required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
includes_tax = serializers.BooleanField(required=False, allow_null=True)
+17 -12
View File
@@ -764,6 +764,14 @@ 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',
@@ -933,6 +941,14 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'show_quota_left',
'show_dates_on_frontpage',
'max_items_per_order',
'attendee_names_asked',
'attendee_names_required',
'attendee_emails_asked',
'attendee_emails_required',
'attendee_addresses_asked',
'attendee_addresses_required',
'attendee_company_asked',
'attendee_company_required',
'ticket_download',
'ticket_download_addons',
'ticket_download_nonadm',
@@ -963,6 +979,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'reusable_media_type_nfc_mf0aes',
'reusable_media_type_nfc_mf0aes_random_uid',
'reusable_media_usage_enforced',
'system_question_order',
'tax_rule_payment',
'tax_rule_cancellation',
]
@@ -987,18 +1004,6 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
)
)
# Provide fixed values for old configuration fields to ensure apps never use their built-in FakeQuestions, only the
# ones we provide from POSCompatQuestionViewSet
self.fields['system_question_order'] = serializers.JSONField(read_only=True, default=dict(), source='_invalid')
self.fields['attendee_names_asked'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
self.fields['attendee_names_required'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
self.fields['attendee_emails_asked'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
self.fields['attendee_emails_required'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
self.fields['attendee_addresses_asked'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
self.fields['attendee_addresses_required'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
self.fields['attendee_company_asked'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
self.fields['attendee_company_required'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
class MultiLineStringField(serializers.Field):
+32 -325
View File
@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>.
#
import logging
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
@@ -34,41 +33,26 @@ import logging
# 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 rest_framework
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import QuerySet
from django.utils.functional import cached_property, lazy
from django.utils.translation import gettext_lazy as _
from i18nfield.rest_framework import I18nField
from rest_framework import serializers
from rest_framework.exceptions import ValidationError as DrfValidationError
from rest_framework.serializers import as_serializer_error
from pretix.api.compat import MergeField
from pretix.api.serializers import (
CompatDecimalField, SalesChannelMigrationMixin,
)
from pretix.api.serializers.event import MetaDataField
from pretix.api.serializers.fields import UploadedFileField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.forms.questions import REQUIRED_NAME_PARTS
from pretix.base.models import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemProgramTime,
ItemVariation, ItemVariationMetaValue, Question, QuestionAnswer,
QuestionOption, Quota, SalesChannel,
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
SalesChannel,
)
from pretix.base.models.items import Questionnaire, QuestionnaireChild
from pretix.base.settings import (
PERSON_NAME_SALUTATIONS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
)
from pretix.base.templatetags.rich_text import rich_text
from pretix.helpers.i18n import i18n_all_from_gettext
logger = logging.getLogger(__name__)
class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
@@ -555,118 +539,18 @@ class LegacyDependencyValueField(serializers.CharField):
return [data] if data else []
class POSCompatQuestionSerializer(I18nAwareModelSerializer):
id = serializers.IntegerField(source='cooked_id', read_only=True)
question = I18nField(source='label', allow_null=True)
type = serializers.CharField(source='user_datafield.type', allow_null=True, default=Question.FieldType.STRING)
identifier = serializers.SerializerMethodField(read_only=True)
show_during_checkin = serializers.BooleanField(source='user_datafield.show_during_checkin', allow_null=True, default=False)
ask_during_checkin = serializers.SerializerMethodField(read_only=True)
hidden = serializers.SerializerMethodField(read_only=True)
print_on_invoice = serializers.BooleanField(source='user_datafield.print_on_invoice', allow_null=True, default=False)
valid_number_min = serializers.IntegerField(source='user_datafield.valid_number_min', allow_null=True)
valid_number_max = serializers.IntegerField(source='user_datafield.valid_number_max', allow_null=True)
valid_date_min = serializers.DateField(source='user_datafield.valid_date_min', allow_null=True)
valid_date_max = serializers.DateField(source='user_datafield.valid_date_max', allow_null=True)
valid_datetime_min = serializers.DateTimeField(source='user_datafield.valid_datetime_min', allow_null=True)
valid_datetime_max = serializers.DateTimeField(source='user_datafield.valid_datetime_max', allow_null=True)
valid_string_length_max = serializers.IntegerField(source='user_datafield.valid_string_length_max', allow_null=True)
valid_string_length_min = serializers.IntegerField(source='user_datafield.valid_string_length_min', allow_null=True)
valid_file_portrait = serializers.BooleanField(source='user_datafield.valid_file_portrait', allow_null=True)
dependency_question = serializers.SerializerMethodField('cook_dependency_question_id', read_only=True)
dependency_value = serializers.SerializerMethodField('empty', read_only=True)
position = serializers.SerializerMethodField(read_only=True)
items = serializers.PrimaryKeyRelatedField(source='questionnaire.items', many=True, queryset=Item.objects.none(), allow_null=True, required=False)
questionnaire_id = serializers.IntegerField(source='questionnaire.id', read_only=True)
options = InlineQuestionOptionSerializer(source='user_datafield.options', many=True, required=False, default=[])
class QuestionSerializer(I18nAwareModelSerializer):
options = InlineQuestionOptionSerializer(many=True, required=False)
identifier = serializers.CharField(allow_null=True)
dependency_value = LegacyDependencyValueField(source='dependency_values', required=False, allow_null=True)
class Meta:
model = QuestionnaireChild
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',
'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', 'questionnaire_id')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['items'].child_relation.queryset = self.context['event'].items.all() if 'event' in self.context else Item.objects.none()
def get_attribute(self, instance):
if isinstance(instance, QuestionAnswer):
position = instance.cartposition if instance.cartposition_id else instance.orderposition
print('searching ', repr(instance), repr(position), repr(instance.question), instance.pk, position.item_id, self.context) # TODO remove print
qcs = list(QuestionnaireChild.objects.filter(user_datafield_id=instance.question_id, questionnaire__items__in=[position.item_id]))
return next((qc for qc in qcs if qc.questionnaire.type == self.context.get('preferred_questionnaire_type', Questionnaire.QuestionnaireType.ORDER_POSITION_SALE)), qcs[0])
return super().get_attribute(instance)
def cook_dependency_question_id(self, qc):
if qc.dependency_question:
return qc.dependency_question.cooked_id
else:
return None
def get_ask_during_checkin(self, qc):
return qc.questionnaire.type == Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN
def get_hidden(self, qc):
return qc.questionnaire.type == Questionnaire.QuestionnaireType.ORDER_POSITION_HIDDEN
def get_identifier(self, qc):
return qc.user_datafield.identifier if qc.user_datafield else f'@{qc.system_datafield}'
def empty(self, qc):
return None
def get_position(self, qc):
return qc.questionnaire.position * 1000000000 + qc.position
@staticmethod
def mangle_results(event, results):
mangled_results = []
pos_offset = 0
for row in results:
row['position'] += pos_offset
if row['identifier'] == '@attendee_name_parts':
settings = event.settings
for k, v, w, *__ in PERSON_NAME_SCHEMES.get(settings.name_scheme)['fields']:
part = dict(row)
part["identifier"] += ":" + k
part["question"] = i18n_all_from_gettext(v, settings.locales)
if k == "title" and settings.name_scheme_titles:
part["type"] = Question.FieldType.CHOICE
part["options"] = [{"id": idx + 1, "identifier": k, "answer": i18n_all_from_gettext(k, settings.locales), "position": idx + 1} for idx, k in
enumerate(PERSON_NAME_TITLE_GROUPS.get(settings.name_scheme_titles)[1])]
elif k == "salutation":
part["type"] = Question.FieldType.CHOICE
part["options"] = [{"id": idx + 1, "identifier": k, "answer": i18n_all_from_gettext(v, settings.locales), "position": idx + 1} for idx, (k, v) in
enumerate(PERSON_NAME_SALUTATIONS)]
part["id"] = QuestionnaireChild.cook_id_from_parts(row['questionnaire_id'], None, part["identifier"][1:])
part["required"] = part["required"] and k in REQUIRED_NAME_PARTS
mangled_results.append(part)
pos_offset += 1
row["position"] += 1
else:
if row['identifier'] == '@country':
row['type'] = Question.FieldType.COUNTRYCODE
mangled_results.append(row)
return mangled_results
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)
class Meta:
model = Question
fields = ('id', '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',)
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait')
def validate_identifier(self, value):
Question._clean_identifier(self.context['event'], value, self.instance)
@@ -677,6 +561,14 @@ class DatafieldSerializer(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:
@@ -688,6 +580,21 @@ class DatafieldSerializer(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.'))
@@ -712,213 +619,13 @@ class DatafieldSerializer(I18nAwareModelSerializer):
options_data = validated_data.pop('options') if 'options' in validated_data else []
items = validated_data.pop('items', [])
question = Question.objects.create(**validated_data, container_type=self.context['container_type'])
question = Question.objects.create(**validated_data, container_type=Question.ContainerType.ORDERPOSITION)
question.items.set(items)
for opt_data in options_data:
QuestionOption.objects.create(question=question, **opt_data)
return question
class CompatDatafieldSerializer(DatafieldSerializer):
question = MergeField('referenced_by', I18nField(source='label'), mode='consensus')
required = MergeField('referenced_by', serializers.BooleanField(), mode='consensus')
items = MergeField('referenced_by', serializers.PrimaryKeyRelatedField(source='questionnaire.items', many=True, queryset=Item.objects.none(), allow_null=True, required=False), mode='union')
position = serializers.IntegerField(required=False, read_only=True, default=0)
ask_during_checkin = MergeField('referenced_by', serializers.SerializerMethodField(), mode='bool_or')
dependency_question = MergeField('referenced_by', serializers.IntegerField(source='dependency_question.user_datafield_id', required=False, default=None, allow_null=True), mode='consensus')
dependency_values = MergeField('referenced_by', serializers.JSONField(), mode='consensus')
dependency_value = MergeField('referenced_by', LegacyDependencyValueField(source='dependency_values'), mode='consensus')
help_text = MergeField('referenced_by', I18nField(), mode='consensus')
referenced_by_questionnaires = MergeField('referenced_by', serializers.IntegerField(source='questionnaire.id'), mode='list')
questionnaire_types = MergeField('referenced_by', serializers.CharField(source='questionnaire.type'), mode='list')
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',
'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',
# new
'internal_name', 'referenced_by_questionnaires', 'questionnaire_types',
)
def __init__(self, *args, **kwargs):
self.fields['items'].inner_field.child_relation.queryset = kwargs['context']['event'].items.all()
super().__init__(*args, **kwargs)
def get_ask_during_checkin(self, qc):
return qc.questionnaire.type == Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN
class DatafieldRelatedField(serializers.PrimaryKeyRelatedField):
default_error_messages = {
'invalid_choice': _('"{input}" is not a valid choice.')
}
def to_representation(self, qc):
if not qc:
return None
elif qc.system_datafield:
return qc.system_datafield
elif qc.user_datafield_id:
return qc.user_datafield_id
else:
return None
def to_internal_value(self, data):
if type(data) == int:
return {'user_datafield': super().to_internal_value(data), 'system_datafield': None}
elif type(data) == str:
if data not in QuestionnaireChild.SystemQuestion.values:
self.fail("invalid_choice", input=data)
return {'user_datafield': None, 'system_datafield': data}
elif data is None:
return {'user_datafield': None, 'system_datafield': None}
else:
self.fail('incorrect_type', data_type=type(data).__name__)
def use_pk_only_optimization(self):
return self.source == '*'
class RenderedMarkdownField(serializers.CharField):
def to_representation(self, value):
return rich_text(value)
class InlineQuestionnaireChildSerializer(I18nAwareModelSerializer):
datafield = DatafieldRelatedField(allow_null=True, source='*', queryset=Question.objects.none())
dependency_question = DatafieldRelatedField(allow_null=True, required=False, queryset=Question.objects.none())
rendered_help_text = RenderedMarkdownField(read_only=True, source='help_text')
cooked_id = serializers.IntegerField(read_only=True)
class Meta:
model = QuestionnaireChild
fields = ('datafield', 'required', 'label', 'help_text', 'dependency_question', 'dependency_values', 'rendered_help_text', 'cooked_id')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["datafield"].queryset = self.context["event"].questions.all()
self.fields["dependency_question"].queryset = self.context["event"].questions.all()
def validate_dependency_question(self, value):
if value:
if value['user_datafield']:
if value['user_datafield'].type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
return value
class QuestionnaireSerializer(I18nAwareModelSerializer):
items = serializers.PrimaryKeyRelatedField(many=True, queryset=Item.objects.none(), allow_null=True, required=False)
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta:
model = Questionnaire
fields = ('id', 'type', 'internal_name', 'items', 'position', 'all_sales_channels', 'limit_sales_channels', 'children')
def __init__(self, *args, **kwargs):
self.fields['children'] = InlineQuestionnaireChildSerializer(many=True, required=True, context=kwargs['context'], partial=False)
self.fields['items'].child_relation.queryset = kwargs['context']['event'].items.all()
self.fields['limit_sales_channels'].child_relation.queryset = kwargs['context']['event'].organizer.sales_channels.all()
super().__init__(*args, **kwargs)
def validate(self, data):
data = super().validate(data)
try:
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
except rest_framework.exceptions.ValidationError as e:
# we already have invalid state in the database, what should we do? hope it gets better after saving?
logger.exception("Invalid state in database, ignoring some validations!")
else:
#if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
# raise ValidationError('Dependencies are not supported during check-in.')
#if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
# raise ValidationError(_('This type of question cannot be asked during check-in.'))
#if full_data.get('show_during_checkin') and full_data.get('type') in Question.SHOW_DURING_CHECKIN_UNSUPPORTED:
# raise ValidationError(_('This type of question cannot be shown during check-in.'))
#Question.clean_items(event, full_data.get('items') or [])
if (full_data.get('type') == Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN
and any(c['dependency_question'] for c in full_data['children'])):
raise ValidationError('Dependencies are not supported during check-in.')
if (not full_data.get('type').startswith('P')
and any(c['system_datafield'] for c in full_data['children'])):
raise ValidationError('System data fields are only supported on order positions.')
system_fields = set(c['system_datafield'] for c in full_data['children'])
if ('zipcode' in system_fields or 'state' in system_fields or 'street' in system_fields or 'city' in system_fields) and 'country' not in system_fields:
raise ValidationError('The system data fields "street", "zip code", "city", and "state" can only be used in combination with the "country" field.')
return data
@transaction.atomic
def create(self, validated_data):
children_data = validated_data.pop('children') if 'children' in validated_data else []
questionnaire = super().create(validated_data)
self.set_children(questionnaire, children_data)
try:
Questionnaire.check_constraints(questionnaire.event)
except ValidationError as e:
raise DrfValidationError(as_serializer_error(e))
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)
try:
Questionnaire.check_constraints(questionnaire.event)
except ValidationError as e:
raise DrfValidationError(as_serializer_error(e))
return questionnaire
def set_children(self, questionnaire, new_data):
result = {}
child_serializer = self.fields['children'].child
existing_children = questionnaire.children.all()
def q(qc):
return qc['user_datafield'] or qc['system_datafield']
for i, d in enumerate(new_data):
d['questionnaire'] = questionnaire
d['position'] = i + 1
d.setdefault('required', False)
d.setdefault('help_text', None)
d.setdefault('dependency_question', None)
d.setdefault('dependency_values', None)
for existing, update_data in zip_longest(existing_children, new_data):
if update_data:
if update_data.get('dependency_question'):
try:
update_data['dependency_question'] = result[q(update_data['dependency_question'])]
except KeyError:
raise ValidationError('A question can only depend on a previous question from the same questionnaire.')
if existing:
result[q(update_data)] = child_serializer.update(existing, update_data)
else:
result[q(update_data)] = child_serializer.create(update_data)
else:
existing.delete()
return result
class QuotaSerializer(I18nAwareModelSerializer):
available = serializers.BooleanField(read_only=True)
available_number = serializers.IntegerField(read_only=True)
+7 -99
View File
@@ -40,7 +40,6 @@ from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField
from rest_framework.reverse import reverse
from rest_framework.serializers import ListSerializer
from pretix.api.auth.utils import get_session_key_for_api_request
from pretix.api.serializers import CompatDecimalField, CompatibleJSONField
@@ -48,7 +47,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, POSCompatQuestionSerializer,
InlineItemVariationSerializer, ItemSerializer, QuestionSerializer,
)
from pretix.api.signals import order_api_details, orderposition_api_details
from pretix.base.decimal import round_decimal
@@ -58,8 +57,8 @@ from pretix.base.media import MEDIA_TYPES
from pretix.base.models import (
CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress,
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
QuestionAnswer, QuestionnaireChild, ReusableMedium, SalesChannel, Seat,
SubEvent, TaxRule, Voucher,
QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
Voucher,
)
from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -75,8 +74,7 @@ from pretix.base.services.pricing import (
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SALUTATIONS,
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, ROUNDING_MODES,
COUNTRIES_WITH_STATE_IN_ADDRESS, ROUNDING_MODES,
)
from pretix.base.signals import register_ticket_outputs
from pretix.helpers.countries import CachedCountries
@@ -231,95 +229,6 @@ class InlineSeatSerializer(I18nAwareModelSerializer):
fields = ('id', 'name', 'seat_guid', 'zone_name', 'row_name', 'row_label', 'seat_label', 'seat_number')
class MixedAnswerListSerializer(ListSerializer):
def __init__(self, instance=None, *args, **kwargs):
self.parent_instance = instance
super().__init__(instance.answers if instance else None, *args, **kwargs)
def get_value(self, dictionary):
return dictionary
def to_representation(self, instance):
answers = super().to_representation(instance.answers)
answers_dict = {answer["question"]: answer for answer in answers}
for qc in QuestionnaireChild.objects.filter(questionnaire__items__id=instance.item_id):
if qc.user_datafield_id:
if answer := answers_dict.get(qc.user_datafield_id):
answer = dict(answer)
answer["question"] = qc.cooked_id
answer["questionnaire_id"] = qc.questionnaire_id
answer["user_datafield_id"] = qc.user_datafield_id
answers.append(answer)
elif (sys_answer := instance.get_system_answer(qc.system_datafield)) is not None:
if qc.system_datafield == 'attendee_name_parts':
name_parts = instance.attendee_name_parts
settings = self.context['event'].settings
for k, v, w, *__ in PERSON_NAME_SCHEMES.get(settings.name_scheme)['fields']:
if k in name_parts:
options = []
options_ident = []
try:
if k == "salutation":
salutation_keys = [key for key, value in PERSON_NAME_SALUTATIONS]
options = [salutation_keys.index(name_parts[k]) + 1]
options_ident = [name_parts[k]]
elif k == "title" and settings.name_scheme_titles:
options = [PERSON_NAME_TITLE_GROUPS.get(settings.name_scheme_titles)[1].index(name_parts[k]) + 1]
options_ident = [name_parts[k]]
except ValueError:
pass
answers.append({
"question": QuestionnaireChild.cook_id_from_parts(qc.questionnaire_id, None, f'{qc.system_datafield}:{k}'),
"answer": name_parts[k],
"question_identifier": f'@{qc.system_datafield}:{k}',
"options": options,
"option_identifiers": options_ident,
"questionnaire_id": qc.questionnaire_id,
"system_datafield": f'{qc.system_datafield}:{k}',
})
answers.append({
"question": qc.cooked_id,
"answer": sys_answer,
"question_identifier": f'@{qc.system_datafield}',
"options": [],
"option_identifiers": [],
"questionnaire_id": qc.questionnaire_id,
"system_datafield": qc.system_datafield,
})
return answers
def to_internal_value(self, data):
result = {}
answers = []
attendee_name_parts = {}
settings = self.context['event'].settings
for answer in data["answers"]:
try:
qid, dfid, sys_df = QuestionnaireChild.uncook_id(answer["question"])
except ValueError:
if type(answer["question"]) is str and answer["question"].lstrip("@") in QuestionnaireChild.SYSTEM_DATAFIELD_NUMBERS:
qid, dfid, sys_df = None, None, answer["question"].lstrip("@")
else:
raise ValidationError('Invalid question ID')
if dfid:
answer["question"] = dfid
answers.append(answer)
elif sys_df == "country":
result[sys_df] = Country(answer["answer"])
elif sys_df == "attendee_name_parts:salutation":
attendee_name_parts['salutation'] = PERSON_NAME_SALUTATIONS[answer["options"][0] - 1][0]
elif sys_df == "attendee_name_parts:title" and settings.name_scheme_titles:
attendee_name_parts['title'] = PERSON_NAME_TITLE_GROUPS.get(settings.name_scheme_titles)[1][answer["options"][0] - 1]
elif sys_df.startswith("attendee_name_parts:"):
attendee_name_parts[sys_df[len("attendee_name_parts:"):]] = answer["answer"]
else:
result[sys_df] = answer["answer"]
result["answers"] = super().to_internal_value(answers)
if attendee_name_parts:
result["attendee_name_parts"] = attendee_name_parts
return result
class AnswerSerializer(I18nAwareModelSerializer):
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
@@ -682,7 +591,7 @@ class OrderPositionPluginDataField(serializers.Field):
class OrderPositionSerializer(I18nAwareModelSerializer):
checkins = InlineCheckinSerializer(many=True, read_only=True)
print_logs = PrintLogSerializer(many=True, read_only=True)
answers = MixedAnswerListSerializer(child=AnswerSerializer(), source='*')
answers = AnswerSerializer(many=True)
downloads = PositionDownloadsField(source='*', read_only=True)
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
pdf_data = PdfDataSerializer(source='*', read_only=True)
@@ -783,7 +692,6 @@ class AttendeeNamePartsField(serializers.Field):
class CheckinListOrderPositionSerializer(OrderPositionSerializer):
require_attention = RequireAttentionField(source='*')
answers = AnswerSerializer(many=True)
attendee_name = AttendeeNameField(source='*')
attendee_name_parts = AttendeeNamePartsField(source='*')
order__status = serializers.SlugRelatedField(read_only=True, slug_field='status', source='order')
@@ -813,7 +721,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'] = POSCompatQuestionSerializer(read_only=True) # TODO(questionnaires)
self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True)
if 'addons' in self.context['expand']:
# Experimental feature, undocumented on purpose for now in case we need to remove it again
@@ -1133,7 +1041,7 @@ class OrderFeeCreateSerializer(I18nAwareModelSerializer):
class OrderPositionCreateSerializer(I18nAwareModelSerializer):
answers = MixedAnswerListSerializer(child=AnswerCreateSerializer(required=False), source='*', required=False)
answers = AnswerCreateSerializer(many=True, required=False)
addon_to = serializers.IntegerField(required=False, allow_null=True)
secret = serializers.CharField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
+3 -4
View File
@@ -30,8 +30,7 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, CompatibleCountryField,
MixedAnswerListSerializer, OrderFeeCreateSerializer,
OrderPositionCreateSerializer,
OrderFeeCreateSerializer, OrderPositionCreateSerializer,
)
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
from pretix.base.services.orders import OrderChangeManager, OrderError
@@ -42,7 +41,7 @@ logger = logging.getLogger(__name__)
class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerializer):
order = serializers.SlugRelatedField(slug_field='code', queryset=Order.objects.none(), required=True, allow_null=False)
answers = MixedAnswerListSerializer(child=AnswerCreateSerializer(required=False), source='*', required=False)
answers = AnswerCreateSerializer(many=True, required=False)
addon_to = serializers.IntegerField(required=False, allow_null=True)
secret = serializers.CharField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
@@ -155,7 +154,7 @@ class OrderFeeCreateForExistingOrderSerializer(OrderFeeCreateSerializer):
class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
answers = MixedAnswerListSerializer(child=AnswerSerializer(), source='*')
answers = AnswerSerializer(many=True)
country = CompatibleCountryField(source='*')
attendee_name = serializers.CharField(required=False)
+1 -8
View File
@@ -26,7 +26,6 @@ from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.db.models import Q
from django.templatetags.static import static
from django.utils.crypto import get_random_string
from django.utils.translation import gettext, gettext_lazy as _
from i18nfield.rest_framework import I18nField
@@ -220,12 +219,10 @@ class FlexibleTicketRelatedField(serializers.PrimaryKeyRelatedField):
class SalesChannelSerializer(I18nAwareModelSerializer):
type = serializers.CharField(default="api")
icon_url = serializers.SerializerMethodField()
class Meta:
model = SalesChannel
fields = ('identifier', 'type', 'label', 'position', 'icon', 'icon_url')
read_only_fields = ('icon',)
fields = ('identifier', 'type', 'label', 'position')
def validate_type(self, value):
if (not self.instance or not self.instance.pk) and value != "api":
@@ -249,10 +246,6 @@ class SalesChannelSerializer(I18nAwareModelSerializer):
)
return value
def get_icon_url(self, instance):
if '.' in instance.icon:
return static(instance.icon)
class GiftCardSerializer(I18nAwareModelSerializer):
value = serializers.DecimalField(max_digits=13, decimal_places=2, min_value=Decimal('0.00'))
+1 -4
View File
@@ -80,10 +80,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'questions', item.CompatDatafieldViewSet, basename='compatquestion')
event_router.register(r'datafields', item.DatafieldViewSet, basename='datafield')
event_router.register(r'orderdatafields', item.OrderDatafieldViewSet, basename='orderdatafield')
event_router.register(r'questionnaires', item.QuestionnaireViewSet)
event_router.register(r'questions', item.QuestionViewSet)
event_router.register(r'discounts', discount.DiscountViewSet)
event_router.register(r'quotas', item.QuotaViewSet)
event_router.register(r'vouchers', voucher.VoucherViewSet)
+12 -17
View File
@@ -55,7 +55,7 @@ from pretix.api.serializers.checkin import (
CheckinListSerializer, CheckinRPCAnnulInputSerializer,
CheckinRPCRedeemInputSerializer, MiniCheckinListSerializer,
)
from pretix.api.serializers.item import POSCompatQuestionSerializer
from pretix.api.serializers.item import QuestionSerializer
from pretix.api.serializers.order import (
CheckinListOrderPositionSerializer, CheckinSerializer,
FailedCheckinSerializer,
@@ -65,8 +65,7 @@ from pretix.api.views.order import OrderPositionFilter
from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition,
Question, Questionnaire, QuestionnaireChild, ReusableMedium,
RevokedTicketSecret, TeamAPIToken,
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
)
from pretix.base.models.orders import PrintLog
from pretix.base.permissions import AnyPermissionOf
@@ -490,7 +489,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'pdf_data': pdf_data and (
user if user and user.is_authenticated else auth
).has_event_permission(request.organizer, event, 'event.orders:read', request),
'preferred_questionnaire_type': Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN,
}
common_checkin_args = dict(
@@ -799,19 +797,16 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
# 5. Pre-validate all incoming answers, handle file upload
given_answers = {}
if answers_data:
for qc in QuestionnaireChild.objects.filter(
questionnaire__items__in=[op.item], questionnaire__type=Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN
):
if qc.user_datafield_id and (str(qc.user_datafield_id) in answers_data or str(qc.cooked_id) in answers_data):
for q in op.item.questions.filter(ask_during_checkin=True):
if str(q.pk) in answers_data:
try:
ans = answers_data.get(str(qc.user_datafield_id), answers_data[str(qc.cooked_id)])
if qc.user_datafield.type == Question.TYPE_FILE:
if ans:
given_answers[qc.user_datafield] = _handle_file_upload(ans, user, auth)
if q.type == Question.TYPE_FILE:
if answers_data[str(q.pk)]:
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
else:
given_answers[qc.user_datafield] = None
given_answers[q] = None
else:
given_answers[qc.user_datafield] = qc.user_datafield.clean_answer(ans)
given_answers[q] = q.clean_answer(answers_data[str(q.pk)])
except (ValidationError, BaseValidationError):
pass
@@ -872,13 +867,14 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
else:
perform_checkin(**checkin_args)
except RequiredQuestionsError as e:
qs = POSCompatQuestionSerializer(context={'event': op.order.event})
return Response({
'status': 'incomplete',
'require_attention': op.require_checkin_attention,
'checkin_texts': op.checkin_texts,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
'questions': POSCompatQuestionSerializer.mangle_results(op.order.event, (qs.to_representation(q) for q in e.questions)), # TODO(questionnaires)
'questions': [
QuestionSerializer(q).data for q in e.questions
],
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=400)
except RequiredMediaExchangeError as e:
@@ -977,7 +973,6 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
ctx['event'] = self.request.event
ctx['expand'] = self.request.query_params.getlist('expand')
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
ctx['preferred_questionnaire_type'] = Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN
return ctx
def get_filterset_kwargs(self):
+16 -314
View File
@@ -32,9 +32,6 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import logging
from typing import Mapping
import django_filters
from django.db import transaction
from django.db.models import Q
@@ -47,27 +44,21 @@ from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.response import Response
from pretix.api.compat import CompatException
from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.item import (
CompatDatafieldSerializer, DatafieldSerializer, ItemAddOnSerializer,
ItemBundleSerializer, ItemCategorySerializer, ItemProgramTimeSerializer,
ItemSerializer, ItemVariationSerializer, POSCompatQuestionSerializer,
QuestionnaireSerializer, QuestionOptionSerializer, QuotaSerializer,
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
)
from pretix.api.views import ConditionalListView
from pretix.base.models import (
CartPosition, Device, Item, ItemAddOn, ItemBundle, ItemCategory,
ItemProgramTime, ItemVariation, Question, QuestionOption, Quota,
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
ItemVariation, Question, QuestionOption, Quota,
)
from pretix.base.models.items import Questionnaire, QuestionnaireChild
from pretix.base.services.quotas import QuotaAvailability
from pretix.helpers.dicts import merge_dicts
from pretix.helpers.i18n import i18ncomp
logger = logging.getLogger(__name__)
with scopes_disabled():
class ItemFilter(FilterSet):
tax_rate = django_filters.CharFilter(method='tax_rate_qs')
@@ -467,152 +458,27 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
with scopes_disabled():
class CompatDatafieldFilter(FilterSet):
identifier = django_filters.CharFilter(field_name='question__identifier')
ask_during_checkin = django_filters.BooleanFilter(method='ask_during_checkin_qs')
class Meta:
model = QuestionnaireChild
fields = ['required']
def ask_during_checkin_qs(self, qs, name, value):
return qs.filter(
questionnaire__type=Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN if value else Questionnaire.QuestionnaireType.ORDER_POSITION_SALE
)
class DeprecationWarningMixin:
def finalize_response(self, request, response, *args, **kwargs):
if self.deprecation_warning and isinstance(getattr(response, 'data', None), Mapping) and not 'x-deprecated' in response.headers:
response.data = {
'deprecation_warning': self.deprecation_warning,
**response.data
}
response.headers['x-deprecated'] = self.deprecation_warning
return super().finalize_response(request, response, *args, **kwargs)
class POSCompatQuestionViewSet(DeprecationWarningMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = POSCompatQuestionSerializer
queryset = QuestionnaireChild.objects.none()
permission = None
sales_channel_ident = None
def __init__(self, sales_channel_ident, *args, **kwargs):
self.sales_channel_ident = sales_channel_ident
super().__init__(*args, **kwargs)
@property
def deprecation_warning(self):
return (
f'This API endpoint is deprecated and will be removed in the future. '
f'Results are generated on-the-fly, count and page size may be inaccurate. '
f'Filtered for sales_channel "{self.sales_channel_ident}"'
)
def get_queryset(self):
return QuestionnaireChild.objects.filter(
Q(questionnaire__all_sales_channels=True) | Q(questionnaire__limit_sales_channels__identifier=self.sales_channel_ident),
questionnaire__event=self.request.event,
# legacy compat only for position-level questions
questionnaire__type__startswith='P',
).prefetch_related(
'user_datafield', 'user_datafield__options', 'questionnaire'
).order_by('questionnaire__position', 'position', 'id')
def list(self, request, **kwargs):
resp = super().list(request, **kwargs)
resp.data['results'] = POSCompatQuestionSerializer.mangle_results(self.request.event, resp.data['results'])
return resp
def get_object(self):
queryset = self.filter_queryset(self.get_queryset())
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
qid, dfid, sys_df = QuestionnaireChild.uncook_id(self.kwargs[lookup_url_kwarg])
print(self.kwargs[lookup_url_kwarg], qid, dfid, sys_df)
if qid and sys_df:
obj = get_object_or_404(queryset, questionnaire__pk=qid, system_datafield=sys_df)
elif qid and dfid:
obj = get_object_or_404(queryset, questionnaire__pk=qid, user_datafield__pk=dfid)
else:
obj = get_object_or_404(queryset, user_datafield__pk=dfid)
# May raise a permission denied
self.check_object_permissions(self.request, obj)
return obj
@transaction.atomic()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.question.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
@transaction.atomic()
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.question.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
@transaction.atomic()
def perform_destroy(self, instance):
instance.log_action(
'pretix.event.question.deleted',
user=self.request.user,
auth=self.request.auth,
)
super().perform_destroy(instance)
with scopes_disabled():
class DatafieldFilter(FilterSet):
class QuestionFilter(FilterSet):
class Meta:
model = Question
fields = ['identifier']
fields = ['ask_during_checkin', 'required', 'identifier']
class BaseDatafieldViewSet(viewsets.ModelViewSet):
serializer_class = DatafieldSerializer
class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = QuestionSerializer
queryset = Question.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
filterset_class = DatafieldFilter
ordering_fields = ('id')
ordering = ('id')
filterset_class = QuestionFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
permission = None
write_permission = 'event.items:write'
container_type = Question.ContainerType.ORDERPOSITION
def get_queryset(self):
return self.request.event.questions.filter(container_type=self.container_type).prefetch_related('options')
def get_object(self):
queryset = self.filter_queryset(self.get_queryset())
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
qid, dfid, sys_df = QuestionnaireChild.uncook_id(self.kwargs[lookup_url_kwarg])
self.via_questionnaire_id = qid
obj = get_object_or_404(queryset, pk=dfid)
# May raise a permission denied
self.check_object_permissions(self.request, obj)
return obj
return self.request.event.questions.filter(
# the container_type parameter is undocumented, this API is going to change in a later release
container_type=self.request.GET.get('container_type', Question.ContainerType.ORDERPOSITION),
).prefetch_related('options').all()
@transaction.atomic()
def perform_create(self, serializer):
@@ -627,7 +493,6 @@ class BaseDatafieldViewSet(viewsets.ModelViewSet):
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['container_type'] = self.container_type
return ctx
@transaction.atomic()
@@ -650,110 +515,6 @@ class BaseDatafieldViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance)
class DatafieldViewSet(ConditionalListView, BaseDatafieldViewSet):
pass
class OrderDatafieldViewSet(ConditionalListView, BaseDatafieldViewSet):
container_type = Question.ContainerType.ORDER
def ordered_union(*lists):
from graphlib import TopologicalSorter
items_set = {item for lst in lists for __ignore, item in lst}
dependencies = {item: set() for item in items_set}
for lst in lists:
for i in range(1, len(lst)):
current_item = lst[i][1]
previous_item = lst[i - 1][1]
dependencies[current_item].add(previous_item)
ts = TopologicalSorter(dependencies)
try:
return list(ts.static_order())
except ValueError:
raise CompatException("Questionnaire configuration incompatible with old data model due to conflicting field orders.")
class CompatListMixin:
deprecation_warning = (
f'This API endpoint is deprecated and will be removed in the future. It only works as long as '
f'your event\'s questionnaire configuration is compatible with the old data model. '
f'Please switch to the .../datafields/ and .../questionnaires/ endpoints.'
)
@property
def ignore_errors(self):
return self.request.query_params.get('ignore_errors', '') == 'true'
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['ignore_errors'] = self.ignore_errors
return ctx
def do_the_topo_sort(self, queryset):
products = {}
for df in queryset:
for qc in df.referenced_by.all():
for item_id in qc.questionnaire.items.values_list('pk', flat=True):
k = (qc.questionnaire.type, item_id)
products[k] = products.get(k, []) + [((qc.questionnaire.position, qc.position), df)]
for q in products.values():
q.sort()
try:
result = ordered_union(*products.values())
for i, d in enumerate(result):
d.position = i
except ValueError:
if self.ignore_errors:
pass
else:
raise
return result
def list(self, request, **kwargs):
logger.info('Returning compatible datafield list on %s/%s', request.organizer.slug, request.event.slug)
queryset = self.filter_queryset(self.get_queryset())
queryset = self.do_the_topo_sort(queryset)
serializer = self.get_serializer(queryset, many=True)
resultlist = serializer.data
return Response({
'next': None, 'previous': None, 'count': len(resultlist),
'results': resultlist,
})
class CompatDatafieldViewSet(ConditionalListView, DeprecationWarningMixin, CompatListMixin, BaseDatafieldViewSet):
serializer_class = CompatDatafieldSerializer
pagination_class = None
def get_queryset(self):
return super().get_queryset().prefetch_related('referenced_by', 'referenced_by__questionnaire')
def _check_defer(self, mode, request, *args, **kwargs):
sales_channel_ident = request.query_params.get('sales_channel', None)
if sales_channel_ident is None and isinstance(request.auth, Device):
sales_channel_ident = 'pretixpos' if request.auth.software_brand.startswith("pretixPOS") else 'web'
if sales_channel_ident:
logger.info('Returning app compatible question %s for %s (device: %r %s %s) on %s/%s', mode,sales_channel_ident, request.auth, getattr(request.auth, 'software_brand', ''), getattr(request.auth, 'software_version', ''), request.organizer.slug, request.event.slug)
return POSCompatQuestionViewSet.as_view(
{'get': mode}, sales_channel_ident=sales_channel_ident
)(*self.args, request=request._request, **self.kwargs)
def list(self, request, *args, **kwargs):
if response := self._check_defer('list', request, *args, **kwargs):
return response
logger.info('Returning compatible datafield list on %s/%s', request.organizer.slug, request.event.slug)
return super().list(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs):
if response := self._check_defer('retrieve', request, *args, **kwargs):
return response
return super().retrieve(request, *args, **kwargs)
class QuestionOptionViewSet(viewsets.ModelViewSet):
serializer_class = QuestionOptionSerializer
queryset = QuestionOption.objects.none()
@@ -805,65 +566,6 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance)
with scopes_disabled():
class QuestionnaireFilter(FilterSet):
sales_channel = django_filters.CharFilter(method='sales_channel_qs')
class Meta:
model = Questionnaire
fields = ['type', 'items', 'sales_channel']
def sales_channel_qs(self, qs, name, value):
return qs.filter(
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=value)
)
class QuestionnaireViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = QuestionnaireSerializer
queryset = Questionnaire.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
filterset_class = QuestionnaireFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
permission = None
write_permission = 'event.items:write'
def get_queryset(self):
return self.request.event.questionnaires.prefetch_related('children').all()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.questionnaire.added',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
return ctx
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.questionnaire.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def perform_destroy(self, instance):
instance.log_action(
'pretix.event.questionnaire.deleted',
user=self.request.user,
auth=self.request.auth,
)
super().perform_destroy(instance)
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
pass
+2 -4
View File
@@ -256,7 +256,7 @@ class OrderViewSetMixin:
[request.event],
Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'),
'questionnaires',
'questions',
'item_meta_properties',
)
return Prefetch(
@@ -287,9 +287,7 @@ class OrderViewSetMixin:
Prefetch('checkins', queryset=Checkin.objects.select_related('device')),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'item', 'variation',
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question')
# TODO(questionnaires) .order_by('question__position')
),
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question').order_by('question__position')),
'seat',
)
)
+148 -154
View File
@@ -36,6 +36,7 @@ import copy
import json
import logging
import re
from collections import namedtuple
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
@@ -49,8 +50,7 @@ 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 import ProgrammingError
from django.db.models import Prefetch, Q, QuerySet
from django.db.models import QuerySet
from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.utils.formats import date_format
@@ -82,10 +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, Questionnaire, QuestionnaireChild,
QuestionOption,
)
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
from pretix.base.models.tax import ask_for_vat_id
from pretix.base.services.tax import (
VATIDFinalError, VATIDTemporaryError, normalize_vat_id, validate_vat_id,
@@ -650,22 +647,49 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
super().__init__(*args, **kwargs)
FakeQuestion = namedtuple(
'FakeQuestion', 'id question position required help_text container_type', defaults=('', Question.ContainerType.ORDERPOSITION)
)
def get_fake_attendee_questions(settings):
fq = []
sqo = settings.system_question_order
if settings.attendee_names_asked:
fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), settings.attendee_names_required))
if settings.attendee_emails_asked:
fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), settings.attendee_emails_required))
if settings.attendee_company_asked:
fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), settings.attendee_company_required))
if settings.attendee_addresses_asked:
fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('state', _('State'), sqo.get('country', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), settings.attendee_addresses_required))
return fq
class BaseQuestionsForm(forms.Form):
"""
This is the base form class responsible for asking order- or ticket-related questions.
"""
address_validation = False
def build_user_question_field(self, request, event, answerlist, qc, datafield):
def build_user_question_field(self, request, event, answerlist, q):
# Do we already have an answer? Provide it as the initial value
answers = [a for a in answerlist if a.question_id == datafield.id]
answers = [a for a in answerlist if a.question_id == q.id]
if answers:
initial = answers[0]
else:
initial = None
tz = ZoneInfo(event.settings.timezone)
required = qc.required and not self.all_optional
if datafield.type == Question.TYPE_BOOLEAN:
required = q.required and not self.all_optional
if q.type == Question.TYPE_BOOLEAN:
if required:
# For some reason, django-bootstrap3 does not set the required attribute
# itself.
@@ -679,105 +703,107 @@ class BaseQuestionsForm(forms.Form):
initialbool = False
field = forms.BooleanField(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
initial=initialbool, widget=widget,
)
elif datafield.type == Question.TYPE_NUMBER:
elif q.type == Question.TYPE_NUMBER:
field = forms.DecimalField(
label=escape(qc.label), required=required,
min_value=datafield.valid_number_min or Decimal('0.00'),
max_value=datafield.valid_number_max,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
min_value=q.valid_number_min or Decimal('0.00'),
max_value=q.valid_number_max,
help_text=rich_text(q.help_text),
initial=initial.answer if initial else None,
)
elif datafield.type == Question.TYPE_STRING:
elif q.type == Question.TYPE_STRING:
field = forms.CharField(
label=escape(qc.label), required=required,
min_length=datafield.valid_string_length_min,
max_length=datafield.valid_string_length_max,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
min_length=q.valid_string_length_min,
max_length=q.valid_string_length_max,
help_text=rich_text(q.help_text),
initial=initial.answer if initial else None,
)
elif datafield.type == Question.TYPE_TEXT:
elif q.type == Question.TYPE_TEXT:
field = forms.CharField(
label=escape(qc.label), required=required,
min_length=datafield.valid_string_length_min,
max_length=datafield.valid_string_length_max,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
min_length=q.valid_string_length_min,
max_length=q.valid_string_length_max,
help_text=rich_text(q.help_text),
widget=forms.Textarea,
initial=initial.answer if initial else None,
)
elif datafield.type == Question.TYPE_COUNTRYCODE:
elif q.type == Question.TYPE_COUNTRYCODE:
field = CountryField(
countries=CachedCountries,
blank=True, null=True, blank_label=' ',
).formfield(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
widget=forms.Select,
empty_label=' ',
initial=initial.answer if initial else (guess_country_from_request(request, event) if required else None),
initial=initial.answer if initial else (
guess_country_from_request(request, event) if required else None),
)
elif datafield.type == Question.TYPE_CHOICE:
elif q.type == Question.TYPE_CHOICE:
field = forms.ModelChoiceField(
queryset=datafield.options,
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
queryset=q.options,
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
widget=forms.Select,
to_field_name='identifier',
empty_label='',
initial=initial.options.first() if initial else None,
)
elif datafield.type == Question.TYPE_CHOICE_MULTIPLE:
elif q.type == Question.TYPE_CHOICE_MULTIPLE:
field = forms.ModelMultipleChoiceField(
queryset=datafield.options,
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
queryset=q.options,
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
to_field_name='identifier',
widget=QuestionCheckboxSelectMultiple,
initial=initial.options.all() if initial else None,
)
elif datafield.type == Question.TYPE_FILE:
if datafield.valid_file_portrait:
elif q.type == Question.TYPE_FILE:
if q.valid_file_portrait:
field = PortraitImageField(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
initial=initial.file if initial else None,
widget=PortraitImageWidget(answer=initial, request=request, attrs={'data-portrait-photo': 'true'}),
widget=PortraitImageWidget(answer=initial, request=request,
attrs={'data-portrait-photo': 'true'}),
)
else:
field = ExtFileField(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
initial=initial.file if initial else None,
widget=UploadedFileWidget(answer=initial, request=request),
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
)
elif datafield.type == Question.TYPE_DATE:
elif q.type == Question.TYPE_DATE:
attrs = {}
if datafield.valid_date_min:
attrs['data-min'] = datafield.valid_date_min.isoformat()
if datafield.valid_date_max:
attrs['data-max'] = datafield.valid_date_max.isoformat()
help_text = qc.help_text
if q.valid_date_min:
attrs['data-min'] = q.valid_date_min.isoformat()
if q.valid_date_max:
attrs['data-max'] = q.valid_date_max.isoformat()
help_text = q.help_text
if not help_text:
if datafield.valid_date_min and datafield.valid_date_max:
if q.valid_date_min and q.valid_date_max:
help_text = format_lazy(
_('Please enter a date between {min} and {max}.'),
min=date_format(datafield.valid_date_min, "SHORT_DATE_FORMAT"),
max=date_format(datafield.valid_date_max, "SHORT_DATE_FORMAT"),
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
)
elif datafield.valid_date_min:
elif q.valid_date_min:
help_text = format_lazy(
_('Please enter a date no earlier than {min}.'),
min=date_format(datafield.valid_date_min, "SHORT_DATE_FORMAT"),
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
)
elif datafield.valid_date_max:
elif q.valid_date_max:
help_text = format_lazy(
_('Please enter a date no later than {max}.'),
max=date_format(datafield.valid_date_max, "SHORT_DATE_FORMAT"),
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
)
if initial and initial.answer:
try:
@@ -787,16 +813,16 @@ class BaseQuestionsForm(forms.Form):
else:
_initial = None
field = forms.DateField(
label=escape(qc.label), required=required,
label=escape(q.question), required=required,
help_text=rich_text(help_text),
initial=_initial,
widget=DatePickerWidget(attrs),
)
if datafield.valid_date_min:
field.validators.append(MinDateValidator(datafield.valid_date_min))
if datafield.valid_date_max:
field.validators.append(MaxDateValidator(datafield.valid_date_max))
elif datafield.type == Question.TYPE_TIME:
if q.valid_date_min:
field.validators.append(MinDateValidator(q.valid_date_min))
if q.valid_date_max:
field.validators.append(MaxDateValidator(q.valid_date_max))
elif q.type == Question.TYPE_TIME:
if initial and initial.answer:
try:
_initial = dateutil.parser.parse(initial.answer).time()
@@ -805,29 +831,29 @@ class BaseQuestionsForm(forms.Form):
else:
_initial = None
field = forms.TimeField(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
initial=_initial,
widget=TimePickerWidget(without_seconds=True),
)
elif datafield.type == Question.TYPE_DATETIME:
help_text = qc.help_text
elif q.type == Question.TYPE_DATETIME:
help_text = q.help_text
if not help_text:
if datafield.valid_datetime_min and datafield.valid_datetime_max:
if q.valid_datetime_min and q.valid_datetime_max:
help_text = format_lazy(
_('Please enter a date and time between {min} and {max}.'),
min=date_format(datafield.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
max=date_format(datafield.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
)
elif datafield.valid_datetime_min:
elif q.valid_datetime_min:
help_text = format_lazy(
_('Please enter a date and time no earlier than {min}.'),
min=date_format(datafield.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
)
elif datafield.valid_datetime_max:
elif q.valid_datetime_max:
help_text = format_lazy(
_('Please enter a date and time no later than {max}.'),
max=date_format(datafield.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
)
if initial and initial.answer:
@@ -839,20 +865,20 @@ class BaseQuestionsForm(forms.Form):
_initial = None
field = SplitDateTimeField(
label=escape(qc.label), required=required,
label=escape(q.question), required=required,
help_text=rich_text(help_text),
initial=_initial,
widget=SplitDateTimePickerWidget(
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
min_date=datafield.valid_datetime_min,
max_date=datafield.valid_datetime_max
min_date=q.valid_datetime_min,
max_date=q.valid_datetime_max
),
)
if datafield.valid_datetime_min:
field.validators.append(MinDateTimeValidator(datafield.valid_datetime_min))
if datafield.valid_datetime_max:
field.validators.append(MaxDateTimeValidator(datafield.valid_datetime_max))
elif datafield.type == Question.TYPE_PHONENUMBER:
if q.valid_datetime_min:
field.validators.append(MinDateTimeValidator(q.valid_datetime_min))
if q.valid_datetime_max:
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
elif q.type == Question.TYPE_PHONENUMBER:
if initial:
try:
initial = PhoneNumber().from_string(initial.answer)
@@ -865,40 +891,28 @@ class BaseQuestionsForm(forms.Form):
initial = "+{}.".format(phone_prefix)
field = PhoneNumberField(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
# the future.
initial=initial,
widget=WrappedPhoneNumberPrefixWidget()
)
else:
raise ProgrammingError('Invalid question type')
field.datafield = datafield
field.question = q
if answers:
# Cache the answer object for later use
field.answer = answers[0]
if qc.dependency_question_id:
field.widget.attrs['data-question-dependency'] = f"question_{qc.dependency_question.user_datafield_id}" if qc.dependency_question.user_datafield_id else qc.dependency_question.system_datafield
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(qc.dependency_values))
if datafield.type != 'M':
field.widget.attrs['required'] = qc.required and not self.all_optional
field._required = qc.required and not self.all_optional
if q.dependency_question_id:
field.widget.attrs['data-question-dependency'] = q.dependency_question_id
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values))
if q.type != 'M':
field.widget.attrs['required'] = q.required and not self.all_optional
field._required = q.required and not self.all_optional
field.required = False
return field
def 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)}
@@ -950,7 +964,8 @@ class OrderLevelQuestionsForm(BaseQuestionsForm):
"""
Takes two additional keyword arguments:
:param container: The checkout session or order the form should be for
:param checkoutsession: The checkout session the form should be for
:param order: The order the form should be for
:param event: The event this belongs to
"""
request = kwargs.pop('request', None)
@@ -959,35 +974,14 @@ class OrderLevelQuestionsForm(BaseQuestionsForm):
super().__init__(*args, **kwargs)
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')
)
questions = Question.objects.filter(
event=event, container_type=Question.ContainerType.ORDER,
ask_during_checkin=False, hidden=False,
).order_by('position')
answerlist = container.answers.prefetch_related('options')
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
for q in questions:
self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, answerlist, q)
def clean(self):
d = super().clean()
@@ -1014,7 +1008,6 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
orderpos = self.orderpos = kwargs.pop('orderpos', None)
pos = cartpos or orderpos
item = pos.item
questionnaires = pos.item.relevant_questionnaires
event = kwargs.pop('event')
self.all_optional = kwargs.pop('all_optional', False)
self.attendee_addresses_required = event.settings.attendee_addresses_required and not self.all_optional
@@ -1024,17 +1017,18 @@ 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:
df = child.user_datafield
self.fields['question_%s' % df.id] = self.build_user_question_field(request, event, pos.answerlist, child, df)
elif child.system_datafield:
self.fields[child.system_datafield] = self.build_system_question_field(request, event, pos, child)
else:
self.fields['text_%d' % idx] = self.build_text_block(request, event, child)
idx += 1
questions = []
if item.ask_attendee_data:
questions += get_fake_attendee_questions(event.settings)
questions += pos.item.questions_to_ask
questions.sort(key=lambda q: q.position)
for q in questions:
if isinstance(q, FakeQuestion):
self.fields[q.id] = self.build_system_question_field(request, event, pos, q)
else:
self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, pos.answerlist, q)
responses = question_form_fields.send(sender=event, position=pos)
data = pos.meta_info_data
@@ -1099,21 +1093,21 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
)
def build_system_question_field(self, request, event, pos, qc):
field_name = qc.system_datafield
field_name = qc.id
if field_name == 'attendee_name_parts':
return NamePartsFormField(
max_length=255,
required=qc.required and not self.all_optional,
scheme=event.settings.name_scheme,
titles=event.settings.name_scheme_titles,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
initial=pos.attendee_name_parts,
)
if field_name == 'attendee_email':
return forms.EmailField(
required=qc.required and not self.all_optional,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
initial=pos.attendee_email,
widget=forms.EmailInput(
@@ -1125,7 +1119,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if field_name == 'company':
return forms.CharField(
required=qc.required and not self.all_optional,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
max_length=255,
initial=pos.company,
@@ -1134,7 +1128,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if field_name == 'street':
return forms.CharField(
required=qc.required and not self.all_optional,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
widget=forms.Textarea(attrs={
'rows': 2,
@@ -1147,7 +1141,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
return forms.CharField(
required=False,
max_length=30,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
initial=pos.zipcode,
widget=forms.TextInput(attrs={
@@ -1157,7 +1151,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if field_name == 'city':
return forms.CharField(
required=False,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
max_length=255,
initial=pos.city,
@@ -1171,7 +1165,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
countries=CachedCountries
).formfield(
required=qc.required and not self.all_optional,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
initial=country,
widget=forms.Select(attrs={
@@ -1199,7 +1193,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
del self.data[fprefix + 'state']
field = forms.ChoiceField(
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
required=False,
choices=c,
+2 -2
View File
@@ -71,8 +71,8 @@ class EmailTransmissionType(TransmissionType):
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
return {
"transmission_email_other": bool(transmission_info.get("transmission_email_address")),
"transmission_email_address": transmission_info.get("transmission_email_address"),
"transmission_email_other": bool((transmission_info or {}).get("transmission_email_address")),
"transmission_email_address": (transmission_info or {}).get("transmission_email_address"),
}
def form_data_to_transmission_info(self, form_data: dict) -> dict:
@@ -1,220 +0,0 @@
# Generated by Django 4.2.29 on 2026-03-19 14:24
import json
from collections import namedtuple
from itertools import chain, groupby
import django.db.models.deletion
import i18nfield.fields
from django.db import migrations, models
from i18nfield.strings import LazyI18nString
import pretix.base.models.base
import pretix.base.models.fields
from pretix.base.i18n import language
FakeQuestion = namedtuple(
'FakeQuestion', 'id question position required'
)
def get_fake_questions(settings):
def b(s):
return s == 'True'
fq = []
sqo = json.loads(settings.get('system_question_order', '{}'))
_ = LazyI18nString.from_gettext
if b(settings.get('attendee_names_asked', 'True')):
fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), b(settings.get('attendee_names_required'))))
if b(settings.get('attendee_emails_asked')):
fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), b(settings.get('attendee_emails_required'))))
if b(settings.get('attendee_company_asked')):
fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), b(settings.get('attendee_company_required'))))
if b(settings.get('attendee_addresses_asked')):
fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('state', _('State'), sqo.get('country', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), b(settings.get('attendee_addresses_required'))))
return fq
def migrate_questions_forward(apps, schema_editor):
from django.utils.translation import (
get_supported_language_variant, gettext as _,
)
Event = apps.get_model("pretixbase", "Event")
Item = apps.get_model("pretixbase", "Item")
Question = apps.get_model("pretixbase", "Question")
Questionnaire = apps.get_model("pretixbase", "Questionnaire")
QuestionnaireChild = apps.get_model("pretixbase", "QuestionnaireChild")
EventSettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
def create_grouped_item_questionnaires(event, children, label_prefix, questionnaire_type, personalized_items=None):
# 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]
internal_name = label_prefix + ', '.join(str(iname or name) for (id, iname, name) in items)
if all(i.personalized for i in items) and len(items) == len(personalized_items) and questionnaire_type == 'PS':
# if we have a questionnaire for exactly all personalized items, we just call it "Attendee data"
# (same as the default questionnaire for new events)
internal_name = _('Attendee data')
# create questionnaires and children
questionnaire = Questionnaire.objects.create(
event=event, type=questionnaire_type, position=0, all_sales_channels=True,
internal_name=internal_name
)
questionnaire.items.set([id for (id, iname, name) in items])
deps = {}
for position, child in enumerate(children):
if isinstance(child, FakeQuestion):
QuestionnaireChild.objects.create(
questionnaire=questionnaire,
position=position + 1,
system_datafield=child.id,
required=child.required,
label=child.question,
)
else:
deps[child.id] = QuestionnaireChild.objects.create(
questionnaire=questionnaire,
position=position + 1,
user_datafield=child,
required=child.required,
label=child.question,
help_text=child.help_text,
dependency_question=deps[child.dependency_question.id] if child.dependency_question else None,
dependency_values=child.dependency_values,
)
for event in Event.objects.iterator():
# get relevant settings
settings = {
setting.key: setting.value for setting in EventSettingsStore.objects.filter(object_id=event.id, key__in=(
'system_question_order', 'attendee_names_asked', 'attendee_names_required', 'attendee_emails_asked', 'attendee_emails_required',
'attendee_company_asked', 'attendee_company_required', 'attendee_addresses_asked', 'attendee_addresses_required', 'locale',
))
}
with language(get_supported_language_variant(settings.get('locale', 'en'))):
# [P] get all ticket-level questions (user-defined and system provided), along with the products for which they're asked
questions = event.questions.filter(container_type='P')
# [PS] presale
personalized_items = event.items.filter(personalized=True).values_list('id', 'internal_name', 'name')
children = sorted(chain((
(item, q.position, 0, q)
for q in get_fake_questions(settings)
for item in personalized_items
), (
(item, q.position, q.id, q)
for q in questions.filter(hidden=False, ask_during_checkin=False)
for item in q.items.values_list('id', 'internal_name', 'name')
)), key=lambda t: (t[0], t[1]))
create_grouped_item_questionnaires(event, children, '', 'PS', personalized_items)
# [PC] checkin
children = sorted(chain((
(item, q.position, q.id, q)
for q in questions.filter(ask_during_checkin=True)
for item in q.items.values_list('id', 'internal_name', 'name')
)), key=lambda t: (t[0], t[1]))
create_grouped_item_questionnaires(event, children, _('Check-in questions for '), 'PC')
# [PH] hidden
children = sorted(chain((
(item, q.position, q.id, q)
for q in questions.filter(hidden=True, ask_during_checkin=False)
for item in q.items.values_list('id', 'internal_name', 'name')
)), key=lambda t: (t[0], t[1]))
create_grouped_item_questionnaires(event, children, _('Hidden questions for '), 'PH')
# [O] get all order-level questions
questions = list(event.questions.filter(container_type='O', hidden=False).order_by('position'))
if questions:
# create questionnaires and children
questionnaire = Questionnaire.objects.create(
event=event, type='OS', position=0, all_sales_channels=True,
internal_name=_('Per-order questions'),
)
deps = {}
for position, child in enumerate(questions):
deps[child.id] = QuestionnaireChild.objects.create(
questionnaire=questionnaire,
position=position + 1,
user_datafield=child,
required=child.required,
label=child.question,
help_text=child.help_text,
dependency_question=deps[child.dependency_question.id] if child.dependency_question else None,
dependency_values=child.dependency_values,
)
def migrate_questions_backward(apps, schema_editor):
pass # as long as we don't delete the old columns, this is a no op. after that, it gets complicated...
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0311_fix_unshredded_invoices'),
]
operations = [
migrations.CreateModel(
name='Questionnaire',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('internal_name', models.CharField(max_length=255)),
('type', models.CharField(max_length=5)),
('position', models.PositiveIntegerField(default=0)),
('all_sales_channels', models.BooleanField(default=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questionnaires', to='pretixbase.event')),
('items', models.ManyToManyField(related_name='questionnaires', to='pretixbase.item')),
('limit_sales_channels', models.ManyToManyField(to='pretixbase.saleschannel')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='QuestionnaireChild',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('position', models.PositiveIntegerField(default=0)),
('system_datafield', models.CharField(max_length=25, null=True)),
('required', models.BooleanField(default=False)),
('label', i18nfield.fields.I18nTextField()),
('help_text', i18nfield.fields.I18nTextField(null=True)),
('dependency_values', pretix.base.models.fields.MultiStringField(default=[])),
('dependency_question', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dependent_questions', to='pretixbase.questionnairechild')),
('questionnaire', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='children', to='pretixbase.questionnaire')),
('user_datafield', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='references', to='pretixbase.question')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.RunPython(
migrate_questions_forward,
migrate_questions_backward,
),
# TODO(questionnaires) remove old columns from Question model
]
+2 -2
View File
@@ -37,8 +37,8 @@ from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question,
Questionnaire, QuestionnaireChild, QuestionOption, Quota, SubEventItem,
SubEventItemVariation, itempicture_upload_to,
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
itempicture_upload_to,
)
from .log import LogEntry
from .mail import OutgoingMail
-7
View File
@@ -737,9 +737,6 @@ 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):
@@ -1429,10 +1426,6 @@ class Event(EventMixin, LoggedModel):
return issues
@property
def sales_channels(self):
return self.organizer.sales_channels if self.all_sales_channels else self.limit_sales_channels
def get_users_with_any_permission(self):
"""
Returns a queryset of users who have any permission to this event.
+52 -241
View File
@@ -41,7 +41,6 @@ import warnings
from collections import Counter, OrderedDict
from datetime import date, datetime, time, timedelta
from decimal import Decimal, DecimalException
from itertools import groupby
from typing import Optional, Tuple
from zoneinfo import ZoneInfo
@@ -1566,14 +1565,12 @@ class ItemBundle(models.Model):
raise ValidationError(_('The count needs to be equal to or greater than zero.'))
class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
class Question(LoggedModel):
"""
A question is a data field that can be used to extend an order or a ticket by custom
information, e.g. "Attendee age". To be actually useful, questions need to be added to
one or multiple Questionnaires. The answers may be found in QuestionAnswers, attached
to Orders, OrderPositions or CartPositions.
A question can allow one of several input types, currently:
A question is an input field that can be used to extend a ticket by custom information,
e.g. "Attendee age". The answers are found next to the position. The answers may be found
in QuestionAnswers, attached to OrderPositions/CartPositions. A question can allow one of
several input types, currently:
* a number (``TYPE_NUMBER``)
* a one-line string (``TYPE_STRING``)
@@ -1593,7 +1590,7 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
:param required: Whether answering this question is required for submitting an order including
items associated with this question.
:type required: bool
:param items: TO BE REMOVED
:param items: A set of ``Items`` objects that this question should be applied to
:param ask_during_checkin: Whether to ask this question during check-in instead of during check-out.
:type ask_during_checkin: bool
:param show_during_checkin: Whether to show the answer to this question during check-in.
@@ -1610,22 +1607,6 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
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"
@@ -1638,8 +1619,20 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
TYPE_DATETIME = "W"
TYPE_COUNTRYCODE = "CC"
TYPE_PHONENUMBER = "TEL"
TYPE_CHOICES = FieldType.choices
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")),
)
UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME]
ASK_DURING_CHECKIN_UNSUPPORTED = []
SHOW_DURING_CHECKIN_UNSUPPORTED = [TYPE_FILE]
@@ -1655,8 +1648,8 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
verbose_name=_("Asked on"),
default=ContainerType.ORDERPOSITION,
)
question = I18nTextField( # TODO(questionnaires) : to be renamed to 'internal_name'
verbose_name=_("Internal name"),
question = I18nTextField(
verbose_name=_("Question")
)
identifier = models.CharField(
max_length=190,
@@ -1670,46 +1663,42 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
),
],
)
#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(
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=FieldType.choices,
choices=TYPE_CHOICES,
verbose_name=_("Question type")
)
tbd_required = models.BooleanField( # TODO(questionnaires) : to be removed, -> QuestionnaireChild
required = models.BooleanField(
default=False,
verbose_name=_("Required question"),
db_column="required",
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')
# )
tbd_position = models.PositiveIntegerField( # TODO(questionnaires) : to be removed, -> Questionnaire + QuestionnaireChild
items = models.ManyToManyField(
Item,
related_name='questions',
verbose_name=_("Products"),
blank=True,
help_text=_('This question will be asked to buyers of the selected products')
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position"),
db_column="position",
verbose_name=_("Position")
)
tbd_ask_during_checkin = models.BooleanField( # TODO(questionnaires) : to be removed
ask_during_checkin = models.BooleanField(
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
help_text=_('Not supported by all check-in apps for all question types.'),
default=False,
db_column="ask_during_checkin",
default=False
)
show_during_checkin = models.BooleanField(
verbose_name=_('Show answer during check-in'),
help_text=_('Not supported by all check-in apps for all question types.'),
default=False
)
hidden = models.BooleanField( # to be removed
hidden = models.BooleanField(
verbose_name=_('Hidden question'),
help_text=_('This question will only show up in the backend.'),
default=False
@@ -1718,10 +1707,10 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
verbose_name=_('Print answer on invoices'),
default=False
)
dependency_question = models.ForeignKey( # TODO(questionnaires) : to be removed, -> QuestionnaireChild
dependency_question = models.ForeignKey(
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
)
dependency_values = MultiStringField(default=[]) # TODO(questionnaires) : to be removed, -> QuestionnaireChild
dependency_values = MultiStringField(default=[])
valid_number_min = models.DecimalField(decimal_places=6, max_digits=30, null=True, blank=True,
verbose_name=_('Minimum value'),
help_text=_('Currently not supported in our apps and during check-in'))
@@ -1760,9 +1749,9 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Data field")
verbose_name_plural = _("Data fields")
ordering = ('question', 'id')
verbose_name = _("Question")
verbose_name_plural = _("Questions")
ordering = ('position', 'id')
unique_together = (('event', 'identifier'),)
def __str__(self):
@@ -1800,16 +1789,15 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
@property
def sortkey(self):
return self.id
return self.position, self.id
def __lt__(self, other) -> bool:
return self.sortkey < other.sortkey
def clean_answer(self, answer):
# TODO(questionnaires) - we don't have Question.required any more - do we actually need this check? if yes, pass in required from caller
# if self.required:
# if not answer or (self.type == Question.TYPE_BOOLEAN and answer not in ("true", "True", True)):
# raise ValidationError(_('An answer to this question is required to proceed.'))
if self.required:
if not answer or (self.type == Question.TYPE_BOOLEAN and answer not in ("true", "True", True)):
raise ValidationError(_('An answer to this question is required to proceed.'))
if not answer:
if self.type == Question.TYPE_BOOLEAN:
return False
@@ -1914,7 +1902,7 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
return answer
@staticmethod
def clean_items(event, items): # TODO(questionnaires) : remove method / move to qc
def clean_items(event, items):
for item in items:
if event != item.event:
raise ValidationError(_('One or more items do not belong to this event.'))
@@ -1995,183 +1983,6 @@ class QuestionOption(models.Model):
ordering = ('position', 'id')
class Questionnaire(LoggedModel):
class QuestionnaireType(models.TextChoices):
ORDER_SALE = "OS", _("Order-wide, before purchase")
ORDER_POSITION_SALE = "PS", _("Per product, before purchase")
ORDER_POSITION_ATTENDEE_ONLY = "PA", _("Per product, via attendee link")
ORDER_POSITION_CHECKIN = "PC", _("Per product, at check-in")
ORDER_POSITION_HIDDEN = "PH", _("Per product, hidden")
event = models.ForeignKey(
Event,
related_name="questionnaires",
on_delete=models.CASCADE
)
internal_name = models.CharField(
verbose_name=_("Internal name"),
max_length=255,
)
type = models.CharField(
max_length=5,
choices=QuestionnaireType.choices,
verbose_name=_("Questionnaire type")
)
items = models.ManyToManyField(
Item,
related_name='questionnaires',
verbose_name=_("Products"),
blank=True,
help_text=_('This questionnaire will be asked to buyers of the selected products')
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
all_sales_channels = models.BooleanField(
verbose_name=_("Sell on all sales channels the product is sold on"),
default=True,
)
limit_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_("Restrict to specific sales channels"),
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
'selected here but not on product level, the variation will not be available.'),
blank=True,
)
class Meta:
ordering = ('position', 'id')
def __str__(self) -> str:
return self.internal_name
@property
def sortkey(self):
return self.position, self.id
def __lt__(self, other) -> bool:
return self.sortkey < other.sortkey
@staticmethod
def check_constraints(event):
errors = set()
all_questionnaires = Questionnaire.objects.filter(event=event).prefetch_related('children', 'items')
all_sales_channels = event.sales_channels.all()
for (sales_channel, type, item_id, sdf, udf), fields in groupby(sorted(
(sales_channel, q.type, item.id, c.system_datafield or '', c.user_datafield_id or 0, q, item)
for q in all_questionnaires
for sales_channel in (q.limit_sales_channels if not q.all_sales_channels else all_sales_channels).values_list('pk', flat=True)
for item in q.items.all()
for c in q.children.all()
), key=lambda d: (d[0], d[1], d[2], d[3], d[4])):
fields = list(fields)
if len(fields) > 1:
sales_channel, type, item_id, sdf, udf, q, item = fields[0]
questionnaires = set(q for sc, type, item, sdf, udf, q, item in fields)
df_label = _('System data field') + f' "{sdf}"' if sdf else _('User-defined data field') + f' "{str(udf)}"'
if len(questionnaires) == 1:
errors.add(_('{datafield} added twice to questionnaire "{questionnaire}"').format(
datafield=df_label, questionnaire=next(str(q) for q in questionnaires)
))
else:
errors.add(_('{datafield} added twice to the same item "{item}" via questionnaires "{questionnaires}"').format(
datafield=df_label, item=str(item), questionnaires='" and "'.join(str(q) for q in questionnaires)
))
if errors:
raise ValidationError(list(errors))
class QuestionnaireChild(LoggedModel):
class SystemQuestion(models.TextChoices):
ATTENDEE_NAME_PARTS = 'attendee_name_parts', _('Attendee name')
ATTENDEE_EMAIL = 'attendee_email', _('Attendee email')
COMPANY = 'company', _('Company')
STREET = 'street', _('Street')
ZIPCODE = 'zipcode', _('ZIP code')
CITY = 'city', _('City')
STATE = 'state', _('State')
COUNTRY = 'country', _('Country')
# _name_schemes_all_fields = sorted(list(set(k for scheme in PERSON_NAME_SCHEMES.values() for k, v, w, *__ in scheme['fields'])))
_name_schemes_all_fields = ['calling_name', 'degree', 'family_name', 'full_name', 'given_name',
'latin_transcription', 'middle_name', 'salutation', 'title']
SYSTEM_DATAFIELD_NUMBERS = [
SystemQuestion.ATTENDEE_NAME_PARTS.value,
SystemQuestion.ATTENDEE_EMAIL.value,
SystemQuestion.COMPANY.value,
SystemQuestion.STREET.value,
SystemQuestion.ZIPCODE.value,
SystemQuestion.CITY.value,
SystemQuestion.STATE.value,
SystemQuestion.COUNTRY.value,
] + [
'attendee_name_parts:' + field
for field in _name_schemes_all_fields
]
questionnaire = models.ForeignKey(
Questionnaire,
related_name="children",
on_delete=models.CASCADE
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
user_datafield = models.ForeignKey(
Question,
related_name="referenced_by",
on_delete=models.CASCADE,
null=True, blank=True,
)
system_datafield = models.CharField(
max_length=25,
choices=SystemQuestion.choices,
null=True, blank=True,
)
required = models.BooleanField(
default=False,
verbose_name=_("Required question")
)
label = I18nTextField(
verbose_name=_("Question")
)
help_text = I18nTextField(
verbose_name=_("Help text"),
help_text=_("If the question needs to be explained or clarified, do it here!"),
null=True, blank=True,
)
dependency_question = models.ForeignKey(
'QuestionnaireChild', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
)
dependency_values = MultiStringField(default=[])
class Meta:
ordering = ('position', 'id')
@property
def cooked_id(self):
return QuestionnaireChild.cook_id_from_parts(self.questionnaire_id, self.user_datafield_id, self.system_datafield)
@staticmethod
def cook_id_from_parts(qid, dfid, sys_df):
df_id = dfid + 100 if dfid else QuestionnaireChild.SYSTEM_DATAFIELD_NUMBERS.index(sys_df)
assert qid < 1000000000
return qid * 1000000000 + df_id
@staticmethod
def uncook_id(id):
id = int(id)
if id > 1000000000:
qid, dfid = divmod(id, 1000000000)
if dfid >= 100:
return qid, dfid - 100, None
else:
return qid, None, QuestionnaireChild.SYSTEM_DATAFIELD_NUMBERS[dfid]
else:
return None, id, None
class Quota(LoggedModel):
"""
A quota is a "pool of tickets". It is there to limit the number of items
+48 -94
View File
@@ -95,9 +95,7 @@ from ._transactions import (
)
from .base import LockModel, LoggedModel
from .event import Event, SubEvent
from .items import (
Item, ItemVariation, Question, QuestionnaireChild, QuestionOption, Quota,
)
from .items import Item, ItemVariation, Question, QuestionOption, Quota
logger = logging.getLogger(__name__)
@@ -920,7 +918,7 @@ class Order(LockModel, LoggedModel):
positions = list(
self.positions.all().annotate(
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'), list__consider_tickets_used=True))
).select_related('item').prefetch_related('item__questionnaires')
).select_related('item').prefetch_related('item__questions')
)
if not self.event.settings.allow_modifications_after_checkin:
for cp in positions:
@@ -931,7 +929,7 @@ class Order(LockModel, LoggedModel):
return True
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
for cp in positions:
if (cp.item.ask_attendee_data and ask_names) or cp.item.questionnaires.all(): # TODO(questionnaires) : filter to only QuestionnaireType.ORDER_POSITION_SALE
if (cp.item.ask_attendee_data and ask_names) or cp.item.questions.all():
return True
return False # nothing there to modify
@@ -1451,12 +1449,6 @@ class QuestionAnswer(models.Model):
else:
return self.answer
def to_dependency_values(self):
if self.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
return [o.identifier for o in self.options.all()]
elif self.question.type in (Question.TYPE_BOOLEAN, Question.TYPE_COUNTRYCODE):
return self.answer
def save(self, *args, **kwargs):
if self.orderposition and self.cartposition:
raise ValueError('QuestionAnswer cannot be linked to an order and a cart position at the same time.')
@@ -1599,92 +1591,55 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
def meta_info_data(self, d):
self.meta_info = json.dumps(d)
def cache_answers(self, questionnaire_type, sales_channel):
def cache_answers(self, all=True):
"""
Creates a new property on the object:
questions: a list of Question objects, extended by an 'answer' property
Creates two properties on the object.
(1) answ: a dictionary of question.id → answer string
(2) questions: a list of Question objects, extended by an 'answer' property
"""
self.answ = {}
for a in getattr(self, 'answerlist', self.answers.all()): # use prefetch_related cache from get_cart
self.answ[a.question_id] = a
# We need to clone our question objects, otherwise we will override the cached
# answers of other items in the same cart if the question objects have been
# selected via prefetch_related
if hasattr(self.item, 'relevant_questionnaires'):
children = list(copy.copy(qc) for qq in self.item.relevant_questionnaires for qc in qq.childlist)
if not all:
if hasattr(self.item, 'questions_to_ask'):
questions = list(copy.copy(q) for q in self.item.questions_to_ask)
else:
questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False,
hidden=False))
else:
children = QuestionnaireChild.objects.filter(questionnaire__event_id=self.item.event_id, questionnaire__items__in=[self.item.id])
if questionnaire_type is not None:
children = children.filter(questionnaire__type=questionnaire_type)
if sales_channel is not None:
children = children.filter(Q(questionnaire__all_sales_channels=True) | Q(questionnaire__limit_sales_channels=sales_channel))
questions = list(copy.copy(q) for q in self.item.questions.all())
children = list(children)
qc_cache = {
q.pk: q for q in children
question_cache = {
q.pk: q for q in questions
}
def qc_is_visible(parentid, qvals):
if parentid not in qc_cache:
def question_is_visible(parentid, qvals):
if parentid not in question_cache:
return False
parentqc = qc_cache[parentid]
if parentqc.dependency_question_id and not qc_is_visible(parentqc.dependency_question_id, parentqc.dependency_values):
parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
return False
answer_values = self.get_dependency_answer_values(parentqc)
return answer_values and any(qval in answer_values for qval in qvals)
if parentid not in self.answ:
return False
return (
('True' in qvals and self.answ[parentid].answer == 'True')
or ('False' in qvals and self.answ[parentid].answer == 'False')
or (any(qval in [o.identifier for o in self.answ[parentid].options.all()] for qval in qvals))
)
self.questions = []
for qc in children:
try:
qc.answer = self.answer_cache[qc.user_datafield_id or qc.system_datafield]
except KeyError:
continue
if not qc.dependency_question_id or qc_is_visible(qc.dependency_question_id, qc.dependency_values):
self.questions.append(qc)
return self.questions
@cached_property
def answer_cache(self):
return {
"attendee_name_parts": self.attendee_name,
"attendee_email": self.attendee_email,
"company": self.company,
"street": self.street,
"zipcode": self.zipcode,
"city": self.city,
"state": self.state,
"country": str(self.country),
**{aw.question_id: aw for aw in getattr(self, 'answerlist', self.answers.all())},
}
def get_dependency_answer_values(self, qc):
if qc.user_datafield_id:
if qc.user_datafield_id not in self.answer_cache:
return None
answer = self.answer_cache[qc.user_datafield_id]
return answer.to_dependency_values()
elif qc.system_datafield:
return [self.get_system_answer(qc.system_datafield)]
else:
raise ValueError('Questionnaire child without datafield has no answer')
def get_system_answer(self, system_datafield_name):
if system_datafield_name == 'attendee_name_parts':
return self.attendee_name
elif system_datafield_name == 'attendee_email':
return self.attendee_email
elif system_datafield_name == 'company':
return self.company
elif system_datafield_name == 'street':
return self.street
elif system_datafield_name == 'zipcode':
return self.zipcode
elif system_datafield_name == 'city':
return self.city
elif system_datafield_name == 'state':
return self.state
elif system_datafield_name == 'country':
return str(self.country)
else:
raise ValueError('Unknown system question name')
for q in questions:
if q.id in self.answ:
q.answer = self.answ[q.id]
q.answer.question = q # cache object
else:
q.answer = ""
if not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values):
self.questions.append(q)
@property
def net_price(self):
@@ -1697,21 +1652,20 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
else self.variation.quotas.filter(subevent=self.subevent))
def save(self, *args, **kwargs):
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')
update_fields = kwargs.get('update_fields', set())
if 'attendee_name_parts' in update_fields:
kwargs['update_fields'] = {'attendee_name_cached'}.union(kwargs['update_fields'])
name = self.attendee_name
if name != self.attendee_name_cached:
self.attendee_name_cached = name
ensure_updated('attendee_name_cached')
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'attendee_name_cached'}.union(kwargs['update_fields'])
if self.attendee_name_parts is None:
self.attendee_name_parts = {}
ensure_updated('attendee_name_parts')
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'attendee_name_parts'}.union(kwargs['update_fields'])
super().save(*args, **kwargs)
@property
@@ -2787,7 +2741,7 @@ class OrderPosition(AbstractPosition):
positions = list(
self.order.positions.all().annotate(
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'), list__consider_tickets_used=True))
).select_related('item').prefetch_related('item__questionnaires')
).select_related('item').prefetch_related('item__questions')
)
if not self.event.settings.allow_modifications_after_checkin:
for cp in positions:
@@ -2797,7 +2751,7 @@ class OrderPosition(AbstractPosition):
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
for cp in positions:
if cp.pk == self.pk or cp.addon_to_id == self.pk:
if (cp.item.ask_attendee_data and ask_names) or cp.item.questionnaires.all(): # TODO(questionnaires) : filter to only QuestionnaireType.ORDER_POSITION_SALE
if (cp.item.ask_attendee_data and ask_names) or cp.item.questions.all():
return True
return False # nothing there to modify
+3 -3
View File
@@ -72,7 +72,7 @@ from pretix.helpers.countries import CachedCountries
from pretix.helpers.format import format_map
from pretix.helpers.money import DecimalTextInput
from pretix.multidomain.urlreverse import eventreverse_absolute
from pretix.presale.views import get_cart_positions
from pretix.presale.views import get_cart
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
logger = logging.getLogger(__name__)
@@ -1165,7 +1165,7 @@ class FreeOrderProvider(BasePaymentProvider):
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
from .services.cart import get_fees
cart = get_cart_positions(request)
cart = get_cart(request)
try:
fees = get_fees(event=request.event, request=request,
@@ -1433,7 +1433,7 @@ class GiftCardPayment(BasePaymentProvider):
for p in cs.get('payments', [])
if p.get('info_data', {}).get('gift_card')
]
positions = get_cart_positions(request)
positions = get_cart(request)
testmode = self.event.testmode
else:
used_cards = []
-1
View File
@@ -1410,7 +1410,6 @@ class CartManager:
for k, v in self._widget_data.items():
if not k.startswith('question-'):
continue
# TODO(questionnaires) - switch to QuestionnaireChild
q = cp.item.questions.filter(ask_during_checkin=False, identifier__iexact=k[9:]).first()
if q:
try:
+15 -17
View File
@@ -55,7 +55,7 @@ from django_scopes import scope, scopes_disabled
from pretix.base.models import (
Checkin, CheckinList, Device, Event, Gate, Item, ItemVariation, Order,
OrderPosition, Questionnaire, QuestionnaireChild, QuestionOption,
OrderPosition, QuestionOption,
)
from pretix.base.signals import checkin_created, periodic_task
from pretix.helpers import OF_SELF
@@ -897,14 +897,14 @@ def _save_answers(op, answers, given_answers):
written = False
for q, a in given_answers.items():
if not a:
if q.id in answers:
if q in answers:
written = True
answers[q.id].delete()
answers[q].delete()
else:
continue
if isinstance(a, QuestionOption):
if q.id in answers:
qa = answers[q.id]
if q in answers:
qa = answers[q]
qa.answer = str(a.answer)
qa.save()
written = True
@@ -913,8 +913,8 @@ def _save_answers(op, answers, given_answers):
qa = _create_answer(question=q, answer=str(a.answer))
qa.options.add(a)
elif isinstance(a, list):
if q.id in answers:
qa = answers[q.id]
if q in answers:
qa = answers[q]
qa.answer = ", ".join([str(o) for o in a])
qa.save()
written = True
@@ -923,8 +923,8 @@ def _save_answers(op, answers, given_answers):
qa = _create_answer(question=q, answer=", ".join([str(o) for o in a]))
qa.options.add(*a)
elif isinstance(a, File):
if q.id in answers:
qa = answers[q.id]
if q in answers:
qa = answers[q]
else:
qa = _create_answer(question=q, answer=str(a))
qa.file.save(os.path.basename(a.name), a, save=False)
@@ -932,8 +932,8 @@ def _save_answers(op, answers, given_answers):
qa.save()
written = True
else:
if q.id in answers:
qa = answers[q.id]
if q in answers:
qa = answers[q]
qa.answer = str(a)
qa.save()
else:
@@ -1033,19 +1033,17 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
# Do this outside of transaction so it is saved even if the checkin fails for some other reason
checkin_questions = list(
QuestionnaireChild.objects.filter(
questionnaire__event=clist.event, questionnaire__type=Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN,
questionnaire__items__in=[op.item_id],
).select_related('user_datafield', 'questionnaire')
clist.event.questions.filter(ask_during_checkin=True, items__in=[op.item_id])
)
require_answers = []
if type != Checkin.TYPE_EXIT and checkin_questions:
answers = {a.question: a for a in op.answers.all()}
for q in checkin_questions:
if q.user_datafield not in given_answers and q.user_datafield_id not in op.answer_cache:
if q not in given_answers and q not in answers:
require_answers.append(q)
if not simulate:
_save_answers(op, op.answer_cache, given_answers)
_save_answers(op, answers, given_answers)
with conditional_atomic(not simulate):
# Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic
+101 -14
View File
@@ -379,12 +379,99 @@ DEFAULTS = {
)
},
# 'system_question_order': { # TODO(questionnaires) - remove this
# 'default': {},
# 'type': dict,
# 'serializer_class': serializers.DictField,
# 'serializer_kwargs': lambda: dict(read_only=True, allow_empty=True),
# },
'system_question_order': {
'default': {},
'type': dict,
'serializer_class': serializers.DictField,
'serializer_kwargs': lambda: dict(read_only=True, allow_empty=True),
},
'attendee_names_asked': {
'default': 'True',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for attendee names"),
help_text=_("Ask for a name for all personalized tickets."),
)
},
'attendee_names_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require attendee names"),
help_text=_("Require customers to fill in the names of all attendees."),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_names_asked'}),
)
},
'attendee_emails_asked': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for email addresses per ticket"),
help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be sent "
"only to that email address. If you enable this option, the system will additionally ask for "
"individual email addresses for every personalized ticket. This might be useful if you want to "
"obtain individual addresses for every attendee even in case of group orders. However, "
"pretix will send the order confirmation by default only to the one primary email address, not to "
"the per-attendee addresses. You can however enable this in the email settings."),
)
},
'attendee_emails_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require email addresses per ticket"),
help_text=_("Require customers to fill in individual email addresses for all personalized tickets. See the "
"above option for more details. One email address for the order confirmation will always be "
"required regardless of this setting."),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}),
)
},
'attendee_company_asked': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for company per ticket"),
)
},
'attendee_company_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require company per ticket"),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_company_asked'}),
)
},
'attendee_addresses_asked': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for postal addresses per ticket"),
)
},
'attendee_addresses_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require postal addresses per ticket"),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_addresses_asked'}),
)
},
'order_email_asked_twice': {
'default': 'False',
'type': bool,
@@ -4085,14 +4172,14 @@ def validate_event_settings(event, settings_dict):
raise ValidationError({
'locale': _('Your default locale must also be enabled for your event (see box above).')
})
# if settings_dict.get('attendee_names_required') and not settings_dict.get('attendee_names_asked'):
# raise ValidationError({
# 'attendee_names_required': _('You cannot require specifying attendee names if you do not ask for them.')
# })
# if settings_dict.get('attendee_emails_required') and not settings_dict.get('attendee_emails_asked'):
# raise ValidationError({
# 'attendee_emails_required': _('You have to ask for attendee emails if you want to make them required.')
# })
if settings_dict.get('attendee_names_required') and not settings_dict.get('attendee_names_asked'):
raise ValidationError({
'attendee_names_required': _('You cannot require specifying attendee names if you do not ask for them.')
})
if settings_dict.get('attendee_emails_required') and not settings_dict.get('attendee_emails_asked'):
raise ValidationError({
'attendee_emails_required': _('You have to ask for attendee emails if you want to make them required.')
})
if settings_dict.get('invoice_address_required') and not settings_dict.get('invoice_address_asked'):
raise ValidationError({
'invoice_address_required': _('You have to ask for invoice addresses if you want to make them required.')
+1 -1
View File
@@ -44,7 +44,7 @@ def timeline_for_event(event, subevent=None):
ev = subevent or event
if subevent:
ev_edit_url = reverse(
'control:event.subevent', kwargs={
'control:event.subevent.edit', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'subevent': subevent.pk
+21 -31
View File
@@ -27,7 +27,7 @@ from decimal import Decimal
from django import forms
from django.core.files.uploadedfile import UploadedFile
from django.db import IntegrityError
from django.db.models import Prefetch, Q, QuerySet
from django.db.models import Prefetch, QuerySet
from django.utils.functional import cached_property
from django.utils.timezone import make_aware
@@ -37,7 +37,7 @@ from pretix.base.forms.questions import (
)
from pretix.base.models import (
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
Questionnaire, QuestionnaireChild, QuestionOption,
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, 'datafield'):
src = overrides.get(question_field.datafield.identifier)
if hasattr(question_field, 'question'):
src = overrides.get(question_field.question.identifier)
else:
src = overrides.get(question_name)
if not src:
@@ -184,7 +184,7 @@ class BaseQuestionsViewMixin:
field, v,
checkoutsession=checkoutsession,
order=order,
question=field.datafield,
question=field.question,
)
for form in self.forms:
@@ -246,7 +246,7 @@ class BaseQuestionsViewMixin:
field, v,
cartposition=cartposition,
orderposition=orderposition,
question=field.datafield,
question=field.question,
)
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.datafield.type,
'question_identifier': field.datafield.identifier,
'question_type': field.question.type,
'question_identifier': field.question.identifier,
}
@@ -348,37 +348,27 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
@cached_property
def positions(self):
qqs = self.request.event.questionnaires.all()
qqs = self.request.event.questions.all()
if self.only_user_visible:
qqs = qqs.filter(type=Questionnaire.QuestionnaireType.ORDER_POSITION_SALE)
else:
qqs = qqs.filter(type__startswith='P')
qqs = qqs.filter(
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=self.order.sales_channel.identifier)
)
qqs = qqs.filter(ask_during_checkin=False, hidden=False, container_type=Question.ContainerType.ORDERPOSITION)
return list(self.order.positions.select_related(
'item', 'variation'
).prefetch_related(
Prefetch('answers',
QuestionAnswer.objects.prefetch_related('options'),
to_attr='answerlist'),
Prefetch('item__questionnaires',
Prefetch('item__questions',
qqs.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')
),
to_attr='relevant_questionnaires')
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
'question',
Question.objects.none(),
to_attr='dummy'
)))
).select_related('dependency_question'),
to_attr='questions_to_ask')
))
@cached_property
-2
View File
@@ -26,7 +26,6 @@ from django.core import signing
from django.http import HttpResponseBadRequest, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.html import format_html
logger = logging.getLogger(__name__)
@@ -61,7 +60,6 @@ def redir_view(request):
u = urllib.parse.urlparse(url)
return render(request, 'pretixbase/redirect.html', {
'hostname': u.hostname,
'bold_hostname': format_html("<strong>{}</strong>", u.hostname),
'url': url,
})
+8
View File
@@ -639,6 +639,14 @@ 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',
+64 -48
View File
@@ -60,13 +60,12 @@ from pretix.base.models import (
Item, ItemCategory, ItemProgramTime, ItemVariation, Question,
QuestionOption, Quota,
)
from pretix.base.models.items import (
ItemAddOn, ItemBundle, ItemMetaValue, Questionnaire,
)
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.signals import item_copy_data
from pretix.control.forms import (
ButtonGroupRadioSelect, ExtFileField, SalesChannelCheckboxSelectMultiple,
SplitDateTimeField, SplitDateTimePickerWidget,
ButtonGroupRadioSelect, ExtFileField, ItemMultipleChoiceField,
SalesChannelCheckboxSelectMultiple, SplitDateTimeField,
SplitDateTimePickerWidget,
)
from pretix.control.forms.widgets import Select2, Select2ItemVarMulti
from pretix.helpers.models import modelcopy
@@ -154,10 +153,53 @@ class QuestionForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.container_type != Question.ContainerType.ORDERPOSITION:
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']
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')
@@ -191,10 +233,16 @@ 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',
@@ -211,11 +259,17 @@ 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,
}
@@ -393,20 +447,13 @@ class ItemCreateForm(I18nModelForm):
help_text=_('Select this option e.g. for t-shirts that come in multiple sizes. '
'You can select the variations in the next step.'),
required=False)
questionnaires = SafeModelMultipleChoiceField(
queryset=Questionnaire.objects.none(),
required=False,
label=_('Questionnaires'),
widget=forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice'
}),
)
def __init__(self, *args, **kwargs):
self.event = kwargs['event']
self.user = kwargs.pop('user')
kwargs.setdefault('initial', {})
kwargs['initial'].setdefault('admission', True)
kwargs['initial'].setdefault('personalized', True)
super().__init__(*args, **kwargs)
self.fields['category'].queryset = self.instance.event.categories.all()
@@ -422,8 +469,6 @@ class ItemCreateForm(I18nModelForm):
)
self.fields['category'].widget.choices = self.fields['category'].choices
self.fields['questionnaires'].queryset = self.instance.event.questionnaires.all()
self.fields['tax_rule'].queryset = self.instance.event.tax_rules.all()
change_decimal_field(self.fields['default_price'], self.instance.event.currency)
self.fields['copy_from'] = forms.ModelChoiceField(
@@ -530,15 +575,10 @@ class ItemCreateForm(I18nModelForm):
self.instance.picture.save(os.path.basename(src.picture.name), src.picture)
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
if not self.instance.admission:
self.instance.personalized = False
instance = super().save(*args, **kwargs)
instance.questionnaires.clear()
instance.questionnaires.add(*self.cleaned_data['questionnaires'])
try:
Questionnaire.check_constraints(self.event)
except ValidationError as exc:
raise ValidationError({'questionnaires': exc})
if not self.event.has_subevents and not self.cleaned_data.get('has_variations'):
if self.cleaned_data.get('quota_option') == self.EXISTING and self.cleaned_data.get('quota_add_existing') is not None:
quota = self.cleaned_data.get('quota_add_existing')
@@ -662,14 +702,6 @@ class ItemUpdateForm(I18nModelForm):
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
required=False,
)
questionnaires = SafeModelMultipleChoiceField(
queryset=Questionnaire.objects.none(),
required=False,
label=_('Questionnaires'),
widget=forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice'
}),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -765,9 +797,6 @@ class ItemUpdateForm(I18nModelForm):
)
self.fields['category'].widget.choices = self.fields['category'].choices
self.fields['questionnaires'].queryset = self.instance.event.questionnaires.all()
self.initial['questionnaires'] = self.instance.questionnaires.values_list('pk', flat=True)
self.fields['free_price_suggestion'].widget.attrs['data-display-dependency'] = '#id_free_price'
self.fields['validity_dynamic_start_choice'] = forms.TypedChoiceField(
@@ -862,16 +891,6 @@ class ItemUpdateForm(I18nModelForm):
return d
def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)
instance.questionnaires.clear()
instance.questionnaires.add(*self.cleaned_data['questionnaires'])
try:
Questionnaire.check_constraints(self.event)
except ValidationError as exc:
raise ValidationError({'questionnaires': exc})
return instance
class Meta:
model = Item
localized_fields = '__all__'
@@ -883,6 +902,7 @@ class ItemUpdateForm(I18nModelForm):
'all_sales_channels',
'limit_sales_channels',
'admission',
'personalized',
'description',
'picture',
'default_price',
@@ -1177,10 +1197,6 @@ 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="{}" data-iframe-dialog="true" data-iframe-dialog-target="<[name$=addon_category]" target="_blank">{}</a>', reverse('control:event.items.categories.add', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}), _("Create a new category"))
self.fields['addon_category'].widget = Select2(
attrs={
'data-model-select2': 'generic',
-3
View File
@@ -866,9 +866,6 @@ class OrganizerPluginStateLogEntryType(LogEntryType):
'pretix.event.question.option.added': _('An answer option has been added to the question.'),
'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'),
'pretix.event.question.option.changed': _('An answer option has been changed.'),
'pretix.event.questionnaire.added': _('A questionnaire has been created.'),
'pretix.event.questionnaire.deleted': _('A questionnaire has been deleted.'),
'pretix.event.questionnaire.changed': _('A questionnaire has been changed.'),
'pretix.event.permissions.added': _('A user has been added to the event team.'),
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
+3 -3
View File
@@ -182,12 +182,12 @@ def get_event_navigation(request: HttpRequest):
'active': 'event.items.categories' in url.url_name,
},
{
'label': _('Questionnaires'),
'url': reverse('control:event.items.questionnaires', kwargs={
'label': _('Questions'),
'url': reverse('control:event.items.questions', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.items.questionnaires' in url.url_name or 'event.items.questions' in url.url_name,
'active': 'event.items.questions' in url.url_name,
},
{
'label': _('Discounts'),
+3 -11
View File
@@ -133,22 +133,14 @@ This signal is sent out to include custom HTML in the top part of the the event
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
An additional keyword argument ``subevent`` *can* contain a sub-event.
"""
event_dashboard_widgets = EventPluginSignal()
event_dashboard_statistics = EventPluginSignal()
"""
This signal is sent out to include widgets in the event dashboard. Receivers
should return a list of dictionaries, where each dictionary can have the keys:
* content (SafeString, containing HTML)
* display_size (str, one of "full" (whole row), "big" (half a row) or "small"
(quarter of a row). May be ignored on small displays, default is "small")
* priority (int, used for ordering, higher comes first, default is 1)
* url (str, optional, if the full widget should be a link)
This signal is sent out to include statistical content on the event dashboard.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
An additional keyword argument ``subevent`` *can* contain a sub-event.
"""
user_dashboard_widgets = GlobalSignal()
@@ -23,6 +23,7 @@
{% endif %}
{% compress js %}
<script type="text/javascript" src="{% static "jquery/js/jquery-3.6.4.min.js" %}"></script>
<script type="text/javascript" src="{% static "htmx/htmx-2.0.10.min.js" %}"></script>
<script type="text/javascript" src="{% static "js/jquery.formset.js" %}"></script>
<script type="text/javascript" src="{% static "typeahead/typeahead.bundle.js" %}"></script>
<script type="text/javascript" src="{% static "bootstrap/js/bootstrap.js" %}"></script>
@@ -0,0 +1,23 @@
{% load i18n %}
{% load icon %}
{% load humanize %}
{% for cl in lists %}
<a class="quotabox quotabox-full availability"
href="{% url "control:event.orders.checkinlists.show" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}">
<strong>{{ cl.name }}</strong>
<div class="progress">
<div class="progress-bar progress-bar-success progress-bar-{{ cl.percent }}">
</div>
</div>
<div class="numbers">
{% icon "sign-in" %}
{{ cl.checkin_count|default_if_none:0|intcomma }} /
{{ cl.position_count|default_if_none:0|intcomma }}
<br>
{% icon "user" %}
{% blocktrans trimmed with n=cl.inside_count|intcomma %}
{{ n }} present
{% endblocktrans %}
</div>
</a>
{% endfor %}
@@ -0,0 +1,17 @@
{% load i18n %}
{% load icon %}
<div id="comment-form">
<strong>{% trans "Comment" %}:</strong>
{% if "event.settings.general:write" in request.eventpermset %}
<button type="button" class="btn btn-default btn-xs"
hx-get="{% url "control:event.index.comment" organizer=request.organizer.slug event=request.event.slug %}"
hx-target="#comment-form">
{% icon "edit" %}
</button>
{% endif %}
{% if request.event.comment %}
<p>
{{ request.event.comment|linebreaksbr }}
</p>
{% endif %}
</div>
@@ -0,0 +1,16 @@
{% load i18n %}
{% load bootstrap3 %}
<form class="form" method="post"
hx-post="{% url "control:event.index.comment" event=request.event.slug organizer=request.event.organizer.slug %}">
{% csrf_token %}
<div>
<p>
{% bootstrap_field form.comment layout="inline" show_help=True show_label=False horizontal_field_class="col-md-12" %}
</p>
<p class="text-right flip">
<button class="btn btn-default">
{% trans "Update comment" %}
</button>
</p>
</div>
</form>
@@ -0,0 +1,4 @@
{% load i18n %}
{% for q in quotas %}
{% include "pretixcontrol/fragment_quota_box.html" with quota=q full=1 %}
{% endfor %}
@@ -0,0 +1,11 @@
{% load i18n %}
{% load humanize %}
{% if count %}
<a href="{% url "control:event.orders.waitinglist" event=request.event.slug organizer=request.organizer.slug %}">
{% blocktrans trimmed with number=count|intcomma count count=count %}
{{ number }} person waiting
{% plural %}
{{ number }} persons waiting
{% endblocktrans %}
</a>
{% endif %}
@@ -1,4 +1,5 @@
{% load i18n %}
{% load eventsignal %}
{% if has_overpaid_orders %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
@@ -56,3 +57,4 @@
class="btn btn-primary">{% trans "Show sync problems" %}</a>
</div>
{% endif %}
{% eventsignal request.event "pretix.control.signals.event_dashboard_top" request=request %}
@@ -0,0 +1,19 @@
{% load i18n %}
<div class="panel panel-primary">
<div class="panel-heading">
<h2 class="panel-title">{% trans "Welcome to pretix!" %}</h2>
</div>
<div class="panel-body">
<div class="attentionline">{% trans "Get started with our setup tool" %}</div>
<p>
{% blocktrans trimmed %}
To start selling tickets, you need to create products or quotas. The fastest way to create
this is to use our setup tool.
{% endblocktrans %}
</p>
<a href="{% url "control:event.quick" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg">
{% trans "Set up event" %}
</a>
</div>
</div>
@@ -1,12 +0,0 @@
<div class="welcome-wizard">
<h3>{{ title }}</h3>
{% if subtitle %}
<div class="attentionline">{{ subtitle }}</div>
{% endif %}
{% if text %}
<p>{{ text }}</p>
{% endif %}
{% if button_text %}
<p><a href="{{ button_url }}" class="btn btn-primary btn-lg">{{ button_text }}</a></p>
{% endif %}
</div>
@@ -1,10 +1,10 @@
{% load i18n %}
<div class="panel panel-default items">
<div class="panel-heading">
<details class="panel panel-default items" open>
<summary class="panel-heading">
<h3 class="panel-title">
{% trans "Your timeline" %}
{% trans "Timeline" %}
</h3>
</div>
</summary>
<div class="panel-body timeline">
{% regroup timeline by date as tl_list %}
{% for day in tl_list %}
@@ -33,4 +33,4 @@
</div>
{% endfor %}
</div>
</div>
</details>
@@ -3,10 +3,10 @@
{% load eventurl %}
{% load bootstrap3 %}
{% load static %}
{% load eventsignal %}
{% load icon %}
{% block title %}{{ request.event.name }}{% endblock %}
{% block content %}
<h1>
<h1 class="event-dashboard-header">
{{ request.event.name }}
<small>
{% if request.event.has_subevents %}
@@ -14,11 +14,43 @@
{% else %}
{{ request.event.get_date_range_display }}
{% endif %}
<span id="warnings_loading" class="fa fa-cog fa-spin"></span>
<span id="warnings_indicator" class="htmx-indicator">{% icon "cog fa-spin" %}</span>
</small>
<div class="pull-right flip">
<a href="{% url "control:event.live" organizer=request.organizer.slug event=request.event.slug %}"
data-toggle="tooltip"
title="{% trans "Click to change shop status" %}">
{% icon "pencil" %}</a>
<a href="{% url "control:event.live" organizer=request.organizer.slug event=request.event.slug %}"
data-toggle="tooltip"
title="{% trans "Click to change shop status" %}">
{% if request.event.live and request.event.testmode %}
<span class="label label-warning">
<span class="fa fa-warning"></span>
{% trans "public test mode" %}
</span>
{% elif request.event.live %}
<span class="label label-success">
<span class="fa fa-check"></span>
{% trans "live" %}
</span>
{% elif request.event.testmode %}
<span class="label label-warning">
<span class="fa fa-power-off"></span>
{% trans "private test mode" %}
</span>
{% else %}
<span class="label label-danger">
<span class="fa fa-power-off"></span>
{% trans "offline" %}
</span>
{% endif %}
</a>
</div>
<div class="clearfix"></div>
</h1>
<div class="helper-space-below">
{% trans "Shop URL:" %}
<strong>{% trans "Shop URL:" %}</strong>
<span id="shop_url" class="text-muted">{% abseventurl request.event "presale:event.index" %}</span>
<button type="button" class="btn btn-default btn-xs btn-clipboard js-only" data-clipboard-target="#shop_url">
<i class="fa fa-clipboard" aria-hidden="true"></i>
@@ -31,75 +63,80 @@
{% include "pretixcontrol/event/fragment_qr_dropdown.html" with url=0 %}
</div>
<div class="clearfix"></div>
</div>
<div id="warnings_target"></div>
{% eventsignal request.event "pretix.control.signals.event_dashboard_top" request=request %}
{% if request.event.has_subevents %}
<form class="form-inline helper-display-inline" action="" method="get">
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
</form>
{% endif %}
{% if not request.event.has_subevents or subevent %}
{% include "pretixcontrol/event/fragment_timeline.html" %}
{% endif %}
<div class="dashboard">
{% for w in widgets %}
<div class="widget-container widget-{{ w.display_size|default:"small" }} {% if w.lazy %}widget-lazy-loading{% endif %}" data-lazy-id="{{ w.lazy }}">
{% if w.url %}{# backwards compatibility #}
<a href="{{ w.url }}" class="widget">
{% if w.lazy %}
<span class="fa fa-cog fa-4x"></span>
{% else %}
{{ w.content }}
{% endif %}
</a>
{% elif w.link %}
<a href="{{ w.link }}" class="widget">
{% if w.lazy %}
<span class="fa fa-cog fa-4x´"></span>
{% else %}
{{ w.content }}
{% endif %}
</a>
{% else %}
<div class="widget">
{% if w.lazy %}
<span class="fa fa-cog fa-4x"></span>
{% else %}
{{ w.content }}
{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
{% include "pretixcontrol/event/dashboard_partial_comment.html" with url=0 %}
</div>
{% if not request.event.items.exists %}
{% include "pretixcontrol/event/dashboard_partial_welcome.html" %}
{% endif %}
<div hx-get="{% url "control:event.index.warnings" organizer=request.organizer.slug event=request.event.slug %}"
{# loading indicator is somewhere else because the happy case is "no warnings" and shouldn't make the page jump #}
hx-indicator="#warnings_indicator"
hx-trigger="load"></div>
{% if stats %}
{{ stats }}
{% endif %}
<p>&nbsp;</p>
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Internal comment" %}
</h3>
</div>
<div class="panel-body">
<form class="form" method="post"
action="{% url "control:event.comment" event=request.event.slug organizer=request.event.organizer.slug %}">
{% csrf_token %}
<div class="row">
{% bootstrap_field comment_form.comment layout="horizontal" show_help=True show_label=False horizontal_field_class="col-md-12" %}
{% if not request.event.items.exists %}
{# pass #}
{% elif not request.event.has_subevents %}
{% include "pretixcontrol/event/fragment_timeline.html" %}
<div class="row">
<div class="col-md-6 col-sm-12">
<div class="panel panel-default">
<div class="panel-heading">
<div class="pull-right">
<div hx-get="{% url "control:event.index.waiting" organizer=request.organizer.slug event=request.event.slug %}"
hx-trigger="load">
<span class="loading-mock loading-mock-text-short"></span>
</div>
</div>
<h2 class="panel-title">{% trans "Quotas" %}</h2>
</div>
<div class="panel-body">
<div hx-get="{% url "control:event.index.quotas" organizer=request.organizer.slug event=request.event.slug %}"
hx-trigger="load">
{% for i in "12" %}
{% include "pretixcontrol/fragment_quota_box_mock.html" %}
{% endfor %}
</div>
</div>
</div>
{% if not comment_form.readonly %}
<p class="text-right flip">
<br>
<button class="btn btn-default">
{% trans "Update comment" %}
</button>
</p>
{% endif %}
</form>
</div>
<div class="col-md-6 col-sm-12">
<div class="panel panel-default">
<div class="panel-heading">
<h2 class="panel-title">{% trans "Check-in lists" %}</h2>
</div>
<div class="panel-body">
<div hx-get="{% url "control:event.index.checkin" organizer=request.organizer.slug event=request.event.slug %}"
hx-trigger="load">
{% for i in "12" %}
{% include "pretixcontrol/fragment_quota_box_mock.html" %}
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
{% elif has_checkin_widgets %}
<div class="panel panel-default">
<div class="panel-heading">
<h2 class="panel-title">{% trans "Check-in lists" %}</h2>
</div>
<div class="panel-body">
<div hx-get="{% url "control:event.index.checkin" organizer=request.organizer.slug event=request.event.slug %}"
hx-trigger="load">
{% for i in "12" %}
{% include "pretixcontrol/fragment_quota_box_mock.html" %}
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if "event.orders:read" in request.eventpermset or "event.orders:write" in request.eventpermset or "event.settings.general:write" in request.eventpermset or "event.items:write" in request.eventpermset %}
<div class="panel panel-default">
<div class="panel-heading">
@@ -107,10 +144,27 @@
{% trans "Event logs" %}
</h3>
</div>
<ul class="list-group" id="logs_target">
<div class="logs-lazy-loading">
<span class="fa fa-cog fa-4x"></span>
</div>
<ul class="list-group"
hx-get="{% url "control:event.index.logs" organizer=request.organizer.slug event=request.event.slug %}"
hx-trigger="load">
{% for i in "12345" %}
<li class="list-group-item logentry">
<div class="row">
<div class="col-lg-2 col-sm-6 col-xs-12">
<span class="loading-mock loading-mock-text-medium"></span>
</div>
<div class="col-lg-2 col-sm-6 col-xs-12">
<span class="loading-mock loading-mock-text-short"></span>
</div>
<div class="col-lg-2 col-sm-12 col-xs-12">
<span class="loading-mock loading-mock-text-medium"></span>
</div>
<div class="col-lg-6 col-sm-12 col-xs-12">
<span class="loading-mock loading-mock-text-long"></span>
</div>
</div>
</li>
{% endfor %}
</ul>
<div class="panel-footer">
<a href="{% url "control:event.log" event=request.event.slug organizer=request.event.organizer.slug %}">
@@ -94,6 +94,13 @@
</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" %}
@@ -102,27 +109,11 @@
<p>
<a href="{% url "control:event.items.questions" event=request.event.slug organizer=request.organizer.slug %}"
target="_blank">
{% trans "Manage questionnaires" %}
{% trans "Manage questions" %}
</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>
@@ -86,7 +86,7 @@
<a href="?{% url_replace request 'ordering' 'date_to' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Paid tickets per quota" %}
{% trans "Quota utilization" %}
</th>
<th>
{% trans "Status" %}
@@ -151,7 +151,7 @@
</td>
<td>
{% for q in e.first_quotas|slice:":3" %}
{% include "pretixcontrol/fragment_quota_box_paid.html" with quota=q %}
{% include "pretixcontrol/fragment_quota_box.html" with quota=q %}
{% endfor %}
{% if e.first_quotas|length > 3 %}
<a href="{% url "control:event.items.quotas" organizer=e.organizer.slug event=e.slug %}"
@@ -1,18 +1,26 @@
{% load i18n %}
<div class="quotabox availability" data-toggle="tooltip_html" data-placement="top"
title="{% trans "Quota:" %} {{ q.name|force_escape|force_escape }}<br>{% blocktrans with date=q.cached_availability_time|date:"SHORT_DATETIME_FORMAT" %}Numbers as of {{ date }}{% endblocktrans %}">
{% load humanize %}
<a class="quotabox {% if full %}quotabox-full{% endif %}" data-toggle="tooltip_html" data-placement="top"
title="{% trans "Quota:" %} {{ q.name|force_escape|force_escape }}{% if q.cached_avail.1 is not None %}<br>{% blocktrans with num=q.cached_avail.1 %}Currently available: {{ num }}{% endblocktrans %}{% endif %}"
href="{% url "control:event.items.quotas.show" event=q.event.slug organizer=q.event.organizer.slug quota=q.pk %}">
{% if full %}
<strong>{{ q.name }}</strong>
{% endif %}
{% if q.size|default_if_none:"NONE" == "NONE" %}
<div class="progress">
<div class="progress-bar progress-bar-success progress-bar-100">
</div>
</div>
{% else %}
<div class="progress">
<div class="progress-bar progress-bar-{% if q.cached_avail.0 <= 10 or q.cached_avail.0 >= 100 %}danger{% else %}warning{% endif %} progress-bar-{{ q.inv_percent }}">
<div class="progress-bar progress-bar-{% if q.cached_avail.0 < 10 %}danger{% elif q.cached_avail.0 < 100 %}warning{% else %}success{% endif %} progress-bar-{{ q.percent_paid }}">
</div>
<div class="progress-bar progress-bar-{% if q.cached_avail.0 < 10 %}danger{% elif q.cached_avail.0 < 100 %}warning{% else %}success{% endif %} progress-bar-unconfirmed progress-bar-{{ q.percent_other }}">
</div>
</div>
{% endif %}
<div class="numbers">
{{ q.cached_avail.1|default_if_none:"∞" }} / {{ q.size|default_if_none:"∞" }}
{{ q.used|default_if_none:"∞" }} / {{ q.size|default_if_none:"∞" }}<br>
{% blocktrans trimmed with n=q.cached_availability_paid_orders %}
{{ n }} paid
{% endblocktrans %}
</div>
</div>
</a>
@@ -0,0 +1,10 @@
<a class="quotabox quotabox-full">
<strong>
<span class="loading-mock loading-mock-text-short"></span>
</strong>
<div class="progress loading-mock"></div>
<div class="numbers">
<span class="loading-mock loading-mock-text-short"></span>
<br><span class="loading-mock loading-mock-text-short"></span>
</div>
</a>
@@ -1,17 +0,0 @@
{% load i18n %}
<a class="quotabox" data-toggle="tooltip_html" data-placement="top"
title="{% trans "Quota:" %} {{ q.name|force_escape|force_escape }}{% if q.cached_avail.1 is not None %}<br>{% blocktrans with num=q.cached_avail.1 %}Currently available: {{ num }}{% endblocktrans %}{% endif %}"
href="{% url "control:event.items.quotas.show" event=q.event.slug organizer=q.event.organizer.slug quota=q.pk %}">
{% if q.size|default_if_none:"NONE" == "NONE" %}
<div class="progress">
</div>
{% else %}
<div class="progress">
<div class="progress-bar progress-bar-{% if q.cached_avail.0 < 10 %}danger{% elif q.cached_avail.0 < 100 %}warning{% else %}success{% endif %} progress-bar-{{ q.percent_paid }}">
</div>
</div>
{% endif %}
<div class="numbers">
{{ q.cached_availability_paid_orders|default_if_none:"?" }} / {{ q.size|default_if_none:"∞" }}
</div>
</a>
@@ -64,7 +64,47 @@
</div>
</div>
{% bootstrap_field form.questionnaires layout="control" %}
<div class="form-group" data-display-dependency="#admission_on">
<label class="col-md-3 control-label">{% trans "Personalization" %}</label>
<div class="col-md-9">
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.personalized.html_name }}" {% if form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-id-card-o"></span>
<strong>{% trans "Personalized ticket" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
When this ticket is purchased, the system will ask for a name or other details according
to your event settings.
{% endblocktrans %}
{% if not request.event.settings.attendee_names_asked and not request.event.settings.attendee_emails_asked and not request.event.settings.attendee_company_asked and not request.event.settings.attendee_addresses_asked %}
<br>
<span class="text-warning">
<span class="fa fa-warning" aria-hidden="true"></span>
{% trans "This will currently have no effect since all data fields are turned off in event settings." %}
</span>
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
class="btn btn-default btn-xs" target="_blank">{% trans "Change settings" %}</a>
{% endif %}
</div>
</label>
</div>
<div class="big-radio radio">
<label>
<input type="radio" value="" name="{{ form.personalized.html_name }}" {% if not form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-circle-o"></span>
<strong>{% trans "Non-personalized ticket" %}</strong>
<div class="help-block">
{% blocktrans trimmed %}
The system will not ask for a name or other attendee details. This only affects
system-provided fields, you can still add your own questions.
{% endblocktrans %}
</div>
</label>
</div>
</div>
</div>
{% bootstrap_field form.category layout="control" %}
<div class="form-group">
@@ -72,7 +72,51 @@
</div>
</div>
{% bootstrap_field form.questionnaires layout="control" %}
<div class="form-group" data-display-dependency="#admission_on">
<label class="col-md-3 control-label">{% trans "Personalization" %}</label>
<div class="col-md-9">
{% for e in form.errors.personalized %}
<div class="alert alert-danger has-error">
{{ e }}
</div>
{% endfor %}
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.personalized.html_name }}" {% if form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-id-card-o"></span>
<strong>{% trans "Personalized ticket" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
When this ticket is purchased, the system will ask for a name or other details according
to your event settings.
{% endblocktrans %}
{% if not request.event.settings.attendee_names_asked and not request.event.settings.attendee_emails_asked and not request.event.settings.attendee_company_asked and not request.event.settings.attendee_addresses_asked %}
<br>
<span class="text-warning">
<span class="fa fa-warning" aria-hidden="true"></span>
{% trans "This will currently have no effect since all data fields are turned off in event settings." %}
</span>
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
class="btn btn-default btn-xs" target="_blank">{% trans "Change settings" %}</a>
{% endif %}
</div>
</label>
</div>
<div class="big-radio radio">
<label>
<input type="radio" value="" name="{{ form.personalized.html_name }}" {% if not form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-file-text-o"></span>
<strong>{% trans "Non-personalized ticket" %}</strong>
<div class="help-block">
{% blocktrans trimmed %}
The system will not ask for a name or other attendee details. This only affects
system-provided fields, you can still add your own questions.
{% endblocktrans %}
</div>
</label>
</div>
</div>
</div>
{% bootstrap_field form.description layout="control" %}
{% bootstrap_field form.picture layout="control" %}
@@ -6,7 +6,7 @@
<h1>{% trans "Delete question" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>{% blocktrans %}Are you sure you want to delete the data field <strong>{{ question }}</strong>?{% endblocktrans %}</p>
<p>{% blocktrans %}Are you sure you want to delete the question <strong>{{ question }}</strong>?{% endblocktrans %}</p>
{% if dependent|length > 0 %}
<div class="alert alert-warning">
<p>{% blocktrans %}All answers to the question given by the buyers of the following products will be <strong>lost</strong>.{% endblocktrans %}
@@ -5,124 +5,158 @@
{% load escapejson %}
{% block title %}
{% if question %}
{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}
{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}
{% else %}
{% trans "Create new data field" %}
{% trans "Question" %}
{% endif %}
{% endblock %}
{% block inside %}
{% if question %}
<h1>{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}</h1>
<h1>{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}</h1>
{% else %}
<h1>{% trans "Create new data field" %}</h1>
<h1>{% trans "Question" %}</h1>
{% endif %}
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors form %}
{% bootstrap_field form.question layout="control" %}
{% bootstrap_field form.type layout="control" %}
<div id="valid-number">
{% bootstrap_field form.valid_number_min layout="control" %}
{% bootstrap_field form.valid_number_max layout="control" %}
</div>
<div id="valid-date">
{% bootstrap_field form.valid_date_min layout="control" %}
{% bootstrap_field form.valid_date_max layout="control" %}
</div>
<div id="valid-datetime">
{% bootstrap_field form.valid_datetime_min layout="control" %}
{% bootstrap_field form.valid_datetime_max layout="control" %}
</div>
<div id="valid-string">
{% bootstrap_field form.valid_string_length_min layout="control" %}
{% bootstrap_field form.valid_string_length_max layout="control" %}
</div>
<div id="valid-file">
{% bootstrap_field form.valid_file_portrait layout="control" %}
</div>
<div id="answer-options">
<h3>{% trans "Answer options" %}</h3>
<noscript>
<p>{% trans "Only applicable if you choose 'Choose one/multiple from a list' above." %}</p>
</noscript>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" data-formset-delete-confirm-text="{% trans "If you delete an answer option, you will no longer be able to see statistical data on customers who previously selected this option, and when such customers edit their answers, they need to select a different option." %}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% blocktrans trimmed with id=form.instance.identifier %}
Answer option {{ id }}
{% endblocktrans %}
</span>
{% bootstrap_form_errors form %}
{% bootstrap_field form.answer layout='inline' form_group_class="" %}
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
{% endfor %}
<div class="tabbed-form">
<fieldset>
<legend>{% trans "General" %}</legend>
{% bootstrap_field form.question layout="control" %}
{% bootstrap_field form.type layout="control" %}
{% if form.items %}
{% bootstrap_field form.items layout="control" %}
{% endif %}
{% bootstrap_field form.required layout="control" %}
<div class="alert alert-info alert-required-boolean">
{% blocktrans trimmed %}
If you mark a Yes/No question as required, it means that the user has to select Yes and No is not
accepted. If you want to allow both options, do not make this field required.
{% endblocktrans %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% trans "New answer option" %}
</span>
{% bootstrap_field formset.empty_form.answer layout='inline' form_group_class="" %}
<div id="valid-number">
{% bootstrap_field form.valid_number_min layout="control" %}
{% bootstrap_field form.valid_number_max layout="control" %}
</div>
<div id="valid-date">
{% bootstrap_field form.valid_date_min layout="control" %}
{% bootstrap_field form.valid_date_max layout="control" %}
</div>
<div id="valid-datetime">
{% bootstrap_field form.valid_datetime_min layout="control" %}
{% bootstrap_field form.valid_datetime_max layout="control" %}
</div>
<div id="valid-string">
{% bootstrap_field form.valid_string_length_min layout="control" %}
{% bootstrap_field form.valid_string_length_max layout="control" %}
</div>
<div id="valid-file">
{% bootstrap_field form.valid_file_portrait layout="control" %}
</div>
<div id="answer-options">
<h3>{% trans "Answer options" %}</h3>
<noscript>
<p>{% trans "Only applicable if you choose 'Choose one/multiple from a list' above." %}</p>
</noscript>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" data-formset-delete-confirm-text="{% trans "If you delete an answer option, you will no longer be able to see statistical data on customers who previously selected this option, and when such customers edit their answers, they need to select a different option." %}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% blocktrans trimmed with id=form.instance.identifier %}
Answer option {{ id }}
{% endblocktrans %}
</span>
{% bootstrap_form_errors form %}
{% bootstrap_field form.answer layout='inline' form_group_class="" %}
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new option" %}</button>
</p>
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% trans "New answer option" %}
</span>
{% bootstrap_field formset.empty_form.answer layout='inline' form_group_class="" %}
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new option" %}</button>
</p>
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "Advanced" %}</legend>
{% bootstrap_field form.help_text layout="control" %}
{% bootstrap_field form.identifier layout="control" %}
{% if form.ask_during_checkin %}
{% bootstrap_field form.ask_during_checkin layout="control" %}
{% endif %}
{% if form.show_during_checkin %}
{% bootstrap_field form.show_during_checkin layout="control" %}
{% endif %}
{% bootstrap_field form.hidden layout="control" %}
{% if form.print_on_invoice %}
{% bootstrap_field form.print_on_invoice layout="control" %}
{% endif %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_dependency_question">
{% trans "Question dependency" %}
<br><span class="optional">{% trans "Optional" context "form" %}</span>
</label>
<div class="col-md-4">
{% bootstrap_field form.dependency_question layout="inline" form_group_class="inner" %}
</div>
<div class="col-md-5">
<script type="text/plain" id="dependency_value_val">{{ form.instance.dependency_values|escapejson_dumps }}</script>
{% bootstrap_field form.dependency_values layout="inline" form_group_class="inner" %}
</div>
</div>
</fieldset>
</div>
{% bootstrap_field form.identifier layout="control" %}
{% if form.show_during_checkin %}
{% bootstrap_field form.show_during_checkin layout="control" %}
{% endif %}
{% bootstrap_field form.hidden layout="control" %}
{% if form.print_on_invoice %}
{% bootstrap_field form.print_on_invoice layout="control" %}
{% endif %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
@@ -1,49 +0,0 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load static %}
{% load icon %}
{% load compress %}
{% load vite %}
{% block title %}
{% trans "Questionnaires" %}
{% endblock %}
{% block inside %}
<h1>
{% trans "Questionnaires" %}
<a href="{% url "control:event.items.questions" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default pull-right">
{% icon "wrench" %} {% trans "Manage data fields" %}
</a>
</h1>
<p>
{% blocktrans trimmed %}
Questionaires allow your attendees to fill in additional data about their ticket. If you provide food, one
example might be to ask your users about dietary requirements.
{% endblocktrans %}
</p>
<p>
TODO(questionnaires) : add more specific explanation of the questionnaire concept.
</p>
{{ request.event.settings.locales|json_script:"event_locales" }}
{{ questionnaire_type_choices|json_script:"questionnaire_type_choices" }}
{{ question_type_choices|json_script:"question_type_choices" }}
{{ system_question_choices|json_script:"system_question_choices" }}
{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=0 as datafield_edit_url %}
{{ datafield_edit_url|json_script:"datafield_edit_url" }}
{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=0 as datafield_view_url %}
{{ datafield_view_url|json_script:"datafield_view_url" }}
{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug as datafield_create_url %}
{{ datafield_create_url|json_script:"datafield_create_url" }}
<div id="questionnaires-editor">
<!-- Vue app mount point -->
</div>
{% vite_hmr %}
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/questionnaires/index.ts" %}
{% endblock %}
@@ -1,8 +1,8 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% block title %}{% trans "Data fields" %}{% endblock %}
{% block title %}{% trans "Questions" %}{% endblock %}
{% block inside %}
<h1>{% trans "Data fields" %}</h1>
<h1>{% trans "Questions" %}</h1>
<p>
{% blocktrans trimmed %}
Questions allow your attendees to fill in additional data about their ticket. If you provide food, one
@@ -11,40 +11,63 @@
</p>
{% csrf_token %}
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?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 %}
{% if request.event.settings.feature_flag_order_level_questions %}
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new per-ticket question" %}
</a>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new order-level question" %}
</a>
</p>
{% endif %}
<h2>{% trans "Per-order data fields" %}</h2>
<p>{% trans "These data fields can be used on orders." %}</p>
<h2>{% trans "Per-ticket questions" %}</h2>
<p>{% trans "These questions are asked for every ticket, so possibly multiple times in the same order." %}</p>
{% else %}
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new question" %}
</a>
</p>
{% endif %}
{% endif %}
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Internal name" %}</th>
<th>{% trans "Question" %}</th>
<th>{% trans "Type" %}</th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th>{% trans "Products" %}</th>
{% if 'event.items:write' in request.eventpermset %}
<th class="action-col-2"></th>
{% endif %}
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for q in questions %}{% if q.container_type == "O" %}
<tr>
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P">
{% for q in questions %}{% if q.container_type == "P" %}
<tr data-dnd-id="{{ q.id }}">
<td>
<strong>
{% if q.pk %}
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
{% endif %}
{{ q.question }}
{% if q.pk %}
</a>
{% endif %}
</strong><br>
<small class="text-muted">{{ q.identifier }}</small>
</td>
<td>
{{ q.get_type_display }}
{% if q.pk %}
{{ q.get_type_display }}
{% else %}
{% trans "System question" %}
{% endif %}
</td>
<td>
{% if q.required %}
@@ -61,8 +84,26 @@
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
{% endif %}
</td>
<td>
{% if q.pk %}
<ul>
{% for item in q.items.all %}
<li>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a>
</li>
{% endfor %}
</ul>
{% else %}
<small>{% trans "All personalized products" %}</small>
{% endif %}
</td>
{% if 'event.items:write' in request.eventpermset %}
<td class="dnd-container">
</td>
{% endif %}
<td class="text-right flip">
{% if q.pk %}
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-bar-chart"></i></a>
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
@@ -80,61 +121,85 @@
</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>
<tr>
<th>{% trans "Internal name" %}</th>
<th>{% trans "Type" %}</th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for q in questions %}{% if q.container_type == "P" %}
{% if request.event.settings.feature_flag_order_level_questions %}
<h2>
{% trans "Per-order questions" %}
<small><span class="label label-info" title="
{% trans "This functionality is in active development and expected to change significantly over the coming months." %}
{% trans "Per-order questions are currently not supported and will not be displayed in pretixPOS." %}
" data-toggle="tooltip">
<span class="fa fa-flask" aria-hidden="true"></span>
{% trans "Experimental feature" %}
</span></small>
</h2>
<p>{% trans "These questions are asked once per order." %}</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<td>
<strong>
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
{{ q.question }}
</a>
</strong><br>
<small class="text-muted">{{ q.identifier }}</small>
</td>
<td>
{{ 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">
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-bar-chart"></i></a>
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
</td>
<th>{% trans "Question" %}</th>
<th>{% trans "Type" %}</th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="iconcol"></th>
{% if 'event.items:write' in request.eventpermset %}
<th class="action-col-2"></th>
{% endif %}
<th class="action-col-2"></th>
</tr>
{% endif %}{% endfor %}
</tbody>
</table>
</div>
</thead>
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O">
{% for q in questions %}{% if q.container_type == "O" %}
<tr data-dnd-id="{{ q.id }}">
<td>
<strong>
{{ q.question }}
</strong><br>
<small class="text-muted">{{ q.identifier }}</small>
</td>
<td>
{% if q.pk %}
{{ q.get_type_display }}
{% else %}
{% trans "System question" %}
{% endif %}
</td>
<td>
{% if q.required %}
<span class="fa fa-exclamation-circle text-muted" data-toggle="tooltip" title="{% trans "Required question" %}"></span>
{% endif %}
</td>
<td>
{% if q.pk and q.ask_during_checkin %}
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
{% endif %}
</td>
<td>
{% if q.pk and q.hidden %}
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
{% endif %}
</td>
{% if 'event.items:write' in request.eventpermset %}
<td class="dnd-container">
</td>
{% endif %}
<td class="text-right flip">
{% if q.pk %}
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
{% else %}
{% if 'event.settings.general:write' in request.eventpermset %}
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
class="btn btn-default btn-sm"><i class="fa fa-wrench"></i></a>
{% endif %}
{% endif %}
</td>
</tr>
{% endif %}{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
@@ -1,11 +0,0 @@
{% extends "error.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load static %}
{% block content %}
<h1>{% trans "Please wait..." %}</h1>
{{ notify_info|json_script:"notify_info" }}
<script type="text/javascript" src="{% static "pretixcontrol/js/notify_parent.js" %}"></script>
{% endblock %}
@@ -20,10 +20,8 @@
{% endif %}
{% elif question.type == "M" %}
{{ answer.to_string_i18n|rich_text_snippet }}
{% elif question.type %}
{{ answer.to_string_i18n|rich_text_snippet }}
{% else %}
{{ answer|linebreaksbr }}
{{ answer.to_string_i18n|linebreaksbr }}
{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
@@ -610,9 +610,60 @@
{% endif %}
{% if line.has_questions %}
<dl>
{% if line.item.ask_attendee_data and event.settings.attendee_names_asked %}
<dt>{% trans "Attendee name" %}</dt>
<dd>{% if line.attendee_name %}{{ line.attendee_name_all_components }}{% else %}
<em>{% trans "not answered" %}</em>{% endif %}</dd>
{% endif %}
{% if line.item.ask_attendee_data and event.settings.attendee_emails_asked %}
<dt>{% trans "Attendee email" %}</dt>
<dd>
{% if line.attendee_email %}
{{ line.attendee_email }}
{% if not line.addon_to %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}">
{% csrf_token %}
<a href="{% url "control:event.order.position.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}"
class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
</a>
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>
</form>
{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
</dd>
{% endif %}
{% if line.item.ask_attendee_data and event.settings.attendee_company_asked %}
<dt>
{% trans "Attendee company" %}
</dt>
<dd>
{% if line.company %}{{ line.company }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}
</dd>
{% endif %}
{% if line.item.ask_attendee_data and event.settings.attendee_addresses_asked %}
<dt>
{% trans "Attendee address" %}
</dt>
<dd>
{% if line.street or line.zipcode or line.city or line.country %}
{{ line.street|default_if_none:""|linebreaksbr }}<br>
{{ line.zipcode|default_if_none:"" }} {{ line.city|default_if_none:"" }}<br>
{% if line.state %}{{ line.state_for_address }}<br>{% endif %}
{{ line.country.name|default_if_none:"" }}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
</dd>
{% endif %}
{% for q in line.questions %}
<dt>
{{ q.label }}
{{ q.question }}
{% if q.ask_during_checkin %}
<span class="fa fa-qrcode text-muted"
data-toggle="tooltip"
@@ -621,49 +672,7 @@
{% endif %}
</dt>
<dd>
{# {% if q.answer %}#}
{# {% if q.answer.file %}#}
{# <span class="fa fa-file"></span>#}
{# <a href="{{ q.answer.backend_file_url }}?token={% answer_token request q.answer %}">#}
{# {{ q.answer.file_name }}#}
{# </a>#}
{# <span class="label label-danger" data-toggle="tooltip"#}
{# title="{% trans "This file has been uploaded by a user and could contain viruses or other malicious content." %}">#}
{# {% trans "UNSAFE" %}#}
{# </span>#}
{# {% if q.answer.is_image %}#}
{# <br>#}
{# <a href="{{ q.answer.backend_file_url }}?token={% answer_token request q.answer %}" data-lightbox="order"#}
{# class="answer-thumb">#}
{# <img src="{{ q.answer.backend_file_url }}?token={% answer_token request q.answer %}">#}
{# </a>#}
{# {% endif %}#}
{# {% elif q.type == "M" %}#}
{# {{ q.answer.to_string_i18n|rich_text_snippet }}#}
{# {% elif q.type %}#}
{# {{ q.answer.to_string_i18n|linebreaksbr }}#}
{# {% else %}<!-- TODO(questionnaires): proper separation of QuestionAnswer objects and system answers...... -->#}
{# {{ q.answer|linebreaksbr }}#}
{# {% endif %}#}
{# {% else %}#}
{# <em>{% trans "not answered" %}</em>#}
{# {% endif %}#}
{% include "pretixcontrol/order/fragment_question_answer.html" with request=request question=q answer=q.answer %}
{% if q.system_datafield == "attendee_email" and not line.addon_to %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}">
{% csrf_token %}
<a href="{% url "control:event.order.position.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}"
class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
</a>
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>
</form>
{% endif %}
</dd>
{% endfor %}
{% for q in line.additional_fields %}
@@ -72,6 +72,7 @@
{% endif %}
</dl>
</fieldset>
{% include "pretixcontrol/event/fragment_timeline.html" %}
<fieldset>
<legend>{% trans "Quotas" %}</legend>
<div class="table-responsive">
@@ -102,7 +102,7 @@
<a href="?{% url_replace request 'filter-ordering' 'date_from' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Paid tickets per quota" %}
{% trans "Quota utilization" %}
</th>
<th>
{% trans "Status" %}
@@ -155,7 +155,7 @@
</td>
<td>
{% for q in s.first_quotas|slice:":3" %}
{% include "pretixcontrol/fragment_quota_box_paid.html" with quota=q %}
{% include "pretixcontrol/fragment_quota_box.html" with quota=q %}
{% endfor %}
{% if s.first_quotas|length > 3 %}
<a href="{% url "control:event.items.quotas" organizer=request.event.organizer.slug event=request.event.slug %}?subevent={{ s.id }}"
+5 -4
View File
@@ -273,15 +273,16 @@ urlpatterns = [
re_path(r'^event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/', include([
re_path(r'^$', dashboards.event_index, name='event.index'),
re_path(r'^qrcode.(?P<filetype>(png|jpeg|gif|svg))$', event.EventQRCode.as_view(), name='event.qrcode'),
re_path(r'^widgets.json$', dashboards.event_index_widgets_lazy, name='event.index.widgets'),
re_path(r'^dashboard/partials/logs$', dashboards.event_index_log_lazy, name='event.index.logs'),
re_path(r'^dashboard/partials/warnings$', dashboards.event_index_warnings_lazy, name='event.index.warnings'),
re_path(r'^dashboard/partials/quotas$', dashboards.event_index_quotas_lazy, name='event.index.quotas'),
re_path(r'^dashboard/partials/waiting$', dashboards.event_index_waiting_lazy, name='event.index.waiting'),
re_path(r'^dashboard/partials/checkin$', dashboards.event_index_checkin_lazy, name='event.index.checkin'),
re_path(r'^dashboard/partials/comment$', event.EventComment.as_view(), name='event.index.comment'),
re_path(r'^live/$', event.EventLive.as_view(), name='event.live'),
re_path(r'^transfer_session/$', event.EventTransferSession.as_view(), name='event.transfer_session'),
re_path(r'^logs/$', event.EventLog.as_view(), name='event.log'),
re_path(r'^delete/$', event.EventDelete.as_view(), name='event.delete'),
re_path(r'^comment/$', event.EventComment.as_view(),
name='event.comment'),
re_path(r'^quickstart/$', event.QuickSetupView.as_view(), name='event.quick'),
re_path(r'^settings/$', event.EventUpdate.as_view(), name='event.settings'),
re_path(r'^settings/plugins$', event.EventPlugins.as_view(), name='event.settings.plugins'),
@@ -343,6 +344,7 @@ urlpatterns = [
name='event.items.categories.edit'),
re_path(r'^categories/add$', item.CategoryCreate.as_view(), name='event.items.categories.add'),
re_path(r'^questions/$', item.QuestionList.as_view(), name='event.items.questions'),
re_path(r'^questions/reorder$', item.reorder_questions, name='event.items.questions.reorder'),
re_path(r'^questions/(?P<question>\d+)/delete$', item.QuestionDelete.as_view(),
name='event.items.questions.delete'),
re_path(r'^questions/(?P<question>\d+)/$', item.QuestionView.as_view(),
@@ -350,7 +352,6 @@ urlpatterns = [
re_path(r'^questions/(?P<question>\d+)/change$', item.QuestionUpdate.as_view(),
name='event.items.questions.edit'),
re_path(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'),
re_path(r'^questionnaires/$', item.QuestionnairesEditor.as_view(), name='event.items.questionnaires'),
re_path(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'),
re_path(r'^quotas/bulk_action$', item.QuotaBulkAction.as_view(), name='event.items.quotas.bulkaction'),
re_path(r'^quotas/bulk_edit$', item.QuotaBulkUpdateView.as_view(), name='event.items.quotas.bulkedit'),
+2 -19
View File
@@ -22,12 +22,9 @@
import collections.abc
import warnings
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.utils.translation import gettext_lazy as _
from django.views.generic import edit
@@ -41,21 +38,7 @@ class EventBasedFormMixin:
return kwargs
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]
return render(self.request, 'pretixcontrol/notify_parent.html', {
'notify_info': {'object': self.object.pk, 'object_str': str(self.object), 'redirect_url': result.url, 'messages': msgs},
})
else:
return result
class CreateView(EventBasedFormMixin, TellParentFormMixin, edit.CreateView):
class CreateView(EventBasedFormMixin, edit.CreateView):
"""
Like Django's default CreateView, but passes the optional event
argument to the form. This is necessary for I18nModelForms to work
@@ -64,7 +47,7 @@ class CreateView(EventBasedFormMixin, TellParentFormMixin, edit.CreateView):
pass
class UpdateView(EventBasedFormMixin, TellParentFormMixin, edit.UpdateView):
class UpdateView(EventBasedFormMixin, edit.UpdateView):
"""
Like Django's default UpdateView, but passes the optional event
argument to the form. This is necessary for I18nModelForms to work
+63 -333
View File
@@ -33,317 +33,48 @@
# License for the specific language governing permissions and limitations under the License.
from datetime import timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.humanize.templatetags.humanize import intcomma
from django.db.models import (
Count, IntegerField, Max, Min, OuterRef, Prefetch, Q, Subquery, Sum,
Count, IntegerField, Max, Min, OuterRef, Q, Subquery,
)
from django.db.models.functions import Coalesce, Greatest
from django.dispatch import receiver
from django.http import JsonResponse
from django.http import Http404, JsonResponse
from django.shortcuts import render
from django.template.loader import get_template
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.html import conditional_escape, escape, format_html
from django.utils.html import (
conditional_escape, escape, format_html, format_html_join,
)
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, ngettext, pgettext
from pretix.base.decimal import round_decimal
from pretix.base.models import (
Item, ItemCategory, ItemVariation, Order, OrderPosition, OrderRefund,
Question, Quota, SubEvent, Voucher, WaitingListEntry,
Item, ItemCategory, Order, OrderRefund, Question, Quota, Voucher,
WaitingListEntry,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timeline import timeline_for_event
from pretix.control.signals import (
event_dashboard_widgets, user_dashboard_widgets,
event_dashboard_statistics, user_dashboard_widgets,
)
from pretix.helpers.daterange import daterange
from ...base.models.orders import CancellationRequest
from ...base.models.organizer import TeamQuerySet
from ...base.templatetags.money import money_filter
from ..logdisplay import OVERVIEW_BANLIST
NUM_WIDGET = '<div class="numwidget"><span class="num">{num}</span><span class="text">{text}</span></div>'
from .utils import prepare_quotas_for_boxes
@receiver(signal=event_dashboard_widgets)
def base_widgets(sender, subevent=None, lazy=False, **kwargs):
if not lazy:
prodc = Item.objects.filter(
event=sender, active=True,
).filter(
(Q(available_until__isnull=True) | Q(available_until__gte=now())) &
(Q(available_from__isnull=True) | Q(available_from__lte=now()))
).count()
if subevent:
opqs = OrderPosition.objects.filter(subevent=subevent)
else:
opqs = OrderPosition.objects
tickc = opqs.filter(
order__event=sender, item__admission=True,
order__status__in=(Order.STATUS_PAID, Order.STATUS_PENDING),
).count()
paidc = opqs.filter(
order__event=sender, item__admission=True,
order__status=Order.STATUS_PAID,
).count()
if subevent:
rev = opqs.filter(
order__event=sender, order__status=Order.STATUS_PAID
).aggregate(
sum=Sum('price')
)['sum'] or Decimal('0.00')
else:
rev = Order.objects.filter(
event=sender,
status=Order.STATUS_PAID
).aggregate(sum=Sum('total'))['sum'] or Decimal('0.00')
return [
def event_index_waiting_lazy(request, organizer, event):
wles = WaitingListEntry.objects.filter(event=request.event, voucher__isnull=True)
return render(
request,
'pretixcontrol/event/dashboard_partial_waiting.html',
{
'content': None if lazy else format_html(NUM_WIDGET, num=intcomma(tickc), text=_('Attendees (ordered)')),
'lazy': 'attendees-ordered',
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.orders', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
}) + ('?subevent={}'.format(subevent.pk) if subevent else '')
},
{
'content': None if lazy else format_html(NUM_WIDGET, num=intcomma(paidc), text=_('Attendees (paid)')),
'lazy': 'attendees-paid',
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.orders.overview', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
}) + ('?subevent={}'.format(subevent.pk) if subevent else '')
},
{
'content': None if lazy else format_html(
NUM_WIDGET,
num=money_filter(round_decimal(rev, sender.currency), sender.currency, hide_currency=True),
text=_('Total revenue ({currency})').format(currency=sender.currency)
),
'lazy': 'total-revenue',
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.orders.overview', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
}) + ('?subevent={}'.format(subevent.pk) if subevent else '')
},
{
'content': None if lazy else format_html(NUM_WIDGET, num=prodc, text=_('Active products')),
'lazy': 'active-products',
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.items', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
})
},
]
@receiver(signal=event_dashboard_widgets)
def waitinglist_widgets(sender, subevent=None, lazy=False, **kwargs):
widgets = []
wles = WaitingListEntry.objects.filter(event=sender, subevent=subevent, voucher__isnull=True)
if wles.exists():
if not lazy:
quota_cache = {}
happy = 0
tuples = wles.values('item', 'variation').order_by().annotate(cnt=Count('id'))
items = {
i.pk: i for i in sender.items.filter(id__in=[t['item'] for t in tuples]).prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=sender.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent)),
)
}
vars = {
i.pk: i for i in ItemVariation.objects.filter(
item__event=sender, id__in=[t['variation'] for t in tuples if t['variation']]
).prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=sender.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent)),
)
}
for wlt in tuples:
item = items.get(wlt['item'])
variation = vars.get(wlt['variation'])
if not item:
continue
quotas = (
variation._get_quotas(subevent=subevent)
if variation
else item._get_quotas(subevent=subevent)
)
row = (
variation.check_quotas(subevent=subevent, count_waitinglist=False, _cache=quota_cache)
if variation
else item.check_quotas(subevent=subevent, count_waitinglist=False, _cache=quota_cache)
)
if row[1] is None:
happy += wlt['cnt']
elif row[1] > 0:
happy += min(wlt['cnt'], row[1])
for q in quotas:
if q.size is not None:
quota_cache[q.pk] = (quota_cache[q.pk][0], quota_cache[q.pk][1] - min(wlt['cnt'], row[1]))
widgets.append({
'content': None if lazy else format_html(
NUM_WIDGET, num=intcomma(happy), text=_('available to give to people on waiting list')
),
'lazy': 'waitinglist-avail',
'priority': 50,
'url': reverse('control:event.orders.waitinglist', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug,
})
})
widgets.append({
'content': None if lazy else format_html(
NUM_WIDGET, num=intcomma(wles.count()), text=_('total waiting list length')
),
'lazy': 'waitinglist-length',
'display_size': 'small',
'priority': 50,
'url': reverse('control:event.orders.waitinglist', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug,
})
})
return widgets
@receiver(signal=event_dashboard_widgets)
def quota_widgets(sender, subevent=None, lazy=False, **kwargs):
widgets = []
quotas = sender.quotas.filter(subevent=subevent)
qa = QuotaAvailability()
if quotas:
qa.queue(*quotas)
qa.compute(allow_cache=True)
for q in quotas:
if not lazy:
status, left = qa.results[q] if q in qa.results else q.availability(allow_cache=True)
widgets.append({
'content': None if lazy else format_html(
NUM_WIDGET,
num='{}/{}'.format(intcomma(left), intcomma(q.size)) if q.size is not None else '\u221e',
text=format_html(_('{quota} left'), quota=q.name)
),
'lazy': 'quota-{}'.format(q.pk),
'display_size': 'small',
'priority': 50,
'url': reverse('control:event.items.quotas.show', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug,
'quota': q.id
})
})
return widgets
@receiver(signal=event_dashboard_widgets)
def shop_state_widget(sender, **kwargs):
return [{
'display_size': 'small',
'priority': 1000,
'content': format_html(
'<div class="shopstate">{t1}<br><span class="{cls}"><span class="fa {icon}"></span> {state}</span>{t2}</div>',
t1=_('Your ticket shop is'), t2=_('Click here to change'),
state=_('live') if sender.live and not sender.testmode else (
_('live and in test mode') if sender.live else (
_('not yet public') if not sender.testmode else (
_('in private test mode')
)
)
),
icon='fa-check-circle' if sender.live and not sender.testmode else (
'fa-warning' if sender.live else (
'fa-times-circle' if not sender.testmode else (
'fa-times-circle'
)
)
),
cls='live' if sender.live else 'off'
),
'url': reverse('control:event.live', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
})
}]
@receiver(signal=event_dashboard_widgets)
def checkin_widget(sender, subevent=None, lazy=False, **kwargs):
widgets = []
qs = sender.checkin_lists.filter(subevent=subevent)
for cl in qs:
widgets.append({
'content': None if lazy else format_html(
NUM_WIDGET,
num='{}/{}'.format(intcomma(cl.inside_count), intcomma(cl.position_count)),
text=format_html(_('Present – {list}'), list=cl.name)
),
'lazy': 'checkin-{}'.format(cl.pk),
'display_size': 'small',
'priority': 50,
'url': reverse('control:event.orders.checkinlists.show', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug,
'list': cl.pk
})
})
return widgets
@receiver(signal=event_dashboard_widgets)
def welcome_wizard_widget(sender, **kwargs):
template = get_template('pretixcontrol/event/dashboard_widget_welcome.html')
ctx = {
'title': _('Welcome to pretix!')
}
kwargs = {'event': sender.slug, 'organizer': sender.organizer.slug}
if not sender.items.exists():
ctx.update({
'subtitle': _('Get started with our setup tool'),
'text': _('To start selling tickets, you need to create products or quotas. The fastest way to create '
'this is to use our setup tool.'),
'button_text': _('Set up event'),
'button_url': reverse('control:event.quick', kwargs=kwargs)
})
else:
return []
return [{
'display_size': 'full',
'priority': 2000,
'content': template.render(ctx)
}]
'count': wles.count,
}
)
def build_json_response(widgets):
@@ -353,65 +84,38 @@ def build_json_response(widgets):
def event_index(request, organizer, event):
from pretix.control.forms.event import CommentForm
can_view_orders = request.user.has_event_permission(
request.organizer,
request.event,
'event.orders:read',
request=request
)
subevent = None
if request.GET.get("subevent", "") != "" and request.event.has_subevents:
i = request.GET.get("subevent", "")
try:
subevent = request.event.subevents.get(pk=i)
except SubEvent.DoesNotExist:
pass
can_view_orders = request.user.has_event_permission(request.organizer, request.event, 'event.orders:read',
request=request)
can_change_event_settings = request.user.has_event_permission(request.organizer, request.event,
'event.settings.general:write', request=request)
widgets = []
stats = []
if can_view_orders:
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=True):
widgets.extend(result)
for r, result in event_dashboard_statistics.send(sender=request.event, request=request):
stats.append(result)
ctx = {
'widgets': rearrange(widgets),
'subevent': subevent,
'comment_form': CommentForm(initial={'comment': request.event.comment}, readonly=not can_change_event_settings),
'stats': format_html_join("", "{}", [(s,) for s in stats]),
}
ctx['timeline'] = [
{
'date': t.datetime.astimezone(request.event.timezone).date(),
'entry': t,
'time': t.datetime.astimezone(request.event.timezone)
}
for t in timeline_for_event(request.event, subevent)
]
if not request.event.has_subevents:
ctx['timeline'] = [
{
'date': t.datetime.astimezone(request.event.timezone).date(),
'entry': t,
'time': t.datetime.astimezone(request.event.timezone)
}
for t in timeline_for_event(request.event, None)
]
ctx['today'] = now().astimezone(request.event.timezone).date()
ctx['nearly_now'] = now().astimezone(request.event.timezone) - timedelta(seconds=20)
ctx['has_checkin_widgets'] = not request.event.has_subevents or request.event.checkin_lists.filter(subevent=None).exists()
resp = render(request, 'pretixcontrol/event/index.html', ctx)
return resp
def event_index_widgets_lazy(request, organizer, event):
subevent = None
if request.GET.get("subevent", "") != "" and request.event.has_subevents:
i = request.GET.get("subevent", "")
try:
subevent = request.event.subevents.get(pk=i)
except SubEvent.DoesNotExist:
pass
can_view_orders = request.user.has_event_permission(request.organizer, request.event, 'event.orders:read',
request=request)
widgets = []
if can_view_orders:
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=False):
widgets.extend(result)
return build_json_response(widgets)
def event_index_warnings_lazy(request, organizer, event):
can_view_orders = request.user.has_event_permission(request.organizer, request.event, 'event.orders:read',
request=request)
@@ -448,6 +152,32 @@ def event_index_warnings_lazy(request, organizer, event):
)
def event_index_quotas_lazy(request, organizer, event):
if request.event.has_subevents:
raise Http404()
quotas = request.event.quotas.filter(subevent=None)[:10]
prepare_quotas_for_boxes(quotas)
return render(
request,
'pretixcontrol/event/dashboard_partial_quotas.html',
{
'quotas': quotas,
}
)
def event_index_checkin_lazy(request, organizer, event):
lists = request.event.checkin_lists.filter(subevent=None)[:10]
return render(
request,
'pretixcontrol/event/dashboard_partial_checkin.html',
{
'lists': lists,
}
)
def event_index_log_lazy(request, organizer, event):
qs = request.event.logentry_set.all().select_related('user', 'content_type', 'api_token', 'oauth_application',
'device').order_by('-datetime')
+16 -21
View File
@@ -57,10 +57,9 @@ from django.db import transaction
from django.db.models import Count, ProtectedError
from django.forms import inlineformset_factory
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed,
JsonResponse,
Http404, HttpResponse, HttpResponseBadRequest, JsonResponse,
)
from django.shortcuts import redirect
from django.shortcuts import redirect, render
from django.urls import NoReverseMatch, reverse
from django.utils.functional import cached_property
from django.utils.html import conditional_escape, format_html
@@ -1299,24 +1298,23 @@ class EventLog(EventPermissionRequiredMixin, PaginationMixin, ListView):
return LogFilterForm(data=self.request.GET, organizer=self.request.organizer)
class EventComment(EventPermissionRequiredMixin, View):
class EventComment(EventPermissionRequiredMixin, UpdateView):
permission = 'event.settings.general:write'
form_class = CommentForm
template_name = 'pretixcontrol/event/dashboard_partial_comment_form.html'
def post(self, *args, **kwargs):
form = CommentForm(self.request.POST)
if form.is_valid():
self.request.event.comment = form.cleaned_data.get('comment')
self.request.event.save()
self.request.event.log_action('pretix.event.comment', user=self.request.user, data={
'new_comment': form.cleaned_data.get('comment')
})
messages.success(self.request, _('The comment has been updated.'))
else:
messages.error(self.request, _('Could not update the comment.'))
return redirect(self.get_success_url())
def get_object(self, queryset=None):
return self.request.event
def get(self, *args, **kwargs):
return HttpResponseNotAllowed(['POST'])
def form_valid(self, form):
form.save()
self.request.event.log_action('pretix.event.comment', user=self.request.user, data={
'new_comment': form.cleaned_data.get('comment')
})
return render(
self.request,
'pretixcontrol/event/dashboard_partial_comment.html',
)
def get_success_url(self) -> str:
return reverse('control:event.index', kwargs={
@@ -1670,7 +1668,6 @@ 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
@@ -1686,8 +1683,6 @@ 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(
+80 -45
View File
@@ -39,7 +39,7 @@ from itertools import groupby
from json.decoder import JSONDecodeError
from django.contrib import messages
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.exceptions import PermissionDenied
from django.core.files import File
from django.db import models, transaction
from django.db.models import (
@@ -56,7 +56,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _
from django.views.decorators.http import require_http_methods
from django.views.generic import FormView, ListView, TemplateView, View
from django.views.generic import FormView, ListView, View
from django.views.generic.detail import DetailView, SingleObjectMixin
from django_countries.fields import Country
@@ -65,15 +65,14 @@ from pretix.api.serializers.item import (
ItemVariationSerializer,
)
from pretix.base.forms import I18nFormSet
from pretix.base.forms.questions import get_fake_attendee_questions
from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, LogEntry,
OrderPosition, Question, QuestionAnswer, QuestionnaireChild,
QuestionOption, Quota, SeatCategoryMapping, Voucher,
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
SeatCategoryMapping, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import (
ItemAddOn, ItemBundle, ItemMetaValue, Questionnaire,
)
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tickets import invalidate_cache
from pretix.base.signals import quota_availability
@@ -95,7 +94,6 @@ from pretix.helpers.models import modelcopy
from ...helpers import GroupConcat
from ...helpers.compat import CompatDeleteView
from ...helpers.i18n import i18n_all_from_gettext
from . import ChartContainingView, CreateView, PaginationMixin, UpdateView
@@ -439,7 +437,66 @@ class QuestionList(ListView):
template_name = 'pretixcontrol/items/questions.html'
def get_queryset(self):
return self.request.event.questions.all()
return self.request.event.questions.prefetch_related('items')
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
questions = get_fake_attendee_questions(self.request.event.settings)
questions += list(ctx['questions'])
questions.sort(key=lambda q: q.position)
ctx['questions'] = questions
return ctx
@transaction.atomic
@event_permission_required("event.items:write")
@require_http_methods(["POST"])
def reorder_questions(request, organizer, event):
try:
ids = json.loads(request.body.decode('utf-8'))['ids']
except (JSONDecodeError, KeyError, ValueError):
return HttpResponseBadRequest("expected JSON: {ids:[]}")
qs = request.event.questions.filter(container_type=request.GET['container_type'])
# filter system_questions - normal questions are int/digit, system_questions strings
custom_question_ids = [i for i in ids if i.isdigit()]
input_questions = list(qs.filter(id__in=custom_question_ids))
if len(input_questions) != len(custom_question_ids):
raise Http404(_("Some of the provided object ids are invalid."))
if len(input_questions) != qs.count():
raise Http404(_("Not all objects have been selected."))
for q in input_questions:
pos = ids.index(str(q.pk))
if pos != q.position: # Save unneccessary UPDATE queries
q.position = pos
q.save(update_fields=['position'])
q.log_action(
'pretix.event.question.reordered', user=request.user, data={
'position': pos,
}
)
if request.GET['container_type'] == Question.ContainerType.ORDERPOSITION:
system_question_order = {}
for s in ('attendee_name_parts', 'attendee_email', 'company', 'street', 'zipcode', 'city', 'country'):
if s in ids:
system_question_order[s] = ids.index(s)
else:
system_question_order[s] = -1
request.event.settings.system_question_order = system_question_order
request.event.log_action(
'pretix.event.settings', user=request.user, data={
'system_question_order': system_question_order,
}
)
return HttpResponse()
class QuestionDelete(EventPermissionRequiredMixin, CompatDeleteView):
@@ -458,7 +515,7 @@ class QuestionDelete(EventPermissionRequiredMixin, CompatDeleteView):
def get_context_data(self, *args, **kwargs) -> dict:
context = super().get_context_data(*args, **kwargs)
# TODO(questionnaires) context['dependent'] = list(self.get_object().items.all())
context['dependent'] = list(self.get_object().items.all())
context['edit_url'] = reverse('control:event.items.questions.edit', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
@@ -557,7 +614,7 @@ class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView
question=self.object, orderposition__isnull=False,
)
qs = qs.filter(orderposition__in=opqs)
op_cnt = 0 # TODO opqs.filter(item__in=self.object.items.all()).count()
op_cnt = opqs.filter(item__in=self.object.items.all()).count()
if self.object.type == Question.TYPE_FILE:
qs = [
@@ -602,7 +659,7 @@ class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
# TODO(questionnaires) 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
@@ -709,22 +766,6 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
return ret
def textchoices_to_json(choices, event):
return [(c.name, c.value, i18n_all_from_gettext(c.label, event.settings.locales)) for c in choices]
class QuestionnairesEditor(EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_items'
template_name = 'pretixcontrol/items/questionnaires.html'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['questionnaire_type_choices'] = textchoices_to_json(Questionnaire.QuestionnaireType, self.request.event)
ctx['system_question_choices'] = textchoices_to_json(QuestionnaireChild.SystemQuestion, self.request.event)
ctx['question_type_choices'] = textchoices_to_json(Question.FieldType, self.request.event)
return ctx
class QuotaQueryMixin:
@cached_property
@@ -1330,10 +1371,9 @@ class ItemCreate(EventPermissionRequiredMixin, MetaDataEditorMixin, CreateView):
initial['tax_rule'] = trs[0]
if self.copy_from:
fields = ('name', 'internal_name', 'category', 'admission', 'default_price', 'tax_rule')
fields = ('name', 'internal_name', 'category', 'admission', 'personalized', 'default_price', 'tax_rule')
for f in fields:
initial[f] = getattr(self.copy_from, f)
initial['questionnaires'] = self.copy_from.questionnaires.values_list('pk', flat=True)
initial['copy_from'] = self.copy_from
initial['has_variations'] = self.copy_from.variations.exists()
@@ -1367,7 +1407,7 @@ class ItemCreate(EventPermissionRequiredMixin, MetaDataEditorMixin, CreateView):
return super().form_invalid(form)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx = super().get_context_data()
ctx['meta_forms'] = self.meta_forms
return ctx
@@ -1375,11 +1415,9 @@ class ItemCreate(EventPermissionRequiredMixin, MetaDataEditorMixin, CreateView):
self.object = None
form = self.get_form()
if form.is_valid() and all([f.is_valid() for f in self.meta_forms]):
try:
return self.form_valid(form)
except ValidationError as e:
form.add_error(None, e)
return self.form_invalid(form)
return self.form_valid(form)
else:
return self.form_invalid(form)
class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataEditorMixin, UpdateView):
@@ -1443,11 +1481,9 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
self.get_object()
form = self.get_form()
if self.is_valid(form) and all([f.is_valid() for f in self.meta_forms]):
try:
return self.form_valid(form)
except ValidationError as exc:
form.add_error(None, exc)
return self.form_invalid(form)
return self.form_valid(form)
else:
return self.form_invalid(form)
def save_formset(self, key, log_base, attr='item', order=True, serializer=None,
rm_verb='removed'):
@@ -1496,6 +1532,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
@transaction.atomic
def form_valid(self, form):
self.save_meta()
messages.success(self.request, _('Your changes have been saved.'))
change_data = {
k: form.cleaned_data.get(k)
@@ -1555,9 +1592,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
else:
v.save()
response = super().form_valid(form)
messages.success(self.request, _('Your changes have been saved.'))
return response
return super().form_valid(form)
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
@@ -1570,7 +1605,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
return o
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx = super().get_context_data()
ctx['plugin_forms'] = self.plugin_forms
ctx['meta_forms'] = self.meta_forms
ctx['formsets'] = self.formsets
+2 -14
View File
@@ -52,13 +52,13 @@ from pretix.base.forms import SafeSessionWizardView
from pretix.base.i18n import language
from pretix.base.models import Event, EventMetaValue, Organizer, Quota, Team
from pretix.base.models.organizer import TeamQuerySet
from pretix.base.services.quotas import QuotaAvailability
from pretix.control.forms.event import (
EventWizardBasicsForm, EventWizardCopyForm, EventWizardFoundationForm,
)
from pretix.control.forms.filter import EventFilterForm
from pretix.control.permissions import OrganizerPermissionRequiredMixin
from pretix.control.views import PaginationMixin
from pretix.control.views.utils import prepare_quotas_for_boxes
class EventList(PaginationMixin, ListView):
@@ -117,19 +117,7 @@ class EventList(PaginationMixin, ListView):
s.first_quotas = s.first_quotas[:4]
quotas += list(s.first_quotas)
qa = QuotaAvailability(early_out=False)
for q in quotas:
qa.queue(q)
qa.compute()
for q in quotas:
q.cached_avail = qa.results[q]
q.cached_availability_paid_orders = qa.count_paid_orders.get(q, 0)
if q.size is not None:
q.percent_paid = min(
100,
round(q.cached_availability_paid_orders / q.size * 100) if q.size > 0 else 100
)
prepare_quotas_for_boxes(quotas)
return ctx
@cached_property
+3 -3
View File
@@ -600,7 +600,7 @@ class OrderDetail(OrderView):
'item', 'variation', 'addon_to', 'tax_rule', 'used_membership', 'used_membership__membership_type',
'discount',
).prefetch_related(
'item__questionnaires', 'issued_gift_cards', 'owned_gift_cards', 'linked_media', # TODO(questionnaires) : item__questions gibts nicht mehr
'item__questions', 'issued_gift_cards', 'owned_gift_cards', 'linked_media',
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')),
Prefetch('all_checkins', queryset=Checkin.all.select_related('list', 'gate').order_by('datetime')),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device').order_by('datetime')),
@@ -624,9 +624,9 @@ class OrderDetail(OrderView):
p.additional_fields or
(p.item.ask_attendee_data and self.request.event.settings.attendee_names_asked) or
(p.item.ask_attendee_data and self.request.event.settings.attendee_emails_asked) or
p.item.questionnaires.all()
p.item.questions.all()
)
p.cache_answers(questionnaire_type=None, sales_channel=None)
p.cache_answers()
p.order = self.order
positions.append(p)
+14 -13
View File
@@ -69,6 +69,7 @@ from pretix.base.models.orders import CancellationRequest
from pretix.base.reldate import RelativeDate, RelativeDateWrapper
from pretix.base.services import tickets
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timeline import timeline_for_event
from pretix.base.views.tasks import AsyncFormView
from pretix.control.forms.checkin import SimpleCheckinListForm
from pretix.control.forms.filter import SubEventFilterForm
@@ -83,6 +84,7 @@ from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import subevent_forms
from pretix.control.views import PaginationMixin
from pretix.control.views.event import MetaDataEditorMixin
from pretix.control.views.utils import prepare_quotas_for_boxes
from pretix.helpers import GroupConcat
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.i18n import get_format_without_seconds
@@ -144,19 +146,7 @@ class SubEventList(EventPermissionRequiredMixin, PaginationMixin, SubEventQueryM
s.first_quotas = s.first_quotas[:4]
quotas += list(s.first_quotas)
qa = QuotaAvailability(early_out=False)
for q in quotas:
qa.queue(q)
qa.compute()
for q in quotas:
q.cached_avail = qa.results[q]
q.cached_availability_paid_orders = qa.count_paid_orders.get(q, 0)
if q.size is not None:
q.percent_paid = min(
100,
round(q.cached_availability_paid_orders / q.size * 100) if q.size > 0 else 100
)
prepare_quotas_for_boxes(quotas)
return ctx
@@ -566,6 +556,17 @@ class SubEventDetail(EventPermissionRequiredMixin, DetailView):
for quota in ctx["quotas"]:
quota.cached_avail = qa.results[quota]
ctx['timeline'] = [
{
'date': t.datetime.astimezone(self.request.event.timezone).date(),
'entry': t,
'time': t.datetime.astimezone(self.request.event.timezone)
}
for t in timeline_for_event(self.request.event, self.object)
]
ctx['today'] = now().astimezone(self.request.event.timezone).date()
ctx['nearly_now'] = now().astimezone(self.request.event.timezone) - timedelta(seconds=20)
return super().get_context_data(
**kwargs,
**ctx,
+51
View File
@@ -0,0 +1,51 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from pretix.base.services.quotas import QuotaAvailability
def prepare_quotas_for_boxes(quotas):
qa = QuotaAvailability(early_out=False)
for q in quotas:
qa.queue(q)
qa.compute()
for q in quotas:
q.cached_avail = qa.results[q]
q.cached_availability_paid_orders = qa.count_paid_orders.get(q, 0)
q.used = (
qa.count_paid_orders.get(q, 0) +
qa.count_pending_orders.get(q, 0) +
qa.count_exited_orders.get(q, 0) +
qa.count_vouchers.get(q, 0) +
qa.count_waitinglist.get(q, 0) +
qa.count_cart.get(q, 0)
)
if q.size is not None:
other_blocked = q.size - q.cached_availability_paid_orders - q.cached_avail[1]
q.percent_paid = min(
100,
round(q.cached_availability_paid_orders / q.size * 100) if q.size > 0 else 100
)
q.percent_other = min(
100,
round(other_blocked / q.size * 100) if q.size > 0 else 100
)
-12
View File
@@ -32,7 +32,6 @@ from django.conf import settings
from django.utils import translation
from django.utils.formats import get_format
from django.utils.translation import to_locale
from i18nfield.strings import LazyI18nString
date_conversion_to_moment = {
'%a': 'ddd',
@@ -245,14 +244,3 @@ def parse_date_localized(date_str) -> Optional[datetime]:
except (ValueError, TypeError):
continue
return dt
def i18n_all_from_gettext(gettext_str, locales):
return i18n_all(LazyI18nString.from_gettext(gettext_str).data, locales)
def i18n_all(data, locales):
out = {}
for locale in locales:
out[locale] = data[locale]
return out
+11 -1
View File
@@ -161,7 +161,12 @@ class OrderMailForm(BaseMailForm):
),
label=pgettext_lazy('sendmail_form', 'Restrict to products'),
required=True,
queryset=Item.objects.none()
queryset=Item.objects.none(),
help_text=pgettext_lazy(
'sendmail_form',
'There may be multiple mails sent out to the same mail address if one order contains multiple attendee '
'products for it, if you restrict to products while also restricting mails to attendees only. '
'This is intended, as every one of those get linked to their own separate order page restricted to only that product.')
)
filter_checkins = forms.BooleanField(
label=_('Filter check-in status'),
@@ -374,6 +379,11 @@ class RuleForm(FormPlaceholderMixin, I18nModelForm):
del self.fields['subevent']
self.fields['limit_products'].queryset = Item.objects.filter(event=self.event)
self.fields['limit_products'].help_text = pgettext_lazy(
'sendmail_form',
'There may be multiple mails sent out to the same mail address if one order contains multiple attendee '
'products for it, if you restrict to products while also restricting mails to attendees only. '
'This is intended, as every one of those get linked to their own separate order page restricted to only that product.')
self.fields['schedule_type'] = forms.ChoiceField(
label=_('Type of schedule time'),
+50 -35
View File
@@ -19,6 +19,7 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
from datetime import datetime, time, timedelta
from dateutil.tz import datetime_exists
@@ -39,6 +40,8 @@ from pretix.base.models import (
)
from pretix.base.models.base import LoggingMixin
logger = logging.getLogger(__name__)
class ScheduledMail(models.Model):
STATE_SCHEDULED = 'scheduled'
@@ -115,7 +118,7 @@ class ScheduledMail(models.Model):
filter_orders_by_op = False
op_qs = OrderPosition.objects.filter(
order__event=self.event,
order__event=e,
canceled=False,
)
@@ -167,14 +170,58 @@ class ScheduledMail(models.Model):
for o in orders:
with language(o.locale, e.settings.region):
positions = list(o.positions.all())
o_sent = False
send_to_order = send_to_orders
try:
ia = o.invoice_address
except InvoiceAddress.DoesNotExist:
ia = InvoiceAddress(order=o)
if send_to_orders and o.email:
if send_to_attendees:
parent_op = None
sent_to_positions = set()
for p in positions:
if p.addon_to_id is None:
# this op might have matching add-ons, so save for later
parent_op = p
elif not parent_op or p.addon_to_id != parent_op.id:
# this op is an add-on, but not to the current parent_op
# something got mixed up as add-ons should always come directly after their parent
logger.warning(f"Add-ons are mixed up for position #{p.positionid} in order {o.full_code}")
continue
if p.id not in position_ids:
# not a matching op, just there for parent_op
continue
if p.addon_to_id and (
not p.attendee_email or p.attendee_email == parent_op.attendee_email
):
# if op is addon and either has no mail or the same as parent => send to parent
p = parent_op
if p.pk in sent_to_positions:
# this position already got an email
continue
if not p.attendee_email or p.attendee_email == o.email:
# no email or same as order => send to order
send_to_order = True
continue
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
position=p,
event_or_subevent=self.subevent or e,
)
p.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent')
sent_to_positions.add(p.pk)
if send_to_order and o.email:
email_ctx = get_email_context(
event=e,
order=o,
@@ -184,38 +231,6 @@ class ScheduledMail(models.Model):
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.email.sent')
o_sent = True
if send_to_attendees:
if not self.rule.all_products:
positions = [p for p in positions if p.item_id in limit_products]
if self.subevent_id:
positions = [p for p in positions if p.subevent_id == self.subevent_id]
for p in positions:
if p.id in position_ids:
if p.attendee_email and (p.attendee_email != o.email or not o_sent):
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
position=p,
event_or_subevent=self.subevent or e,
)
p.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent')
elif not o_sent and o.email:
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
event_or_subevent=self.subevent or e,
)
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.email.sent')
o_sent = True
self.last_successful_order_id = o.pk
+31 -9
View File
@@ -31,6 +31,7 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import logging
from datetime import datetime
from django.db.models import Exists, OuterRef, Q
@@ -43,6 +44,8 @@ from pretix.base.services.mail import mail
from pretix.base.services.tasks import ProfiledEventTask
from pretix.celery_app import app
logger = logging.getLogger(__name__)
def _chunks(lst, n):
"""
@@ -70,6 +73,8 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
except InvoiceAddress.DoesNotExist:
ia = InvoiceAddress(order=o)
parent_op = None
sent_to_positions = set()
if recipients in ('both', 'attendees'):
for p in o.positions.annotate(
any_checkins=Exists(
@@ -85,10 +90,16 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
)
),
).prefetch_related('addons', 'subevent'):
if p.addon_to_id is not None:
if p.addon_to_id is None:
parent_op = p
elif not parent_op or p.addon_to_id != parent_op.id:
# this op is an add-on, but not to the current parent_op
# something got mixed up as add-ons should always come directly after their parent
logger.warning(f"Add-ons are mixed up for position #{p.positionid} in order {o.full_code}")
continue
if p.item_id not in items and not any(a.item_id in items for a in p.addons.all()):
if p.item_id not in items:
continue
if filter_checkins:
@@ -99,13 +110,11 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
if not allowed:
continue
if not p.attendee_email:
if recipients == 'attendees':
send_to_order = True
continue
if p.attendee_email == o.email and send_to_order:
continue
if p.addon_to_id and (
not p.attendee_email or p.attendee_email == parent_op.attendee_email
):
# if op is addon and either has no mail or the same as parent => send to parent
p = parent_op
if subevent and p.subevent_id != subevent:
continue
@@ -116,6 +125,18 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
if subevents_to and p.subevent.date_from >= subevents_to:
continue
if not p.attendee_email:
send_to_order = True
continue
if p.pk in sent_to_positions:
# this position already got an email
continue
if p.attendee_email == o.email:
send_to_order = True
continue
with language(o.locale, event.settings.region):
email_context = get_email_context(event=event, order=o, invoice_address=ia, position=p)
outgoing_mail = mail(
@@ -137,6 +158,7 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
user=user,
data=outgoing_mail.log_data(),
)
sent_to_positions.add(p.pk)
if send_to_order and o.email:
with language(o.locale, event.settings.region):
+69 -1
View File
@@ -19,12 +19,21 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from decimal import Decimal
from django.contrib.humanize.templatetags.humanize import intcomma
from django.db.models import Sum
from django.dispatch import receiver
from django.template.loader import get_template
from django.urls import resolve, reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from pretix.base.decimal import round_decimal
from pretix.base.models import Order, OrderPosition
from pretix.base.signals import order_paid, order_placed
from pretix.control.signals import nav_event
from pretix.base.templatetags.money import money_filter
from pretix.control.signals import event_dashboard_statistics, nav_event
@receiver(nav_event, dispatch_uid="statistics_nav")
@@ -58,3 +67,62 @@ def clear_cache(sender, *args, **kwargs):
order_placed.connect(clear_cache)
order_paid.connect(clear_cache)
@receiver(event_dashboard_statistics, dispatch_uid="statistics_event_dashboard_state")
def event_dashboard_stats_receivers(sender, request, **kwargs):
if len(event_dashboard_statistics.receivers) > 1:
# Only render this as a fallback if no other plugins exist
return ""
opqs = OrderPosition.objects
tickc = opqs.filter(
order__event=sender, item__admission=True,
order__status__in=(Order.STATUS_PAID, Order.STATUS_PENDING),
).count()
paidc = opqs.filter(
order__event=sender, item__admission=True,
order__status=Order.STATUS_PAID,
).count()
rev = Order.objects.filter(
event=sender,
status=Order.STATUS_PAID
).aggregate(sum=Sum('total'))['sum'] or Decimal('0.00')
num_widget = '<div class="numwidget"><span class="num">{num}</span><span class="text">{text}</span></div>'
widgets = [
{
'content': format_html(num_widget, num=intcomma(tickc), text=_('Attendees (ordered)')),
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.orders', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
})
},
{
'content': format_html(num_widget, num=intcomma(paidc), text=_('Attendees (paid)')),
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.orders.overview', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
})
},
{
'content': format_html(
num_widget,
num=money_filter(round_decimal(rev, sender.currency), sender.currency, hide_currency=True),
text=_('Total revenue ({currency})').format(currency=sender.currency)
),
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.orders.overview', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
})
},
]
template = get_template('pretixplugins/statistics/dashboard.html')
ctx = {'request': request, 'widgets': widgets}
return template.render(ctx)
@@ -0,0 +1,15 @@
<div class="dashboard">
{% for w in widgets %}
<div class="widget-container widget-{{ w.display_size|default:"small" }}">
{% if w.url %}
<a href="{{ w.url }}" class="widget">
{{ w.content }}
</a>
{% else %}
<div class="widget">
{{ w.content }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
+47 -26
View File
@@ -98,9 +98,7 @@ from pretix.presale.signals import (
question_form_fields_overrides,
)
from pretix.presale.utils import customer_login
from pretix.presale.views import (
CartMixin, get_cart_is_free, get_cart_positions,
)
from pretix.presale.views import CartMixin, get_cart, get_cart_is_free
from pretix.presale.views.cart import (
_items_from_post_data, cart_session, create_empty_cart_id,
get_or_create_cart_id,
@@ -499,7 +497,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
self.request = request
# check whether addons are applicable
if get_cart_positions(request).filter(item__addons__isnull=False).exists():
if get_cart(request).filter(item__addons__isnull=False).exists():
return True
# don't re-check whether cross-selling is applicable if we're already past the AddOnsStep
@@ -525,7 +523,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_positions(request).filter(addon_to__isnull=True).prefetch_related(
for cartpos in get_cart(request).filter(addon_to__isnull=True).prefetch_related(
'item__addons', 'item__addons__addon_category', 'addons', 'addons__item'
):
a = cartpos.addons.all()
@@ -588,7 +586,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
formset = []
quota_cache = {}
item_cache = {}
for cartpos in sorted(get_cart_positions(self.request).filter(addon_to__isnull=True).prefetch_related(
for cartpos in sorted(get_cart(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 = {
@@ -1092,18 +1090,26 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
return False
for cp in self._positions_for_questions:
qc_cache = {
qc.pk: qc for qq in cp.item.relevant_questionnaires for qc in qq.childlist
answ = {
aw.question_id: aw for aw in cp.answerlist
}
question_cache = {
q.pk: q for q in cp.item.questions_to_ask
}
def question_is_visible(parentid, qvals):
if parentid not in qc_cache:
if parentid not in question_cache:
return False
parentqc = qc_cache[parentid]
if parentqc.dependency_question_id and not question_is_visible(parentqc.dependency_question_id, parentqc.dependency_values):
parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
return False
answer_values = cp.get_dependency_answer_values(parentqc)
return answer_values and any(qval in answer_values for qval in qvals)
if parentid not in answ:
return False
return (
('True' in qvals and answ[parentid].answer == 'True')
or ('False' in qvals and answ[parentid].answer == 'False')
or (any(qval in [o.identifier for o in answ[parentid].options.all()] for qval in qvals))
)
def question_is_required(q):
return (
@@ -1112,16 +1118,31 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
)
if not self.all_optional:
for qq in cp.item.relevant_questionnaires:
for qc in qq.childlist:
if qc.user_datafield_id and question_is_required(qc) and qc.user_datafield_id not in cp.answer_cache:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if qc.system_datafield and question_is_required(qc) and not cp.get_system_answer(qc.system_datafield):
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
for q in cp.item.questions_to_ask:
if question_is_required(q) and q.id not in answ:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_names_required', as_type=bool) \
and not cp.attendee_name_parts:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_emails_required', as_type=bool) \
and cp.attendee_email is None:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_company_required', as_type=bool) \
and cp.company is None:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_addresses_required', as_type=bool) \
and (cp.street is None and cp.city is None and cp.country is None):
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
responses = question_form_fields.send(sender=self.request.event, position=cp)
form_data = cp.meta_info_data.get('question_form_data', {})
@@ -1289,7 +1310,7 @@ class PaymentStep(CartMixin, TemplateFlowStep):
@cached_property
def _total_order_value(self):
cart = get_cart_positions(self.request)
cart = get_cart(self.request)
try:
fees = get_fees(
event=self.request.event, request=self.request, invoice_address=self.invoice_address,
@@ -1481,7 +1502,7 @@ class PaymentStep(CartMixin, TemplateFlowStep):
messages.error(request, _('Please select a payment method to proceed.'))
return False
cart = get_cart_positions(self.request)
cart = get_cart(self.request)
try:
fees = get_fees(
event=self.request.event,
@@ -1521,7 +1542,7 @@ class PaymentStep(CartMixin, TemplateFlowStep):
def is_applicable(self, request):
self.request = request
for cartpos in get_cart_positions(self.request):
for cartpos in get_cart(self.request):
if cartpos.requires_approval(invoice_address=self.invoice_address):
if 'payments' in self.cart_session:
del self.cart_session['payments']
@@ -204,7 +204,7 @@
{% endif %}
{% endif %}
{% for q in line.questions %}
<dt>{{ q.label }}</dt>
<dt>{{ q.question }}</dt>
<dd>
{% include "pretixpresale/event/fragment_question_answer.html" with request=request question=q answer=q.answer %}
</dd>
@@ -1,8 +1,7 @@
{% load safelink %}
{% load i18n %}
{% load rich_text %}
{% if not answer and not question %}
{% elif answer %}
{% if answer %}
{% if answer.file %}
<span class="fa fa-file" aria-hidden="true"></span>
<a href="{{ answer.frontend_file_url }}?token={% answer_token request answer %}">
+21 -33
View File
@@ -41,7 +41,7 @@ from itertools import groupby
from django.conf import settings
from django.contrib import messages
from django.db.models import Exists, OuterRef, Prefetch, Q, Sum
from django.db.models import Exists, OuterRef, Prefetch, Sum
from django.utils import translation
from django.utils.functional import cached_property
from django.utils.timezone import now
@@ -52,9 +52,8 @@ from pretix.base.i18n import get_language_without_region, set_region
from pretix.base.middleware import get_supported_language
from pretix.base.models import (
CartPosition, Customer, InvoiceAddress, ItemAddOn, OrderFee, Question,
QuestionAnswer, Questionnaire, QuestionOption, TaxRule,
QuestionAnswer, QuestionOption, TaxRule,
)
from pretix.base.models.items import QuestionnaireChild
from pretix.base.models.orders import CheckoutSession
from pretix.base.services.cart import get_fees
from pretix.base.services.pricing import apply_rounding
@@ -96,7 +95,7 @@ class CartMixin:
"""
A list of this users cart position
"""
return list(get_cart_positions(self.request))
return list(get_cart(self.request))
@cached_property
def cart_session(self):
@@ -136,7 +135,7 @@ class CartMixin:
if queryset is not None:
prefetch = []
if answers:
prefetch.append('item__questionnaires')
prefetch.append('item__questions')
prefetch.append(Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options')))
cartpos = queryset.order_by(
@@ -213,7 +212,7 @@ class CartMixin:
# Never group if the position has add-ons
pos.pk not in has_addons and
# Never group if we have answers to show
(not answers or (not has_attendee_data and not bool(pos.item.questionnaires.all()))) and # do not use .exists() to re-use prefetch cache
(not answers or (not has_attendee_data and not bool(pos.item.questions.all()))) and # do not use .exists() to re-use prefetch cache
# Never group when we have a final order and a gift card code
(isinstance(pos, CartPosition) or not pos.item.issue_giftcard)
)
@@ -251,7 +250,7 @@ class CartMixin:
group.price_for_input_net = group.net_price_before_rounding + sum(a.net_price_before_rounding for a in has_addons[group.pk])
if answers:
group.cache_answers(questionnaire_type=Questionnaire.QuestionnaireType.ORDER_POSITION_SALE, sales_channel=self.request.sales_channel)
group.cache_answers(all=False)
group.additional_answers = pos_additional_fields.get(group.pk)
positions.append(group)
@@ -400,7 +399,7 @@ def cart_exists(request):
return bool(request._cart_cache)
def get_cart_positions(request):
def get_cart(request):
from pretix.presale.views.cart import get_or_create_cart_id
if not hasattr(request, '_cart_cache'):
@@ -408,11 +407,8 @@ def get_cart_positions(request):
if not cart_id:
request._cart_cache = CartPosition.objects.none()
else:
qqs = request.event.questionnaires.all()
qqs = qqs.filter(
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=request.sales_channel.identifier),
type='PS'
)
qqs = request.event.questions.all()
qqs = qqs.filter(ask_during_checkin=False, hidden=False, container_type=Question.ContainerType.ORDERPOSITION)
request._cart_cache = CartPosition.objects.filter(
cart_id=cart_id, event=request.event
).annotate(
@@ -433,23 +429,18 @@ def get_cart_positions(request):
Prefetch('answers',
QuestionAnswer.objects.prefetch_related('options'),
to_attr='answerlist'),
Prefetch('item__questionnaires',
Prefetch('item__questions',
qqs.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')
),
to_attr='relevant_questionnaires')
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
'question',
Question.objects.none(),
to_attr='dummy'
)))
).select_related('dependency_question'),
to_attr='questions_to_ask')
)
by_id = {cp.pk: cp for cp in request._cart_cache}
for cp in request._cart_cache:
@@ -460,9 +451,6 @@ def get_cart_positions(request):
return request._cart_cache
get_cart = get_cart_positions # legacy compatibility
def get_cart_total(request):
"""
Use the following pattern instead::
@@ -513,7 +501,7 @@ def get_cart_is_free(request):
if not hasattr(request, '_cart_free_cache'):
cs = cart_session(request)
pos = get_cart_positions(request)
pos = get_cart(request)
ia = get_cart_invoice_address(request)
try:
fees = get_fees(event=request.event, request=request, invoice_address=ia,
-1
View File
@@ -183,7 +183,6 @@ class OrderPositionDetailMixin(NoSearchIndexViewMixin):
def position(self):
qs = OrderPosition.objects.filter(
order__event=self.request.event,
addon_to__isnull=True,
order__code=self.kwargs['order'],
positionid=self.kwargs['position']
).select_related('order', 'order__event')
File diff suppressed because one or more lines are too long
@@ -87,8 +87,6 @@ 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) */
@@ -1,8 +0,0 @@
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)
}
@@ -1,5 +1,7 @@
/* global add_log_expand_handlers */
/* global $,gettext */
$(function () {
// Still used on global dashboard
if ($('div[data-lazy-id]').length == 0) {
return
}
@@ -10,16 +12,11 @@ $(function () {
})
})
})
$(function () {
if ($('#logs_target').length == 0) {
return
}
$.get('dashboard/partials/logs', function (data) {
$('#logs_target').html(data)
add_log_expand_handlers($('#logs_target'))
})
$.get('dashboard/partials/warnings', function (data) {
$('#warnings_loading').remove()
$('#warnings_target').html(data)
$('.timeline').each(function () {
let $tl = $(this)
let $first = $(this).find('.row:not(.text-muted)').first()
$tl.scrollTop($tl.scrollTop() + Math.max($first.position().top - 50, 0))
})
})
+14 -101
View File
@@ -72,10 +72,6 @@ $(document).ajaxError(function (event, jqXHR, settings, thrownError) {
})
let form_handlers = function (el) {
// Prevent running multiple times on the same elements
if (el.is('[data-formset-form-handlers-created]')) return
el.find('[data-formset-form]').attr('data-formset-form-handlers-created', 'true')
el.trigger('rescan.areYouSure')
el.find('[data-formset]').formset(
{
@@ -446,15 +442,6 @@ let form_handlers = function (el) {
})
})
function addClearButton($input) {
var $btn = $("<button class='clear-input-button'><span class='fa fa-times'></span></button>").insertAfter($input).on('click', function(e) {
$input.val('').trigger('input').trigger('change'); e.preventDefault()
}).toggle($input.val() !== '')
$input.on('input', function() {
$btn.toggle($input.val() !== '')
})
}
el.find('div.scrolling-choice:not(.no-search)').each(function () {
if ($(this).find('input[type=text]').length > 0) {
return
@@ -462,7 +449,6 @@ let form_handlers = function (el) {
let $menu = $('<div>').addClass('choice-options-menu')
let $inp_search = $('<input>').addClass('form-control').attr('type', 'text').attr('placeholder', gettext('Search query'))
$menu.append($inp_search)
addClearButton($inp_search)
$(this).prepend($menu)
$inp_search.on('keyup change', function (e) {
@@ -491,7 +477,6 @@ let form_handlers = function (el) {
$menu.append($lbl_tgl)
}
$(this).prepend($menu)
addClearButton($inp_search)
$(this).find('.choice-options-none').click(function (e) {
$(this).closest('.scrolling-multiple-choice').find('input[type=checkbox]:not(.menu-checkbox)').prop('checked', false)
@@ -751,27 +736,6 @@ let form_handlers = function (el) {
questions_toggle_dependent()
questions_init_photos(el)
el.find("[data-iframe-dialog]").on("click", function(e) {
let url;
if (this.tagName === "A" && !this.hasAttribute("data-iframe-dialog-url")) {
url = this.getAttribute("href")
url += (url.includes('?') ? '&' : '?') + 'notify_parent=true'
} else {
url = this.getAttribute("data-iframe-dialog-url")
}
show_iframe_dialog(url, (data) => {
if (this.getAttribute("data-iframe-dialog-target")) {
var $target = findDependency(this.getAttribute("data-iframe-dialog-target"), this)
if ($target.is('select')) {
$target.append(new Option(data.object_str, data.object, false, true)).trigger('change')
}
}
})
e.preventDefault()
})
}
function setup_placeholders() {
let lastFocusedInput
$(document).on('focusin', 'input, textarea', function (e) {
lastFocusedInput = e.target
@@ -849,7 +813,13 @@ function setup_basics (el) {
return
}
$('<li><a href="#' + scrollTarget.id + '">' + $.trim(label) + '</a> – ' + description + '</li>')
$('<li></li>')
.append(
$('<a></a>').attr("href", "#" + scrollTarget.id).text($.trim(label))
)
.append(
document.createTextNode(' - ' + description)
)
.appendTo(alert.querySelector('ul') || $('<ul>').appendTo(alert))
.find('a').on('click', function (e) {
$panel.collapse('show')
@@ -1115,7 +1085,6 @@ $(function () {
setup_basics($('body'))
form_handlers($('body'))
setup_placeholders()
$(document).trigger('pretix:bind-forms')
$('#ajaxerr').on('click', '.ajaxerr-close', ajaxErrDialog.hide)
@@ -1157,67 +1126,11 @@ $(function () {
$('form[method=post]').filter(function () {
return $(this).find('button:not([type=button]), input[type=submit]').length > 0
}).areYouSure({ message: gettext('You have unsaved changes!') })
})
function show_iframe_dialog(url, callback) {
function messageEvent(e) {
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="100" 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 setup_dialog_frame_events() {
var dom_ready = false, parent_responded = false
function notify_dialog_loaded() {
console.log('ready', window.innerWidth, window.innerHeight, $('#page-wrapper > .container-fluid').outerHeight() + 20)
window.parent.postMessage({
type: 'pretix:dialog-loaded',
contentHeight: $('#page-wrapper > .container-fluid').outerHeight() + 20,
title: document.title,
}, location.origin)
}
window.addEventListener('message', function(e) {
if (e.source === window) return // don't handle messages from ourselves
if (e.origin !== location.origin) return // only handle messages from same origin
});
if (e.data.type === 'pretix:dialog-handshake') {
// we are inside an iframe in a dialog
if (!parent_responded) {
window.document.documentElement.classList.add('in-iframe')
if (dom_ready) setTimeout(notify_dialog_loaded, 1)
parent_responded = true
}
} else if (e.data.type === 'pretix:dialog-loading') {
// we are parent to a dialog
e.source.postMessage({ type: 'pretix:dialog-handshake' })
}
})
$(function () {
if (parent_responded) setTimeout(notify_dialog_loaded, 1)
dom_ready = true
})
try {
window.parent.postMessage({
type: 'pretix:dialog-loading',
}, location.origin)
} catch {}
}
setup_dialog_frame_events()
htmx.on('htmx:afterSettle', (e) => {
console.log("afterSwap", e)
setup_basics($(e.detail.elt))
form_handlers($(e.detail.elt))
add_log_expand_handlers($(e.detail.elt))
})
@@ -1,52 +0,0 @@
<script setup lang="ts">
import Editor from './Editor.vue';
import {provide, ref} from "vue";
import * as api from "./api";
import {sort, numericComp, groupBy} from './helper';
import {Category, GroupedItems, Item, SalesChannel} from "./model";
const sales_channels_list = ref<SalesChannel[]>();
const grouped_items = ref<GroupedItems>()
provide('pretix:env:organizer:sales_channels', sales_channels_list)
provide('pretix:env:event:grouped_items', grouped_items)
async function loadEnvironmentData() {
sales_channels_list.value = 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));
grouped_items.value = [...groupBy(items_list, item => categories[item.category])]
}
loadEnvironmentData()
</script>
<template>
<Suspense>
<Editor/>
<template #fallback>
<div class="big-load-indicator"><i class="fa fa-cog big-rotating-icon"></i></div>
</template>
</Suspense>
</template>
<style>
.big-load-indicator { text-align: center; padding-bottom: 100px }
.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>
@@ -1,62 +0,0 @@
<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>
@@ -1,276 +0,0 @@
<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 {inject, nextTick, onMounted, onUnmounted, ref} from 'vue';
import { SlickList, SlickItem } from 'vue-slicksort';
import { ProgressBar } from "./ProgressBar";
const grouped_items = inject('pretix:env:event: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: position_questionnaires.value.length,
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: order_questionnaires.value.length,
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) {
if (document.querySelector("dialog[open]")) {
// TODO this should not be skipped completely but handled in a more robust way
// currently dialogs are very flaky if the whole questionnaires list gets replaced underneath
// them (they will stay open, but saving them will cause weird problems i don't fully understand yet)
console.log('skipping refresh because a dialog is open')
return
}
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):not(:focus-within) .btn.btn-default,
.questionnaires-editor .editor-action-row:not(:hover):not(:focus-within) .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"
:grouped_items="null"
:selected_product="null"
:preview_mode="false"
@update="saveQuestionnaire(questionnaire)"
@invalidate:datafields="refreshDatafieldList()"
@invalidate:questionnaires="refreshQuestionnaireList()"/>
</SlickItem>
</SlickList>
<div class="editor-action-row form-horizontal">
<div class="form-group">
<div class="col-md-9 col-md-push-3">
<button class="btn btn-default" @click="addOrderQuestionnaire()"><i class="fa fa-plus"></i> {{ gettext('New questionnaire') }}</button>
</div>
</div>
</div>
</div>
</div>
<div :class="`questionnaires-editor ${selected_product ? 'product-selected':''} ${preview_mode ? 'preview-mode':''}`">
<div class="filter-row" v-if="preview_mode">
<button class="btn btn-default" @click="preview_mode = false"><span class="fa fa-chevron-left"></span> {{ gettext('Back') }}</button>
Previewing questionnaires for product {{ selected_product }}
</div>
<div class="filter-row" v-else>
Questionnaires for product:
<select v-model="selected_product">
<option value="">(all)</option>
<optgroup v-for="[category, items] in grouped_items" :label="category.internal_name || i18n_any(category.name)">
<option v-for="item in items" :value="item.id">
{{ item.internal_name || i18n_any(item.name) }}
</option>
</optgroup>
</select>
&nbsp;
<button class="btn btn-default" @click="preview_mode = true" :disabled="!selected_product"> {{ gettext('Preview') }}</button>
</div>
<div class="questionnaires-list" v-if="preview_mode && selected_product">
<details class="panel panel-default details-open" open>
<summary class="panel-heading">
<h4 class="panel-title">
<strong>Product {{ selected_product }}</strong>
</h4>
</summary>
<QuestionnaireElement v-for="(questionnaire, index) in position_questionnaires.filter(q => q.items.indexOf(selected_product as any) !== -1)"
:questionnaire="questionnaire"
:datafields="position_datafields"
:grouped_items="grouped_items"
:selected_product="selected_product"
:preview_mode="true"
@update="saveQuestionnaire(questionnaire)"
@invalidate:datafields="refreshDatafieldList()"
@invalidate:questionnaires="refreshQuestionnaireList()"/>
</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"
:grouped_items="grouped_items"
:selected_product="selected_product"
:preview_mode="false"
@update="saveQuestionnaire(questionnaire)"
@invalidate:datafields="refreshDatafieldList()"
@invalidate:questionnaires="refreshQuestionnaireList()"/>
</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>
@@ -1,13 +0,0 @@
<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,13 +0,0 @@
<script setup lang="ts">
import {getEventLocales} from "./api"
const locales = getEventLocales();
const props = defineProps<{ value: any, id?: string }>();
</script>
<template>
<div class="i18n-form-group" :id="id">
<input v-for="locale in locales" cols="40" rows="2" :lang="locale" dir="ltr" class="form-control" title="Englisch" :id="`${id}_${locale}`" :placeholder="locale" v-model="value[locale]">
</div>
</template>
@@ -1,58 +0,0 @@
<script setup lang="ts">
import { ref, useId } from 'vue';
const dialog = ref<HTMLDialogElement>();
const props = defineProps({
classes: {
type: String,
default: "",
},
title: {type: String, default: ''},
noPadding: {type: Boolean, default: false},
noScroll: {type: Boolean, default: true},
maxWidth: {type: String, default: '43em'},
});
const visible = ref(false);
const showModal = () => {
dialog.value?.showModal();
visible.value = true;
};
defineExpose({
show: showModal,
close: (returnVal?: string): void => dialog.value?.close(returnVal),
visible,
});
const id = useId();
</script>
<template>
<dialog
ref="dialog" :class="`modal-card ${props.noPadding ? 'no-padding' : ''} ${props.noPadding ? 'no-scroll' : ''}`"
@close="visible = false"
closedby="any"
:aria-labelledby="`${id}-title`"
:style="{maxWidth: props.maxWidth}"
>
<form
v-if="visible"
method="dialog" class="modal-card-inner form-horizontal"
:class="{
[props.classes]: props.classes,
}"
>
<div class="modal-card-content">
<h2 :id="`${id}-title`" class="modal-card-title h3" v-if="title">{{ title }}</h2>
<slot />
</div>
</form>
</dialog>
</template>
<style>
.modal-card.no-padding, .modal-card.no-padding .modal-card-content { padding: 0; }
.modal-card.no-scroll { overflow: hidden; }
</style>
@@ -1,71 +0,0 @@
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}%`;
}
};
}
}
@@ -1,169 +0,0 @@
<script setup lang="ts">
import { i18n_any, QUESTION_TYPE, QUESTION_TYPE_LABEL, SYSTEM_DATAFIELDS } from './helper';
import NativeDialog from './NativeDialog.vue';
import I18nTextField from './I18nTextField.vue';
import {useId, ref, computed} from 'vue'
import { DragHandle } from 'vue-slicksort';
import {getDatafieldCreateUrl, getDatafieldEditUrl, getDatafieldViewUrl} from "./api";
import I18nTextArea from "./I18nTextArea.vue";
import DjangoDialog from "./DjangoDialog.vue";
const id = useId();
const props = defineProps(['question', 'datafields', 'editable', 'possible_dependencies'])
const emit = defineEmits(['removeSelf', 'update', 'invalidate:datafields']);
const gettext = (window as any).gettext;
const question = ref(props.question);
const dlgEditDatafield = ref()
const df = computed(() => typeof question.value.datafield === 'number' ?
props.datafields.find(el => el.id === question.value.datafield) :
typeof question.value.datafield === 'string' ?
SYSTEM_DATAFIELDS[question.value.datafield] :
null);
const dependency_values_options = computed(() => props.datafields.find(el => el.id === question.value.dependency_question)?.options);
const dependency_values_resolved = computed(() => question.value.dependency_values.map(ident => i18n_any(dependency_values_options.value.find(opt => opt.identifier === ident)?.answer) ?? ident));
if (!question.value.label) question.value.label = {};
if (!question.value.help_text) question.value.help_text = {};
const editor = ref();
</script>
<template>
<div class="editor-row">
<div class="editor-preview-area">
<div class="form-group">
<template v-if="df">
<div v-if="question.dependency_question" class="dependency-info debuginfo">
<span><span class="fa fa-link"></span> {{ question.dependency_question }} = {{ dependency_values_resolved }}</span>
</div>
<div class="col-md-3 control-label label-empty" v-if="df.type === QUESTION_TYPE.BOOLEAN"></div>
<label class="col-md-3 control-label" :for="id" v-else>
{{ i18n_any(question.label) }}
</label>
<div class="col-md-9">
<input :id="id" type="text" v-if="df.type === QUESTION_TYPE.STRING || df.type === QUESTION_TYPE.PHONENUMBER" class="form-control">
<textarea :id="id" v-if="df.type === QUESTION_TYPE.TEXT" class="form-control"></textarea>
<div class="checkbox" v-if="df.type === QUESTION_TYPE.BOOLEAN">
<label :for="id">
<input :id="id" type="checkbox"> {{ i18n_any(question.label) }}
</label>
</div>
<input :id="id" type="number" v-if="df.type === QUESTION_TYPE.NUMBER" class="form-control">
<input :id="id" type="file" v-if="df.type === QUESTION_TYPE.FILE" class="form-control">
<select :id="id" class="form-control"
v-if="df.type === QUESTION_TYPE.CHOICE || df.type === QUESTION_TYPE.COUNTRYCODE">
<option></option>
<option v-for="opt in df.options">{{ i18n_any(opt.answer) }}</option>
</select>
<div class="checkbox" v-if="df.type === QUESTION_TYPE.CHOICE_MULTIPLE" v-for="(opt, index) in df.options">
<label :for="`${id}-${index}`">
<input :id="`${id}-${index}`" type="checkbox"> {{ i18n_any(opt.answer) }}
</label>
</div>
<div class="help-block" v-html="question.rendered_help_text" v-if="question.rendered_help_text"></div>
</div>
</template>
<div v-else class="col-md-9 col-md-push-3">
<h4>{{ i18n_any(question.label) }}</h4>
<p v-html="question.rendered_help_text" v-if="question.rendered_help_text"></p>
</div>
</div>
</div>
<aside class="editor-action-area">
<div class="btn-group" v-if="editable">
<DragHandle tag="button" class="btn btn-default"><i class="fa fa-arrows"></i></DragHandle>
<button class="btn btn-default" @click="editor.show()"><i class="fa fa-edit"></i></button>
</div>
</aside>
</div>
<Teleport to="body">
<NativeDialog ref="editor" class="modal-card"
:title="df ? gettext('Edit question') : gettext('Edit text block')">
<div class="form-group">
<label class="col-md-3 control-label">
{{ df ? gettext('Question') : gettext('Headline') }}
</label>
<div class="col-md-9">
<I18nTextField :value="question.label"/>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Help text') }}
</label>
<div class="col-md-9">
<I18nTextArea :value="question.help_text"/>
<div class="help-block">Wenn diese Frage noch weitere Erklärung braucht, können Sie sie hier eintragen.</div>
</div>
</div>
<div class="form-group" v-if="df">
<label class="col-md-3 control-label">
Data field
</label>
<div class="col-md-9">
<p class="form-control-static">
<template v-if="typeof question.datafield === 'number'">
{{ df.internal_name }}
<div>
<a class="btn btn-sm btn-default" href="javascript:" @click="dlgEditDatafield.open(getDatafieldEditUrl(df.id))"><span class="fa fa-wrench"></span> Manage data field details</a>
<a class="btn btn-sm btn-default" :href="getDatafieldViewUrl(df.id)" target="_blank"><span class="fa fa-external-link"></span> View answers</a>
</div>
</template>
<template v-else>
{{ question.datafield }}
</template>
</p>
</div>
</div>
<div class="form-group" v-if="df">
<label class="col-md-3 control-label">
Data field type
</label>
<div class="col-md-9">
<select v-model="df.type" class="form-control" disabled>
<option v-for="(label, type) in QUESTION_TYPE_LABEL" :value="QUESTION_TYPE[type]">{{ label }}</option>
</select>
</div>
</div>
<div class="form-group" v-if="df">
<label class="col-md-3 control-label label-empty">&nbsp;</label>
<div class="col-md-9">
<div class="checkbox">
<label>
<input type="checkbox" v-model="question.required">
{{ gettext('Required question') }}
</label>
</div>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Only visible if...') }}
</label>
<div class="col-md-9">
<select v-model="question.dependency_question" class="form-control">
<option :value="null">{{ gettext('(none)') }}</option>
<option v-for="(qc, index) in possible_dependencies" :value="qc.datafield">{{ i18n_any(qc.label) }}</option>
</select>
<select v-model="question.dependency_values" class="form-control" multiple>
<option v-for="(qc, index) in dependency_values_options" :value="qc.identifier">{{ i18n_any(qc.answer) }}</option>
</select>
<div v-if="question.dependency_question">
<p><span class="debuginfo">depends on {{ question.dependency_question }} = {{ dependency_values_resolved }}</span></p>
</div>
</div>
</div>
<button @click="editor.close(); emit('update')" class="btn btn-primary pull-right"><span class="fa fa-check"></span> Save and close</button>
<button @click="emit('removeSelf')" class="btn btn-default">Remove from questionnaire</button>
</NativeDialog>
<DjangoDialog ref="dlgEditDatafield" @confirm="emit('invalidate:datafields')" max-width="60em"></DjangoDialog>
</Teleport>
</template>
@@ -1,80 +0,0 @@
<script setup lang="ts">
import {gettext} from "./gettextstub";
import {GroupedItems, Questionnaire, SalesChannel} from "./model";
import {inject, Ref, useId} from "vue";
import {i18n_any, QUESTIONNAIRE_TYPE, QUESTIONNAIRE_TYPE_LABEL, setListState} from "./helper";
import SalesChannelIcon from "./SalesChannelIcon.vue";
const sales_channels: Ref<SalesChannel[]> = inject('pretix:env:organizer:sales_channels')
const id = useId()
const props = defineProps<{
questionnaire: Questionnaire,
grouped_items: GroupedItems,
}>()
</script>
<template>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Internal name') }}
</label>
<div class="col-md-9">
<input type="text" class="form-control" v-model="questionnaire.internal_name"/>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('When to ask') }}
</label>
<div class="col-md-9">
<select v-model="questionnaire.type" class="form-control">
<template v-for="(label, type) in QUESTIONNAIRE_TYPE_LABEL">
<option v-if="QUESTIONNAIRE_TYPE[type][0] == questionnaire.type[0]" :value="QUESTIONNAIRE_TYPE[type]">{{ label }}</option>
</template>
</select>
</div>
</div>
<div class="form-group" v-if="questionnaire.type[1] == 'S'">
<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 => setListState(questionnaire.limit_sales_channels, (e.target as HTMLInputElement).checked, channel.identifier)"
:disabled="questionnaire.all_sales_channels">
<SalesChannelIcon :sales-channel="channel"/>
{{ i18n_any(channel.label) }}
</label>
</div>
</div>
</div>
<div class="form-group" v-if="grouped_items">
<label class="col-md-3 control-label">
{{ gettext('Visible on products') }}
</label>
<div class="col-md-9">
<div v-for="[category, items] in grouped_items">
<div class="category-header">{{ category.internal_name || i18n_any(category.name) }}</div>
<div class="checkbox" v-for="item in items">
<label :for="id + '_' + item.id">
<input :id="id + '_' + item.id" type="checkbox" :checked="questionnaire.items.indexOf(item.id) !== -1" @change="e => setListState(questionnaire.items, (e.target as HTMLInputElement).checked, item.id)"> {{ item.internal_name || i18n_any(item.name) }}
</label>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="sass">
</style>
@@ -1,230 +0,0 @@
<script setup lang="ts">
import {useId, ref, computed, onMounted, watch, Ref, inject} from 'vue'
import QuestionElement from "./QuestionElement.vue";
import {
i18n_any,
QUESTION_TYPE,
QUESTION_TYPE_LABEL,
QUESTIONNAIRE_TYPE,
QUESTIONNAIRE_TYPE_LABEL, setListState,
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";
import * as api from './api';
import QuestionnaireDetailForm from "./QuestionnaireDetailForm.vue";
import SalesChannelIcon from "./SalesChannelIcon.vue";
import {SalesChannel} from "./model";
const dlgEditor = ref()
const dlgAddExisting = ref()
const dlgAddTextblock = ref()
const dlgNewDatafield = ref()
const newTextblockTitle = ref()
const newTextblockText = ref()
const id = useId();
const props = defineProps(['questionnaire', 'datafields', 'selected_product', 'grouped_items', 'preview_mode', 'err_mes'])
const emit = defineEmits(['update', 'invalidate:datafields', 'invalidate:questionnaires'])
const sales_channels: Ref<SalesChannel[]> = inject('pretix:env:organizer:sales_channels')
let nextId = 1;
watch(() => props.questionnaire.children, () => {
for (let qc of props.questionnaire.children) {
if (!qc._cid) qc._cid = id + (++nextId);
}
})
function addExistingDatafield (field) {
props.questionnaire.children.push({
_cid: useId(),
datafield: field.id,
required: false,
label: field.question ?? {en: field.internal_name},
help_text: {},
dependency_question: null,
dependency_values: [],
})
dlgAddExisting.value.close()
emit('update')
}
function showAddTextblockDialog () {
newTextblockTitle.value = {}
newTextblockText.value = {}
dlgAddTextblock.value.show()
}
function addTextblock () {
props.questionnaire.children.push({
_cid: useId(),
datafield: null,
required: false,
label: newTextblockTitle.value,
help_text: newTextblockText.value,
dependency_question: null,
dependency_values: [],
})
dlgAddTextblock.value.close()
emit('update')
}
function newDatafield (container_type) {
dlgNewDatafield.value.open(getDatafieldCreateUrl(props.questionnaire.type[0]))
}
async function onNewDatafieldCreated (data) {
watch(
() => props.datafields,
(newValue, oldValue) => {
console.log('datafields changed watcher called')
addExistingDatafield(newValue.find(f => f.id === data.object))
},
{ once: true }
)
emit('invalidate:datafields')
}
async function deleteQuestionnaire () {
if (confirm("Are you sure?")) {
try {
await api.deleteQuestionnaire(props.questionnaire.id)
} catch (e) {
console.error('Failed to delete questionnaire', e)
alert('Error while deleting questionnaire')
}
emit('invalidate:questionnaires')
}
}
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>
<style>
.sales-channel-icons > * { margin-left: 4px; opacity: 0.8; }
</style>
<template>
<details class="questionnaire-panel" :open="preview_mode"
:class="{ 'hidden-questionnaire': isHidden }">
<summary class="questionnaire-panel-heading">
<div class=" editor-row">
<div class="editor-preview-area">
<input type="checkbox" @change="e => {setListState(props.questionnaire.items, (e.target as HTMLInputElement).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>
<span class="pull-right" v-if="questionnaire.type === 'PC'">
<span class="label label-default">{{ gettext('Check-in') }}</span>
</span>
<span class="pull-right" v-else-if="questionnaire.type === 'PA'">
<span class="label label-default">{{ gettext('Attendee link') }}</span>
</span>
<span class="pull-right sales-channel-icons" v-else>
<template v-for="sc in sales_channels">
<SalesChannelIcon :sales-channel="sc" :tooltip="true"
v-if="props.questionnaire.all_sales_channels || props.questionnaire.limit_sales_channels.indexOf(sc.identifier) !== -1" />
</template>
</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()" :title="`#${questionnaire.id}`"><i class="fa fa-wrench"></i></button>
</div></aside>
</div>
</summary>
<div class="questionnaire-panel-body" v-if="!isHidden">
<div class="alert alert-warning" v-if="questionnaire._err_mes">
<dl v-if="questionnaire._err_mes.api_error">
<template v-for="(messages, key) in questionnaire._err_mes.api_error">
<dt>{{ key }}</dt>
<dd><ul><li v-for="message in messages">{{ message }}</li></ul></dd>
</template>
</dl>
<p v-else>{{ questionnaire._err_mes }}</p>
</div>
<div class="form-horizontal" :id="`questionListParent${props.questionnaire.id}`">
<SlickList axis="y" v-model:list="props.questionnaire.children" useDragHandle :appendTo="`#questionListParent${props.questionnaire.id}`" @update:list="emit('update')">
<SlickItem v-for="(child, index) in props.questionnaire.children" :key="child._cid" :index="index">
<QuestionElement
:datafields="props.datafields"
:question="child"
:editable="true"
:possible_dependencies="props.questionnaire.children.slice(0, index)"
@remove-self="questionnaire.children.splice(index, 1); emit('update')"
@update="emit('update')"
@invalidate:datafields="emit('invalidate:datafields')"/>
</SlickItem>
</SlickList>
</div>
<div class="editor-action-row form-horizontal">
<div class="form-group">
<div class="col-md-9 col-md-push-3">
<p class="btn-group" role="group">
<button class="btn btn-default" @click="dlgAddExisting.show()"><i class="fa fa-plus"></i> {{ gettext('Existing data field') }}</button>
<button class="btn btn-default" @click="newDatafield(questionnaire.type[0])"><i class="fa fa-plus"></i> {{ gettext('New data field') }}</button>
<button class="btn btn-default" @click="showAddTextblockDialog()"><i class="fa fa-plus"></i> {{ gettext('Text') }}</button>
</p>
</div>
</div>
</div>
</div>
</details>
<Teleport to="body">
<NativeDialog ref="dlgEditor" class="modal-card"
:title="gettext('Edit questionnaire')">
<QuestionnaireDetailForm :questionnaire="questionnaire" :grouped_items="grouped_items"/>
<button @click="dlgEditor.close(); emit('update')" class="btn btn-primary pull-right"><span class="fa fa-check"></span> {{ gettext('Save and close') }}</button>
<button @click="deleteQuestionnaire" class="btn btn-default">{{ gettext('Delete') }}</button>
</NativeDialog>
<NativeDialog ref="dlgAddExisting" class="modal-card"
:title="gettext('Add existing data field')">
<div class="list-group">
<a href="javascript:" @click="addExistingDatafield(field)" v-for="field in datafields" class="list-group-item">{{ field.internal_name ?? i18n_any(field.question) }}</a>
</div>
<button @click="dlgAddExisting.close()" class="btn btn-default pull-right">{{ gettext('Cancel') }}</button>
</NativeDialog>
<NativeDialog ref="dlgAddTextblock" class="modal-card"
:title="gettext('Add sub heading')">
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Title') }}
</label>
<div class="col-md-9">
<I18nTextField :value="newTextblockTitle"/>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Text') }}
</label>
<div class="col-md-9">
<I18nTextField :value="newTextblockText"/>
</div>
</div>
<button @click="addTextblock()" class="btn btn-default pull-right">{{ gettext('OK') }}</button>
<button @click="dlgAddTextblock.close()" class="btn btn-default pull-right">{{ gettext('Cancel') }}</button>
</NativeDialog>
<DjangoDialog ref="dlgNewDatafield" @confirm="onNewDatafieldCreated" max-width="60em"></DjangoDialog>
</Teleport>
</template>
@@ -1,16 +0,0 @@
<script setup lang="ts">
import {SalesChannel} from "./model";
import {i18n_any} from "./helper";
const props = defineProps<{ salesChannel: SalesChannel, tooltip?: boolean }>();
</script>
<template>
<template v-if="salesChannel.icon_url">
<img :src="salesChannel.icon_url" class="fa-like-image" :alt="i18n_any(salesChannel.label)" :data-toggle="tooltip ? 'tooltip' : ''" :title="tooltip ? i18n_any(salesChannel.label) : ''">
</template>
<template v-else>
<span :class="`fa fa-${salesChannel.icon} fa-fw text-muted`" :data-toggle="tooltip ? 'tooltip' : ''" :title="tooltip ? i18n_any(salesChannel.label) : ''"></span>
</template>
</template>
@@ -1,98 +0,0 @@
import {ApiListResponse, Datafield, Questionnaire, Item, Category, SalesChannel} from './model'
import { ProgressBar } from "./ProgressBar";
import {fromJsonScript} from "./helper";
const organizer_slug = document.body.getAttribute('data-organizer'),
event_slug = document.body.getAttribute('data-event')
async function api_get(resource) {
return await $.getJSON(`/api/v1/${resource}?_nocache=${+new Date()}`)
}
async function api_get_all<T>(resource): Promise<T[]> {
let next = `/api/v1/${resource}?_nocache=${+new Date()}`
const result: T[] = [];
while (next) {
const response: ApiListResponse<T> = await $.getJSON(next)
result.push(...response.results)
next = response.next
console.log('api_get_all: ' + resource, next, response, result)
}
return result
}
class APIError extends Error {
api_error: string;
constructor(json) {
super('' + Object.values(json)[0]);
this.api_error = json;
}
}
async function api_json_request(resource, method, json_body) {
const response = await fetch(`/api/v1/${resource}`, {
body: JSON.stringify(json_body),
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': $('[name=csrfmiddlewaretoken]').val() as string,
},
});
if (response.status === 204)
return {}
else if (response.status >= 400)
throw new APIError(await response.json());
else
return await response.json()
}
export async function getDatafields(container_type) {
using pb = ProgressBar.show('loading data fields')
return await api_get_all<Datafield>(`organizers/${organizer_slug}/events/${event_slug}/${container_type == 'O' ? 'orderdatafields' : 'datafields'}/`);
}
export async function getQuestionnaires() {
using pb = ProgressBar.show('loading questionnaires')
return await api_get_all<Questionnaire>(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/`);
}
export async function updateQuestionnaire(id, data) {
return await api_json_request(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/${id}/`, 'PATCH', data);
}
export async function createQuestionnaire(data) {
return await api_json_request(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/`, 'POST', data);
}
export async function deleteQuestionnaire(id) {
return await api_json_request(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/${id}/`, 'DELETE', {});
}
export async function getItems() {
using pb = ProgressBar.show('loading product list')
return await api_get_all<Item>(`organizers/${organizer_slug}/events/${event_slug}/items/`);
}
export async function getCategories() {
using pb = ProgressBar.show('loading category list')
return await api_get_all<Category>(`organizers/${organizer_slug}/events/${event_slug}/categories/`);
}
export async function getSalesChannels() {
return await api_get_all<SalesChannel>(`organizers/${organizer_slug}/saleschannels/`);
}
export function getEventLocales() {
return fromJsonScript('event_locales');
}
export function getDatafieldViewUrl(datafield_id) {
return fromJsonScript('datafield_view_url').replace('/0/', `/${datafield_id}/`);
}
export function getDatafieldEditUrl(datafield_id) {
return fromJsonScript('datafield_edit_url').replace('/0/', `/${datafield_id}/`) + '?notify_parent=true&';
}
export function getDatafieldCreateUrl(container_type) {
return fromJsonScript('datafield_create_url') + '?notify_parent=true&container_type=' + container_type;
}
@@ -1,29 +0,0 @@
// 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()) })
}
}

Some files were not shown because too many files have changed in this diff Show More