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