Customer accounts & Memberships (#2024)

This commit is contained in:
Raphael Michel
2021-05-04 16:56:06 +02:00
committed by GitHub
parent 62e412bbc0
commit 8e79eb570e
116 changed files with 7975 additions and 279 deletions

View File

@@ -45,7 +45,7 @@ from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from ...base.forms import I18nModelForm
from ...base.forms import I18nModelForm, SecretKeySettingsField
# Import for backwards compatibility with okd import paths
from ...base.forms.widgets import ( # noqa
@@ -368,3 +368,46 @@ class SplitDateTimeField(forms.SplitDateTimeField):
class FontSelect(forms.RadioSelect):
option_template_name = 'pretixcontrol/font_option.html'
class SMTPSettingsMixin(forms.Form):
smtp_use_custom = forms.BooleanField(
label=_("Use custom SMTP server"),
help_text=_("All mail related to your event will be sent over the smtp server specified by you."),
required=False
)
smtp_host = forms.CharField(
label=_("Hostname"),
required=False,
widget=forms.TextInput(attrs={'placeholder': 'mail.example.org'})
)
smtp_port = forms.IntegerField(
label=_("Port"),
required=False,
widget=forms.TextInput(attrs={'placeholder': 'e.g. 587, 465, 25, ...'})
)
smtp_username = forms.CharField(
label=_("Username"),
widget=forms.TextInput(attrs={'placeholder': 'myuser@example.org'}),
required=False
)
smtp_password = SecretKeySettingsField(
label=_("Password"),
required=False,
)
smtp_use_tls = forms.BooleanField(
label=_("Use STARTTLS"),
help_text=_("Commonly enabled on port 587."),
required=False
)
smtp_use_ssl = forms.BooleanField(
label=_("Use SSL"),
help_text=_("Commonly enabled on port 465."),
required=False
)
def clean(self):
data = super().clean()
if data.get('smtp_use_tls') and data.get('smtp_use_ssl'):
raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.'))
return data

View File

@@ -63,7 +63,7 @@ from pretix.base.settings import (
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
)
from pretix.control.forms import (
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
MultipleLanguagesWidget, SlugWidget, SMTPSettingsMixin, SplitDateTimeField,
SplitDateTimePickerWidget,
)
from pretix.control.forms.widgets import Select2
@@ -825,7 +825,7 @@ def contains_web_channel_validate(val):
raise ValidationError(_("The online shop must be selected to receive these emails."))
class MailSettingsForm(SettingsForm):
class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
auto_fields = [
'mail_prefix',
'mail_from',
@@ -1020,43 +1020,6 @@ class MailSettingsForm(SettingsForm):
required=False,
widget=I18nTextarea,
)
smtp_use_custom = forms.BooleanField(
label=_("Use custom SMTP server"),
help_text=_("All mail related to your event will be sent over the smtp server specified by you."),
required=False
)
smtp_host = forms.CharField(
label=_("Hostname"),
required=False,
widget=forms.TextInput(attrs={'placeholder': 'mail.example.org'})
)
smtp_port = forms.IntegerField(
label=_("Port"),
required=False,
widget=forms.TextInput(attrs={'placeholder': 'e.g. 587, 465, 25, ...'})
)
smtp_username = forms.CharField(
label=_("Username"),
widget=forms.TextInput(attrs={'placeholder': 'myuser@example.org'}),
required=False
)
smtp_password = forms.CharField(
label=_("Password"),
required=False,
widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
}),
)
smtp_use_tls = forms.BooleanField(
label=_("Use STARTTLS"),
help_text=_("Commonly enabled on port 587."),
required=False
)
smtp_use_ssl = forms.BooleanField(
label=_("Use SSL"),
help_text=_("Commonly enabled on port 465."),
required=False
)
base_context = {
'mail_text_order_placed': ['event', 'order', 'payment'],
'mail_text_order_placed_attendee': ['event', 'order', 'position'],
@@ -1110,17 +1073,6 @@ class MailSettingsForm(SettingsForm):
# the user interface with it
del self.fields[k]
def clean(self):
data = self.cleaned_data
if not data.get('smtp_password') and data.get('smtp_username'):
# Leave password unchanged if the username is set and the password field is empty.
# This makes it impossible to set an empty password as long as a username is set, but
# Python's smtplib does not support password-less schemes anyway.
data['smtp_password'] = self.initial.get('smtp_password')
if data.get('smtp_use_tls') and data.get('smtp_use_ssl'):
raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.'))
class TicketSettingsForm(SettingsForm):
auto_fields = [

View File

@@ -1022,6 +1022,44 @@ class GiftCardFilterForm(FilterForm):
return qs.distinct()
class CustomerFilterForm(FilterForm):
orders = {
'email': 'email',
'identifier': 'identifier',
'name_cached': 'name_cached',
}
query = forms.CharField(
label=_('Search query'),
widget=forms.TextInput(attrs={
'placeholder': _('Search query'),
'autofocus': 'autofocus'
}),
required=False
)
def __init__(self, *args, **kwargs):
kwargs.pop('request')
super().__init__(*args, **kwargs)
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('query'):
query = fdata.get('query')
qs = qs.filter(
Q(email__icontains=query)
| Q(name_cached__icontains=query)
| Q(identifier__istartswith=query)
)
if fdata.get('ordering'):
qs = qs.order_by(self.get_order_by())
else:
qs = qs.order_by('-email')
return qs
class TeamFilterForm(FilterForm):
orders = {
'name': 'name',

View File

@@ -368,6 +368,11 @@ class ItemCreateForm(I18nModelForm):
'hidden_if_available',
'require_bundling',
'checkin_attention',
'require_membership',
'grant_membership_type',
'grant_membership_duration_like_event',
'grant_membership_duration_days',
'grant_membership_duration_months',
)
for f in fields:
setattr(self.instance, f, getattr(self.cleaned_data['copy_from'], f))
@@ -399,6 +404,10 @@ class ItemCreateForm(I18nModelForm):
'items': [self.instance.pk]
})
if self.cleaned_data.get('copy_from'):
self.instance.require_membership_types.set(
self.cleaned_data['copy_from'].require_membership_types.all()
)
if self.cleaned_data.get('has_variations'):
if self.cleaned_data.get('copy_from') and self.cleaned_data.get('copy_from').has_variations:
for variation in self.cleaned_data['copy_from'].variations.all():
@@ -523,6 +532,19 @@ class ItemUpdateForm(I18nModelForm):
)
self.fields['category'].widget.choices = self.fields['category'].choices
qs = self.event.organizer.membership_types.all()
if qs:
self.fields['require_membership_types'].queryset = qs
self.fields['grant_membership_type'].queryset = qs
self.fields['grant_membership_type'].empty_label = _('No membership granted')
else:
del self.fields['require_membership']
del self.fields['require_membership_types']
del self.fields['grant_membership_type']
del self.fields['grant_membership_duration_like_event']
del self.fields['grant_membership_duration_days']
del self.fields['grant_membership_duration_months']
def clean(self):
d = super().clean()
if d['issue_giftcard']:
@@ -571,15 +593,26 @@ class ItemUpdateForm(I18nModelForm):
'show_quota_left',
'hidden_if_available',
'issue_giftcard',
'require_membership',
'require_membership_types',
'grant_membership_type',
'grant_membership_duration_like_event',
'grant_membership_duration_days',
'grant_membership_duration_months',
]
field_classes = {
'available_from': SplitDateTimeField,
'available_until': SplitDateTimeField,
'hidden_if_available': SafeModelChoiceField,
'grant_membership_type': SafeModelChoiceField,
'require_membership_types': SafeModelMultipleChoiceField,
}
widgets = {
'available_from': SplitDateTimePickerWidget(),
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
'require_membership_types': forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice'
}),
'generate_tickets': TicketNullBooleanSelect(),
'show_quota_left': ShowQuotaNullBooleanSelect()
}
@@ -632,6 +665,13 @@ class ItemVariationForm(I18nModelForm):
super().__init__(*args, **kwargs)
change_decimal_field(self.fields['default_price'], self.event.currency)
qs = self.event.organizer.membership_types.all()
if qs:
self.fields['require_membership_types'].queryset = qs
else:
del self.fields['require_membership']
del self.fields['require_membership_types']
class Meta:
model = ItemVariation
localized_fields = '__all__'
@@ -641,7 +681,14 @@ class ItemVariationForm(I18nModelForm):
'default_price',
'original_price',
'description',
'require_membership',
'require_membership_types'
]
widgets = {
'require_membership_types': forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice'
}),
}
class ItemAddOnsFormSet(I18nFormSet):

