diff --git a/src/pretix/base/migrations/0196_auto_20210523_1322.py b/src/pretix/base/migrations/0196_auto_20210523_1322.py
new file mode 100644
index 000000000..f60f7c3a7
--- /dev/null
+++ b/src/pretix/base/migrations/0196_auto_20210523_1322.py
@@ -0,0 +1,38 @@
+# Generated by Django 3.2.2 on 2021-05-23 13:22
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+import pretix.helpers.countries
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pretixbase', '0195_auto_20210622_1457'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='invoiceaddress',
+ name='customer',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invoice_addresses', to='pretixbase.customer'),
+ ),
+ migrations.CreateModel(
+ name='AttendeeProfile',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('attendee_name_cached', models.CharField(max_length=255, null=True)),
+ ('attendee_name_parts', models.JSONField(default=dict)),
+ ('attendee_email', models.EmailField(max_length=254, null=True)),
+ ('company', models.CharField(max_length=255, null=True)),
+ ('street', models.TextField(null=True)),
+ ('zipcode', models.CharField(max_length=30, null=True)),
+ ('city', models.CharField(max_length=255, null=True)),
+ ('country', pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2, null=True)),
+ ('state', models.CharField(max_length=255, null=True)),
+ ('answers', models.JSONField(default=list)),
+ ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attendee_profiles', to='pretixbase.customer')),
+ ],
+ ),
+ ]
diff --git a/src/pretix/base/models/customers.py b/src/pretix/base/models/customers.py
index ce9d6a586..6de35f3fd 100644
--- a/src/pretix/base/models/customers.py
+++ b/src/pretix/base/models/customers.py
@@ -19,19 +19,21 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# .
#
+import pycountry
from django.conf import settings
from django.contrib.auth.hashers import (
check_password, is_password_usable, make_password,
)
from django.db import models
from django.utils.crypto import get_random_string, salted_hmac
-from django.utils.translation import gettext_lazy as _
+from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager, scopes_disabled
from pretix.base.banlist import banned
from pretix.base.models.base import LoggedModel
from pretix.base.models.organizer import Organizer
from pretix.base.settings import PERSON_NAME_SCHEMES
+from pretix.helpers.countries import FastCountryField
class Customer(LoggedModel):
@@ -88,6 +90,8 @@ class Customer(LoggedModel):
self.all_logentries().update(data={}, shredded=True)
self.orders.all().update(customer=None)
self.memberships.all().update(attendee_name_parts=None)
+ self.attendee_profiles.all().delete()
+ self.invoice_addresses.all().delete()
@scopes_disabled()
def assign_identifier(self):
@@ -174,3 +178,88 @@ class Customer(LoggedModel):
continue
ctx['name_%s' % f] = self.name_parts.get(f, '')
return ctx
+
+ @property
+ def stored_addresses(self):
+ return self.invoice_addresses(manager='profiles')
+
+
+class AttendeeProfile(models.Model):
+ customer = models.ForeignKey(
+ Customer,
+ related_name='attendee_profiles',
+ on_delete=models.CASCADE
+ )
+ attendee_name_cached = models.CharField(
+ max_length=255,
+ verbose_name=_("Attendee name"),
+ blank=True, null=True,
+ )
+ attendee_name_parts = models.JSONField(
+ blank=True, default=dict
+ )
+ attendee_email = models.EmailField(
+ verbose_name=_("Attendee email"),
+ blank=True, null=True,
+ )
+ company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'), null=True)
+ street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
+ zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True, null=True)
+ city = models.CharField(max_length=255, verbose_name=_('City'), blank=True, null=True)
+ country = FastCountryField(verbose_name=_('Country'), blank=True, blank_label=_('Select country'), null=True)
+ state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True, null=True)
+ answers = models.JSONField(default=list)
+
+ objects = ScopedManager(organizer='customer__organizer')
+
+ @property
+ def attendee_name(self):
+ if not self.attendee_name_parts:
+ return None
+ if '_legacy' in self.attendee_name_parts:
+ return self.attendee_name_parts['_legacy']
+ if '_scheme' in self.attendee_name_parts:
+ scheme = PERSON_NAME_SCHEMES[self.attendee_name_parts['_scheme']]
+ else:
+ scheme = PERSON_NAME_SCHEMES[self.customer.organizer.settings.name_scheme]
+ return scheme['concatenation'](self.attendee_name_parts).strip()
+
+ @property
+ def state_name(self):
+ sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
+ if sd:
+ return sd.name
+ return self.state
+
+ @property
+ def state_for_address(self):
+ from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
+ if not self.state or str(self.country) not in COUNTRIES_WITH_STATE_IN_ADDRESS:
+ return ""
+ if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.country)][1] == 'long':
+ return self.state_name
+ return self.state
+
+ def describe(self):
+ from .items import Question
+ from .orders import QuestionAnswer
+
+ parts = [
+ self.attendee_name,
+ self.attendee_email,
+ self.company,
+ self.street,
+ (self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
+ self.country.name,
+ ]
+ for a in self.answers:
+ value = a.get('value')
+ try:
+ value = ", ".join(value.values())
+ except AttributeError:
+ value = str(value)
+ answer = QuestionAnswer(question=Question(type=a.get('question_type')), answer=value)
+ val = str(answer)
+ parts.append(f'{a["field_label"]}: {val}')
+
+ return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py
index 476fa5a12..0c932a544 100644
--- a/src/pretix/base/models/orders.py
+++ b/src/pretix/base/models/orders.py
@@ -2334,6 +2334,12 @@ class CartPosition(AbstractPosition):
class InvoiceAddress(models.Model):
last_modified = models.DateTimeField(auto_now=True)
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
+ customer = models.ForeignKey(
+ Customer,
+ related_name='invoice_addresses',
+ null=True, blank=True,
+ on_delete=models.CASCADE
+ )
is_business = models.BooleanField(default=False, verbose_name=_('Business customer'))
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
@@ -2360,6 +2366,7 @@ class InvoiceAddress(models.Model):
)
objects = ScopedManager(organizer='order__event__organizer')
+ profiles = ScopedManager(organizer='customer__organizer')
def save(self, **kwargs):
if self.order:
@@ -2372,6 +2379,20 @@ class InvoiceAddress(models.Model):
self.name_parts = {}
super().save(**kwargs)
+ def describe(self):
+ parts = [
+ self.company,
+ self.name,
+ self.street,
+ (self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
+ self.country.name,
+ self.vat_id,
+ self.custom_field,
+ self.internal_reference,
+ (_('Beneficiary') + ': ' + self.beneficiary) if self.beneficiary else '',
+ ]
+ return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
+
@property
def is_empty(self):
return (
@@ -2407,6 +2428,30 @@ class InvoiceAddress(models.Model):
raise TypeError("Invalid name given.")
return scheme['concatenation'](self.name_parts).strip()
+ def for_js(self):
+ d = {}
+
+ if self.name_parts:
+ if '_scheme' in self.name_parts:
+ scheme = PERSON_NAME_SCHEMES[self.name_parts['_scheme']]
+ for i, (k, l, w) in enumerate(scheme['fields']):
+ d[f'name_parts_{i}'] = self.name_parts.get(k) or ''
+
+ d.update({
+ 'company': self.company,
+ 'is_business': self.is_business,
+ 'street': self.street,
+ 'zipcode': self.zipcode,
+ 'city': self.city,
+ 'country': str(self.country) if self.country else None,
+ 'state': str(self.state) if self.state else None,
+ 'vat_id': self.vat_id,
+ 'custom_field': self.custom_field,
+ 'internal_reference': self.internal_reference,
+ 'beneficiary': self.beneficiary,
+ })
+ return d
+
def cachedticket_name(instance, filename: str) -> str:
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)
diff --git a/src/pretix/base/services/cleanup.py b/src/pretix/base/services/cleanup.py
index 7a28f7ff5..06fc89539 100644
--- a/src/pretix/base/services/cleanup.py
+++ b/src/pretix/base/services/cleanup.py
@@ -40,7 +40,7 @@ def clean_cart_positions(sender, **kwargs):
cp.delete()
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
cp.delete()
- for ia in InvoiceAddress.objects.filter(order__isnull=True, last_modified__lt=now() - timedelta(days=14)):
+ for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)):
ia.delete()
diff --git a/src/pretix/base/templatetags/escapejson.py b/src/pretix/base/templatetags/escapejson.py
index 0bcf7fcf0..89b25bf11 100644
--- a/src/pretix/base/templatetags/escapejson.py
+++ b/src/pretix/base/templatetags/escapejson.py
@@ -24,7 +24,7 @@ import json
from django import template
from django.template.defaultfilters import stringfilter
-from pretix.helpers.escapejson import escapejson
+from pretix.helpers.escapejson import escapejson, escapejson_attr
register = template.Library()
@@ -40,3 +40,9 @@ def escapejs_filter(value):
def escapejs_dumps_filter(value):
"""Hex encodes characters for use in a application/json type script."""
return escapejson(json.dumps(value))
+
+
+@register.filter("attr_escapejson_dumps")
+def attr_escapejs_dumps_filter(value):
+ """Hex encodes characters for use in an HTML attribute."""
+ return escapejson_attr(json.dumps(value))
diff --git a/src/pretix/base/templatetags/lists.py b/src/pretix/base/templatetags/lists.py
new file mode 100644
index 000000000..530239214
--- /dev/null
+++ b/src/pretix/base/templatetags/lists.py
@@ -0,0 +1,13 @@
+from django import template
+
+register = template.Library()
+
+
+@register.filter(name='splitlines')
+def splitlines(value):
+ return value.split("\n")
+
+
+@register.filter(name='joinlines')
+def joinlines(value):
+ return "\n".join(value)
diff --git a/src/pretix/base/views/mixins.py b/src/pretix/base/views/mixins.py
index 69eae34b7..a4a2e0699 100644
--- a/src/pretix/base/views/mixins.py
+++ b/src/pretix/base/views/mixins.py
@@ -36,6 +36,7 @@ from pretix.base.models import (
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
QuestionOption,
)
+from pretix.base.models.customers import AttendeeProfile
from pretix.presale.signals import contact_form_fields_overrides
@@ -60,6 +61,9 @@ class BaseQuestionsViewMixin:
def get_question_override_sets(self, position):
return []
+ def question_form_kwargs(self, cr):
+ return {}
+
@cached_property
def forms(self):
"""
@@ -71,13 +75,16 @@ class BaseQuestionsViewMixin:
for cr in self._positions_for_questions:
cartpos = cr if isinstance(cr, CartPosition) else None
orderpos = cr if isinstance(cr, OrderPosition) else None
+
+ kwargs = self.question_form_kwargs(cr)
form = self.form_class(event=self.request.event,
prefix=cr.id,
cartpos=cartpos,
orderpos=orderpos,
all_optional=self.all_optional,
data=(self.request.POST if self.request.method == 'POST' else None),
- files=(self.request.FILES if self.request.method == 'POST' else None))
+ files=(self.request.FILES if self.request.method == 'POST' else None),
+ **kwargs)
form.pos = cartpos or orderpos
form.show_copy_answers_to_addon_button = form.pos.addon_to and (
set(form.pos.addon_to.item.questions.all()) & set(form.pos.item.questions.all()) or
@@ -136,25 +143,28 @@ class BaseQuestionsViewMixin:
if not form.is_valid():
failed = True
else:
+ if form.cleaned_data.get('saved_id'):
+ prof = AttendeeProfile.objects.filter(
+ customer=self.cart_customer, pk=form.cleaned_data.get('saved_id')
+ ).first() or AttendeeProfile(customer=getattr(self, 'cart_customer', None))
+ answers_key_to_index = {a.get('field_name'): i for i, a in enumerate(prof.answers)}
+ else:
+ prof = AttendeeProfile(customer=getattr(self, 'cart_customer', None))
+ answers_key_to_index = {}
+
# This form was correctly filled, so we store the data as
# answers to the questions / in the CartPosition object
for k, v in form.cleaned_data.items():
- if k == 'attendee_name_parts':
+ if k in ('save', 'saved_id'):
+ continue
+ elif k == 'attendee_name_parts':
form.pos.attendee_name_parts = v if v else None
- elif k == 'attendee_email':
- form.pos.attendee_email = v if v != '' else None
- elif k == 'company':
- form.pos.company = v if v != '' else None
- elif k == 'street':
- form.pos.street = v if v != '' else None
- elif k == 'zipcode':
- form.pos.zipcode = v if v != '' else None
- elif k == 'city':
- form.pos.city = v if v != '' else None
- elif k == 'country':
- form.pos.country = v if v != '' else None
- elif k == 'state':
- form.pos.state = v if v != '' else None
+ prof.attendee_name_parts = form.pos.attendee_name_parts
+ prof.attendee_name_cached = form.pos.attendee_name
+ elif k in ('attendee_email', 'company', 'street', 'zipcode', 'city', 'country', 'state'):
+ v = v if v != '' else None
+ setattr(form.pos, k, v)
+ setattr(prof, k, v)
elif k.startswith('question_'):
field = form.fields[k]
if hasattr(field, 'answer'):
@@ -168,6 +178,23 @@ class BaseQuestionsViewMixin:
else:
self._save_to_answer(field, field.answer, v)
field.answer.save()
+ if isinstance(field, forms.ModelMultipleChoiceField) or isinstance(field, forms.ModelChoiceField):
+ answer_value = {o.identifier: str(o) for o in field.answer.options.all()}
+ elif isinstance(field, forms.BooleanField):
+ answer_value = bool(field.answer.answer)
+ else:
+ answer_value = str(field.answer.answer)
+ answer_dict = {
+ 'field_name': k,
+ 'field_label': str(field.label),
+ 'value': answer_value,
+ 'question_type': field.question.type,
+ 'question_identifier': field.question.identifier,
+ }
+ if k in answers_key_to_index:
+ prof.answers[answers_key_to_index[k]] = answer_dict
+ else:
+ prof.answers.append(answer_dict)
elif v != '' and v is not None:
answer = QuestionAnswer(
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
@@ -192,7 +219,27 @@ class BaseQuestionsViewMixin:
self._save_to_answer(field, answer, v)
answer.save()
+ if isinstance(field, forms.ModelMultipleChoiceField) or isinstance(field, forms.ModelChoiceField):
+ answer_value = {o.identifier: str(o) for o in answer.options.all()}
+ elif isinstance(field, forms.BooleanField):
+ answer_value = bool(answer.answer)
+ else:
+ answer_value = str(answer.answer)
+ answer_dict = {
+ 'field_name': k,
+ 'field_label': str(field.label),
+ 'value': answer_value,
+ 'question_type': field.question.type,
+ 'question_identifier': field.question.identifier,
+ }
+ if k in answers_key_to_index:
+ prof.answers[answers_key_to_index[k]] = answer_dict
+ else:
+ prof.answers.append(answer_dict)
+
else:
+ field = form.fields[k]
+
meta_info.setdefault('question_form_data', {})
if v is None:
if k in meta_info['question_form_data']:
@@ -200,8 +247,25 @@ class BaseQuestionsViewMixin:
else:
meta_info['question_form_data'][k] = v
+ answer_dict = {
+ 'field_name': k,
+ 'field_label': str(field.label),
+ 'value': str(v),
+ 'question_type': None,
+ 'question_identifier': None,
+ }
+ if k in answers_key_to_index:
+ prof.answers[answers_key_to_index[k]] = answer_dict
+ else:
+ prof.answers.append(answer_dict)
+
form.pos.meta_info = json.dumps(meta_info)
form.pos.save()
+
+ if form.cleaned_data.get('save') and not failed:
+ prof.save()
+ self.cart_session[f'saved_attendee_profile_{form.pos.pk}'] = prof.pk
+
return not failed
def _save_to_answer(self, field, answer, value):
diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py
index 5b8ab0bdd..4088ab97d 100644
--- a/src/pretix/presale/checkoutflow.py
+++ b/src/pretix/presale/checkoutflow.py
@@ -31,7 +31,7 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
-
+import copy
import inspect
from collections import defaultdict
from decimal import Decimal
@@ -59,6 +59,7 @@ from pretix.base.services.cart import (
)
from pretix.base.services.memberships import validate_memberships_in_order
from pretix.base.services.orders import perform_order
+from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import validate_cart_addons
from pretix.base.templatetags.phone_format import phone_format
from pretix.base.templatetags.rich_text import rich_text_snippet
@@ -227,7 +228,7 @@ class TemplateFlowStep(TemplateResponseMixin, BaseCheckoutFlowStep):
raise NotImplementedError()
-class CustomerStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
+class CustomerStep(CartMixin, TemplateFlowStep):
priority = 45
identifier = "customer"
template_name = "pretixpresale/event/checkout_customer.html"
@@ -352,7 +353,7 @@ class CustomerStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
return ctx
-class MembershipStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
+class MembershipStep(CartMixin, TemplateFlowStep):
priority = 47
identifier = "membership"
template_name = "pretixpresale/event/checkout_membership.html"
@@ -772,6 +773,9 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
'name_parts': self.cart_customer.name_parts
})
+ if 'saved_invoice_address' in self.cart_session:
+ initial['saved_id'] = self.cart_session['saved_invoice_address']
+
override_sets = self._contact_override_sets
for overrides in override_sets:
initial.update({
@@ -791,6 +795,7 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
request=self.request,
initial=initial,
instance=self.invoice_address,
+ allow_save=bool(self.cart_customer),
validate_vat_id=self.eu_reverse_charge_relevant, all_optional=self.all_optional)
for name, field in f.fields.items():
if wd_initial.get(name) and wd.get('fix', '') == 'true':
@@ -828,6 +833,23 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
self.cart_session['contact_form_data'] = d
if self.address_asked or self.request.event.settings.invoice_name_required:
addr = self.invoice_form.save()
+
+ if self.cart_customer and self.invoice_form.cleaned_data.get('save'):
+ if self.invoice_form.cleaned_data.get('saved_id'):
+ saved = InvoiceAddress.profiles.filter(
+ customer=self.cart_customer, pk=self.invoice_form.cleaned_data.get('saved_id')
+ ).first() or InvoiceAddress(customer=self.cart_customer)
+ else:
+ saved = InvoiceAddress(customer=self.cart_customer)
+
+ for f in InvoiceAddress._meta.fields:
+ if f.name not in ('order', 'customer', 'last_modified', 'pk', 'id'):
+ val = getattr(addr, f.name)
+ setattr(saved, f.name, copy.deepcopy(val))
+
+ saved.save()
+ self.cart_session['saved_invoice_address'] = saved.pk
+
try:
diff = update_tax_rates(
event=request.event,
@@ -946,6 +968,93 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
ctx['cart'] = self.get_cart()
ctx['cart_session'] = self.cart_session
ctx['invoice_address_asked'] = self.address_asked
+
+ if self.cart_customer:
+ if self.address_asked:
+ addresses = self.cart_customer.stored_addresses.all()
+ addresses_list = []
+ for a in addresses:
+ data = {
+ "_pk": a.pk,
+ "_country_for_address": a.country.name,
+ "_state_for_address": a.state_for_address,
+ "_name": a.name,
+ "is_business": "business" if a.is_business else "individual",
+ }
+ if a.name_parts:
+ name_parts = a.name_parts
+ # map full_name to name_parts and vice versa
+ scheme = PERSON_NAME_SCHEMES[self.request.event.settings.name_scheme]
+ available_keys = name_parts.keys()
+ asked_keys = [k for (k, l, w) in scheme["fields"]]
+ if not set(available_keys).intersection(asked_keys):
+ if "full_name" in available_keys:
+ name_keys = ("given_name", "family_name")
+ name_split = name_parts.get("full_name").rsplit(" ", 1)
+ name_parts = dict(zip(name_keys, name_split))
+ elif "full_name" in asked_keys:
+ name_parts = {
+ "full_name": a.name
+ }
+ for i, k in enumerate(asked_keys):
+ data[f"name_parts_{i}"] = name_parts.get(k) or ""
+
+ for k in (
+ "company", "street", "zipcode", "city", "country", "state",
+ "state_for_address", "vat_id", "custom_field", "internal_reference", "beneficiary"
+ ):
+ v = getattr(a, k) or ""
+ # always add all values of an address even when empty,
+ # so an address always gets fully overwritten client-side
+ data[k] = str(v)
+
+ addresses_list.append(data)
+
+ ctx['addresses_data'] = addresses_list
+
+ profiles = list(self.cart_customer.attendee_profiles.all())
+ profiles_list = []
+ for p in profiles:
+ data = {
+ "_pk": p.pk,
+ "_country_for_address": p.country.name,
+ "_state_for_address": p.state_for_address,
+ "_attendee_name": p.attendee_name,
+ }
+ if p.attendee_name_parts:
+ name_parts = p.attendee_name_parts
+ # map full_name to name_parts and vice versa
+ scheme = PERSON_NAME_SCHEMES[self.request.event.settings.name_scheme]
+ available_keys = name_parts.keys()
+ asked_keys = [k for (k, l, w) in scheme["fields"]]
+ if not set(available_keys).intersection(asked_keys):
+ if "full_name" in available_keys:
+ name_keys = ("given_name", "family_name")
+ name_split = name_parts.get("full_name").rsplit(" ", 1)
+ name_parts = dict(zip(name_keys, name_split))
+ elif "full_name" in asked_keys:
+ name_parts = {
+ "full_name": p.attendee_name
+ }
+
+ for i, k in enumerate(asked_keys):
+ data[f"attendee_name_parts_{i}"] = name_parts.get(k) or ""
+
+ for k in ("attendee_email", "company", "street", "zipcode", "city", "country", "state"):
+ v = getattr(p, k) or ""
+ # always add all values of an address even when empty,
+ # so an address always gets fully overwritten client-side
+ data[k] = str(v)
+
+ for a in p.answers:
+ data[a["field_name"]] = {
+ "label": a["field_label"],
+ "value": a["value"],
+ "identifier": a["question_identifier"],
+ "type": a["question_type"],
+ }
+ profiles_list.append(data)
+ ctx['profiles_data'] = profiles_list
return ctx
diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py
index dc1546d53..1729f2231 100644
--- a/src/pretix/presale/forms/checkout.py
+++ b/src/pretix/presale/forms/checkout.py
@@ -124,6 +124,22 @@ class InvoiceAddressForm(BaseInvoiceAddressForm):
required_css_class = 'required'
vat_warning = True
+ def __init__(self, *args, **kwargs):
+ allow_save = kwargs.pop('allow_save', False)
+ super().__init__(*args, **kwargs)
+ if allow_save:
+ self.fields['saved_id'] = forms.IntegerField(
+ required=False,
+ help_text=" ", # non-breaking-space, will be overwritten by JavaScript, needed here for HTML-output
+ label=_("Save to address"),
+ widget=forms.Select(choices=(("", _("Create new address")),))
+ )
+ self.fields['save'] = forms.BooleanField(
+ label=_('Save address in my customer account for future purchases'),
+ required=False,
+ initial=False,
+ )
+
class InvoiceNameForm(InvoiceAddressForm):
@@ -142,6 +158,22 @@ class QuestionsForm(BaseQuestionsForm):
"""
required_css_class = 'required'
+ def __init__(self, *args, **kwargs):
+ allow_save = kwargs.pop('allow_save', False)
+ super().__init__(*args, **kwargs)
+ if allow_save and self.fields:
+ self.fields['save'] = forms.BooleanField(
+ label=_('Save answers to my customer profiles for future purchases'),
+ required=False,
+ initial=False,
+ )
+ self.fields['saved_id'] = forms.IntegerField(
+ required=False,
+ help_text=" ", # non-breaking-space, will be overwritten by JavaScript, needed here for HTML-output
+ label=_("Save to profile"),
+ widget=forms.Select(choices=(("", _("Create new profile")),))
+ )
+
class AddOnRadioSelect(forms.RadioSelect):
option_template_name = 'pretixpresale/forms/addon_choice_option.html'
diff --git a/src/pretix/presale/forms/renderers.py b/src/pretix/presale/forms/renderers.py
index d97a1c49f..b799dbbb7 100644
--- a/src/pretix/presale/forms/renderers.py
+++ b/src/pretix/presale/forms/renderers.py
@@ -29,11 +29,11 @@ from django.utils.safestring import mark_safe
from django.utils.translation import pgettext
-def render_label(content, label_for=None, label_class=None, label_title='', label_id='', optional=False, is_valid=None):
+def render_label(content, label_for=None, label_class=None, label_title='', label_id='', optional=False, is_valid=None, attrs=None):
"""
Render a label with content
"""
- attrs = {}
+ attrs = attrs or {}
if label_for:
attrs['for'] = label_for
if label_class:
@@ -118,6 +118,7 @@ class CheckoutFieldRenderer(FieldRenderer):
widget.attrs["aria-describedby"] = " ".join(help_ids)
def add_label(self, html):
+ attrs = {}
label = self.get_label()
if hasattr(self.field.field, '_show_required'):
@@ -141,11 +142,15 @@ class CheckoutFieldRenderer(FieldRenderer):
label_for = self.field.id_for_label
label_id = ""
+ if hasattr(self.field.field, 'question') and self.field.field.question.identifier:
+ attrs["data-identifier"] = self.field.field.question.identifier
+
html = render_label(
label,
label_for=label_for,
label_class=self.get_label_class(),
label_id=label_id,
+ attrs=attrs,
optional=not required and not isinstance(self.widget, CheckboxInput),
is_valid=is_valid
) + html
diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html b/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html
index 8e0bfd686..3646b67bb 100644
--- a/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html
+++ b/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html
@@ -2,6 +2,8 @@
{% load i18n %}
{% load bootstrap3 %}
{% load rich_text %}
+{% load lists %}
+{% load escapejson %}
{% block inner %}
{% trans "Before we continue, we need you to answer some questions." %}
@@ -9,6 +11,9 @@
You need to fill all fields that are marked with * to continue.
{% endblocktrans %}
+ {% if profiles_data %}
+ {{ profiles_data|json_script:"profiles_json" }}
+ {% endif %}