diff --git a/src/pretix/base/exporters/json.py b/src/pretix/base/exporters/json.py index fa361e818d..168a3e3319 100644 --- a/src/pretix/base/exporters/json.py +++ b/src/pretix/base/exporters/json.py @@ -68,6 +68,7 @@ class JSONExporter(BaseExporter): 'variation': position.variation_id, 'price': position.price, 'attendee_name': position.attendee_name, + 'attendee_email': position.attendee_email, 'secret': position.secret, 'answers': [ { diff --git a/src/pretix/base/exporters/mail.py b/src/pretix/base/exporters/mail.py index 1e4cbb3157..4afecaad63 100644 --- a/src/pretix/base/exporters/mail.py +++ b/src/pretix/base/exporters/mail.py @@ -4,6 +4,7 @@ from django import forms from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ +from pretix.base.models import OrderPosition from ..exporter import BaseExporter from ..models import Order from ..signals import register_data_exporters @@ -16,7 +17,11 @@ class MailExporter(BaseExporter): def render(self, form_data: dict): qs = self.event.orders.filter(status__in=form_data['status']) addrs = qs.values('email') - data = "\r\n".join(set(a['email'] for a in addrs)) + pos = OrderPosition.objects.filter( + order__event=self.event, order__status__in=form_data['status'] + ).values('attendee_email') + data = "\r\n".join(set(a['email'] for a in addrs) + | set(a['attendee_email'] for a in pos if a['attendee_email'])) return 'pretixemails.txt', 'text/plain', data.encode("utf-8") @property diff --git a/src/pretix/base/migrations/0055_auto_20170413_1537.py b/src/pretix/base/migrations/0055_auto_20170413_1537.py new file mode 100644 index 0000000000..932fea27e3 --- /dev/null +++ b/src/pretix/base/migrations/0055_auto_20170413_1537.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-04-13 15:37 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0054_auto_20170413_1050'), + ] + + operations = [ + migrations.AddField( + model_name='cartposition', + name='attendee_email', + field=models.EmailField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=254, null=True, verbose_name='Attendee email'), + ), + migrations.AddField( + model_name='orderposition', + name='attendee_email', + field=models.EmailField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=254, null=True, verbose_name='Attendee email'), + ), + migrations.AlterField( + model_name='event_settingsstore', + name='key', + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name='globalsettingsobject_settingsstore', + name='key', + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name='organizer_settingsstore', + name='key', + field=models.CharField(max_length=255), + ), + ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index fb3dec4fd7..f149001bc8 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -394,6 +394,8 @@ class AbstractPosition(models.Model): :type price: decimal.Decimal :param attendee_name: The attendee's name, if entered. :type attendee_name: str + :param attendee_email: The attendee's email, if entered. + :type attendee_email: str :param voucher: A voucher that has been applied to this sale :type voucher: Voucher """ @@ -418,6 +420,11 @@ class AbstractPosition(models.Model): blank=True, null=True, help_text=_("Empty, if this product is not an admission ticket") ) + attendee_email = models.EmailField( + verbose_name=_("Attendee email"), + blank=True, null=True, + help_text=_("Empty, if this product is not an admission ticket") + ) voucher = models.ForeignKey( 'Voucher', null=True, blank=True ) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 8649d768ac..60f8f24472 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -27,6 +27,14 @@ DEFAULTS = { 'default': 'False', 'type': bool }, + 'attendee_emails_asked': { + 'default': 'False', + 'type': bool + }, + 'attendee_emails_required': { + 'default': 'False', + 'type': bool + }, 'invoice_address_asked': { 'default': 'True', 'type': bool, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 40c887ceea..76ef5a06f3 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -240,6 +240,21 @@ class EventSettingsForm(SettingsForm): help_text=_("Require customers to fill in the names of all attendees."), required=False ) + attendee_emails_asked = forms.BooleanField( + label=_("Ask for attendee e-mails"), + help_text=_("Ask for an e-mail address for all tickets which include admission to the event. Important: For " + "every order, an e-mail address needs to be provided, regardless of this setting. The order " + "confirmation which can be used to change the order will always only be sent to the e-mail " + "address provided for the order. Only check this box if you want to ask for additional e-mail " + "addresses for each attendee, e.g. in case of group orders."), + required=False + ) + attendee_emails_required = forms.BooleanField( + label=_("Require attendee e-mails"), + help_text=_("Require customers to fill in the e-mail addresses of all attendees. See the above option for " + "more details."), + required=False + ) max_items_per_order = forms.IntegerField( min_value=1, label=_("Maximum number of items per order") @@ -274,6 +289,11 @@ class EventSettingsForm(SettingsForm): raise ValidationError({ 'attendee_names_required': _('You cannot require specifying attendee names if you do not ask for them.') }) + if data['attendee_emails_required'] and not data['attendee_emails_asked']: + raise ValidationError({ + 'attendee_emails_required': _('You cannot require specifying attendee emails if you do not ask for ' + 'them.') + }) return data diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index b48e57d63b..82019bcf63 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -41,6 +41,8 @@ {% bootstrap_field sform.max_items_per_order layout="horizontal" %} {% bootstrap_field sform.attendee_names_asked layout="horizontal" %} {% bootstrap_field sform.attendee_names_required layout="horizontal" %} + {% bootstrap_field sform.attendee_emails_asked layout="horizontal" %} + {% bootstrap_field sform.attendee_emails_required layout="horizontal" %} {% bootstrap_field sform.cancel_allow_user layout="horizontal" %}
diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 674679fd18..0f37a1e3ee 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -187,6 +187,11 @@
{% if line.attendee_name %}{{ line.attendee_name }}{% else %} {% trans "not answered" %}{% endif %}
{% endif %} + {% if line.item.admission and event.settings.attendee_emails_asked %} +
{% trans "Attendee email" %}
+
{% if line.attendee_email %}{{ line.attendee_email }}{% else %} + {% trans "not answered" %}{% endif %}
+ {% endif %} {% for q in line.questions %}
{{ q.question }}
{% if q.answer %}{{ q.answer|linebreaksbr }}{% else %} diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index d9de87fe24..56105f0d9b 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -54,6 +54,7 @@ class OrderList(EventPermissionRequiredMixin, ListView): u = self.request.GET.get("user", "") qs = qs.filter( Q(email__icontains=u) | Q(positions__attendee_name__icontains=u) + | Q(positions__attendee_email__icontains=u) ) if self.request.GET.get("status", "") != "": s = self.request.GET.get("status", "") @@ -172,6 +173,7 @@ class OrderDetail(OrderView): for p in cartpos: p.has_questions = ( (p.item.admission and self.request.event.settings.attendee_names_asked) or + (p.item.admission and self.request.event.settings.attendee_emails_asked) or p.item.questions.all() ) p.cache_answers() diff --git a/src/pretix/plugins/checkinlists/exporters.py b/src/pretix/plugins/checkinlists/exporters.py index db0645a502..e428f1085c 100644 --- a/src/pretix/plugins/checkinlists/exporters.py +++ b/src/pretix/plugins/checkinlists/exporters.py @@ -89,6 +89,9 @@ class CSVCheckinList(BaseCheckinList): if form_data['secrets']: headers.append(_('Secret')) + if self.event.settings.attendee_emails_asked: + headers.append(_('E-mail')) + for q in questions: headers.append(str(q.question)) @@ -105,6 +108,8 @@ class CSVCheckinList(BaseCheckinList): row.append(_('Yes') if op.order.status == Order.STATUS_PAID else _('No')) if form_data['secrets']: row.append(op.secret) + if self.event.settings.attendee_emails_asked: + row.append(op.attendee_email) acache = {} for a in op.answers.all(): acache[a.question_id] = str(a) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index c8f44195b9..32a4f44ca5 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -202,6 +202,11 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): if warn: messages.warning(request, _('Please fill in answers to all required questions.')) return False + if cp.item.admission and self.request.event.settings.get('attendee_emails_required', as_type=bool) \ + and cp.attendee_email is None: + if warn: + messages.warning(request, _('Please fill in answers to all required questions.')) + return False return True def get_context_data(self, **kwargs): diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index 11ea35656f..44f899a5af 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -9,7 +9,10 @@ from pretix.base.models.orders import InvoiceAddress class ContactForm(forms.Form): - email = forms.EmailField(label=_('E-mail')) + email = forms.EmailField(label=_('E-mail'), + help_text=_('Make sure to enter a valid email address. We will send you an order ' + 'confirmation including a link that you need in case you want to make ' + 'modifications to your order or download your ticket later.')) class InvoiceAddressForm(forms.ModelForm): @@ -67,6 +70,12 @@ class QuestionsForm(forms.Form): label=_('Attendee name'), initial=(cartpos.attendee_name if cartpos else orderpos.attendee_name) ) + if item.admission and event.settings.attendee_emails_asked: + self.fields['attendee_email'] = forms.EmailField( + required=event.settings.attendee_emails_required, + label=_('Attendee email'), + initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email) + ) for q in questions: # Do we already have an answer? Provide it as the initial value diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index 1662defa06..8462fbae94 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -18,6 +18,10 @@
{% trans "Attendee name" %}
{% if line.attendee_name %}{{ line.attendee_name }}{% else %}{% trans "not answered" %}{% endif %}
{% endif %} + {% if line.item.admission and event.settings.attendee_emails_asked%} +
{% trans "Attendee email" %}
+
{% if line.attendee_email %}{{ line.attendee_email }}{% else %}{% trans "not answered" %}{% endif %}
+ {% endif %} {% for q in line.questions %}
{{ q.question }}
{% if q.answer %}{{ q.answer|linebreaksbr }}{% else %}{% trans "not answered" %}{% endif %}
diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index a25d11821c..706fa658d7 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -46,8 +46,13 @@ class CartMixin: i = pos.pk if downloads: return i, pos.pk, 0, 0, 0, 0, - if answers and ((pos.item.admission and self.request.event.settings.attendee_names_asked) - or pos.item.questions.all()): + + has_attendee_data = pos.item.admission and ( + self.request.event.settings.attendee_names_asked + or self.request.event.settings.attendee_emails_asked + ) + + if answers and (has_attendee_data or pos.item.questions.all()): return i, pos.pk, 0, 0, 0, 0, return 0, 0, pos.item_id, pos.variation_id, pos.price, (pos.voucher_id or 0) diff --git a/src/pretix/presale/views/questions.py b/src/pretix/presale/views/questions.py index fffe87323f..385adc05ca 100644 --- a/src/pretix/presale/views/questions.py +++ b/src/pretix/presale/views/questions.py @@ -45,6 +45,9 @@ class QuestionsViewMixin: if k == 'attendee_name': form.pos.attendee_name = v if v != '' else None form.pos.save() + elif k == 'attendee_email': + form.pos.attendee_email = v if v != '' else None + form.pos.save() elif k.startswith('question_') and v is not None: field = form.fields[k] if hasattr(field, 'answer'): diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index a543ab956f..b17067af8d 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -96,6 +96,36 @@ class CheckoutTestCase(TestCase): self.assertEqual(cr1.answers.filter(question=q2).count(), 1) self.assertFalse(cr2.answers.filter(question=q2).exists()) + def test_attendee_email_required(self): + self.event.settings.set('attendee_emails_asked', True) + self.event.settings.set('attendee_emails_required', True) + cr1 = 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") + self.assertEqual(len(doc.select('input[name=%s-attendee_email]' % cr1.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-attendee_email' % cr1.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-attendee_email' % cr1.id: 'foo@localhost', + '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) + self.assertEqual(cr1.attendee_email, 'foo@localhost') + def test_attendee_name_required(self): self.event.settings.set('attendee_names_asked', True) self.event.settings.set('attendee_names_required', True)