View File

@@ -46,6 +46,7 @@ from django.utils.timezone import make_aware, now
from django.utils.translation import (
gettext_lazy as _, gettext_noop, pgettext_lazy,
)
from django_scopes.forms import SafeModelChoiceField
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString
@@ -290,6 +291,10 @@ class OrderPositionAddForm(forms.Form):
widget=forms.TextInput(attrs={'placeholder': _('General admission'), 'data-seat-guid-field': 'true'}),
label=_('Seat')
)
used_membership = forms.ChoiceField(
label=_('Membership'),
required=False,
)
price = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
@@ -360,6 +365,23 @@ class OrderPositionAddForm(forms.Form):
del self.fields['subevent']
change_decimal_field(self.fields['price'], order.event.currency)
choices = [
('', ''),
]
if order.customer:
self.memberships = list(order.customer.memberships.all())
for m in self.memberships:
choices.append((str(m.pk), str(m)))
self.fields['used_membership'].choices = choices
def clean(self):
d = super().clean()
if d['used_membership']:
d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0]
else:
d['used_membership'] = None
return d
class OrderPositionAddFormset(forms.BaseFormSet):
def __init__(self, *args, **kwargs):
@@ -405,6 +427,9 @@ class OrderPositionChangeForm(forms.Form):
localize=True,
label=_('New price (gross)')
)
used_membership = forms.ChoiceField(
required=False,
)
tax_rule = forms.ModelChoiceField(
TaxRule.objects.none(),
required=False,
@@ -478,6 +503,24 @@ class OrderPositionChangeForm(forms.Form):
self.fields['itemvar'].choices = choices
change_decimal_field(self.fields['price'], instance.order.event.currency)
choices = [
('', _('(Unchanged)')),
('CLEAR', _('(No membership)')),
]
if instance.order.customer:
self.memberships = list(instance.order.customer.memberships.all())
for m in self.memberships:
choices.append((str(m.pk), str(m)))
self.fields['used_membership'].choices = choices
def clean(self):
d = super().clean()
if d['used_membership'] and d['used_membership'] != 'CLEAR':
d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0]
elif not d['used_membership']:
d['used_membership'] = None
return d
class OrderFeeChangeForm(forms.Form):
value = forms.DecimalField(
@@ -516,16 +559,36 @@ class OrderContactForm(forms.ModelForm):
class Meta:
model = Order
fields = ['email', 'email_known_to_work', 'phone']
fields = ['customer', 'email', 'email_known_to_work', 'phone']
widgets = {
'phone': WrappedPhoneNumberPrefixWidget()
'phone': WrappedPhoneNumberPrefixWidget(),
}
field_classes = {
'customer': SafeModelChoiceField,
}
def __init__(self, *args, **kwargs):
customers = kwargs.pop('customers')
super().__init__(*args, **kwargs)
if not self.instance.event.settings.order_phone_asked and not self.instance.phone:
del self.fields['phone']
if customers:
self.fields['customer'].queryset = self.instance.event.organizer.customers.all()
self.fields['customer'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.customers.select2', kwargs={
'organizer': self.instance.event.organizer.slug,
}),
'data-placeholder': _('Customer')
}
)
self.fields['customer'].widget.choices = self.fields['customer'].choices
self.fields['customer'].required = False
else:
del self.fields['customer']
class OrderLocaleForm(forms.ModelForm):
locale = forms.ChoiceField()

