diff --git a/doc/api/resources/questions.rst b/doc/api/resources/questions.rst index 5446132936..f790dbce47 100644 --- a/doc/api/resources/questions.rst +++ b/doc/api/resources/questions.rst @@ -1,4 +1,7 @@ -.. spelling:: checkin +.. spelling:: + + checkin + datetime .. _rest-questions: @@ -53,6 +56,12 @@ options list of objects In case of ques ├ identifier string An arbitrary string that can be used for matching with other sources. └ answer multi-lingual string The displayed value of this option +valid_number_min string Minimum value for number questions (optional) +valid_number_max string Maximum value for number questions (optional) +valid_date_min date Minimum value for date questions (optional) +valid_date_max date Maximum value for date questions (optional) +valid_datetime_min datetime Minimum value for date and time questions (optional) +valid_datetime_max datetime Maximum value for date and time questions (optional) dependency_question integer Internal ID of a different question. The current question will only be shown if the question given in this attribute is set to the value given in @@ -92,6 +101,10 @@ dependency_value string An old version The attribute ``help_text`` has been added. +.. versionchanged:: 3.14 + + The attributes ``valid_*`` have been added. + Endpoints --------- @@ -137,6 +150,12 @@ Endpoints "ask_during_checkin": false, "hidden": false, "print_on_invoice": false, + "valid_number_min": null, + "valid_number_max": null, + "valid_date_min": null, + "valid_date_max": null, + "valid_datetime_min": null, + "valid_datetime_max": null, "dependency_question": null, "dependency_value": null, "dependency_values": [], @@ -208,6 +227,12 @@ Endpoints "ask_during_checkin": false, "hidden": false, "print_on_invoice": false, + "valid_number_min": null, + "valid_number_max": null, + "valid_date_min": null, + "valid_date_max": null, + "valid_datetime_min": null, + "valid_datetime_max": null, "dependency_question": null, "dependency_value": null, "dependency_values": [], @@ -302,6 +327,12 @@ Endpoints "dependency_question": null, "dependency_value": null, "dependency_values": [], + "valid_number_min": null, + "valid_number_max": null, + "valid_date_min": null, + "valid_date_max": null, + "valid_datetime_min": null, + "valid_datetime_max": null, "options": [ { "id": 1, @@ -377,6 +408,12 @@ Endpoints "dependency_question": null, "dependency_value": null, "dependency_values": [], + "valid_number_min": null, + "valid_number_max": null, + "valid_date_min": null, + "valid_date_max": null, + "valid_datetime_min": null, + "valid_datetime_max": null, "options": [ { "id": 1, diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index f48f120314..403601aee8 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -277,7 +277,9 @@ class QuestionSerializer(I18nAwareModelSerializer): model = Question fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position', 'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_values', - 'hidden', 'dependency_value', 'print_on_invoice', 'help_text') + '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' + ) def validate_identifier(self, value): Question._clean_identifier(self.context['event'], value, self.instance) diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index 551a667a32..a124b9c2bc 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -13,10 +13,13 @@ from babel import localedata from django import forms from django.contrib import messages from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator from django.db.models import QuerySet from django.forms import Select +from django.utils.formats import date_format from django.utils.html import escape from django.utils.safestring import mark_safe +from django.utils.timezone import get_current_timezone from django.utils.translation import ( get_language, gettext_lazy as _, pgettext_lazy, ) @@ -234,6 +237,43 @@ class QuestionCheckboxSelectMultiple(forms.CheckboxSelectMultiple): option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html' +class MinDateValidator(MinValueValidator): + def __call__(self, value): + try: + return super().__call__(value) + except ValidationError as e: + e.params['limit_value'] = date_format(e.params['limit_value'], 'SHORT_DATE_FORMAT') + raise e + + +class MinDateTimeValidator(MinValueValidator): + def __call__(self, value): + try: + return super().__call__(value) + except ValidationError as e: + e.params['limit_value'] = date_format(e.params['limit_value'].astimezone(get_current_timezone()), 'SHORT_DATETIME_FORMAT') + raise e + + +class MaxDateValidator(MaxValueValidator): + + def __call__(self, value): + try: + return super().__call__(value) + except ValidationError as e: + e.params['limit_value'] = date_format(e.params['limit_value'], 'SHORT_DATE_FORMAT') + raise e + + +class MaxDateTimeValidator(MaxValueValidator): + def __call__(self, value): + try: + return super().__call__(value) + except ValidationError as e: + e.params['limit_value'] = date_format(e.params['limit_value'].astimezone(get_current_timezone()), 'SHORT_DATETIME_FORMAT') + raise e + + class BaseQuestionsForm(forms.Form): """ This form class is responsible for asking order-related questions. This includes @@ -392,9 +432,10 @@ class BaseQuestionsForm(forms.Form): elif q.type == Question.TYPE_NUMBER: field = forms.DecimalField( label=label, required=required, + min_value=q.valid_number_min or Decimal('0.00'), + max_value=q.valid_number_max, help_text=q.help_text, initial=initial.answer if initial else None, - min_value=Decimal('0.00'), ) elif q.type == Question.TYPE_STRING: field = forms.CharField( @@ -453,12 +494,21 @@ class BaseQuestionsForm(forms.Form): max_size=10 * 1024 * 1024, ) elif q.type == Question.TYPE_DATE: + attrs = {} + 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() field = forms.DateField( label=label, required=required, help_text=help_text, initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None, - widget=DatePickerWidget(), + widget=DatePickerWidget(attrs), ) + 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: field = forms.TimeField( label=label, required=required, @@ -471,8 +521,16 @@ class BaseQuestionsForm(forms.Form): label=label, required=required, help_text=help_text, initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None, - widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')), + widget=SplitDateTimePickerWidget( + time_format=get_format_without_seconds('TIME_INPUT_FORMATS'), + min_date=q.valid_datetime_min, + max_date=q.valid_datetime_max + ), ) + 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: babel_locale = 'en' # Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal diff --git a/src/pretix/base/forms/widgets.py b/src/pretix/base/forms/widgets.py index 76b3b70ff8..272d6a6dc6 100644 --- a/src/pretix/base/forms/widgets.py +++ b/src/pretix/base/forms/widgets.py @@ -1,9 +1,10 @@ import os +from datetime import date from django import forms from django.utils.formats import get_format from django.utils.functional import lazy -from django.utils.timezone import now +from django.utils.timezone import get_current_timezone, now from django.utils.translation import gettext_lazy as _ @@ -92,7 +93,7 @@ class UploadedFileWidget(forms.ClearableFileInput): class SplitDateTimePickerWidget(forms.SplitDateTimeWidget): template_name = 'pretixbase/forms/widgets/splitdatetime.html' - def __init__(self, attrs=None, date_format=None, time_format=None): + def __init__(self, attrs=None, date_format=None, time_format=None, min_date=None, max_date=None): attrs = attrs or {} if 'placeholder' in attrs: del attrs['placeholder'] @@ -106,6 +107,14 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget): time_attrs['class'] += ' timepickerfield' date_attrs['autocomplete'] = 'date-picker-do-not-autofill' time_attrs['autocomplete'] = 'time-picker-do-not-autofill' + if min_date: + date_attrs['data-min'] = ( + min_date if isinstance(min_date, date) else min_date.astimezone(get_current_timezone()).date() + ).isoformat() + if max_date: + date_attrs['data-max'] = ( + max_date if isinstance(max_date, date) else max_date.astimezone(get_current_timezone()).date() + ).isoformat() def date_placeholder(): df = date_format or get_format('DATE_INPUT_FORMATS')[0] diff --git a/src/pretix/base/migrations/0171_auto_20201126_1635.py b/src/pretix/base/migrations/0171_auto_20201126_1635.py new file mode 100644 index 0000000000..0b440488ce --- /dev/null +++ b/src/pretix/base/migrations/0171_auto_20201126_1635.py @@ -0,0 +1,49 @@ +# Generated by Django 3.0.11 on 2020-11-26 16:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0170_remove_hidden_urls'), + ] + + operations = [ + migrations.AddField( + model_name='question', + name='valid_date_max', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='question', + name='valid_date_min', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='question', + name='valid_datetime_max', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='question', + name='valid_datetime_min', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='question', + name='valid_number_max', + field=models.DecimalField(decimal_places=6, max_digits=16, null=True), + ), + migrations.AddField( + model_name='question', + name='valid_number_min', + field=models.DecimalField(decimal_places=6, max_digits=16, null=True), + ), + migrations.AlterField( + model_name='seat', + name='product', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='seats', to='pretixbase.Item'), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index b010995853..9ed3750fdd 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -1084,6 +1084,18 @@ class Question(LoggedModel): 'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions' ) dependency_values = MultiStringField(default=[]) + valid_number_min = models.DecimalField(decimal_places=6, max_digits=16, null=True, blank=True, + verbose_name=_('Minimum value'), help_text=_('Currently not supported in our apps')) + valid_number_max = models.DecimalField(decimal_places=6, max_digits=16, null=True, blank=True, + verbose_name=_('Maximum value'), help_text=_('Currently not supported in our apps')) + valid_date_min = models.DateField(null=True, blank=True, + verbose_name=_('Minimum value'), help_text=_('Currently not supported in our apps')) + valid_date_max = models.DateField(null=True, blank=True, + verbose_name=_('Maximum value'), help_text=_('Currently not supported in our apps')) + valid_datetime_min = models.DateTimeField(null=True, blank=True, + verbose_name=_('Minimum value'), help_text=_('Currently not supported in our apps')) + valid_datetime_max = models.DateTimeField(null=True, blank=True, + verbose_name=_('Maximum value'), help_text=_('Currently not supporetd in our apps')) objects = ScopedManager(organizer='event__organizer') @@ -1173,14 +1185,24 @@ class Question(LoggedModel): answer = formats.sanitize_separators(answer) answer = str(answer).strip() try: - return Decimal(answer) + v = Decimal(answer) + if self.valid_number_min is not None and v < self.valid_number_min: + raise ValidationError(_('The number is to low.')) + if self.valid_number_max is not None and v > self.valid_number_max: + raise ValidationError(_('The number is to high.')) + return v except DecimalException: raise ValidationError(_('Invalid number input.')) elif self.type == Question.TYPE_DATE: if isinstance(answer, date): return answer try: - return dateutil.parser.parse(answer).date() + dt = dateutil.parser.parse(answer).date() + if self.valid_date_min is not None and dt < self.valid_date_min: + raise ValidationError(_('Please choose a later date.')) + if self.valid_date_max is not None and dt > self.valid_date_max: + raise ValidationError(_('Please choose an earlier date.')) + return dt except: raise ValidationError(_('Invalid date input.')) elif self.type == Question.TYPE_TIME: @@ -1197,9 +1219,14 @@ class Question(LoggedModel): dt = dateutil.parser.parse(answer) if is_naive(dt): dt = make_aware(dt, pytz.timezone(self.event.settings.timezone)) - return dt except: raise ValidationError(_('Invalid datetime input.')) + else: + if self.valid_datetime_min is not None and dt < self.valid_datetime_min: + raise ValidationError(_('Please choose a later date.')) + if self.valid_datetime_max is not None and dt > self.valid_datetime_max: + raise ValidationError(_('Please choose an earlier date.')) + return dt elif self.type == Question.TYPE_COUNTRYCODE and answer: c = Country(answer.upper()) if c.name: diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index d6a81870c8..af7d614625 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -16,6 +16,7 @@ from i18nfield.forms import I18nFormField, I18nTextarea from pretix.base.channels import get_all_sales_channels from pretix.base.forms import I18nFormSet, I18nModelForm +from pretix.base.forms.widgets import DatePickerWidget from pretix.base.models import ( Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota, ) @@ -111,14 +112,26 @@ class QuestionForm(I18nModelForm): 'dependency_question', 'dependency_values', 'print_on_invoice', + 'valid_number_min', + 'valid_number_max', + 'valid_datetime_min', + 'valid_datetime_max', + 'valid_date_min', + 'valid_date_max', ] widgets = { + 'valid_datetime_min': SplitDateTimePickerWidget(), + 'valid_datetime_max': SplitDateTimePickerWidget(), + 'valid_date_min': DatePickerWidget(), + 'valid_date_max': DatePickerWidget(), 'items': forms.CheckboxSelectMultiple( attrs={'class': 'scrolling-multiple-choice'} ), 'dependency_values': forms.SelectMultiple, } field_classes = { + 'valid_datetime_min': SplitDateTimeField, + 'valid_datetime_max': SplitDateTimeField, 'items': SafeModelMultipleChoiceField, 'dependency_question': SafeModelChoiceField, } diff --git a/src/pretix/control/templates/pretixcontrol/items/question_edit.html b/src/pretix/control/templates/pretixcontrol/items/question_edit.html index 337d45f4ce..827d420757 100644 --- a/src/pretix/control/templates/pretixcontrol/items/question_edit.html +++ b/src/pretix/control/templates/pretixcontrol/items/question_edit.html @@ -32,6 +32,18 @@ accepted. If you want to allow both options, do not make this field required. {% endblocktrans %} +
+ {% bootstrap_field form.valid_number_min layout="control" %} + {% bootstrap_field form.valid_number_max layout="control" %} +
+
+ {% bootstrap_field form.valid_date_min layout="control" %} + {% bootstrap_field form.valid_date_max layout="control" %} +
+
+ {% bootstrap_field form.valid_datetime_min layout="control" %} + {% bootstrap_field form.valid_datetime_max layout="control" %} +

{% trans "Answer options" %}