mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
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:
38
src/pretix/base/migrations/0196_auto_20210523_1322.py
Normal file
38
src/pretix/base/migrations/0196_auto_20210523_1322.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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()])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
13
src/pretix/base/templatetags/lists.py
Normal file
13
src/pretix/base/templatetags/lists.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {% 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> {% trans "Fill form" %}</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% bootstrap_form form layout="checkout" %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 profile’s 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 profile’s 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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user