diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 181839c1ac..10d0f44a47 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -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`` diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 3ca522ae89..42b3285334 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -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', diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index cdd0e9e857..2fee373264 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -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 diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index 111a55a0bd..891794fb7d 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -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, ] diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index 4e3280fff8..3645c5381a 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -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): diff --git a/src/pretix/base/migrations/0150_auto_20200401_1123.py b/src/pretix/base/migrations/0150_auto_20200401_1123.py new file mode 100644 index 0000000000..5a0a6855b4 --- /dev/null +++ b/src/pretix/base/migrations/0150_auto_20200401_1123.py @@ -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), + ), + ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 2c6fc368d8..f854d3e79b 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -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 diff --git a/src/pretix/base/orderimport.py b/src/pretix/base/orderimport.py index f9b0dd7829..e9030d5659 100644 --- a/src/pretix/base/orderimport.py +++ b/src/pretix/base/orderimport.py @@ -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), diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py index f634d9a66a..d7c93e1817 100644 --- a/src/pretix/base/pdf.py +++ b/src/pretix/base/pdf.py @@ -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"), diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 26449ff77f..d60d3ae2f1 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -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, diff --git a/src/pretix/base/shredder.py b/src/pretix/base/shredder.py index 3584d7f38d..f233eb97ec 100644 --- a/src/pretix/base/shredder.py +++ b/src/pretix/base/shredder.py @@ -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, diff --git a/src/pretix/base/views/mixins.py b/src/pretix/base/views/mixins.py index f3767d04df..ec88d171e1 100644 --- a/src/pretix/base/views/mixins.py +++ b/src/pretix/base/views/mixins.py @@ -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): diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 3c7981b739..fae0768967 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -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', diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index dd0d15bdcb..d572a8670b 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -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" %}
{% trans "Texts" %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 85583279d7..4e1e7c4e3f 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -388,6 +388,29 @@ {% endif %} {% endif %} + {% if line.item.admission and event.settings.attendee_company_asked %} +
+ {% trans "Attendee company" %} +
+
+ {% if line.company %}{{ line.company }}{% else %}{% trans "not answered" %}{% endif %} +
+ {% endif %} + {% if line.item.admission and event.settings.attendee_addresses_asked %} +
+ {% trans "Attendee address" %} +
+
+ {% if line.street or line.zipcode or line.city or line.country %} + {{ line.street|linebreaksbr }}
+ {{ line.zipcode }} {{ line.city }}
+ {{ line.country.name }} + {% if line.state %}
{{ line.state }}{% endif %} + {% else %} + {% trans "not answered" %} + {% endif %} +
+ {% endif %} {% for q in line.questions %}
{{ q.question }} diff --git a/src/pretix/plugins/checkinlists/exporters.py b/src/pretix/plugins/checkinlists/exporters.py index a41e5973e9..78aef06b48 100644 --- a/src/pretix/plugins/checkinlists/exporters.py +++ b/src/pretix/plugins/checkinlists/exporters.py @@ -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')) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index d52b3be827..720296636f 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -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', {}) diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index 7e309b0433..64c09c1169 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -44,12 +44,51 @@ {% if line.has_questions %}
{% if line.item.admission and event.settings.attendee_names_asked %} -
{% trans "Attendee name" %}
-
{% if line.attendee_name %}{{ line.attendee_name }}{% else %}{% trans "not answered" %}{% endif %}
+
+ {% trans "Attendee name" %} +
+
+ + {% if line.attendee_name %}{{ line.attendee_name }}{% else %}{% trans "not answered" %}{% endif %} + +
{% endif %} {% if line.item.admission and event.settings.attendee_emails_asked %} -
{% trans "Attendee email" %}
-
{% if line.attendee_email %}{{ line.attendee_email }}{% else %}{% trans "not answered" %}{% endif %}
+
+ {% trans "Attendee email" %} +
+
+ + {% if line.attendee_email %}{{ line.attendee_email }}{% else %}{% trans "not answered" %}{% endif %} + +
+ {% endif %} + {% if line.item.admission and event.settings.attendee_company_asked %} +
+ {% trans "Attendee company" %} +
+
+ + {% if line.company %}{{ line.company }}{% else %}{% trans "not answered" %}{% endif %} + +
+ {% endif %} + {% if line.item.admission and event.settings.attendee_addresses_asked %} +
+ {% trans "Attendee address" %} +
+
+ + {% if line.street or line.zipcode or line.city or line.country %} + {{ line.street|linebreaksbr }}
+ {{ line.zipcode }} {{ line.city }}
+ {{ line.country.name }} + {% if line.state %}
{{ line.state }}{% endif %} + {% else %} + {% trans "not answered" %} + {% endif %} +
+
{% endif %} {% for q in line.questions %}
{{ q.question }}
diff --git a/src/pretix/static/pretixpresale/js/ui/main.js b/src/pretix/static/pretixpresale/js/ui/main.js index e10935c824..9f1d20664c 100644 --- a/src/pretix/static/pretixpresale/js/ui/main.js +++ b/src/pretix/static/pretixpresale/js/ui/main.js @@ -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; diff --git a/src/pretix/static/pretixpresale/scss/_cart.scss b/src/pretix/static/pretixpresale/scss/_cart.scss index e3a58e87c2..e8631ad56f 100644 --- a/src/pretix/static/pretixpresale/scss/_cart.scss +++ b/src/pretix/static/pretixpresale/scss/_cart.scss @@ -22,7 +22,7 @@ padding-left: 20px; margin-bottom: 0; - dd { + dd:not(.toplevel) { padding-left: 20px; } } diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 9b8d5ac04a..11efb86b50 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -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 } ], diff --git a/src/tests/base/test_shredders.py b/src/tests/base/test_shredders.py index 11ee7fbd6e..b494277f43 100644 --- a/src/tests/base/test_shredders.py +++ b/src/tests/base/test_shredders.py @@ -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 diff --git a/src/tests/plugins/test_checkinlist.py b/src/tests/plugins/test_checkinlist.py index c828d46e4b..b6a58e141b 100644 --- a/src/tests/plugins/test_checkinlist.py +++ b/src/tests/plugins/test_checkinlist.py @@ -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({ diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index c869da1bac..2ff5849538 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -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)