Allow to save invoice addresses and attendee profiles to customer account (#2084)

Co-authored-by: Raphael Michel <michel@rami.io>
Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
Raphael Michel
2021-09-06 20:50:25 +02:00
committed by GitHub
parent 89554a82eb
commit 28d78e40f9
21 changed files with 1208 additions and 148 deletions

View File

@@ -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')),
],
),
]

View File

@@ -19,19 +19,21 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
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()])

View File

@@ -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)

View File

@@ -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()

View File

@@ -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))

View File

@@ -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)

View File

@@ -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):

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -2,6 +2,8 @@
{% load i18n %}
{% load bootstrap3 %}
{% load rich_text %}
{% load lists %}
{% load escapejson %}
{% block inner %}
<p>{% trans "Before we continue, we need you to answer some questions." %}</p>
<p class="required-legend" aria-hidden="true">
@@ -9,6 +11,9 @@
You need to fill all fields that are marked with <span>*</span> to continue.
{% endblocktrans %}
</p>
{% if profiles_data %}
{{ profiles_data|json_script:"profiles_json" }}
{% endif %}
<form class="form-horizontal" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="panel-group" id="questions_group">
@@ -39,8 +44,25 @@
<i class="fa fa-angle-down collapse-indicator" aria-hidden="true"></i>
</h4>
</summary>
<div id="invoice">
{% if addresses_data %}
{{ addresses_data|json_script:"addresses_json" }}
{% endif %}
<div id="invoice" class="profile-scope" data-profiles-id="addresses_json">
<div class="panel-body">
{% if addresses_data %}
<div class="form-group profile-select-container">
<label class="col-md-3 control-label" for="address-list-select">{% trans "Auto-fill with address" %}</label>
<div class="col-md-9">
<p class="profile-select-control">
<select class="profile-select form-control" id="address-list-select"></select>
</p>
<p class="help-block profile-desc" id="selected-address-desc"></p>
<p><button type="button" class="profile-apply btn btn-default" aria-describedby="selected-address-desc"
><i class="fa fa-address-card-o fa-lg" aria-hidden="true"></i>&nbsp; {% trans "Fill form" %}</button>
</p>
</div>
</div>
{% endif %}
{% if event.settings.invoice_address_explanation_text %}
<div>
{{ event.settings.invoice_address_explanation_text|rich_text }}
@@ -127,7 +149,7 @@
{% for form in forms %}
{% if form.pos.item != pos.item %}
{# Add-Ons #}
<legend>
<legend{% if profiles_data %} class="profile-add-on-legend"{% endif %}>
{% if form.show_copy_answers_to_addon_button and event.settings.checkout_show_copy_answers_button %}
<span class="pull-right flip">
<button type="button" data-id="{{ forloop.parentloop.counter0 }}" data-addonid="{{ forloop.counter0 }}" name="copy" class="js-copy-answers-addon btn btn-default btn-xs">{% trans "Copy answers" %}</button>
@@ -136,7 +158,24 @@
+ {{ form.pos.item.name }}{% if form.pos.variation %} {{ form.pos.variation.value }}{% endif %}
</legend>
{% endif %}
<div data-idx="{{ forloop.parentloop.counter0 }}" data-addonidx="{{ forloop.counter0 }}">
<div data-idx="{{ forloop.parentloop.counter0 }}" data-addonidx="{{ forloop.counter0 }}" class="profile-scope{% if form.pos.item != pos.item %}{% if profiles_data %} profile-add-on{% endif %}{% endif %}">
{% if profiles_data %}
<div class="form-group profile-select-container">
<label class="col-md-3 control-label" for="profile-select-{{ forloop.parentloop.counter0 }}-{{ forloop.counter0 }}">{% trans "Auto-fill with profile" %}</label>
<div class="col-md-9">
<p class="profile-select-control">
<select class="profile-select form-control" id="profile-select-{{ forloop.parentloop.counter0 }}-{{ forloop.counter0 }}"></select>
</p>
<p class="help-block profile-desc" id="selected-profile-desc-{{ forloop.parentloop.counter0 }}-{{ forloop.counter0 }}"></p>
<p>
<button type="button" class="profile-apply btn btn-default"
aria-describedby="selected-profile-desc-{{ forloop.parentloop.counter0 }}-{{ forloop.counter0 }}"
><i class="fa fa-address-card-o fa-lg" aria-hidden="true"></i>&nbsp; {% trans "Fill form" %}</button>
</p>
</div>
</div>
{% endif %}
{% bootstrap_form form layout="checkout" %}
</div>
{% endfor %}

View File

@@ -0,0 +1,33 @@
{% extends "pretixpresale/organizers/base.html" %}
{% load i18n %}
{% load eventurl %}
{% block title %}{% trans "Delete address" %}{% endblock %}
{% block content %}
<h2>
{% trans "Delete address" %}
</h2>
<form method="post">
{% csrf_token %}
<p>
{% trans "Do you really want to delete the following address from your account?" %}
</p>
<address>
{{ address.describe|linebreaksbr }}
</address>
<div class="row">
<div class="col-md-4 col-sm-6">
<a class="btn btn-block btn-default btn-lg"
href="{% abseventurl request.organizer "presale:organizer.customer.profile" %}">
{% trans "Go back" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4 col-sm-6">
<button class="btn btn-block btn-danger btn-lg" type="submit">
{% trans "Delete" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -37,125 +37,203 @@
</div>
</div>
</div>
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Memberships" %}
</h3>
</div>
<table class="panel-body table table-hover">
<thead>
<tr>
<th>{% trans "Membership type" %}</th>
<th>{% trans "Valid from" %}</th>
<th>{% trans "Valid until" %}</th>
<th>{% trans "Attendee name" %}</th>
<th>{% trans "Usages" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for m in memberships %}
<tr>
<td>
{% if m.canceled %}<del>{% endif %}
{{ m.membership_type.name }}
{% if m.canceled %}</del>{% endif %}
{% if m.testmode %}<span class="label label-warning">{% trans "TEST MODE" %}</span>{% endif %}
</td>
<td>
{{ m.date_start|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td>
{{ m.date_end|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td>
{{ m.attendee_name }}
</td>
<td>
<div class="quotabox">
<div class="progress">
<div class="progress-bar progress-bar-success progress-bar-{{ m.percent }}">
<div>
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active">
<a href="#orders" aria-controls="orders" role="tab" data-toggle="tab">{% trans "Orders" %}</a>
</li>
<li role="presentation">
<a href="#memberships" aria-controls="memberships" role="tab" data-toggle="tab">{% trans "Memberships" %}</a>
</li>
<li role="presentation">
<a href="#addresses" aria-controls="addresses" role="tab" data-toggle="tab">{% trans "Addresses" %}</a>
</li>
<li role="presentation">
<a href="#profiles" aria-controls="profiles" role="tab" data-toggle="tab">{% trans "Attendee profiles" %}</a>
</li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="orders">
<table class="panel-body table table-hover">
<thead>
<tr>
<th>{% trans "Order code" %}</th>
<th>{% trans "Event" %}</th>
<th>{% trans "Order date" %}</th>
<th class="text-right">{% trans "Order total" %}</th>
<th class="text-right">{% trans "Positions" %}</th>
<th class="text-right">{% trans "Status" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for o in orders %}
<tr>
<td>
<strong>
<a href="{% abseventurl o.event "presale:event.order" order=o.code secret=o.secret %}" target="_blank">
{{ o.code }}
</a>
</strong>
{% if o.testmode %}
<span class="label label-warning">{% trans "TEST MODE" %}</span>
{% endif %}
</td>
<td>
{{ o.event }}
</td>
<td>
{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}
{% if o.customer_id != customer.pk %}
<span class="fa fa-link text-muted"
data-toggle="tooltip"
title="{% trans "Matched to the account based on the email address." %}"
></span>
{% endif %}
</td>
<td class="text-right flip">
{{ o.total|money:o.event.currency }}
</td>
<td class="text-right flip">{{ o.count_positions|default_if_none:"0" }}</td>
<td class="text-right flip">{% include "pretixpresale/event/fragment_order_status.html" with order=o event=o.event %}</td>
<td class="text-right flip">
<a href="{% abseventurl o.event "presale:event.order" order=o.code secret=o.secret %}"
target="_blank"
class="btn btn-default">
{% trans "Details" %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include "pretixcontrol/pagination.html" %}
</div>
<div role="tabpanel" class="tab-pane" id="memberships">
<table class="panel-body table table-hover">
<thead>
<tr>
<th>{% trans "Membership type" %}</th>
<th>{% trans "Valid from" %}</th>
<th>{% trans "Valid until" %}</th>
<th>{% trans "Attendee name" %}</th>
<th>{% trans "Usages" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for m in memberships %}
<tr>
<td>
{% if m.canceled %}<del>{% endif %}
{{ m.membership_type.name }}
{% if m.canceled %}</del>{% endif %}
{% if m.testmode %}<span class="label label-warning">{% trans "TEST MODE" %}</span>{% endif %}
</td>
<td>
{{ m.date_start|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td>
{{ m.date_end|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td>
{{ m.attendee_name }}
</td>
<td>
<div class="quotabox">
<div class="progress">
<div class="progress-bar progress-bar-success progress-bar-{{ m.percent }}">
</div>
</div>
<div class="numbers">
{{ m.usages }} /
{{ m.membership_type.max_usages|default_if_none:"∞" }}
</div>
</div>
</div>
<div class="numbers">
{{ m.usages }} /
{{ m.membership_type.max_usages|default_if_none:"∞" }}
</div>
</div>
</td>
<td class="text-right flip">
<a href="{% abseventurl request.organizer "presale:organizer.customer.membership" id=m.id %}"
data-toggle="tooltip"
title="{% trans "Details" %}"
class="btn btn-default">
<i class="fa fa-list"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Orders" %}
</h3>
</td>
<td class="text-right flip">
<a href="{% abseventurl request.organizer "presale:organizer.customer.membership" id=m.id %}"
data-toggle="tooltip"
title="{% trans "Details" %}"
class="btn btn-default">
<i class="fa fa-list"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center">{% trans "No memberships are stored in your account." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div role="tabpanel" class="tab-pane" id="addresses">
<table class="panel-body table table-hover">
<thead>
<tr>
<th>{% trans "Address" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for ia in invoice_addresses %}
<tr>
<td>
{{ ia.describe|linebreaksbr }}
</td>
<td class="text-right flip">
<a href="{% abseventurl request.organizer "presale:organizer.customer.address.delete" id=ia.id %}"
data-toggle="tooltip"
title="{% trans "Delete" %}"
class="btn btn-danger">
<i class="fa fa-trash"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-center">
{% trans "No addresses are stored in your account." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div role="tabpanel" class="tab-pane" id="profiles">
<table class="panel-body table table-hover">
<thead>
<tr>
<th>{% trans "Profile" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for ap in customer.attendee_profiles.all %}
<tr>
<td>
{{ ap.describe|linebreaksbr }}
</td>
<td class="text-right flip">
<a href="{% abseventurl request.organizer "presale:organizer.customer.profile.delete" id=ap.id %}"
data-toggle="tooltip"
title="{% trans "Delete" %}"
class="btn btn-danger">
<i class="fa fa-trash"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-center">
{% trans "No attendee profiles are stored in your account." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<table class="panel-body table table-hover">
<thead>
<tr>
<th>{% trans "Order code" %}</th>
<th>{% trans "Event" %}</th>
<th>{% trans "Order date" %}</th>
<th class="text-right">{% trans "Order total" %}</th>
<th class="text-right">{% trans "Positions" %}</th>
<th class="text-right">{% trans "Status" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for o in orders %}
<tr>
<td>
<strong>
<a href="{% abseventurl o.event "presale:event.order" order=o.code secret=o.secret %}" target="_blank">
{{ o.code }}
</a>
</strong>
{% if o.testmode %}
<span class="label label-warning">{% trans "TEST MODE" %}</span>
{% endif %}
</td>
<td>
{{ o.event }}
</td>
<td>
{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}
{% if o.customer_id != customer.pk %}
<span class="fa fa-link text-muted"
data-toggle="tooltip"
title="{% trans "Matched to the account based on the email address." %}"
></span>
{% endif %}
</td>
<td class="text-right flip">
{{ o.total|money:o.event.currency }}
</td>
<td class="text-right flip">{{ o.count_positions|default_if_none:"0" }}</td>
<td class="text-right flip">{% include "pretixpresale/event/fragment_order_status.html" with order=o event=o.event %}</td>
<td class="text-right flip">
<a href="{% abseventurl o.event "presale:event.order" order=o.code secret=o.secret %}"
target="_blank"
class="btn btn-default">
{% trans "Details" %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include "pretixcontrol/pagination.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "pretixpresale/organizers/base.html" %}
{% load i18n %}
{% load eventurl %}
{% block title %}{% trans "Delete profile" %}{% endblock %}
{% block content %}
<h2>
{% trans "Delete profile" %}
</h2>
<form method="post">
{% csrf_token %}
<p>
{% trans "Do you really want to delete the following profile from your account?" %}
</p>
<address>
{{ profile.describe|linebreaksbr }}
</address>
<div class="row">
<div class="col-md-4 col-sm-6">
<a class="btn btn-block btn-default btn-lg"
href="{% abseventurl request.organizer "presale:organizer.customer.profile" %}">
{% trans "Go back" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4 col-sm-6">
<button class="btn btn-block btn-danger btn-lg" type="submit">
{% trans "Delete" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -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<id>\d+)/$', pretix.presale.views.customer.MembershipUsageView.as_view(), name='organizer.customer.membership'),
re_path(r'^account/addresses/(?P<id>\d+)/delete$', pretix.presale.views.customer.AddressDeleteView.as_view(), name='organizer.customer.address.delete'),
re_path(r'^account/profiles/(?P<id>\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'),
]

View File

@@ -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

View File

@@ -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

View File

@@ -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($("<option>").attr("value", s.code).text(s.name));
var o = $("<option>").attr("value", s.code).text(s.name);
if (s.code == selected_value || (selected_value && selected_value.indexOf && selected_value.indexOf(s.code) > -1)) {
o.prop("selected", true);
}
dependent.append(o);
});
dependent.closest(".form-group").show();
dependent.prop('required', dependency.prop("required"));

View File

@@ -113,3 +113,373 @@ function questions_init_photos(el) {
})
});
}
function questions_init_profiles(el) {
/*
Auto-fill answers with profiles and addresses from customer account.
There are two types of profiles:
1. profiles for answers and
2. profiles for invoice addesses
Both are handled the same way.
Each form section/fieldset has its own auto-fill and save to profile
inputs. Each fieldset can define its own profiles by providing the
HTML-attribute data-profiles-id, which defaults to "profiles_json".
Currently only the invoice address fieldset uses this to load a
different set of profiles.
For each section each profiles answers are matched to inputs inside
this section. Only matching ones are shown for auto-fill. If multiple
profiles only match the same inputs with the same values (e.g. name)
then only the first one is shown as showing multiple profile with the
same values is not helpful.
Feature-Idea:
in the original profile description, strikethrough which answer
will be overwritten, followed by the new answer
add new answers with a + in front
change <select> to a list of radio-buttons for multiline-display
of profiles?
*/
var profilesById = {};
function getProfilesById(id) {
if (!(id in profilesById)) {
var element = document.getElementById(id);
profilesById[id] = (!element || !element.textContent) ? [] : JSON.parse(element.textContent);
}
return profilesById[id];
}
function matchProfilesToInputs(profiles, scope) {
var filtered = [];
var data;
var matched_field;
var addSpecialKey;
// special fields are used for substition with human readable or pre-formatted values
var addSpecialFieldMap = {
"country": "_country_for_address",
"state": "_state_for_address",
"name_parts_0": "_name",
"attendee_name_parts_0": "_attendee_name",
}
for (var p of profiles) {
data = {};
for (var key of Object.keys(p)) {
if (key.startsWith("_")) {
continue;
}
matched_field = getMatchingInput(key, p[key], scope);
if (matched_field) {
// TODO: only add if no other answer matches same fields?
data[key] = {
"value": (typeof p[key] == "string") ? p[key] : p[key]["value"],
"field": matched_field
};
if (p[key]["label"]) data[key]["label"] = p[key]["label"];
if (p[key]["type"]) data[key]["type"] = p[key]["type"];
if (addSpecialKey = addSpecialFieldMap[key]) {
data[addSpecialKey] = p[addSpecialKey];
}
}
}
if (Object.keys(data).length) filtered.push(data);
};
return filtered;
}
// For auto-fill with few inputs it could happen that multiple profiles
// only match with the same fields that have the same values. It makes
// no sense to show multiple profiles if all fill the same value(s).
// Therefore filter profiles to unique ones.
function uniqueProfiles(profiles) {
var uniques = [];
var matchIndex;
for (var p of profiles) {
matchIndex = uniques.findIndex(function(element, index, array) {
return _profilesAreEqual(element, p);
});
if (matchIndex === -1) uniques.push(p);
}
return uniques;
}
function _profilesAreEqual(a, b) {
var keysA = Object.keys(a);
var keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
keysA.sort();
keysB.sort();
if (!keysA.every((val, index) => val === keysB[index])) return false;
if (!keysA.every((key, index) => a[key].value === b[key].value)) return false;
return true;
}
function _getInputForLabel(label) {
if (!label) return null;
var input;
if (label.getAttribute("for")) {
input = document.getElementById(label.getAttribute("for"));
if (input) return input;
}
// for grouped inputs like phone number the "label" is more a fieldset/legend
return label.closest(".form-group").querySelectorAll("select, input, textarea");
}
function getMatchingInput(key, answer, scope) {
var $label;
// _0 and _1 are e.g. for phone-fields. name-fields have their parts/keys already split
var $fields = $('[name$="' + key + '"], [name$="' + key + '_0"], [name$="' + key + '_1"]', scope);
if ($fields.length) return $fields;
if (answer.identifier) {
$label = $('[data-identifier="' + answer.identifier + '"]', scope);
var input = _getInputForLabel($label.get(0));
if (input) return $(input);
}
for (var label of scope.getElementsByTagName("label")) {
if (label.textContent === answer.label) {
var input = _getInputForLabel(label);
if (input) return $(input);
break;
}
}
return null;
}
function formatAnswerHumanReadable(answer) {
if (typeof answer == "string") return answer;
if (typeof answer == "number") return answer.toString();
if (!answer && answer !== false) return "";
var value = answer.value;
if ("type" in answer) {
if (answer.type === "TEL") {
// TODO: format phone number with locale or use pre-formatted like with names?
return value;
}
if (answer.type === "W") {
return moment(value).format(document.body.getAttribute("data-datetimeformat"));
}
if (answer.type === "D") {
return moment(value).format(document.body.getAttribute("data-dateformat"));
}
if (answer.type === "H") {
var format = document.body.getAttribute("data-timeformat");
return moment(value, "HH:mm:ss").format(format);
}
if (answer.type === "B") {
return value ? gettext("Yes") : gettext("No");
}
}
if (typeof value == "string") return value;
if (!value) return "";
return Object.values(value).join(", ");
}
// TODO: add as few info as possible to make a distinction between available profiles?
function labelForProfile(p, profiles, scope = null) {
var parts = describeProfile(p);
var label = parts.join(", ");
if (label.length > 74) {
var len = label.lastIndexOf(' ', 74);
label = label.substr(0, Math.max(len, 48)) + " …";
}
return label;
}
function getAnswer(a) {
if (typeof a == "string") return a;
return a && "value" in a ? a["value"] : "";
}
function describeProfile(p) {
if (!p) return [];
var lines = [
getAnswer(p["company"]),
p["_name"],
[p["_attendee_name"], getAnswer(p["attendee_email"])].filter(v => v).join(", "),
[
getAnswer(p["street"]),
[getAnswer(p["zipcode"]), getAnswer(p["city"]), p["_state_for_address"]].filter(v => v).join(" "),
p["_country_for_address"]
].filter(v => v).join(", ")
];
lines = lines.filter(line => line && line.trim());
var answer;
var label;
for (var key of Object.keys(p)) {
if (!key.startsWith("question_")) continue;
answer = p[key];
label = answer["label"] || "";
lines.push(label + ("!?.:".split("").indexOf(label.slice(-1)) > -1 ? " " : ": ") + formatAnswerHumanReadable(answer))
}
return lines;
}
function escapeHTML(t) {
return $("<div>").text(t).get(0).innerHTML;
}
function describeProfileHTML(p) {
return describeProfile(p).map(escapeHTML).join("<br>");
}
function _updateDescription(select, profile, $help) {
// show additional description if different from option-text
var label = select.options[select.selectedIndex].textContent;
var lines = describeProfile(profile).map(escapeHTML);
if (!lines.length || label === lines.join(", ")) {
$help.slideUp(function() {
$help.html("");
});
}
else {
$help.html(lines.join("<br>")).slideDown();
}
}
function setupSaveToProfile(scope, profiles) {
var $select = $('[name$="saved_id"]', scope);
var $selectContainer = $select.closest(".form-group").addClass("profile-save-id");
var $checkbox = $('[name$="save"]', scope);
var $checkboxContainer = $checkbox.closest(".form-group").addClass("profile-save");
var $help = $selectContainer.find(".help-block");
var $container = $("<div class='profile-save-container'></div>");
$selectContainer.after($container);
$container.append($checkboxContainer);
$container.append($selectContainer);
if (!profiles || !profiles.length) {
$selectContainer.hide();
return;
}
$checkbox.change(function() {
if (this.checked) $selectContainer.slideDown();
else $selectContainer.slideUp();
});
for (var p of profiles) {
$select.append($('<option>').attr('value', p._pk).text(labelForProfile(p, profiles)));
}
$select.append('<option value="" disabled></option>');
$select.append($select.find("option").first());
$select.get(0).selectedIndex = 0;
$select.change(function() {
_updateDescription(this, profiles[this.selectedIndex], $help);
}).trigger("change");
$checkbox.trigger("change");
}
// setup auto-fill for each scope/fieldset
// match profiles answers to inputs in scope
// if none match, do not show auto-fill
// if one matches, only show button to auto-fill
// else show select with profiles and button to auto-fill
function setupAutoFill(scope, profiles) {
var matchedProfiles = uniqueProfiles(matchProfilesToInputs(profiles, scope));
if (!matchedProfiles.length) {
$(scope).addClass("profile-none-matched");
return;
}
var selectedProfile = matchedProfiles[0];
var $select = $(".profile-select", scope);
var $button = $(".profile-apply", scope);
var $help = $(".profile-desc", scope);
if (matchedProfiles.length === 1) {
$(".profile-select-control", scope).hide().parent().addClass("form-control-text");
$help.html(describeProfileHTML(selectedProfile)).addClass("single-profile-desc").after($button);
}
else {
var i = 0;
for (p of matchedProfiles) {
$select.append($("<option>").text(labelForProfile(p, matchedProfiles, scope)).attr("value", i));
i++;
}
$select.change(function() {
selectedProfile = matchedProfiles[this.value];
_updateDescription(this, selectedProfile, $help);
}).trigger("change");
}
// Add-Ons sit on same level as their parent product scope
// Therefore use .prevUntil("legend") as an Add-On is
// offset by a <legend>
$(scope).prevUntil("legend").addClass("profile-pre-select");
$button.click(function() {
Object.keys(selectedProfile).forEach(function(key) {
var answer = selectedProfile[key].value;
var $field = selectedProfile[key].field;
if (!$field || !$field.length) return;
if ($field.attr("type") === "checkbox") {
if (answer === true || answer === false) {
// boolean
$field.prop("checked", answer).trigger("change");
}
else if (typeof answer !== 'string') {
answer = Object.keys(answer);
$field.each(function() {
var checked = answer.indexOf(this.value) > -1;
if (checked !== this.checked) {
this.checked = checked;
$(this).trigger("change");
}
});
}
} else if ($field.attr("type") === "radio") {
$field.filter('[value="' + answer + '"]').prop("checked", true).trigger("change");
} else if ($field.length > 1) {
// multiple matching fields, could be phone number or datetime
var $field_0 = $field.filter('[name$="_0"]');
var $field_1 = $field.filter('[name$="_1"]');
if (answer.substr(0, 1) === "+") {
var prefix = "";
var options = $field_0.get(0).options;
for (var i = 0; i < options.length; i++) {
var v = options[i].value;
if (v && answer.substr(0, v.length) === v) {
prefix = v;
break;
}
}
var number = answer.substr(prefix.length);
$field_0.val(prefix).trigger("change");
$field_1.val(number).trigger("change");
}
else if ($field_0.hasClass("datepickerfield")) {
$field_0.data('DateTimePicker').date(moment(answer));
$field_1.data('DateTimePicker').date(moment(answer));
}
} else if ($field.is("select")) {
if (answer && typeof answer !== 'string') {
answer = Object.keys(answer);
}
// save answer as data-attribute so if external event changes select-element/options it can select correct entries
// currently used when country => state changes
$field.prop("data-selected-value", answer);
$field.find("option").each(function() {
this.selected = this.value === answer || (answer && answer.indexOf && answer.indexOf(this.value) > -1);
});
$field.trigger("change");
} else {
if ($field.hasClass("datepickerfield")) {
$field.data('DateTimePicker').date(moment(answer));
}
else {
$field.val(answer).trigger("change");
}
}
});
})
}
// each fieldset is its own scope for auto-fill and save
el.find(".profile-scope").each(function () {
var profiles = getProfilesById(this.getAttribute("data-profiles-id") || "profiles_json");
setupSaveToProfile(this, profiles);
setupAutoFill(this, profiles);
this.classList.add("profile-select-initialized");
});
}

View File

@@ -103,3 +103,56 @@
font-weight: bold;
}
}
.profile-pre-select {
background: $panel-footer-bg;
margin-top: -15px;
padding-top: 15px;
}
.profile-pre-select .addon-list {
margin-bottom: 0;
}
.profile-select-container {
display: none;
border-bottom: 1px solid $hr-border;
background: $panel-footer-bg;
padding-bottom: 6px;
margin-top: -15px;
padding-top: 15px;
}
.profile-save-container {
border-top: 1px solid $hr-border;
padding: 15px;
margin: 15px -15px;
background: $panel-footer-bg;
}
.profile-scope:last-child .profile-save-container {
margin-bottom: -15px;
}
.profile-save-container .help-block {
margin-bottom: 0;
}
.single-profile-desc {
margin-top: 0;
}
.profile-save, .profile-save-id {
display: none;
margin-bottom: 0;
padding-bottom: .5em;
}
.profile-select-initialized .profile-select-container,
.profile-select-initialized .profile-save {
display: block;
}
.profile-none-matched .profile-select-container {
display: none;
}
.profile-add-on-legend {
margin-bottom: 0;
border-bottom: none;
}
.profile-add-on {
padding: 15px;
border: 1px solid $hr-border;
}

View File

@@ -322,6 +322,17 @@ h2 .label {
}
}
.nav-tabs {
border-bottom: 0px solid #ddd;
}
.tab-content {
border: 1px solid #ddd;
border-radius: 0px 5px 5px 5px;
& .tab-pane > table {
margin: 0;
}
}
@for $i from 0 through 100 {
.progress-bar-#{$i} { width: 1% * $i; }
}