Allow dependencies between questions (#1202)

- [x] data model
- [x] api
- [x] backend editor
- [x] backend validation logic
- [x] frontend display logic
- [x] frontend validation logic
- [x] test checkout step
- [x] test modify order in frontend
- [x] test modify order in backend
- [x] validation tests
- [x] correctly evaluate dependency tree in frontend?
- [x] copy events
This commit is contained in:
Raphael Michel
2019-03-13 16:49:20 +01:00
committed by GitHub
parent d10cbd07a7
commit f95e8f374d
22 changed files with 825 additions and 211 deletions

View File

@@ -22,7 +22,7 @@ from pretix.base.models.items import ItemAddOn, ItemVariation, SubEventItem
from pretix.testutils.sessions import get_cart_session_key
class CheckoutTestCase(TestCase):
class BaseCheckoutTestCase:
def setUp(self):
super().setUp()
self.orga = Organizer.objects.create(name='CCC', slug='ccc')
@@ -60,6 +60,14 @@ class CheckoutTestCase(TestCase):
self.workshopquota.variations.add(self.workshop2a)
self.workshopquota.variations.add(self.workshop2b)
def _set_session(self, key, value):
session = self.client.session
session['carts'][get_cart_session_key(self.client, self.event)][key] = value
session.save()
class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
def _enable_reverse_charge(self):
self.tr19.eu_reverse_charge = True
self.tr19.home_country = Country('DE')
@@ -76,135 +84,6 @@ class CheckoutTestCase(TestCase):
self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug),
target_status_code=200)
def test_timezone(self):
""" Test basic timezone change handling by date and time questions """
q1 = Question.objects.create(
event=self.event, question='When did you wake up today?', type=Question.TYPE_TIME,
required=True
)
q2 = Question.objects.create(
event=self.event, question='When was your last haircut?', type=Question.TYPE_DATE,
required=True
)
q3 = Question.objects.create(
event=self.event, question='When are you going to arrive?', type=Question.TYPE_DATETIME,
required=True
)
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.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-question_%s' % (cr.id, q1.id): '06:30',
'%s-question_%s' % (cr.id, q2.id): '2005-12-31',
'%s-question_%s_0' % (cr.id, q3.id): '2018-01-01',
'%s-question_%s_1' % (cr.id, q3.id): '5:23',
'email': 'admin@localhost',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), target_status_code=200)
self.event.settings.set('timezone', 'US/Central')
o1 = QuestionAnswer.objects.get(question=q1)
o2 = QuestionAnswer.objects.get(question=q2)
o3 = QuestionAnswer.objects.get(question=q3)
order = Order.objects.create(event=self.event, status=Order.STATUS_PAID,
expires=now() + timedelta(days=3),
total=4)
op = OrderPosition.objects.create(order=order, item=self.ticket, price=42)
o1.cartposition, o2.cartposition, o3.cartposition = None, None, None
o1.orderposition, o2.orderposition, o3.orderposition = op, op, op
# only time and date answers should be unaffected by timezone change
self.assertEqual(str(o1), '06:30')
self.assertEqual(str(o2), '2005-12-31')
o3date, o3time = str(o3).split(' ')
self.assertEqual(o3date, '2017-12-31')
self.assertEqual(o3time, '23:23')
def test_addon_questions(self):
q1 = Question.objects.create(
event=self.event, question='Age', type=Question.TYPE_NUMBER,
required=True
)
q1.items.add(self.ticket)
q1.items.add(self.workshop1)
ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1,
price_included=True)
cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
cp1.answers.create(question=q1, answer='12')
cp2 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.workshop1, addon_to=cp1,
price=0, expires=now() + timedelta(minutes=10)
)
cp2.answers.create(question=q1, answer='12')
self._set_session('payment', 'banktransfer')
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select(".thank-you")), 1)
self.assertEqual(OrderPosition.objects.filter(item=self.ticket).first().answers.first().answer, '12')
self.assertEqual(OrderPosition.objects.filter(item=self.workshop1).first().answers.first().answer, '12')
def test_questions(self):
q1 = Question.objects.create(
event=self.event, question='Age', type=Question.TYPE_NUMBER,
required=True
)
q2 = Question.objects.create(
event=self.event, question='How have you heard from us?', type=Question.TYPE_STRING,
required=False
)
self.ticket.questions.add(q1)
self.ticket.questions.add(q2)
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
cr2 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=20, 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")
self.assertEqual(len(doc.select('input[name="%s-question_%s"]' % (cr1.id, q1.id))), 1)
self.assertEqual(len(doc.select('input[name="%s-question_%s"]' % (cr2.id, q1.id))), 1)
self.assertEqual(len(doc.select('input[name="%s-question_%s"]' % (cr1.id, q2.id))), 1)
self.assertEqual(len(doc.select('input[name="%s-question_%s"]' % (cr2.id, q2.id))), 1)
# Not all required fields filled out, expect failure
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-question_%s' % (cr1.id, q1.id): '42',
'%s-question_%s' % (cr2.id, q1.id): '',
'%s-question_%s' % (cr1.id, q2.id): 'Internet',
'%s-question_%s' % (cr2.id, q2.id): '',
'email': 'admin@localhost'
}, follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-question_%s' % (cr1.id, q1.id): '42',
'%s-question_%s' % (cr2.id, q1.id): '23',
'%s-question_%s' % (cr1.id, q2.id): 'Internet',
'%s-question_%s' % (cr2.id, q2.id): '',
'email': 'admin@localhost'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
cr1 = CartPosition.objects.get(id=cr1.id)
cr2 = CartPosition.objects.get(id=cr2.id)
self.assertEqual(cr1.answers.filter(question=q1).count(), 1)
self.assertEqual(cr2.answers.filter(question=q1).count(), 1)
self.assertEqual(cr1.answers.filter(question=q2).count(), 1)
self.assertFalse(cr2.answers.filter(question=q2).exists())
def test_reverse_charge(self):
self.tr19.eu_reverse_charge = True
self.tr19.home_country = Country('DE')
@@ -900,11 +779,6 @@ class CheckoutTestCase(TestCase):
self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug),
target_status_code=200)
def _set_session(self, key, value):
session = self.client.session
session['carts'][get_cart_session_key(self.client, self.event)][key] = value
session.save()
def test_subevent(self):
self.event.has_subevents = True
self.event.save()
@@ -1831,3 +1705,279 @@ class CheckoutTestCase(TestCase):
self.assertEqual(len(doc.select(".thank-you")), 1)
assert not Order.objects.last().testmode
assert "0" not in Order.objects.last().code
class QuestionsTestCase(BaseCheckoutTestCase, TestCase):
def test_timezone(self):
""" Test basic timezone change handling by date and time questions """
q1 = Question.objects.create(
event=self.event, question='When did you wake up today?', type=Question.TYPE_TIME,
required=True
)
q2 = Question.objects.create(
event=self.event, question='When was your last haircut?', type=Question.TYPE_DATE,
required=True
)
q3 = Question.objects.create(
event=self.event, question='When are you going to arrive?', type=Question.TYPE_DATETIME,
required=True
)
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.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-question_%s' % (cr.id, q1.id): '06:30',
'%s-question_%s' % (cr.id, q2.id): '2005-12-31',
'%s-question_%s_0' % (cr.id, q3.id): '2018-01-01',
'%s-question_%s_1' % (cr.id, q3.id): '5:23',
'email': 'admin@localhost',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), target_status_code=200)
self.event.settings.set('timezone', 'US/Central')
o1 = QuestionAnswer.objects.get(question=q1)
o2 = QuestionAnswer.objects.get(question=q2)
o3 = QuestionAnswer.objects.get(question=q3)
order = Order.objects.create(event=self.event, status=Order.STATUS_PAID,
expires=now() + timedelta(days=3),
total=4)
op = OrderPosition.objects.create(order=order, item=self.ticket, price=42)
o1.cartposition, o2.cartposition, o3.cartposition = None, None, None
o1.orderposition, o2.orderposition, o3.orderposition = op, op, op
# only time and date answers should be unaffected by timezone change
self.assertEqual(str(o1), '06:30')
self.assertEqual(str(o2), '2005-12-31')
o3date, o3time = str(o3).split(' ')
self.assertEqual(o3date, '2017-12-31')
self.assertEqual(o3time, '23:23')
def test_addon_questions(self):
q1 = Question.objects.create(
event=self.event, question='Age', type=Question.TYPE_NUMBER,
required=True
)
q1.items.add(self.ticket)
q1.items.add(self.workshop1)
ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1,
price_included=True)
cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
cp1.answers.create(question=q1, answer='12')
cp2 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.workshop1, addon_to=cp1,
price=0, expires=now() + timedelta(minutes=10)
)
cp2.answers.create(question=q1, answer='12')
self._set_session('payment', 'banktransfer')
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select(".thank-you")), 1)
self.assertEqual(OrderPosition.objects.filter(item=self.ticket).first().answers.first().answer, '12')
self.assertEqual(OrderPosition.objects.filter(item=self.workshop1).first().answers.first().answer, '12')
def test_questions(self):
q1 = Question.objects.create(
event=self.event, question='Age', type=Question.TYPE_NUMBER,
required=True
)
q2 = Question.objects.create(
event=self.event, question='How have you heard from us?', type=Question.TYPE_STRING,
required=False
)
self.ticket.questions.add(q1)
self.ticket.questions.add(q2)
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
cr2 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=20, 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")
self.assertEqual(len(doc.select('input[name="%s-question_%s"]' % (cr1.id, q1.id))), 1)
self.assertEqual(len(doc.select('input[name="%s-question_%s"]' % (cr2.id, q1.id))), 1)
self.assertEqual(len(doc.select('input[name="%s-question_%s"]' % (cr1.id, q2.id))), 1)
self.assertEqual(len(doc.select('input[name="%s-question_%s"]' % (cr2.id, q2.id))), 1)
# Not all required fields filled out, expect failure
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-question_%s' % (cr1.id, q1.id): '42',
'%s-question_%s' % (cr2.id, q1.id): '',
'%s-question_%s' % (cr1.id, q2.id): 'Internet',
'%s-question_%s' % (cr2.id, q2.id): '',
'email': 'admin@localhost'
}, follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-question_%s' % (cr1.id, q1.id): '42',
'%s-question_%s' % (cr2.id, q1.id): '23',
'%s-question_%s' % (cr1.id, q2.id): 'Internet',
'%s-question_%s' % (cr2.id, q2.id): '',
'email': 'admin@localhost'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
cr1 = CartPosition.objects.get(id=cr1.id)
cr2 = CartPosition.objects.get(id=cr2.id)
self.assertEqual(cr1.answers.filter(question=q1).count(), 1)
self.assertEqual(cr2.answers.filter(question=q1).count(), 1)
self.assertEqual(cr1.answers.filter(question=q2).count(), 1)
self.assertFalse(cr2.answers.filter(question=q2).exists())
def _test_question_input(self, data, should_fail):
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
pl = {
('%s-question_%s' % (cr1.id, k.id)): v for k, v in data.items() if v != 'False'
}
pl['email'] = 'admin@localhost'
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), pl, follow=True)
if should_fail:
doc = BeautifulSoup(response.rendered_content, "lxml")
assert doc.select('.has-error')
assert doc.select('.alert-danger')
else:
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
cr1.answers.all().delete()
for k, v in data.items():
a = cr1.answers.create(question=k, answer=str(v))
if k.type in ('M', 'C'):
a.options.add(*k.options.filter(identifier__in=(v if isinstance(v, list) else [v])))
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
if should_fail:
self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug),
target_status_code=200)
doc = BeautifulSoup(response.rendered_content, "lxml")
assert doc.select('.alert-warning')
else:
assert response.status_code == 200
doc = BeautifulSoup(response.rendered_content, "lxml")
assert not doc.select('.alert-warning')
def _setup_dependency_questions(self):
self.q1 = self.event.questions.create(
event=self.event, question='What industry are you in?', type=Question.TYPE_CHOICE,
required=True
)
self.q1.options.create(answer='Tech', identifier='TECH')
self.q1.options.create(answer='Health', identifier='HEALTH')
self.q2a = self.event.questions.create(
event=self.event, question='What is your occupation?', type=Question.TYPE_CHOICE_MULTIPLE,
required=False, dependency_question=self.q1, dependency_value='TECH'
)
self.q2a.options.create(answer='Software developer', identifier='DEV')
self.q2a.options.create(answer='System administrator', identifier='ADMIN')
self.q2b = self.event.questions.create(
event=self.event, question='What is your occupation?', type=Question.TYPE_CHOICE_MULTIPLE,
required=True, dependency_question=self.q1, dependency_value='HEALTH'
)
self.q2b.options.create(answer='Doctor', identifier='DOC')
self.q2b.options.create(answer='Nurse', identifier='NURSE')
self.q3 = self.event.questions.create(
event=self.event, question='Do you like Python?', type=Question.TYPE_BOOLEAN,
required=False, dependency_question=self.q2a, dependency_value='DEV'
)
self.q4a = self.event.questions.create(
event=self.event, question='Why?', type=Question.TYPE_TEXT,
required=True, dependency_question=self.q3, dependency_value='True'
)
self.q4b = self.event.questions.create(
event=self.event, question='Why not?', type=Question.TYPE_TEXT,
required=True, dependency_question=self.q3, dependency_value='False'
)
self.ticket.questions.add(self.q1)
self.ticket.questions.add(self.q2a)
self.ticket.questions.add(self.q2b)
self.ticket.questions.add(self.q3)
self.ticket.questions.add(self.q4a)
self.ticket.questions.add(self.q4b)
def test_question_dependencies_first_path(self):
self._setup_dependency_questions()
self._test_question_input({
self.q1: 'HEALTH',
self.q2b: 'NURSE'
}, should_fail=False)
def test_question_dependencies_sidepath_ignored(self):
self._setup_dependency_questions()
self._test_question_input({
self.q1: 'HEALTH',
self.q2b: 'NURSE',
self.q2a: 'DEV',
self.q3: 'True',
}, should_fail=False)
def test_question_dependencies_first_path_required(self):
self._setup_dependency_questions()
self._test_question_input({
self.q1: 'HEALTH',
}, should_fail=True)
def test_question_dependencies_second_path(self):
self._setup_dependency_questions()
self._test_question_input({
self.q1: 'TECH',
self.q2a: 'DEV',
self.q3: 'True',
self.q4a: 'No curly braces!'
}, should_fail=False)
def test_question_dependencies_subitem_required(self):
self._setup_dependency_questions()
self._test_question_input({
self.q1: 'HEALTH',
}, should_fail=True)
def test_question_dependencies_subsubitem_required(self):
self._setup_dependency_questions()
self._test_question_input({
self.q1: 'TECH',
self.q2a: 'DEV',
self.q3: 'True',
}, should_fail=True)
def test_question_dependencies_parent_not_required(self):
self._setup_dependency_questions()
self._test_question_input({
self.q1: 'TECH',
}, should_fail=False)
def test_question_dependencies_conditional_require_bool(self):
self._setup_dependency_questions()
self._test_question_input({
self.q1: 'TECH',
self.q2a: 'DEV',
self.q3: 'False',
self.q4b: 'No curly braces!'
}, should_fail=False)
def test_question_dependencies_conditional_require_bool_fail(self):
self._setup_dependency_questions()
self._test_question_input({
self.q1: 'TECH',
self.q2a: 'DEV',
self.q3: 'False',
}, should_fail=True)