View File

@@ -39,20 +39,31 @@ from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.utils.crypto import get_random_string
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelMultipleChoiceField
from i18nfield.forms import I18nFormField, I18nTextarea
from pytz import common_timezones
from pretix.api.models import WebHook
from pretix.api.webhooks import get_all_webhook_events
from pretix.base.forms import I18nModelForm, SettingsForm
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
from pretix.base.forms.questions import NamePartsFormField
from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import (
Device, EventMetaProperty, Gate, GiftCard, Organizer, Team,
Customer, Device, EventMetaProperty, Gate, GiftCard, Membership,
MembershipType, Organizer, Team,
)
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
from pretix.control.forms import (
ExtFileField, SMTPSettingsMixin, SplitDateTimeField,
)
from pretix.control.forms.event import (
SafeEventMultipleChoiceField, multimail_validate,
)
from pretix.control.forms import ExtFileField, SplitDateTimeField
from pretix.control.forms.event import SafeEventMultipleChoiceField
from pretix.multidomain.models import KnownDomain
from pretix.multidomain.urlreverse import build_absolute_uri
class OrganizerForm(I18nModelForm):
@@ -168,6 +179,12 @@ class EventMetaPropertyForm(forms.ModelForm):
}
class MembershipTypeForm(I18nModelForm):
class Meta:
model = MembershipType
fields = ['name', 'transferable', 'allow_parallel_usage', 'max_usages']
class TeamForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
@@ -181,7 +198,7 @@ class TeamForm(forms.ModelForm):
model = Team
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
'can_change_teams', 'can_change_organizer_settings',
'can_manage_gift_cards',
'can_manage_gift_cards', 'can_manage_customers',
'can_change_event_settings', 'can_change_items',
'can_view_orders', 'can_change_orders', 'can_checkin_orders',
'can_view_vouchers', 'can_change_vouchers']
@@ -250,7 +267,24 @@ class DeviceForm(forms.ModelForm):
class OrganizerSettingsForm(SettingsForm):
timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones),
label=_("Default timezone"),
)
name_scheme = forms.ChoiceField(
label=_("Name format"),
help_text=_("This defines how pretix will ask for human names. Changing this after you already received "
"orders might lead to unexpected behavior when sorting or changing names."),
required=True,
)
name_scheme_titles = forms.ChoiceField(
label=_("Allowed titles"),
help_text=_("If the naming scheme you defined above allows users to input a title, you can use this to "
"restrict the set of selectable titles."),
required=False,
)
auto_fields = [
'customer_accounts',
'contact_mail',
'imprint_url',
'organizer_info_text',
@@ -292,6 +326,115 @@ class OrganizerSettingsForm(SettingsForm):
'We recommend a size of at least 200x200px to accommodate most devices.')
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['name_scheme'].choices = (
(k, _('Ask for {fields}, display like {example}').format(
fields=' + '.join(str(vv[1]) for vv in v['fields']),
example=v['concatenation'](v['sample'])
))
for k, v in PERSON_NAME_SCHEMES.items()
)
self.fields['name_scheme_titles'].choices = [('', _('Free text input'))] + [
(k, '{scheme}: {samples}'.format(
scheme=v[0],
samples=', '.join(v[1])
))
for k, v in PERSON_NAME_TITLE_GROUPS.items()
]
class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
auto_fields = [
'mail_from',
'mail_from_name',
]
mail_bcc = forms.CharField(
label=_("Bcc address"),
help_text=_("All emails will be sent to this address as a Bcc copy"),
validators=[multimail_validate],
required=False,
max_length=255
)
mail_text_signature = I18nFormField(
label=_("Signature"),
required=False,
widget=I18nTextarea,
help_text=_("This will be attached to every email."),
validators=[PlaceholderValidator([])],
widget_kwargs={'attrs': {
'rows': '4',
'placeholder': _(
'e.g. your contact details'
)
}}
)
mail_text_customer_registration = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
)
mail_text_customer_email_change = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
)
mail_text_customer_reset = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
)
base_context = {
'mail_text_customer_registration': ['customer', 'url'],
'mail_text_customer_email_change': ['customer', 'url'],
'mail_text_customer_reset': ['customer', 'url'],
}
def _get_sample_context(self, base_parameters):
placeholders = {
'organizer': self.organizer.name
}
if 'url' in base_parameters:
placeholders['url'] = build_absolute_uri(
self.organizer,
'presale:organizer.customer.activate'
) + '?token=' + get_random_string(30)
if 'customer' in base_parameters:
placeholders['name'] = pgettext_lazy('person_name_sample', 'John Doe')
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
for f, l, w in name_scheme['fields']:
if f == 'full_name':
continue
placeholders['name_%s' % f] = name_scheme['sample'][f]
return placeholders
def _set_field_placeholders(self, fn, base_parameters):
phs = [
'{%s}' % p
for p in sorted(self._get_sample_context(base_parameters).keys())
]
ht = _('Available placeholders: {list}').format(
list=', '.join(phs)
)
if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht)
else:
self.fields[fn].help_text = ht
self.fields[fn].validators.append(
PlaceholderValidator(phs)
)
def __init__(self, *args, **kwargs):
self.organizer = kwargs.get('obj')
super().__init__(*args, **kwargs)
for k, v in self.base_context.items():
self._set_field_placeholders(k, v)
class WebHookForm(forms.ModelForm):
events = forms.MultipleChoiceField(
@@ -373,3 +516,67 @@ class GiftCardUpdateForm(forms.ModelForm):
'expires': SplitDateTimePickerWidget,
'conditions': forms.Textarea(attrs={"rows": 2})
}
class CustomerUpdateForm(forms.ModelForm):
error_messages = {
'duplicate': _("An account with this email address is already registered."),
}
class Meta:
model = Customer
fields = ['is_active', 'name_parts', 'email', 'is_verified', 'locale']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['name_parts'] = NamePartsFormField(
max_length=255,
required=False,
scheme=self.instance.organizer.settings.name_scheme,
titles=self.instance.organizer.settings.name_scheme_titles,
label=_('Name'),
)
def clean(self):
email = self.cleaned_data.get('email')
if email is not None:
try:
self.instance.organizer.customers.exclude(pk=self.instance.pk).get(email=email)
except Customer.DoesNotExist:
pass
else:
raise forms.ValidationError(
self.error_messages['duplicate'],
code='duplicate',
)
return self.cleaned_data
class MembershipUpdateForm(forms.ModelForm):
class Meta:
model = Membership
fields = ['membership_type', 'date_start', 'date_end', 'attendee_name_parts']
field_classes = {
'date_start': SplitDateTimeField,
'date_end': SplitDateTimeField,
}
widgets = {
'date_start': SplitDateTimePickerWidget(),
'date_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_Start'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['membership_type'].queryset = self.instance.customer.organizer.membership_types.all()
self.fields['attendee_name_parts'] = NamePartsFormField(
max_length=255,
required=False,
scheme=self.instance.customer.organizer.settings.name_scheme,
titles=self.instance.customer.organizer.settings.name_scheme_titles,
label=_('Attendee name'),
)