Compare commits

...

14 Commits

Author SHA1 Message Date
johan12345
df25ae3c7c add tests in test_checkout.py 2019-03-27 21:33:26 +01:00
johan12345
d686b386e3 add tests in test_items.py 2019-03-27 21:07:26 +01:00
johan12345
bdda68c82d move JS to the right location 2019-03-27 20:54:04 +01:00
johan12345
2aa36ee2a7 reorder migrations 2019-03-27 20:34:29 +01:00
johan12345
43affc22ab remove unused import 2019-03-27 18:43:44 +01:00
Johan von Forstner
60575377d7 move migration 2019-03-27 18:43:44 +01:00
Raphael Michel
5ec8c7ed96 Store date(times) in ISO formats and UTC 2019-03-27 18:43:44 +01:00
johan12345
ef55a018f8 fix datetime fields 2019-03-27 18:43:04 +01:00
johan12345
6bf9327f87 improvements after review 2019-03-27 18:42:42 +01:00
johan12345
f761f93550 add migration 2019-03-27 18:42:42 +01:00
Johan von Forstner
c996289563 fix default answers for booleans 2019-03-27 18:42:42 +01:00
Johan von Forstner
89bbed42e6 replace form field for default value depending on question type 2019-03-27 18:42:42 +01:00
Johan von Forstner
8e22c0f3a4 Show initial values in form 2019-03-27 18:42:01 +01:00
Johan von Forstner
3483c522da Add default_value model field, validation and form field 2019-03-27 18:41:40 +01:00
8 changed files with 261 additions and 9 deletions

View File

@@ -173,6 +173,7 @@ class BaseQuestionsForm(forms.Form):
initial = None initial = None
tz = pytz.timezone(event.settings.timezone) tz = pytz.timezone(event.settings.timezone)
help_text = rich_text(q.help_text) help_text = rich_text(q.help_text)
default = q.default_value
label = escape(q.question) # django-bootstrap3 calls mark_safe label = escape(q.question) # django-bootstrap3 calls mark_safe
required = q.required and not self.all_optional required = q.required and not self.all_optional
if q.type == Question.TYPE_BOOLEAN: if q.type == Question.TYPE_BOOLEAN:
@@ -185,6 +186,8 @@ class BaseQuestionsForm(forms.Form):
if initial: if initial:
initialbool = (initial.answer == "True") initialbool = (initial.answer == "True")
elif default:
initialbool = (default == "True")
else: else:
initialbool = False initialbool = False
@@ -197,21 +200,21 @@ class BaseQuestionsForm(forms.Form):
field = forms.DecimalField( field = forms.DecimalField(
label=label, required=required, label=label, required=required,
help_text=q.help_text, help_text=q.help_text,
initial=initial.answer if initial else None, initial=initial.answer if initial else (default if default else None),
min_value=Decimal('0.00'), min_value=Decimal('0.00'),
) )
elif q.type == Question.TYPE_STRING: elif q.type == Question.TYPE_STRING:
field = forms.CharField( field = forms.CharField(
label=label, required=required, label=label, required=required,
help_text=help_text, help_text=help_text,
initial=initial.answer if initial else None, initial=initial.answer if initial else (default if default else None),
) )
elif q.type == Question.TYPE_TEXT: elif q.type == Question.TYPE_TEXT:
field = forms.CharField( field = forms.CharField(
label=label, required=required, label=label, required=required,
help_text=help_text, help_text=help_text,
widget=forms.Textarea, widget=forms.Textarea,
initial=initial.answer if initial else None, initial=initial.answer if initial else (default if default else None),
) )
elif q.type == Question.TYPE_CHOICE: elif q.type == Question.TYPE_CHOICE:
field = forms.ModelChoiceField( field = forms.ModelChoiceField(
@@ -243,21 +246,24 @@ class BaseQuestionsForm(forms.Form):
field = forms.DateField( field = forms.DateField(
label=label, required=required, label=label, required=required,
help_text=help_text, help_text=help_text,
initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None, initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else (
dateutil.parser.parse(default).date() if default else None),
widget=DatePickerWidget(), widget=DatePickerWidget(),
) )
elif q.type == Question.TYPE_TIME: elif q.type == Question.TYPE_TIME:
field = forms.TimeField( field = forms.TimeField(
label=label, required=required, label=label, required=required,
help_text=help_text, help_text=help_text,
initial=dateutil.parser.parse(initial.answer).time() if initial and initial.answer else None, initial=dateutil.parser.parse(initial.answer).time() if initial and initial.answer else (
dateutil.parser.parse(default).time() if default else None),
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')), widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
) )
elif q.type == Question.TYPE_DATETIME: elif q.type == Question.TYPE_DATETIME:
field = SplitDateTimeField( field = SplitDateTimeField(
label=label, required=required, label=label, required=required,
help_text=help_text, help_text=help_text,
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None, initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else (
dateutil.parser.parse(default).astimezone(tz) if default else None),
widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')), widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
) )
field.question = q field.question = q

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-05-31 15:39
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0115_auto_20190323_2238'),
]
operations = [
migrations.AddField(
model_name='question',
name='default_value',
field=models.TextField(blank=True, help_text='The question will be filled with this response by default', null=True, verbose_name='Default answer'),
),
]

View File

@@ -963,6 +963,11 @@ class Question(LoggedModel):
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions' 'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
) )
dependency_value = models.TextField(null=True, blank=True) dependency_value = models.TextField(null=True, blank=True)
default_value = models.TextField(
verbose_name=_('Default answer'),
help_text=_('The question will be filled with this response by default'),
null=True, blank=True,
)
class Meta: class Meta:
verbose_name = _("Question") verbose_name = _("Question")
@@ -980,6 +985,10 @@ class Question(LoggedModel):
def clean_identifier(self, code): def clean_identifier(self, code):
Question._clean_identifier(self.event, code, self) Question._clean_identifier(self.event, code, self)
def clean(self):
if self.default_value is not None:
self.clean_answer(self.default_value, check_required=False)
@staticmethod @staticmethod
def _clean_identifier(event, code, instance=None): def _clean_identifier(event, code, instance=None):
qs = Question.objects.filter(event=event, identifier__iexact=code) qs = Question.objects.filter(event=event, identifier__iexact=code)
@@ -1007,8 +1016,8 @@ class Question(LoggedModel):
def __lt__(self, other) -> bool: def __lt__(self, other) -> bool:
return self.sortkey < other.sortkey return self.sortkey < other.sortkey
def clean_answer(self, answer): def clean_answer(self, answer, check_required=True):
if self.required: if self.required and check_required:
if not answer or (self.type == Question.TYPE_BOOLEAN and answer not in ("true", "True", True)): 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.')) raise ValidationError(_('An answer to this question is required to proceed.'))
if not answer: if not answer:

