diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index 657f27b008..5c135c3ab8 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -25,7 +25,7 @@ Frontend -------- .. automodule:: pretix.presale.signals - :members: html_head, html_footer, footer_links, front_page_top, front_page_bottom, contact_form_fields, checkout_confirm_messages + :members: html_head, html_footer, footer_links, front_page_top, front_page_bottom, contact_form_fields, question_form_fields, checkout_confirm_messages .. automodule:: pretix.presale.signals diff --git a/src/pretix/base/migrations/0063_auto_20170702_1711.py b/src/pretix/base/migrations/0063_auto_20170702_1711.py new file mode 100644 index 0000000000..01fa646e98 --- /dev/null +++ b/src/pretix/base/migrations/0063_auto_20170702_1711.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.2 on 2017-07-02 17:11 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models + +import pretix.base.models.invoices +import pretix.base.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0062_auto_20170602_0948'), + ] + + operations = [ + migrations.AlterModelOptions( + name='voucher', + options={'ordering': ('code',), 'verbose_name': 'Voucher', 'verbose_name_plural': 'Vouchers'}, + ), + migrations.AddField( + model_name='cartposition', + name='meta_info', + field=models.TextField(blank=True, null=True, verbose_name='Meta information'), + ), + migrations.AddField( + model_name='orderposition', + name='meta_info', + field=models.TextField(blank=True, null=True, verbose_name='Meta information'), + ), + migrations.AlterField( + model_name='event', + name='currency', + field=models.CharField(choices=[('AED', 'AED - UAE Dirham'), ('AFN', 'AFN - Afghani'), ('ALL', 'ALL - Lek'), ('AMD', 'AMD - Armenian Dram'), ('ANG', 'ANG - Netherlands Antillean Guilder'), ('AOA', 'AOA - Kwanza'), ('ARS', 'ARS - Argentine Peso'), ('AUD', 'AUD - Australian Dollar'), ('AWG', 'AWG - Aruban Florin'), ('AZN', 'AZN - Azerbaijanian Manat'), ('BAM', 'BAM - Convertible Mark'), ('BBD', 'BBD - Barbados Dollar'), ('BDT', 'BDT - Taka'), ('BGN', 'BGN - Bulgarian Lev'), ('BHD', 'BHD - Bahraini Dinar'), ('BIF', 'BIF - Burundi Franc'), ('BMD', 'BMD - Bermudian Dollar'), ('BND', 'BND - Brunei Dollar'), ('BOB', 'BOB - Boliviano'), ('BRL', 'BRL - Brazilian Real'), ('BSD', 'BSD - Bahamian Dollar'), ('BTN', 'BTN - Ngultrum'), ('BWP', 'BWP - Pula'), ('BYN', 'BYN - Belarusian Ruble'), ('BZD', 'BZD - Belize Dollar'), ('CAD', 'CAD - Canadian Dollar'), ('CDF', 'CDF - Congolese Franc'), ('CHF', 'CHF - Swiss Franc'), ('CLP', 'CLP - Chilean Peso'), ('CNY', 'CNY - Yuan Renminbi'), ('COP', 'COP - Colombian Peso'), ('CRC', 'CRC - Costa Rican Colon'), ('CUC', 'CUC - Peso Convertible'), ('CUP', 'CUP - Cuban Peso'), ('CVE', 'CVE - Cabo Verde Escudo'), ('CZK', 'CZK - Czech Koruna'), ('DJF', 'DJF - Djibouti Franc'), ('DKK', 'DKK - Danish Krone'), ('DOP', 'DOP - Dominican Peso'), ('DZD', 'DZD - Algerian Dinar'), ('EGP', 'EGP - Egyptian Pound'), ('ERN', 'ERN - Nakfa'), ('ETB', 'ETB - Ethiopian Birr'), ('EUR', 'EUR - Euro'), ('FJD', 'FJD - Fiji Dollar'), ('FKP', 'FKP - Falkland Islands Pound'), ('GBP', 'GBP - Pound Sterling'), ('GEL', 'GEL - Lari'), ('GHS', 'GHS - Ghana Cedi'), ('GIP', 'GIP - Gibraltar Pound'), ('GMD', 'GMD - Dalasi'), ('GNF', 'GNF - Guinea Franc'), ('GTQ', 'GTQ - Quetzal'), ('GYD', 'GYD - Guyana Dollar'), ('HKD', 'HKD - Hong Kong Dollar'), ('HNL', 'HNL - Lempira'), ('HRK', 'HRK - Kuna'), ('HTG', 'HTG - Gourde'), ('HUF', 'HUF - Forint'), ('IDR', 'IDR - Rupiah'), ('ILS', 'ILS - New Israeli Sheqel'), ('INR', 'INR - Indian Rupee'), ('IQD', 'IQD - Iraqi Dinar'), ('IRR', 'IRR - Iranian Rial'), ('ISK', 'ISK - Iceland Krona'), ('JMD', 'JMD - Jamaican Dollar'), ('JOD', 'JOD - Jordanian Dinar'), ('JPY', 'JPY - Yen'), ('KES', 'KES - Kenyan Shilling'), ('KGS', 'KGS - Som'), ('KHR', 'KHR - Riel'), ('KMF', 'KMF - Comoro Franc'), ('KPW', 'KPW - North Korean Won'), ('KRW', 'KRW - Won'), ('KWD', 'KWD - Kuwaiti Dinar'), ('KYD', 'KYD - Cayman Islands Dollar'), ('KZT', 'KZT - Tenge'), ('LAK', 'LAK - Kip'), ('LBP', 'LBP - Lebanese Pound'), ('LKR', 'LKR - Sri Lanka Rupee'), ('LRD', 'LRD - Liberian Dollar'), ('LSL', 'LSL - Loti'), ('LYD', 'LYD - Libyan Dinar'), ('MAD', 'MAD - Moroccan Dirham'), ('MDL', 'MDL - Moldovan Leu'), ('MGA', 'MGA - Malagasy Ariary'), ('MKD', 'MKD - Denar'), ('MMK', 'MMK - Kyat'), ('MNT', 'MNT - Tugrik'), ('MOP', 'MOP - Pataca'), ('MRO', 'MRO - Ouguiya'), ('MUR', 'MUR - Mauritius Rupee'), ('MVR', 'MVR - Rufiyaa'), ('MWK', 'MWK - Malawi Kwacha'), ('MXN', 'MXN - Mexican Peso'), ('MYR', 'MYR - Malaysian Ringgit'), ('MZN', 'MZN - Mozambique Metical'), ('NAD', 'NAD - Namibia Dollar'), ('NGN', 'NGN - Naira'), ('NIO', 'NIO - Cordoba Oro'), ('NOK', 'NOK - Norwegian Krone'), ('NPR', 'NPR - Nepalese Rupee'), ('NZD', 'NZD - New Zealand Dollar'), ('OMR', 'OMR - Rial Omani'), ('PAB', 'PAB - Balboa'), ('PEN', 'PEN - Sol'), ('PGK', 'PGK - Kina'), ('PHP', 'PHP - Philippine Peso'), ('PKR', 'PKR - Pakistan Rupee'), ('PLN', 'PLN - Zloty'), ('PYG', 'PYG - Guarani'), ('QAR', 'QAR - Qatari Rial'), ('RON', 'RON - Romanian Leu'), ('RSD', 'RSD - Serbian Dinar'), ('RUB', 'RUB - Russian Ruble'), ('RWF', 'RWF - Rwanda Franc'), ('SAR', 'SAR - Saudi Riyal'), ('SBD', 'SBD - Solomon Islands Dollar'), ('SCR', 'SCR - Seychelles Rupee'), ('SDG', 'SDG - Sudanese Pound'), ('SEK', 'SEK - Swedish Krona'), ('SGD', 'SGD - Singapore Dollar'), ('SHP', 'SHP - Saint Helena Pound'), ('SLL', 'SLL - Leone'), ('SOS', 'SOS - Somali Shilling'), ('SRD', 'SRD - Surinam Dollar'), ('SSP', 'SSP - South Sudanese Pound'), ('STD', 'STD - Dobra'), ('SVC', 'SVC - El Salvador Colon'), ('SYP', 'SYP - Syrian Pound'), ('SZL', 'SZL - Lilangeni'), ('THB', 'THB - Baht'), ('TJS', 'TJS - Somoni'), ('TMT', 'TMT - Turkmenistan New Manat'), ('TND', 'TND - Tunisian Dinar'), ('TOP', 'TOP - Pa’anga'), ('TRY', 'TRY - Turkish Lira'), ('TTD', 'TTD - Trinidad and Tobago Dollar'), ('TWD', 'TWD - New Taiwan Dollar'), ('TZS', 'TZS - Tanzanian Shilling'), ('UAH', 'UAH - Hryvnia'), ('UGX', 'UGX - Uganda Shilling'), ('USD', 'USD - US Dollar'), ('UYU', 'UYU - Peso Uruguayo'), ('UZS', 'UZS - Uzbekistan Sum'), ('VEF', 'VEF - Bolívar'), ('VND', 'VND - Dong'), ('VUV', 'VUV - Vatu'), ('WST', 'WST - Tala'), ('XAF', 'XAF - CFA Franc BEAC'), ('XAG', 'XAG - Silver'), ('XAU', 'XAU - Gold'), ('XBA', 'XBA - Bond Markets Unit European Composite Unit (EURCO)'), ('XBB', 'XBB - Bond Markets Unit European Monetary Unit (E.M.U.-6)'), ('XBC', 'XBC - Bond Markets Unit European Unit of Account 9 (E.U.A.-9)'), ('XBD', 'XBD - Bond Markets Unit European Unit of Account 17 (E.U.A.-17)'), ('XCD', 'XCD - East Caribbean Dollar'), ('XDR', 'XDR - SDR (Special Drawing Right)'), ('XOF', 'XOF - CFA Franc BCEAO'), ('XPD', 'XPD - Palladium'), ('XPF', 'XPF - CFP Franc'), ('XPT', 'XPT - Platinum'), ('XSU', 'XSU - Sucre'), ('XTS', 'XTS - Codes specifically reserved for testing purposes'), ('XUA', 'XUA - ADB Unit of Account'), ('XXX', 'XXX - The codes assigned for transactions where no currency is involved'), ('YER', 'YER - Yemeni Rial'), ('ZAR', 'ZAR - Rand'), ('ZMW', 'ZMW - Zambian Kwacha'), ('ZWL', 'ZWL - Zimbabwe Dollar')], default='EUR', max_length=10, verbose_name='Default currency'), + ), + migrations.AlterField( + model_name='event', + name='slug', + field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. We recommend some kind of abbreviation or a date with less than 10 characters that can be easily remembered, but you can also choose to use a random value. This will be used in URLs, order codes, invoice numbers, and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'), + ), + migrations.AlterField( + model_name='invoice', + name='date', + field=models.DateField(default=pretix.base.models.invoices.today), + ), + migrations.AlterField( + model_name='item', + name='allow_cancel', + field=models.BooleanField(default=True, help_text='If this is active and the general event settings allow it, orders containing this product can be canceled by the user until the order is paid for. Users cannot cancel paid orders on their own and you can cancel orders at all times, regardless of this setting', verbose_name='Allow product to be canceled'), + ), + ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 3c6e30ad02..8a37d3a30f 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -405,6 +405,8 @@ class AbstractPosition(models.Model): :type attendee_email: str :param voucher: A voucher that has been applied to this sale :type voucher: Voucher + :param meta_info: Additional meta information on the position, JSON-encoded. + :type meta_info: str """ item = models.ForeignKey( Item, @@ -438,10 +440,21 @@ class AbstractPosition(models.Model): addon_to = models.ForeignKey( 'self', null=True, blank=True, on_delete=models.CASCADE, related_name='addons' ) + meta_info = models.TextField( + verbose_name=_("Meta information"), + null=True, blank=True + ) class Meta: abstract = True + @property + def meta_info_data(self): + if self.meta_info: + return json.loads(self.meta_info) + else: + return {} + def cache_answers(self): """ Creates two properties on the object. diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index c08be83683..3764859e28 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -201,6 +201,11 @@
{% if q.answer %}{{ q.answer|linebreaksbr }}{% else %} {% trans "not answered" %}{% endif %}
{% endfor %} + {% for q in line.additional_fields %} +
{{ q.question }}
+
{% if q.answer %}{{ q.answer|linebreaksbr }}{% else %} + {% trans "not answered" %}{% endif %}
+ {% endfor %} {% endif %} diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 3149343805..dcac9eb894 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -36,6 +36,7 @@ from pretix.control.forms.orders import ( ) from pretix.control.permissions import EventPermissionRequiredMixin from pretix.multidomain.urlreverse import build_absolute_uri +from pretix.presale.signals import question_form_fields class OrderList(EventPermissionRequiredMixin, ListView): @@ -141,12 +142,24 @@ class OrderDetail(OrderView): positions = [] for p in cartpos: + responses = question_form_fields.send(sender=self.request.event, position=p) + p.additional_fields = [] + data = p.meta_info_data + for r, response in sorted(responses, key=lambda r: str(r[0])): + for key, value in response.items(): + p.additional_fields.append({ + 'answer': data.get('question_form_data', {}).get(key), + 'question': value.label + }) + p.has_questions = ( + p.additional_fields or (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() + positions.append(p) positions.sort(key=lambda p: p.sort_key) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index cdaf475567..fd354eb93b 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -19,7 +19,7 @@ from pretix.presale.forms.checkout import ( ) from pretix.presale.signals import ( checkout_confirm_messages, checkout_flow_steps, contact_form_fields, - order_meta_from_request, + order_meta_from_request, question_form_fields, ) from pretix.presale.views import CartMixin, get_cart, get_cart_total from pretix.presale.views.async import AsyncAction @@ -330,6 +330,13 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): if warn: messages.warning(request, _('Please fill in answers to all required questions.')) return False + + responses = question_form_fields.send(sender=self.request.event, position=cp) + form_data = cp.meta_info_data.get('question_form_data', {}) + for r, response in sorted(responses, key=lambda r: str(r[0])): + for key, value in response.items(): + if value.required and not form_data.get(key): + 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 98d3350e8c..104c75ea02 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -12,7 +12,7 @@ from django.utils.translation import ugettext_lazy as _ from pretix.base.models import ItemVariation, Question from pretix.base.models.orders import InvoiceAddress from pretix.base.templatetags.rich_text import rich_text -from pretix.presale.signals import contact_form_fields +from pretix.presale.signals import contact_form_fields, question_form_fields class ContactForm(forms.Form): @@ -89,7 +89,8 @@ class QuestionsForm(forms.Form): """ cartpos = self.cartpos = kwargs.pop('cartpos', None) orderpos = self.orderpos = kwargs.pop('orderpos', None) - item = cartpos.item if cartpos else orderpos.item + pos = cartpos or orderpos + item = pos.item questions = list(item.questions.all()) event = kwargs.pop('event') @@ -174,6 +175,14 @@ class QuestionsForm(forms.Form): field.answer = answers[0] self.fields['question_%s' % q.id] = field + responses = question_form_fields.send(sender=event, position=pos) + data = pos.meta_info_data + for r, response in sorted(responses, key=lambda r: str(r[0])): + for key, value in response.items(): + # We need to be this explicit, since OrderedDict.update does not retain ordering + self.fields[key] = value + value.initial = data.get('question_form_data', {}).get(key) + class AddOnRadioSelect(forms.RadioSelect): option_template_name = 'pretixpresale/forms/addon_choice_option.html' diff --git a/src/pretix/presale/signals.py b/src/pretix/presale/signals.py index ac0ac695b3..a6531d6c3f 100644 --- a/src/pretix/presale/signals.py +++ b/src/pretix/presale/signals.py @@ -76,7 +76,23 @@ contact_form_fields = EventPluginSignal( This signals allows you to add form fields to the contact form that is presented during checkout and by default only asks for the email address. You are supposed to return a dictionary of form fields with globally unique keys. The validated form results will be saved into the -``contact_form_data`` entry of the order metadata dictionary. +``contact_form_data`` entry of the order's meta_info dictionary. + +As with all plugin signals, the ``sender`` keyword argument will contain the event. +""" + +question_form_fields = EventPluginSignal( + providing_args=["position"] +) +""" +This signals allows you to add form fields to the questions form that is presented during checkout +and by default asks for the questions configured in the backend. You are supposed to return a dictionary +of form fields with globally unique keys. The validated form results will be saved into the +``question_form_data`` entry of the position's meta_info dictionary. + +The ``position`` keyword argument will contain either a ``CartPosition`` object or an ``OrderPosition`` +object, depending on whether the form is called as part of the order checkout or for changing an order +later. As with all plugin signals, the ``sender`` keyword argument will contain the event. """ diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index 71335d2cb6..66b37c3cad 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -17,11 +17,11 @@ {% if line.has_questions %}
- {% if line.item.admission and event.settings.attendee_names_asked%} + {% if line.item.admission and event.settings.attendee_names_asked %}
{% 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%} + {% 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 %} @@ -29,6 +29,10 @@
{{ q.question }}
{% if q.answer %}{{ q.answer|linebreaksbr }}{% else %}{% trans "not answered" %}{% endif %}
{% endfor %} + {% for q in line.additional_answers %} +
{{ q.question }}
+
{% if q.answer %}{{ q.answer|linebreaksbr }}{% else %}{% trans "not answered" %}{% endif %}
+ {% endfor %}
{% endif %} diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index 7f25422854..60b046d4d3 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -1,3 +1,4 @@ +from collections import defaultdict from datetime import timedelta from decimal import Decimal from itertools import groupby @@ -8,6 +9,7 @@ from django.utils.timezone import now from pretix.base.decimal import round_decimal from pretix.base.models import CartPosition, OrderPosition +from pretix.presale.signals import question_form_fields class CartMixin: @@ -38,6 +40,17 @@ class CartMixin: lcp = list(cartpos) has_addons = {cp.addon_to.pk for cp in lcp if cp.addon_to} + pos_additional_fields = defaultdict(list) + for cp in lcp: + responses = question_form_fields.send(sender=self.request.event, position=cp) + data = cp.meta_info_data + for r, response in sorted(responses, key=lambda r: str(r[0])): + for key, value in response.items(): + pos_additional_fields[cp.pk].append({ + 'answer': data.get('question_form_data', {}).get(key), + 'question': value.label + }) + # Group items of the same variation # We do this by list manipulations instead of a GROUP BY query, as # Django is unable to join related models in a .values() query @@ -56,6 +69,7 @@ class CartMixin: has_attendee_data = pos.item.admission and ( self.request.event.settings.attendee_names_asked or self.request.event.settings.attendee_emails_asked + or pos_additional_fields.get(pos.pk) ) addon_penalty = 1 if pos.addon_to else 0 if downloads or pos.pk in has_addons or pos.addon_to: @@ -75,6 +89,7 @@ class CartMixin: group.has_questions = answers and k[0] != "" if answers: group.cache_answers() + group.additional_answers = pos_additional_fields.get(group.pk) positions.append(group) total = sum(p.total for p in positions) diff --git a/src/pretix/presale/views/questions.py b/src/pretix/presale/views/questions.py index 69b72cf4d4..fa522313b8 100644 --- a/src/pretix/presale/views/questions.py +++ b/src/pretix/presale/views/questions.py @@ -1,3 +1,4 @@ +import json from collections import defaultdict from django import forms @@ -58,6 +59,7 @@ class QuestionsViewMixin: def save(self): failed = False for form in self.forms: + meta_info = form.pos.meta_info_data # Every form represents a CartPosition or OrderPosition with questions attached if not form.is_valid(): failed = True @@ -89,6 +91,16 @@ class QuestionsViewMixin: ) self._save_to_answer(field, answer, v) answer.save() + else: + meta_info.setdefault('question_form_data', {}) + if v is None: + if k in meta_info['question_form_data']: + del meta_info['question_form_data'][k] + else: + meta_info['question_form_data'][k] = v + + form.pos.meta_info = json.dumps(meta_info) + form.pos.save(update_fields=['meta_info']) return not failed def _save_to_answer(self, field, answer, value):