Add min/max validation for date, datetime, and number questions (#1858)

This commit is contained in:
Raphael Michel
2020-11-27 11:02:07 +01:00
committed by GitHub
parent 921b28f8d4
commit 66af5973ec
13 changed files with 262 additions and 13 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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]

View File

@@ -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'),
),
]

View File

@@ -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:

View File

@@ -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,
}

View File

@@ -32,6 +32,18 @@
accepted. If you want to allow both options, do not make this field required.
{% endblocktrans %}
</div>
<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="answer-options">
<h3>{% trans "Answer options" %}</h3>
<noscript>

View File

@@ -113,6 +113,14 @@ var form_handlers = function (el) {
close: 'fa fa-remove'
},
};
if ($(this).is('[data-min]')) {
opts["minDate"] = $(this).attr("data-min");
opts["viewDate"] = $(this).attr("data-min");
}
if ($(this).is('[data-max]')) {
opts["maxDate"] = $(this).attr("data-max");
opts["viewDate"] = $(this).attr("data-max");
}
if ($(this).is('[data-is-payment-date]'))
opts["daysOfWeekDisabled"] = JSON.parse($("body").attr("data-payment-weekdays-disabled"));
$(this).datetimepicker(opts);

View File

@@ -102,6 +102,10 @@ $(function () {
var show = $("#id_type").val() == "C" || $("#id_type").val() == "M";
$("#answer-options").toggle(show);
$("#valid-date").toggle($("#id_type").val() == "D");
$("#valid-datetime").toggle($("#id_type").val() == "W");
$("#valid-number").toggle($("#id_type").val() == "N");
show = $("#id_type").val() == "B" && $("#id_required").prop("checked");
$(".alert-required-boolean").toggle(show);
}

View File

@@ -64,6 +64,14 @@ var form_handlers = function (el) {
close: 'fa fa-remove'
},
};
if ($(this).is('[data-min]')) {
opts["minDate"] = $(this).attr("data-min");
opts["viewDate"] = $(this).attr("data-min");
}
if ($(this).is('[data-max]')) {
opts["maxDate"] = $(this).attr("data-max");
opts["viewDate"] = $(this).attr("data-max");
}
$(this).datetimepicker(opts);
if ($(this).parent().is('.splitdatetimerow')) {
$(this).on("dp.change", function (ev) {

View File

@@ -1808,6 +1808,12 @@ TEST_QUESTION_RES = {
"dependency_question": None,
"dependency_value": None,
"dependency_values": [],
"valid_number_min": None,
"valid_number_max": None,
"valid_date_min": None,
"valid_date_max": None,
"valid_datetime_min": None,
"valid_datetime_max": None,
"help_text": {"en": "This is an example question"},
"options": [
{

View File

@@ -2468,11 +2468,15 @@ class SeatingTestCase(TestCase):
@pytest.mark.parametrize("qtype,answer,expected", [
(Question.TYPE_STRING, "a", "a"),
(Question.TYPE_TEXT, "v", "v"),
(Question.TYPE_NUMBER, "0.9", ValidationError),
(Question.TYPE_NUMBER, "1", Decimal("1")),
(Question.TYPE_NUMBER, "3", Decimal("3")),
(Question.TYPE_NUMBER, "2.56", Decimal("2.56")),
(Question.TYPE_NUMBER, 2.45, Decimal("2.45")),
(Question.TYPE_NUMBER, 3, Decimal("3")),
(Question.TYPE_NUMBER, Decimal("4.56"), Decimal("4.56")),
(Question.TYPE_NUMBER, 100, Decimal("100")),
(Question.TYPE_NUMBER, 100.1, ValidationError),
(Question.TYPE_NUMBER, "abc", ValidationError),
(Question.TYPE_BOOLEAN, "True", True),
(Question.TYPE_BOOLEAN, "true", True),
@@ -2485,16 +2489,20 @@ class SeatingTestCase(TestCase):
(Question.TYPE_DATE, "2018-01-16", datetime.date(2018, 1, 16)),
(Question.TYPE_DATE, datetime.date(2018, 1, 16), datetime.date(2018, 1, 16)),
(Question.TYPE_DATE, "2018-13-16", ValidationError),
(Question.TYPE_DATE, "2018-12-16", ValidationError),
(Question.TYPE_DATE, "2018-01-14", ValidationError),
(Question.TYPE_TIME, "15:20", datetime.time(15, 20)),
(Question.TYPE_TIME, datetime.time(15, 20), datetime.time(15, 20)),
(Question.TYPE_TIME, "44:20", ValidationError),
(Question.TYPE_DATETIME, "2018-01-16T15:20:00+01:00",
datetime.datetime(2018, 1, 16, 15, 20, 0, tzinfo=tzoffset(None, 3600))),
(Question.TYPE_DATETIME, "2018-01-16T15:20:00Z",
datetime.datetime(2018, 1, 16, 15, 20, 0, tzinfo=tzoffset(None, 0))),
(Question.TYPE_DATETIME, "2018-01-16T14:20:00Z",
datetime.datetime(2018, 1, 16, 14, 20, 0, tzinfo=tzoffset(None, 0))),
(Question.TYPE_DATETIME, "2018-01-16T15:20:00",
datetime.datetime(2018, 1, 16, 15, 20, 0, tzinfo=tzoffset(None, 3600))),
(Question.TYPE_DATETIME, "2018-01-16T15:AB:CD", ValidationError),
(Question.TYPE_DATETIME, "2018-01-16T13:20:00+01:00", ValidationError),
(Question.TYPE_DATETIME, "2018-01-16T16:20:00+01:00", ValidationError),
])
def test_question_answer_validation(qtype, answer, expected):
o = Organizer.objects.create(name='Dummy', slug='dummy')
@@ -2504,7 +2512,15 @@ def test_question_answer_validation(qtype, answer, expected):
date_from=now(),
)
event.settings.timezone = 'Europe/Berlin'
q = Question(type=qtype, event=event)
q = Question(
type=qtype, event=event,
valid_date_min=datetime.date(2018, 1, 15),
valid_date_max=datetime.date(2018, 12, 15),
valid_datetime_min=datetime.datetime(2018, 1, 16, 14, 0, 0, tzinfo=tzoffset(None, 3600)),
valid_datetime_max=datetime.datetime(2018, 1, 16, 16, 0, 0, tzinfo=tzoffset(None, 3600)),
valid_number_min=Decimal('1'),
valid_number_max=Decimal('100'),
)
if isinstance(expected, type) and issubclass(expected, Exception):
with pytest.raises(expected):
q.clean_answer(answer)