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 0000000000..f60f7c3a77 --- /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 ce9d6a586e..6de35f3fdf 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 476fa5a12a..0c932a5445 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 7a28f7ff5f..06fc895399 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 0bcf7fcf02..89b25bf11b 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 0000000000..530239214b --- /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 69eae34b78..a4a2e0699c 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 5b8ab0bdd4..4088ab97dd 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 dc1546d534..1729f2231d 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 d97a1c49fa..b799dbbb78 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 8e0bfd686a..3646b67bb4 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." %}

+ {% if profiles_data %} + {{ profiles_data|json_script:"profiles_json" }} + {% endif %}
{% csrf_token %}
@@ -39,8 +44,25 @@ -
+ {% if addresses_data %} + {{ addresses_data|json_script:"addresses_json" }} + {% endif %} +
+ {% if addresses_data %} +
+ +
+

+ +

+

+

+

+
+
+ {% endif %} {% if event.settings.invoice_address_explanation_text %}
{{ event.settings.invoice_address_explanation_text|rich_text }} @@ -127,7 +149,7 @@ {% for form in forms %} {% if form.pos.item != pos.item %} {# Add-Ons #} - + {% if form.show_copy_answers_to_addon_button and event.settings.checkout_show_copy_answers_button %} @@ -136,7 +158,24 @@ + {{ form.pos.item.name }}{% if form.pos.variation %} – {{ form.pos.variation.value }}{% endif %} {% endif %} -
+
+ {% if profiles_data %} +
+ +
+

+ +

+

+

+ +

+
+
+ {% endif %} + {% bootstrap_form form layout="checkout" %}
{% endfor %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_address_delete.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_address_delete.html new file mode 100644 index 0000000000..9da35f9dba --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_address_delete.html @@ -0,0 +1,33 @@ +{% extends "pretixpresale/organizers/base.html" %} +{% load i18n %} +{% load eventurl %} +{% block title %}{% trans "Delete address" %}{% endblock %} +{% block content %} +

+ {% trans "Delete address" %} +

+ + {% csrf_token %} +

+ {% trans "Do you really want to delete the following address from your account?" %} +

+
+ {{ address.describe|linebreaksbr }} +
+
+ +
+ +
+
+
+ + +{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_profile.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_profile.html index 452cd1b5ce..76e51978a5 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/customer_profile.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_profile.html @@ -37,125 +37,203 @@
-
-
-

- {% trans "Memberships" %} -

-
- - - - - - - - - - - - - {% for m in memberships %} - - - - - - + + + {% empty %} + + + + {% endfor %} + +
{% trans "Membership type" %}{% trans "Valid from" %}{% trans "Valid until" %}{% trans "Attendee name" %}{% trans "Usages" %}
- {% if m.canceled %}{% endif %} - {{ m.membership_type.name }} - {% if m.canceled %}{% endif %} - {% if m.testmode %}{% trans "TEST MODE" %}{% endif %} - - {{ m.date_start|date:"SHORT_DATETIME_FORMAT" }} - - {{ m.date_end|date:"SHORT_DATETIME_FORMAT" }} - - {{ m.attendee_name }} - -
-
-
+
+ +
+
+ + + + + + + + + + + + + + {% for o in orders %} + + + + + + + + + + {% endfor %} + +
{% trans "Order code" %}{% trans "Event" %}{% trans "Order date" %}{% trans "Order total" %}{% trans "Positions" %}{% trans "Status" %}
+ + + {{ o.code }} + + + {% if o.testmode %} + {% trans "TEST MODE" %} + {% endif %} + + {{ o.event }} + + {{ o.datetime|date:"SHORT_DATETIME_FORMAT" }} + {% if o.customer_id != customer.pk %} + + {% endif %} + + {{ o.total|money:o.event.currency }} + {{ o.count_positions|default_if_none:"0" }}{% include "pretixpresale/event/fragment_order_status.html" with order=o event=o.event %} + + {% trans "Details" %} + +
+ {% include "pretixcontrol/pagination.html" %} +
+
+ + + + + + + + + + + + + {% for m in memberships %} + + + + + + - - - {% endfor %} - -
{% trans "Membership type" %}{% trans "Valid from" %}{% trans "Valid until" %}{% trans "Attendee name" %}{% trans "Usages" %}
+ {% if m.canceled %}{% endif %} + {{ m.membership_type.name }} + {% if m.canceled %}{% endif %} + {% if m.testmode %}{% trans "TEST MODE" %}{% endif %} + + {{ m.date_start|date:"SHORT_DATETIME_FORMAT" }} + + {{ m.date_end|date:"SHORT_DATETIME_FORMAT" }} + + {{ m.attendee_name }} + +
+
+
+
+
+
+ {{ m.usages }} / + {{ m.membership_type.max_usages|default_if_none:"∞" }} +
- -
- {{ m.usages }} / - {{ m.membership_type.max_usages|default_if_none:"∞" }} -
- -
- - - -
-
-
-
-

- {% trans "Orders" %} -

+
+ + + +
{% trans "No memberships are stored in your account." %}
+
+
+ + + + + + + + + {% for ia in invoice_addresses %} + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Address" %}
+ {{ ia.describe|linebreaksbr }} + + + + +
+ {% trans "No addresses are stored in your account." %} +
+
+
+ + + + + + + + + {% for ap in customer.attendee_profiles.all %} + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Profile" %}
+ {{ ap.describe|linebreaksbr }} + + + + +
+ {% trans "No attendee profiles are stored in your account." %} +
+
- - - - - - - - - - - - - - {% for o in orders %} - - - - - - - - - - {% endfor %} - -
{% trans "Order code" %}{% trans "Event" %}{% trans "Order date" %}{% trans "Order total" %}{% trans "Positions" %}{% trans "Status" %}
- - - {{ o.code }} - - - {% if o.testmode %} - {% trans "TEST MODE" %} - {% endif %} - - {{ o.event }} - - {{ o.datetime|date:"SHORT_DATETIME_FORMAT" }} - {% if o.customer_id != customer.pk %} - - {% endif %} - - {{ o.total|money:o.event.currency }} - {{ o.count_positions|default_if_none:"0" }}{% include "pretixpresale/event/fragment_order_status.html" with order=o event=o.event %} - - {% trans "Details" %} - -
- {% include "pretixcontrol/pagination.html" %}
{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_profile_delete.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_profile_delete.html new file mode 100644 index 0000000000..d23d662ae3 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_profile_delete.html @@ -0,0 +1,33 @@ +{% extends "pretixpresale/organizers/base.html" %} +{% load i18n %} +{% load eventurl %} +{% block title %}{% trans "Delete profile" %}{% endblock %} +{% block content %} +

+ {% trans "Delete profile" %} +

+
+ {% csrf_token %} +

+ {% trans "Do you really want to delete the following profile from your account?" %} +

+
+ {{ profile.describe|linebreaksbr }} +
+
+ +
+ +
+
+
+ +
+{% endblock %} diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index 50ef7ab997..3b93a3806d 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -179,6 +179,8 @@ organizer_patterns = [ re_path(r'^account/change$', pretix.presale.views.customer.ChangeInformationView.as_view(), name='organizer.customer.change'), re_path(r'^account/confirmchange$', pretix.presale.views.customer.ConfirmChangeView.as_view(), name='organizer.customer.change.confirm'), re_path(r'^account/membership/(?P\d+)/$', pretix.presale.views.customer.MembershipUsageView.as_view(), name='organizer.customer.membership'), + re_path(r'^account/addresses/(?P\d+)/delete$', pretix.presale.views.customer.AddressDeleteView.as_view(), name='organizer.customer.address.delete'), + re_path(r'^account/profiles/(?P\d+)/delete$', pretix.presale.views.customer.ProfileDeleteView.as_view(), name='organizer.customer.profile.delete'), re_path(r'^account/$', pretix.presale.views.customer.ProfileView.as_view(), name='organizer.customer.profile'), ] diff --git a/src/pretix/presale/views/customer.py b/src/pretix/presale/views/customer.py index 1a1eca2664..05c711a24c 100644 --- a/src/pretix/presale/views/customer.py +++ b/src/pretix/presale/views/customer.py @@ -34,9 +34,9 @@ from django.utils.translation import gettext_lazy as _ from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_protect from django.views.decorators.debug import sensitive_post_parameters -from django.views.generic import FormView, ListView, View +from django.views.generic import DeleteView, FormView, ListView, View -from pretix.base.models import Customer, Order, OrderPosition +from pretix.base.models import Customer, InvoiceAddress, Order, OrderPosition from pretix.base.services.mail import mail from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse from pretix.presale.forms.customer import ( @@ -299,6 +299,7 @@ class ProfileView(CustomerRequiredMixin, ListView): ctx['memberships'] = self.request.customer.memberships.with_usages().select_related( 'membership_type', 'granted_in', 'granted_in__order', 'granted_in__order__event' ) + ctx['invoice_addresses'] = InvoiceAddress.profiles.filter(customer=self.request.customer) ctx['is_paginated'] = True for m in ctx['memberships']: @@ -353,6 +354,28 @@ class MembershipUsageView(CustomerRequiredMixin, ListView): return ctx +class AddressDeleteView(CustomerRequiredMixin, DeleteView): + template_name = 'pretixpresale/organizers/customer_address_delete.html' + context_object_name = 'address' + + def get_object(self, **kwargs): + return get_object_or_404(InvoiceAddress.profiles, customer=self.request.customer, pk=self.kwargs.get('id')) + + def get_success_url(self): + return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={}) + + +class ProfileDeleteView(CustomerRequiredMixin, DeleteView): + template_name = 'pretixpresale/organizers/customer_profile_delete.html' + context_object_name = 'profile' + + def get_object(self, **kwargs): + return get_object_or_404(self.request.customer.attendee_profiles, pk=self.kwargs.get('id')) + + def get_success_url(self): + return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={}) + + class ChangePasswordView(CustomerRequiredMixin, FormView): template_name = 'pretixpresale/organizers/customer_password.html' form_class = ChangePasswordForm diff --git a/src/pretix/presale/views/questions.py b/src/pretix/presale/views/questions.py index 0bb2d5cc7c..2ad8cb270f 100644 --- a/src/pretix/presale/views/questions.py +++ b/src/pretix/presale/views/questions.py @@ -47,3 +47,14 @@ class QuestionsViewMixin(BaseQuestionsViewMixin): def _positions_for_questions(self): cart = get_cart(self.request) return sorted(list(cart), key=self._keyfunc) + + def question_form_kwargs(self, cr): + d = { + 'allow_save': bool(self.cart_customer), + 'initial': {}, + } + + if f'saved_attendee_profile_{cr.pk}' in self.cart_session: + d['initial']['saved_id'] = self.cart_session[f'saved_attendee_profile_{cr.pk}'] + + return d diff --git a/src/pretix/static/pretixpresale/js/ui/main.js b/src/pretix/static/pretixpresale/js/ui/main.js index 97e3d526c1..e6ee82b2b2 100644 --- a/src/pretix/static/pretixpresale/js/ui/main.js +++ b/src/pretix/static/pretixpresale/js/ui/main.js @@ -275,6 +275,7 @@ $(function () { attendee_address_fields.change(function () { copy_to_first_ticket = false; }); + questions_init_profiles($("body")); // Subevent choice if ($(".subevent-toggle").length) { @@ -407,10 +408,15 @@ $(function () { if (counter > curCounter) { return; // Lost race } + var selected_value = dependent.prop("data-selected-value"); dependent.find("option").filter(function (t) {return !!$(this).attr("value")}).remove(); if (data.data.length > 0) { $.each(data.data, function (k, s) { - dependent.append($("