View File

@@ -1,8 +1,14 @@
import datetime
import dateutil
import pytz
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Max from django.db.models import Max
from django.forms.formsets import DELETION_FIELD_NAME from django.forms.formsets import DELETION_FIELD_NAME
from django.forms.utils import from_current_timezone, to_current_timezone
from django.urls import reverse from django.urls import reverse
from django.utils import formats
from django.utils.translation import ( from django.utils.translation import (
pgettext_lazy, ugettext as __, ugettext_lazy as _, pgettext_lazy, ugettext as __, ugettext_lazy as _,
) )
@@ -33,6 +39,63 @@ class CategoryForm(I18nModelForm):
] ]
class AdjustableTypeField(forms.Textarea):
def __init__(self, *args, type=None, **kwargs):
super().__init__(*args, **kwargs)
def format_value(self, value):
if getattr(self, 'type') == 'W' and value:
return str(formats.localize_input(to_current_timezone(dateutil.parser.parse(value))))
elif getattr(self, 'type') == 'D' and value:
return str(formats.localize_input(dateutil.parser.parse(value).date()))
elif getattr(self, 'type') == 'T' and value:
return str(formats.localize_input(dateutil.parser.parse(value).time()))
return super().format_value(value)
def value_from_datadict(self, data, files, name):
if 'type' in data and data['type'] == 'W' and 'default_value_date' in data and 'default_value_time' in data:
d_date = d_time = None
for format in formats.get_format('DATE_INPUT_FORMATS'):
try:
d_date = datetime.datetime.strptime(data['default_value_date'], format).date()
break
except (ValueError, TypeError):
continue
for format in formats.get_format('TIME_INPUT_FORMATS'):
try:
d_time = datetime.datetime.strptime(data['default_value_time'], format).time()
break
except (ValueError, TypeError):
continue
if d_date and d_time:
return from_current_timezone(datetime.datetime.combine(d_date, d_time)).astimezone(pytz.UTC).isoformat()
else:
return None
else:
val = super().value_from_datadict(data, files, name)
if 'type' in data and data['type'] == 'D' and val:
for format in formats.get_format('DATE_INPUT_FORMATS'):
try:
d_date = datetime.datetime.strptime(val, format).date()
val = d_date.isoformat()
break
except (ValueError, TypeError):
continue
elif 'type' in data and data['type'] == 'T' and val:
for format in formats.get_format('TIME_INPUT_FORMATS'):
try:
d_date = datetime.datetime.strptime(val, format).time()
val = d_date.isoformat()
break
except (ValueError, TypeError):
continue
return val
class QuestionForm(I18nModelForm): class QuestionForm(I18nModelForm):
question = I18nFormField( question = I18nFormField(
label=_("Question"), label=_("Question"),
@@ -51,6 +114,8 @@ class QuestionForm(I18nModelForm):
pk=self.instance.pk pk=self.instance.pk
) )
self.fields['identifier'].required = False self.fields['identifier'].required = False
if self.instance:
self.fields['default_value'].widget.type = self.instance.type
self.fields['help_text'].widget.attrs['rows'] = 3 self.fields['help_text'].widget.attrs['rows'] = 3
def clean_dependency_question(self): def clean_dependency_question(self):
@@ -80,6 +145,7 @@ class QuestionForm(I18nModelForm):
'help_text', 'help_text',
'type', 'type',
'required', 'required',
'default_value',
'ask_during_checkin', 'ask_during_checkin',
'identifier', 'identifier',
'items', 'items',
@@ -90,7 +156,8 @@ class QuestionForm(I18nModelForm):
'items': forms.CheckboxSelectMultiple( 'items': forms.CheckboxSelectMultiple(
attrs={'class': 'scrolling-multiple-choice'} attrs={'class': 'scrolling-multiple-choice'}
), ),
'dependency_value': forms.Select, 'default_value': AdjustableTypeField(),
'dependency_value': forms.Select
} }

