mirror of
https://github.com/pretix/pretix.git
synced 2026-05-06 15:24:02 +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):
|
||||
|
||||
Reference in New Issue
Block a user