Add company and address fields to attendees (#1633)

* Add company and address fields to attendees

* Update src/pretix/control/templates/pretixcontrol/event/settings.html

Co-Authored-By: Martin Gross <gross@rami.io>

Co-authored-by: Martin Gross <gross@rami.io>
This commit is contained in:
Raphael Michel
2020-04-02 14:41:09 +02:00
committed by GitHub
co-authored by Martin Gross
parent b498d45621
commit 2c9b2620ea
24 changed files with 632 additions and 40 deletions
+35 -6
View File
@@ -177,6 +177,13 @@ price money (string) Price of this p
attendee_name string Specified attendee name for this position (or ``null``)
attendee_name_parts object of strings Decomposition of attendee name (i.e. given name, family name)
attendee_email string Specified attendee email address for this position (or ``null``)
company string Attendee company name (or ``null``)
street string Attendee street (or ``null``)
zipcode string Attendee ZIP code (or ``null``)
city string Attendee city (or ``null``)
country string Attendee country code (or ``null``)
state string Attendee state (ISO 3166-2 code). Only supported in
AU, BR, CA, CN, MY, MX, and US, otherwise ``null``.
voucher integer Internal ID of the voucher used for this position (or ``null``)
tax_rate decimal (string) VAT rate applied for this position
tax_value money (string) VAT included in this position
@@ -240,6 +247,10 @@ pdf_data object Data object req
The attribute ``canceled`` has been added.
.. versionchanged:: 3.8
The attributes ``company``, ``street``, ``zipcode``, ``city``, ``country``, and ``state`` have been added.
.. _order-payment-resource:
Order payment resource
@@ -384,6 +395,12 @@ List of all orders
"full_name": "Peter",
},
"attendee_email": null,
"company": "Sample company",
"street": "Test street 12",
"zipcode": "12345",
"city": "Testington",
"country": "DE",
"state": null,
"voucher": null,
"tax_rate": "0.00",
"tax_value": "0.00",
@@ -540,6 +557,12 @@ Fetching individual orders
"full_name": "Peter",
},
"attendee_email": null,
"company": "Sample company",
"street": "Test street 12",
"zipcode": "12345",
"city": "Testington",
"country": "DE",
"state": null,
"voucher": null,
"tax_rate": "0.00",
"tax_rule": null,
@@ -820,9 +843,9 @@ Creating orders
* ``consume_carts`` (optional) A list of cart IDs. All cart positions with these IDs will be deleted if the
order creation is successful. Any quotas or seats that become free by this operation will be credited to your order
creation.
* ``email``
* ``email`` (optional)
* ``locale``
* ``sales_channel``
* ``sales_channel`` (optional)
* ``payment_provider`` (optional) The identifier of the payment provider set for this order. This needs to be an
existing payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"``
for all orders you create as paid. This field is optional when the order status is ``"n"`` or the order total is
@@ -855,15 +878,21 @@ Creating orders
* ``positionid`` (optional, see below)
* ``item``
* ``variation``
* ``variation`` (optional)
* ``price`` (optional, if set to ``null`` or missing the price will be computed from the given product)
* ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.)
* ``attendee_name`` **or** ``attendee_name_parts``
* ``attendee_name`` **or** ``attendee_name_parts`` (optional)
* ``voucher`` (optional, the ``code`` attribute of a valid voucher)
* ``attendee_email``
* ``attendee_email`` (optional)
* ``company`` (optional)
* ``street`` (optional)
* ``zipcode`` (optional)
* ``city`` (optional)
* ``country`` (optional)
* ``state`` (optional)
* ``secret`` (optional)
* ``addon_to`` (optional, see below)
* ``subevent``
* ``subevent`` (optional)
* ``answers``
* ``question``
+4
View File
@@ -558,6 +558,10 @@ class EventSettingsSerializer(serializers.Serializer):
'attendee_names_required',
'attendee_emails_asked',
'attendee_emails_required',
'attendee_addresses_asked',
'attendee_addresses_required',
'attendee_company_asked',
'attendee_company_required',
'confirm_text',
'order_email_asked_twice',
'payment_term_days',
+31 -1
View File
@@ -39,7 +39,7 @@ class CompatibleCountryField(serializers.Field):
def to_representation(self, instance: InvoiceAddress):
if instance.country:
return str(instance.country)
else:
elif hasattr(instance, 'country_old'):
return instance.country_old
@@ -211,10 +211,12 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
pdf_data = PdfDataSerializer(source='*')
seat = InlineSeatSerializer(read_only=True)
country = CompatibleCountryField(source='*')
class Meta:
model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'company', 'street', 'zipcode', 'city', 'country', 'state',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled')
@@ -516,12 +518,22 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
max_digits=10)
voucher = serializers.SlugRelatedField(slug_field='code', queryset=Voucher.objects.none(),
required=False, allow_null=True)
country = CompatibleCountryField(source='*')
class Meta:
model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state',
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for k, v in self.fields.items():
if k in ('company', 'street', 'zipcode', 'city', 'country', 'state'):
v.required = False
v.allow_blank = True
v.allow_null = True
def validate_secret(self, secret):
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
raise ValidationError(
@@ -576,6 +588,24 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
)
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
if data.get('country'):
if not pycountry.countries.get(alpha_2=data.get('country')):
raise ValidationError(
{'country': ['Invalid country code.']}
)
if data.get('state'):
cc = str(data.get('country') or self.instance.country or '')
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(
{'state': ['States are not supported in country "{}".'.format(cc)]}
)
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
raise ValidationError(
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
return data
+12
View File
@@ -305,6 +305,12 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('Attendee name') + ': ' + str(label))
headers += [
_('Attendee email'),
_('Company'),
_('Address'),
_('ZIP code'),
_('City'),
_('Country'),
pgettext('address', 'State'),
_('Voucher'),
_('Pseudonymization ID'),
]
@@ -364,6 +370,12 @@ class OrderListExporter(MultiSheetListExporter):
)
row += [
op.attendee_email,
op.company or '',
op.street or '',
op.zipcode or '',
op.city or '',
op.country if op.country else '',
op.state or '',
op.voucher.code if op.voucher else '',
op.pseudonymization_id,
]
+73 -2
View File
@@ -245,7 +245,7 @@ class BaseQuestionsForm(forms.Form):
if item.admission and event.settings.attendee_names_asked:
self.fields['attendee_name_parts'] = NamePartsFormField(
max_length=255,
required=event.settings.attendee_names_required,
required=event.settings.attendee_names_required and not self.all_optional,
scheme=event.settings.name_scheme,
titles=event.settings.name_scheme_titles,
label=_('Attendee name'),
@@ -253,7 +253,7 @@ class BaseQuestionsForm(forms.Form):
)
if item.admission and event.settings.attendee_emails_asked:
self.fields['attendee_email'] = forms.EmailField(
required=event.settings.attendee_emails_required,
required=event.settings.attendee_emails_required and not self.all_optional,
label=_('Attendee email'),
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email),
widget=forms.EmailInput(
@@ -262,6 +262,73 @@ class BaseQuestionsForm(forms.Form):
}
)
)
if item.admission and event.settings.attendee_company_asked:
self.fields['company'] = forms.CharField(
required=event.settings.attendee_company_required and not self.all_optional,
label=_('Company'),
initial=(cartpos.company if cartpos else orderpos.company),
)
if item.admission and event.settings.attendee_addresses_asked:
self.fields['street'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('Address'),
widget=forms.Textarea(attrs={
'rows': 2,
'placeholder': _('Street and Number'),
'autocomplete': 'street-address'
}),
initial=(cartpos.street if cartpos else orderpos.street),
)
self.fields['zipcode'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('ZIP code'),
initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
widget=forms.TextInput(attrs={
'autocomplete': 'postal-code',
}),
)
self.fields['city'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('City'),
initial=(cartpos.city if cartpos else orderpos.city),
widget=forms.TextInput(attrs={
'autocomplete': 'address-level2',
}),
)
country = (cartpos.country if cartpos else orderpos.country) or guess_country(event)
self.fields['country'] = CountryField().formfield(
required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('Country'),
initial=country,
widget=forms.Select(attrs={
'autocomplete': 'country',
}),
)
c = [('', pgettext_lazy('address', 'Select state'))]
fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
cc = None
if fprefix + 'country' in self.data:
cc = str(self.data[fprefix + 'country'])
elif country:
cc = str(country)
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
elif fprefix + 'state' in self.data:
self.data = self.data.copy()
del self.data[fprefix + 'state']
self.fields['state'] = forms.ChoiceField(
label=pgettext_lazy('address', 'State'),
required=False,
choices=c,
widget=forms.Select(attrs={
'autocomplete': 'address-level1',
}),
)
self.fields['state'].widget.is_required = True
for q in questions:
# Do we already have an answer? Provide it as the initial value
@@ -423,6 +490,10 @@ class BaseQuestionsForm(forms.Form):
def clean(self):
d = super().clean()
if d.get('city') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if not d.get('state'):
self.add_error('state', _('This field is required.'))
question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
def question_is_visible(parentid, qvals):
@@ -0,0 +1,74 @@
# Generated by Django 3.0.4 on 2020-04-01 11:24
import django_countries.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0149_order_cancellation_date'),
]
operations = [
migrations.AddField(
model_name='cartposition',
name='city',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='cartposition',
name='company',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='cartposition',
name='country',
field=django_countries.fields.CountryField(max_length=2, null=True),
),
migrations.AddField(
model_name='cartposition',
name='state',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='cartposition',
name='street',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='cartposition',
name='zipcode',
field=models.CharField(max_length=30, null=True),
),
migrations.AddField(
model_name='orderposition',
name='city',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='orderposition',
name='company',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='orderposition',
name='country',
field=django_countries.fields.CountryField(max_length=2, null=True),
),
migrations.AddField(
model_name='orderposition',
name='state',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='orderposition',
name='street',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='orderposition',
name='zipcode',
field=models.CharField(max_length=30, null=True),
),
]
+7
View File
@@ -1065,6 +1065,13 @@ class AbstractPosition(models.Model):
'Seat', null=True, blank=True, on_delete=models.PROTECT
)
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 = CountryField(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)
class Meta:
abstract = True
+99
View File
@@ -398,6 +398,99 @@ class AttendeeEmail(ImportColumn):
position.attendee_email = value
class AttendeeCompany(ImportColumn):
identifier = 'attendee_company'
@property
def verbose_name(self):
return _('Attendee address') + ': ' + _('Company')
def assign(self, value, order, position, invoice_address, **kwargs):
position.company = value or ''
class AttendeeStreet(ImportColumn):
identifier = 'attendee_street'
@property
def verbose_name(self):
return _('Attendee address') + ': ' + _('Address')
def assign(self, value, order, position, invoice_address, **kwargs):
position.address = value or ''
class AttendeeZip(ImportColumn):
identifier = 'attendee_zipcode'
@property
def verbose_name(self):
return _('Attendee address') + ': ' + _('ZIP code')
def assign(self, value, order, position, invoice_address, **kwargs):
position.zipcode = value or ''
class AttendeeCity(ImportColumn):
identifier = 'attendee_city'
@property
def verbose_name(self):
return _('Attendee address') + ': ' + _('City')
def assign(self, value, order, position, invoice_address, **kwargs):
position.city = value or ''
class AttendeeCountry(ImportColumn):
identifier = 'attendee_country'
default_value = None
@property
def initial(self):
return 'static:' + str(guess_country(self.event))
@property
def verbose_name(self):
return _('Attendee address') + ': ' + _('Country')
def static_choices(self):
return list(countries)
def clean(self, value, previous_values):
if value and not Country(value).numeric:
raise ValidationError(_("Please enter a valid country code."))
return value
def assign(self, value, order, position, invoice_address, **kwargs):
position.country = value or ''
class AttendeeState(ImportColumn):
identifier = 'attendee_state'
@property
def verbose_name(self):
return _('Attendee address') + ': ' + _('State')
def clean(self, value, previous_values):
if value:
if previous_values.get('attendee_country') not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(_("States are not supported for this country."))
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[previous_values.get('attendee_country')]
match = [
s for s in pycountry.subdivisions.get(country_code=previous_values.get('attendee_country'))
if s.type in types and (s.code[3:] == value or s.name == value)
]
if len(match) == 0:
raise ValidationError(_("Please enter a valid state."))
return match[0].code[3:]
def assign(self, value, order, position, invoice_address, **kwargs):
position.state = value or ''
class Price(ImportColumn):
identifier = 'price'
verbose_name = gettext_lazy('Price')
@@ -596,6 +689,12 @@ def get_all_columns(event):
default.append(AttendeeNamePart(event, n, l))
default += [
AttendeeEmail(event),
AttendeeCompany(event),
AttendeeStreet(event),
AttendeeZip(event),
AttendeeCity(event),
AttendeeCountry(event),
AttendeeState(event),
Price(event),
Secret(event),
Locale(event),
+5
View File
@@ -205,6 +205,11 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("Sample city"),
"evaluate": lambda op, order, ev: order.invoice_address.city if getattr(order, 'invoice_address', None) else ''
}),
("attendee_company", {
"label": _("Attendee company"),
"editor_sample": _("Sample company"),
"evaluate": lambda op, order, ev: op.company or (op.addon_to.company if op.addon_to else '')
}),
("addons", {
"label": _("List of Add-Ons"),
"editor_sample": _("Addon 1\nAddon 2"),
+38
View File
@@ -105,6 +105,44 @@ DEFAULTS = {
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}),
)
},
'attendee_company_asked': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for company per ticket"),
)
},
'attendee_company_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require company per ticket"),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_company_asked'}),
)
},
'attendee_addresses_asked': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for postal addresses per ticket"),
)
},
'attendee_addresses_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require postal addresses per ticket"),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_addresses_asked'}),
)
},
'order_email_asked_twice': {
'default': 'False',
'type': bool,
+28 -10
View File
@@ -194,15 +194,23 @@ class WaitingListShredder(BaseDataShredder):
le.save(update_fields=['data', 'shredded'])
class AttendeeNameShredder(BaseDataShredder):
verbose_name = _('Attendee names')
identifier = 'attendee_names'
description = _('This will remove all attendee names from order positions, as well as logged changes to them.')
class AttendeeInfoShredder(BaseDataShredder):
verbose_name = _('Attendee info')
identifier = 'attendee_info'
description = _('This will remove all attendee names and postal addresses from order positions, as well as logged '
'changes to them.')
def generate_files(self) -> List[Tuple[str, str, str]]:
yield 'attendee-names.json', 'application/json', json.dumps({
'{}-{}'.format(op.order.code, op.positionid): op.attendee_name
for op in OrderPosition.all.filter(
yield 'attendee-info.json', 'application/json', json.dumps({
'{}-{}'.format(op.order.code, op.positionid): {
'name': op.attendee_name,
'company': op.company,
'street': op.street,
'zipcode': op.zipcode,
'city': op.city,
'country': str(op.country) if op.country else None,
'state': op.state
} for op in OrderPosition.all.filter(
order__event=self.event
).filter(
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
@@ -214,8 +222,10 @@ class AttendeeNameShredder(BaseDataShredder):
OrderPosition.all.filter(
order__event=self.event
).filter(
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
).update(attendee_name_cached=None, attendee_name_parts={'_shredded': True})
Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False) |
Q(company__isnull=False) | Q(street__isnull=False) | Q(zipcode__isnull=False) | Q(city__isnull=False)
).update(attendee_name_cached=None, attendee_name_parts={'_shredded': True}, company=None, street=None,
zipcode=None, city=None)
for le in self.event.logentry_set.filter(action_type="pretix.event.order.modified").exclude(data=""):
d = le.parsed_data
@@ -227,6 +237,14 @@ class AttendeeNameShredder(BaseDataShredder):
d['data'][i]['attendee_name_parts'] = {
'_legacy': ''
}
if 'company' in row:
d['data'][i]['company'] = ''
if 'street' in row:
d['data'][i]['street'] = ''
if 'zipcode' in row:
d['data'][i]['zipcode'] = ''
if 'city' in row:
d['data'][i]['city'] = ''
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
@@ -357,7 +375,7 @@ class PaymentInfoShredder(BaseDataShredder):
def register_payment_provider(sender, **kwargs):
return [
EmailAddressShredder,
AttendeeNameShredder,
AttendeeInfoShredder,
InvoiceAddressShredder,
QuestionAnswerShredder,
InvoiceShredder,
+13 -3
View File
@@ -85,10 +85,20 @@ class BaseQuestionsViewMixin:
for k, v in form.cleaned_data.items():
if k == 'attendee_name_parts':
form.pos.attendee_name_parts = v if v else None
form.pos.save()
elif k == 'attendee_email':
form.pos.attendee_email = v if v != '' else None
form.pos.save()
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
elif k.startswith('question_'):
field = form.fields[k]
if hasattr(field, 'answer'):
@@ -119,7 +129,7 @@ class BaseQuestionsViewMixin:
meta_info['question_form_data'][k] = v
form.pos.meta_info = json.dumps(meta_info)
form.pos.save(update_fields=['meta_info'])
form.pos.save()
return not failed
def _save_to_answer(self, field, answer, value):
+4
View File
@@ -515,6 +515,10 @@ class EventSettingsForm(SettingsForm):
'attendee_names_required',
'attendee_emails_asked',
'attendee_emails_required',
'attendee_company_asked',
'attendee_company_required',
'attendee_addresses_asked',
'attendee_addresses_required',
'confirm_text',
'order_email_asked_twice',
'last_order_modification_date',
@@ -89,6 +89,10 @@
{% bootstrap_field sform.order_email_asked_twice layout="control" %}
{% bootstrap_field sform.attendee_emails_asked layout="control" %}
{% bootstrap_field sform.attendee_emails_required layout="control" %}
{% bootstrap_field sform.attendee_company_asked layout="control" %}
{% bootstrap_field sform.attendee_company_required layout="control" %}
{% bootstrap_field sform.attendee_addresses_asked layout="control" %}
{% bootstrap_field sform.attendee_addresses_required layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Texts" %}</legend>
@@ -388,6 +388,29 @@
{% endif %}
</dd>
{% endif %}
{% if line.item.admission and event.settings.attendee_company_asked %}
<dt>
{% trans "Attendee company" %}
</dt>
<dd>
{% if line.company %}{{ line.company }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}
</dd>
{% endif %}
{% if line.item.admission and event.settings.attendee_addresses_asked %}
<dt>
{% trans "Attendee address" %}
</dt>
<dd>
{% if line.street or line.zipcode or line.city or line.country %}
{{ line.street|linebreaksbr }}<br>
{{ line.zipcode }} {{ line.city }}<br>
{{ line.country.name }}
{% if line.state %}<br>{{ line.state }}{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
</dd>
{% endif %}
{% for q in line.questions %}
<dt>
{{ q.question }}
+1 -1
View File
@@ -428,7 +428,7 @@ class CSVCheckinList(CheckInListMixin, ListExporter):
for q in questions:
row.append(acache.get(q.pk, ''))
row.append(ia.company)
row.append(op.company or ia.company)
row.append(op.voucher.code if op.voucher else "")
row.append(op.order.datetime.astimezone(self.event.timezone).strftime('%Y-%m-%d'))
row.append(_('Yes') if op.order.checkin_attention or op.item.checkin_attention else _('No'))
+10
View File
@@ -488,6 +488,16 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.admission and self.request.event.settings.get('attendee_company_required', as_type=bool) \
and cp.company is None:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.admission and self.request.event.settings.get('attendee_attendees_required', as_type=bool) \
and (cp.street is None or cp.city is None or cp.country is None):
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
responses = question_form_fields.send(sender=self.request.event, position=cp)
form_data = cp.meta_info_data.get('question_form_data', {})
@@ -44,12 +44,51 @@
{% if line.has_questions %}
<dl>
{% if line.item.admission and event.settings.attendee_names_asked %}
<dt>{% trans "Attendee name" %}</dt>
<dd>{% if line.attendee_name %}{{ line.attendee_name }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}</dd>
<dt class="sr-only">
{% trans "Attendee name" %}
</dt>
<dd class="toplevel">
<span data-toggle="tooltip" title="{% trans "Attendee name" %}">
{% if line.attendee_name %}{{ line.attendee_name }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}
</span>
</dd>
{% endif %}
{% if line.item.admission and event.settings.attendee_emails_asked %}
<dt>{% trans "Attendee email" %}</dt>
<dd>{% if line.attendee_email %}{{ line.attendee_email }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}</dd>
<dt class="sr-only">
{% trans "Attendee email" %}
</dt>
<dd class="toplevel">
<span data-toggle="tooltip" title="{% trans "Attendee email" %}">
{% if line.attendee_email %}{{ line.attendee_email }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}
</span>
</dd>
{% endif %}
{% if line.item.admission and event.settings.attendee_company_asked %}
<dt class="sr-only">
{% trans "Attendee company" %}
</dt>
<dd class="toplevel">
<span data-toggle="tooltip" title="{% trans "Attendee company" %}">
{% if line.company %}{{ line.company }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}
</span>
</dd>
{% endif %}
{% if line.item.admission and event.settings.attendee_addresses_asked %}
<dt class="sr-only">
{% trans "Attendee address" %}
</dt>
<dd class="toplevel">
<span data-toggle="tooltip" title="{% trans "Attendee address" %}">
{% if line.street or line.zipcode or line.city or line.country %}
{{ line.street|linebreaksbr }}<br>
{{ line.zipcode }} {{ line.city }}<br>
{{ line.country.name }}
{% if line.state %}<br>{{ line.state }}{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
</span>
</dd>
{% endif %}
{% for q in line.questions %}
<dt>{{ q.question }}</dt>
+18 -5
View File
@@ -167,22 +167,35 @@ $(function () {
return false;
});
var copy_to_first_ticket = true;
$("input[id*=attendee_name_parts_], input[id*=attendee_email]").each(function () {
var attendee_address_fields = $("input[id*=attendee_name_parts_], input[id*=attendee_email], .questions-form" +
" input[id$=company], .questions-form[id$=street], .questions-form input[id$=zipcode], .questions-form" +
" input[id$=city]");
attendee_address_fields.each(function () {
if ($(this).val()) {
copy_to_first_ticket = false;
}
})
$("input[id^=id_name_parts_], #id_email").change(function () {
$("select[id^=id_name_parts], input[id^=id_name_parts_], #id_email, #id_street, #id_company, #id_zipcode," +
" #id_city, #id_country, #id_state").change(function () {
if (copy_to_first_ticket) {
$(".questions-form").first().find("input[id*=attendee_email]").val($("#id_email").val());
$(".questions-form").first().find("input[id*=attendee_name_parts]").each(function () {
$(".questions-form").first().find("input[id$=company]").val($("#id_company").val());
$(".questions-form").first().find("textarea[id$=street]").val($("#id_street").val());
$(".questions-form").first().find("input[id$=zipcode]").val($("#id_zipcode").val());
$(".questions-form").first().find("input[id$=city]").val($("#id_city").val());
$(".questions-form").first().find("select[id$=state]").val($("#id_state").val());
if ($(".questions-form").first().find("select[id$=country]").val() !== $("#id_country").val()) {
$(".questions-form").first().find("select[id$=country]").val($("#id_country").val()).trigger('change');
}
$(".questions-form").first().find("[id*=attendee_name_parts]").each(function () {
var parts = $(this).attr("id").split("_");
var num = parts[parts.length - 1];
$(this).val($("#id_name_parts_" + num).val());
});
}
});
$("input[id*=attendee_name_parts_], input[id*=attendee_email]").change(function () {
attendee_address_fields.change(function () {
copy_to_first_ticket = false;
});
@@ -273,7 +286,7 @@ $(function () {
$("select[name$=state]").each(function () {
var dependent = $(this),
counter = 0,
dependency = $(this).closest("form").find('select[name$=country]'),
dependency = $(this).closest(".panel-body, form").find('select[name$=country]'),
update = function (ev) {
counter++;
var curCounter = counter;
@@ -22,7 +22,7 @@
padding-left: 20px;
margin-bottom: 0;
dd {
dd:not(.toplevel) {
padding-left: 20px;
}
}
+14
View File
@@ -167,6 +167,12 @@ TEST_ORDERPOSITION_RES = {
"checkins": [],
"downloads": [],
"seat": None,
"company": None,
"street": None,
"zipcode": None,
"city": None,
"country": None,
"state": None,
"answers": [
{
"question": 1,
@@ -1503,6 +1509,7 @@ ORDER_CREATE_PAYLOAD = {
"attendee_name_parts": {"full_name": "Peter"},
"attendee_email": None,
"addon_to": None,
"company": "FOOCORP",
"answers": [
{
"question": 1,
@@ -1556,6 +1563,7 @@ def test_order_create(token_client, organizer, event, item, quota, question):
assert pos.item == item
assert pos.price == Decimal("23.00")
assert pos.attendee_name_parts == {"full_name": "Peter", "_scheme": "full"}
assert pos.company == "FOOCORP"
with scopes_disabled():
answ = pos.answers.first()
assert answ.question == question
@@ -1655,6 +1663,12 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
'tax_rule': None,
'pseudonymization_id': 'PREVIEW',
'seat': None,
'company': "FOOCORP",
'street': None,
'city': None,
'zipcode': None,
'state': None,
'country': None,
'canceled': False
}
],
+15 -5
View File
@@ -15,7 +15,7 @@ from pretix.base.models import (
from pretix.base.services.invoices import generate_invoice, invoice_pdf_task
from pretix.base.services.tickets import generate
from pretix.base.shredder import (
AttendeeNameShredder, CachedTicketShredder, EmailAddressShredder,
AttendeeInfoShredder, CachedTicketShredder, EmailAddressShredder,
InvoiceAddressShredder, InvoiceShredder, PaymentInfoShredder,
QuestionAnswerShredder, WaitingListShredder, shred_constraints,
)
@@ -57,7 +57,8 @@ def order(event, item):
variation=None,
price=Decimal("14"),
attendee_name_parts={'full_name': "Peter", "_scheme": "full"},
attendee_email="foo@example.org"
attendee_email="foo@example.org",
company='Foobar',
)
return o
@@ -145,15 +146,23 @@ def test_attendee_name_shredder(event, order):
l1 = order.log_action(
'pretix.event.order.modified',
data={
"data": [{"attendee_name": "Hans", "question_1": "Test"}],
"data": [{"attendee_name": "Peter", "question_1": "Test", "company": "Foobar"}],
"invoice_data": {"name": "Foo"}
}
)
s = AttendeeNameShredder(event)
s = AttendeeInfoShredder(event)
f = list(s.generate_files())
assert json.loads(f[0][2]) == {
'{}-{}'.format(order.code, 1): 'Peter'
'{}-{}'.format(order.code, 1): {
'name': 'Peter',
'company': 'Foobar',
'street': None,
'zipcode': None,
'city': None,
'country': None,
'state': None
}
}
s.shred_data()
order.refresh_from_db()
@@ -161,6 +170,7 @@ def test_attendee_name_shredder(event, order):
l1.refresh_from_db()
assert 'Hans' not in l1.data
assert 'Foo' in l1.data
assert 'Foobar' not in l1.data
assert 'Test' in l1.data
+5 -2
View File
@@ -139,15 +139,18 @@ def test_csv_order_by_inherited_name_parts(event): # noqa
order=order2,
item=event.items.first(),
variation=None,
company='BARCORP',
price=Decimal("23"),
secret='hutjztuxhkbtwnesv2suqv26k6ttytyy'
)
InvoiceAddress.objects.create(
order=event.orders.get(code='BAR'),
company='FOOCORP',
name_parts={"title": "Mr", "given_name": "Albert", "middle_name": "J", "family_name": "Zulu", "_scheme": "title_given_middle_family"}
)
InvoiceAddress.objects.create(
order=event.orders.get(code='FOO'),
company='FOOCORP',
name_parts={"title": "Mr", "given_name": "Paul", "middle_name": "A", "family_name": "Jones", "_scheme": "title_given_middle_family"}
)
@@ -164,9 +167,9 @@ def test_csv_order_by_inherited_name_parts(event): # noqa
"Checked in","Automatically checked in","Secret","E-mail","Company","Voucher code","Order date","Requires special
attention","Comment"
"BAR","Mr Albert J Zulu","Mr","Albert","J","Zulu","Ticket","23.00","","No","hutjztuxhkbtwnesv2suqv26k6ttytyy",
"dummy@dummy.test","","","2019-02-22","No",""
"dummy@dummy.test","BARCORP","","2019-02-22","No",""
"FOO","Mr Paul A Jones","Mr","Paul","A","Jones","Ticket","23.00","","No","hutjztuxhkbtwnesv2suqv26k6ttytxx",
"dummy@dummy.test","","","2019-02-22","No",""
"dummy@dummy.test","FOOCORP","","2019-02-22","No",""
""")
c = CSVCheckinList(event)
_, _, content = c.render({
+75
View File
@@ -436,6 +436,81 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
cr1 = CartPosition.objects.get(id=cr1.id)
self.assertEqual(cr1.attendee_email, 'foo@localhost')
def test_attendee_company_required(self):
self.event.settings.set('attendee_company_asked', True)
self.event.settings.set('attendee_company_required', True)
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select('input[name="%s-company"]' % cr1.id)), 1)
# Not all required fields filled out, expect failure
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-company' % cr1.id: '',
'email': 'admin@localhost'
}, follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-company' % cr1.id: 'foobar',
'email': 'admin@localhost'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
with scopes_disabled():
cr1 = CartPosition.objects.get(id=cr1.id)
self.assertEqual(cr1.company, 'foobar')
def test_attendee_address_required(self):
self.event.settings.set('attendee_addresses_asked', True)
self.event.settings.set('attendee_addresses_required', True)
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select('textarea[name="%s-street"]' % cr1.id)), 1)
# Not all required fields filled out, expect failure
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-street' % cr1.id: '',
'%s-zipcode' % cr1.id: '',
'%s-city' % cr1.id: '',
'%s-country' % cr1.id: '',
'%s-state' % cr1.id: '',
'email': 'admin@localhost'
}, follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-street' % cr1.id: 'Musterstrasse',
'%s-zipcode' % cr1.id: '12345',
'%s-city' % cr1.id: 'Musterstadt',
'%s-country' % cr1.id: 'DE',
'%s-state' % cr1.id: '',
'email': 'admin@localhost'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
with scopes_disabled():
cr1 = CartPosition.objects.get(id=cr1.id)
self.assertEqual(cr1.street, 'Musterstrasse')
self.assertEqual(cr1.zipcode, '12345')
self.assertEqual(cr1.city, 'Musterstadt')
self.assertEqual(cr1.country, 'DE')
def test_attendee_name_required(self):
self.event.settings.set('attendee_names_asked', True)
self.event.settings.set('attendee_names_required', True)