View File

@@ -107,6 +107,7 @@
<fieldset> <fieldset>
<legend>{% trans "Advanced settings" %}</legend> <legend>{% trans "Advanced settings" %}</legend>
{% bootstrap_field form.help_text layout="control" %} {% bootstrap_field form.help_text layout="control" %}
{% bootstrap_field form.default_value layout="control" %}
{% bootstrap_field form.identifier layout="control" %} {% bootstrap_field form.identifier layout="control" %}
{% bootstrap_field form.ask_during_checkin layout="control" %} {% bootstrap_field form.ask_during_checkin layout="control" %}

View File

@@ -93,6 +93,67 @@ $(function () {
show = $("#id_type").val() == "B" && $("#id_required").prop("checked"); show = $("#id_type").val() == "B" && $("#id_required").prop("checked");
$(".alert-required-boolean").toggle(show); $(".alert-required-boolean").toggle(show);
update_default_value_field()
}
function update_default_value_field() {
let input = $('#id_default_value');
let parent = input.parent();
let field = input.prop("tagName") == 'DIV' ? input.children().first() : input;
let common_attrs = ' name="default_value" placeholder="' + field.attr('placeholder') + '" title="' + field.attr('title') + '" id="id_default_value"';
let value = field.val();
switch ($("#id_type").val()) {
case 'N':
input.replaceWith('<input type="number" class="form-control" value="' + value + '" ' + common_attrs + '>');
$('.form-group:has(#id_default_value)').show();
break;
case 'S':
input.replaceWith('<input type="text" maxlength="190" class="form-control" value="' + value + '" ' + common_attrs + '>');
$('.form-group:has(#id_default_value)').show();
break;
case 'T':
input.replaceWith('<textarea cols="40" rows="10" class="form-control" ' + common_attrs + '>' + value + '</textarea>');
$('.form-group:has(#id_default_value)').show();
break;
case 'B':
let checked = (value === 'True' || value === 'on' ? 'checked' : '');
input.replaceWith('<input type="checkbox" ' + common_attrs + ' ' + checked + '>');
$('.form-group:has(#id_default_value)').show();
break;
case 'D':
let dateField = input.replaceWith('<input type="text" class="form-control datepickerfield" value="' + value + '" ' + common_attrs + '>');
form_handlers(parent);
$('.form-group:has(#id_default_value)').show();
break;
case 'H':
let timeField = input.replaceWith('<input type="text" class="form-control timepickerfield" value="' + value + '" ' + common_attrs + '>');
form_handlers(parent);
$('.form-group:has(#id_default_value)').show();
break;
case 'W':
let split = value.split(' ');
let date, time;
if (split.length > 1) {
date = split[0];
time = split[1];
} else {
date = null;
time = null;
}
let dtField = input.replaceWith('<div class="splitdatetimerow" id="id_default_value">\n' +
'<input type="text" class="form-control splitdatetimepart datepickerfield" value="' + date + '" name="default_value_date" placeholder="' + field.attr('placeholder') + '" title="' + field.attr('title') + '">\n' +
'<input type="text" class="form-control splitdatetimepart timepickerfield" value="' + time + '" name="default_value_time" placeholder="' + field.attr('placeholder') + '" title="' + field.attr('title') + '">\n' +
'</div>\n');
form_handlers(parent);
$('.form-group:has(#id_default_value)').show();
break;
default:
// file, choice, and multiple choice are not implemented
$('.form-group:has(#id_default_value)').hide();
input.val('')
}
} }
var $val = $("#id_dependency_value"); var $val = $("#id_dependency_value");

View File

@@ -251,6 +251,41 @@ class QuestionsTest(ItemFormTest):
form_data) form_data)
assert not doc.select(".alert-success") assert not doc.select(".alert-success")
def test_set_default_value(self):
q = Question.objects.create(event=self.event1, question="What city are you from?", type=Question.TYPE_TEXT,
required=True)
doc = self.get_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, q.id))
form_data = extract_form_fields(doc.select('.container-fluid form')[0])
form_data['default_value'] = 'Heidelberg'
doc = self.post_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, q.id),
form_data)
assert doc.select(".alert-success")
q.refresh_from_db()
assert q.default_value == 'Heidelberg'
def test_set_default_value_datetime(self):
q = Question.objects.create(event=self.event1, question="What city are you from?", type=Question.TYPE_DATETIME,
required=True)
doc = self.get_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, q.id))
form_data = extract_form_fields(doc.select('.container-fluid form')[0])
form_data['default_value_date'] = '2019-01-01'
form_data['default_value_time'] = '10:00'
doc = self.post_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, q.id),
form_data)
assert doc.select(".alert-success")
q.refresh_from_db()
assert q.default_value == '2019-01-01T10:00:00+00:00'
def test_set_default_value_invalid(self):
q = Question.objects.create(event=self.event1, question="What city are you from?", type=Question.TYPE_DATE,
required=True)
doc = self.get_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, q.id))
form_data = extract_form_fields(doc.select('.container-fluid form')[0])
form_data['default_value'] = 'foobar'
doc = self.post_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, q.id),
form_data)
assert not doc.select(".alert-success")
class QuotaTest(ItemFormTest): class QuotaTest(ItemFormTest):

View File

@@ -1985,6 +1985,59 @@ class QuestionsTestCase(BaseCheckoutTestCase, TestCase):
self.q3: 'False', self.q3: 'False',
}, should_fail=True) }, should_fail=True)
def test_datetime_defaultvalues(self):
""" Test timezone and format handling for default values """
q1 = Question.objects.create(
event=self.event, question='When did you wake up today?', type=Question.TYPE_TIME,
required=True, default_value='10:00'
)
q2 = Question.objects.create(
event=self.event, question='When was your last haircut?', type=Question.TYPE_DATE,
required=True, default_value='2019-01-01'
)
q3 = Question.objects.create(
event=self.event, question='When are you going to arrive?', type=Question.TYPE_DATETIME,
required=True, default_value='2019-01-01T10:00:00+00:00'
)
self.ticket.questions.add(q1)
self.ticket.questions.add(q2)
self.ticket.questions.add(q3)
cr = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
assert doc.select('input[name="%s-question_%s"]' % (cr.id, q1.id))[0]['value'] == '10:00'
assert doc.select('input[name="%s-question_%s"]' % (cr.id, q2.id))[0]['value'] == '2019-01-01'
assert doc.select('input[name="%s-question_%s_0"]' % (cr.id, q3.id))[0]['value'] == '2019-01-01'
assert doc.select('input[name="%s-question_%s_1"]' % (cr.id, q3.id))[0]['value'] == '10:00'
# set to different timezone, this should affect the datetime question's default value displayed
self.event.settings.set('timezone', 'US/Central')
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
assert doc.select('input[name="%s-question_%s"]' % (cr.id, q1.id))[0]['value'] == '10:00'
assert doc.select('input[name="%s-question_%s"]' % (cr.id, q2.id))[0]['value'] == '2019-01-01'
assert doc.select('input[name="%s-question_%s_0"]' % (cr.id, q3.id))[0]['value'] == '2019-01-01'
assert doc.select('input[name="%s-question_%s_1"]' % (cr.id, q3.id))[0]['value'] == '04:00'
# set locale, this should affect the date format
self.event.settings.set('locales', ['de'])
self.event.save()
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
assert doc.select('input[name="%s-question_%s"]' % (cr.id, q1.id))[0]['value'] == '10:00'
assert doc.select('input[name="%s-question_%s"]' % (cr.id, q2.id))[0]['value'] == '01.01.2019'
assert doc.select('input[name="%s-question_%s_0"]' % (cr.id, q3.id))[0]['value'] == '01.01.2019'
assert doc.select('input[name="%s-question_%s_1"]' % (cr.id, q3.id))[0]['value'] == '04:00'
class CheckoutBundleTest(BaseCheckoutTestCase, TestCase): class CheckoutBundleTest(BaseCheckoutTestCase, TestCase):
def setUp(self): def setUp(self):