mirror of
https://github.com/pretix/pretix.git
synced 2026-05-06 15:24:02 +00:00
Customer accounts & Memberships (#2024)
This commit is contained in:
@@ -37,6 +37,9 @@ from itertools import chain
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.formfields import PhoneNumberField
|
||||
from phonenumber_field.phonenumber import PhoneNumber
|
||||
@@ -178,3 +181,60 @@ class AddOnVariationField(forms.ChoiceField):
|
||||
if value == k or text_value == force_str(k):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class MembershipForm(forms.Form):
|
||||
required_css_class = 'required'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.memberships = kwargs.pop('memberships')
|
||||
event = kwargs.pop('event')
|
||||
self.position = kwargs.pop('position')
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
ev = self.position.subevent or event
|
||||
if self.position.variation and self.position.variation.require_membership:
|
||||
types = self.position.variation.require_membership_types.all()
|
||||
else:
|
||||
types = self.position.item.require_membership_types.all()
|
||||
|
||||
initial = None
|
||||
|
||||
memberships = [
|
||||
m for m in self.memberships
|
||||
if m.is_valid(ev) and m.membership_type in types
|
||||
]
|
||||
|
||||
if len(memberships) == 1:
|
||||
initial = str(memberships[0].pk)
|
||||
|
||||
self.fields['membership'] = forms.ChoiceField(
|
||||
label=_('Membership'),
|
||||
choices=[
|
||||
(str(m.pk), self._label_from_instance(m))
|
||||
for m in memberships
|
||||
],
|
||||
initial=initial,
|
||||
widget=forms.RadioSelect,
|
||||
)
|
||||
self.is_empty = not memberships
|
||||
|
||||
def _label_from_instance(self, obj):
|
||||
ds = date_format(obj.date_start, 'SHORT_DATE_FORMAT')
|
||||
de = date_format(obj.date_end, 'SHORT_DATE_FORMAT')
|
||||
if obj.membership_type.max_usages is not None:
|
||||
usages = f'({obj.usages} / {obj.membership_type.max_usages})'
|
||||
else:
|
||||
usages = ''
|
||||
return mark_safe(
|
||||
f'<strong>{escape(obj.membership_type)}</strong> {usages}<br>'
|
||||
f'{escape(obj.attendee_name)}<br>'
|
||||
f'<span class="text-muted">{ds} – {de}</span>'
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
if d.get('membership'):
|
||||
d['membership'] = [m for m in self.memberships if str(m.pk) == d['membership']][0]
|
||||
return d
|
||||
|
||||
456
src/pretix/presale/forms/customer.py
Normal file
456
src/pretix/presale/forms/customer.py
Normal file
@@ -0,0 +1,456 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# 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 hashlib
|
||||
import ipaddress
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.contrib.auth.password_validation import (
|
||||
password_validators_help_texts, validate_password,
|
||||
)
|
||||
from django.contrib.auth.tokens import PasswordResetTokenGenerator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.forms.questions import NamePartsFormField
|
||||
from pretix.base.i18n import get_language_without_region
|
||||
from pretix.base.models import Customer
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.helpers.http import get_client_ip
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
|
||||
class TokenGenerator(PasswordResetTokenGenerator):
|
||||
key_salt = "pretix.presale.forms.customer.TokenGenerator"
|
||||
|
||||
|
||||
class AuthenticationForm(forms.Form):
|
||||
required_css_class = 'required'
|
||||
email = forms.EmailField(
|
||||
label=_("E-mail"),
|
||||
widget=forms.EmailInput(attrs={'autofocus': True})
|
||||
)
|
||||
password = forms.CharField(
|
||||
label=_("Password"),
|
||||
strip=False,
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
'incomplete': _('You need to fill out all fields.'),
|
||||
'invalid_login': _(
|
||||
"We have not found an account with this email address and password."
|
||||
),
|
||||
'inactive': _("This account is disabled."),
|
||||
'unverified': _("You have not yet activated your account and set a password. Please click the link in the "
|
||||
"email we sent you. Click \"Reset password\" to receive a new email in case you cannot find "
|
||||
"it again."),
|
||||
}
|
||||
|
||||
def __init__(self, request=None, *args, **kwargs):
|
||||
self.request = request
|
||||
self.customer_cache = None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
email = self.cleaned_data.get('email')
|
||||
password = self.cleaned_data.get('password')
|
||||
|
||||
if email is not None and password:
|
||||
try:
|
||||
u = self.request.organizer.customers.get(email=email)
|
||||
except Customer.DoesNotExist:
|
||||
# Run the default password hasher once to reduce the timing
|
||||
# difference between an existing and a nonexistent user (django #20760).
|
||||
Customer().set_password(password)
|
||||
else:
|
||||
if u.check_password(password):
|
||||
self.customer_cache = u
|
||||
if self.customer_cache is None:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['invalid_login'],
|
||||
code='invalid_login',
|
||||
)
|
||||
else:
|
||||
self.confirm_login_allowed(self.customer_cache)
|
||||
else:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['incomplete'],
|
||||
code='incomplete'
|
||||
)
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
def confirm_login_allowed(self, user):
|
||||
if not user.is_active:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['inactive'],
|
||||
code='inactive',
|
||||
)
|
||||
if not user.is_verified:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['unverified'],
|
||||
code='unverified',
|
||||
)
|
||||
|
||||
def get_customer(self):
|
||||
return self.customer_cache
|
||||
|
||||
|
||||
class RegistrationForm(forms.Form):
|
||||
required_css_class = 'required'
|
||||
name_parts = forms.CharField()
|
||||
email = forms.EmailField(
|
||||
label=_("E-mail"),
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
'rate_limit': _("We've received a lot of registration requests from you, please wait 10 minutes before you try again."),
|
||||
'duplicate': _(
|
||||
"An account with this email address is already registered. Please try to log in or reset your password "
|
||||
"instead."
|
||||
),
|
||||
'required': _('This field is required.'),
|
||||
}
|
||||
|
||||
def __init__(self, request=None, *args, **kwargs):
|
||||
self.request = request
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=True,
|
||||
scheme=request.organizer.settings.name_scheme,
|
||||
titles=request.organizer.settings.name_scheme_titles,
|
||||
label=_('Name'),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def ratelimit_key(self):
|
||||
if not settings.HAS_REDIS:
|
||||
return None
|
||||
client_ip = get_client_ip(self.request)
|
||||
if not client_ip:
|
||||
return None
|
||||
try:
|
||||
client_ip = ipaddress.ip_address(client_ip)
|
||||
except ValueError:
|
||||
# Web server not set up correctly
|
||||
return None
|
||||
if client_ip.is_private:
|
||||
# This is the private IP of the server, web server not set up correctly
|
||||
return None
|
||||
return 'pretix_customer_registration_{}'.format(hashlib.sha1(str(client_ip).encode()).hexdigest())
|
||||
|
||||
def clean(self):
|
||||
email = self.cleaned_data.get('email')
|
||||
|
||||
if email is not None:
|
||||
try:
|
||||
self.request.organizer.customers.get(email=email)
|
||||
except Customer.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
raise forms.ValidationError(
|
||||
{'email': self.error_messages['duplicate']},
|
||||
code='duplicate',
|
||||
)
|
||||
|
||||
if not self.cleaned_data.get('email'):
|
||||
raise forms.ValidationError(
|
||||
{'email': self.error_messages['required']},
|
||||
code='incomplete'
|
||||
)
|
||||
else:
|
||||
if self.ratelimit_key:
|
||||
from django_redis import get_redis_connection
|
||||
|
||||
rc = get_redis_connection("redis")
|
||||
cnt = rc.incr(self.ratelimit_key)
|
||||
rc.expire(self.ratelimit_key, 600)
|
||||
if cnt > 10:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['rate_limit'],
|
||||
code='rate_limit',
|
||||
)
|
||||
return self.cleaned_data
|
||||
|
||||
def create(self):
|
||||
customer = self.request.organizer.customers.create(
|
||||
email=self.cleaned_data['email'],
|
||||
name_parts=self.cleaned_data['name_parts'],
|
||||
is_active=True,
|
||||
is_verified=False,
|
||||
locale=get_language_without_region(),
|
||||
)
|
||||
customer.set_unusable_password()
|
||||
customer.save()
|
||||
customer.log_action('pretix.customer.created', {})
|
||||
ctx = customer.get_email_context()
|
||||
token = TokenGenerator().make_token(customer)
|
||||
ctx['url'] = build_absolute_uri(self.request.organizer,
|
||||
'presale:organizer.customer.activate') + '?id=' + customer.identifier + '&token=' + token
|
||||
mail(
|
||||
customer.email,
|
||||
_('Activate your account at {organizer}').format(organizer=self.request.organizer.name),
|
||||
self.request.organizer.settings.mail_text_customer_registration,
|
||||
ctx,
|
||||
locale=customer.locale,
|
||||
customer=customer,
|
||||
organizer=self.request.organizer,
|
||||
)
|
||||
return customer
|
||||
|
||||
|
||||
class SetPasswordForm(forms.Form):
|
||||
required_css_class = 'required'
|
||||
error_messages = {
|
||||
'pw_mismatch': _("Please enter the same password twice"),
|
||||
}
|
||||
email = forms.EmailField(
|
||||
label=_('E-mail'),
|
||||
disabled=True
|
||||
)
|
||||
password = forms.CharField(
|
||||
label=_('Password'),
|
||||
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
|
||||
required=True
|
||||
)
|
||||
password_repeat = forms.CharField(
|
||||
label=_('Repeat password'),
|
||||
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
|
||||
)
|
||||
|
||||
def __init__(self, customer=None, *args, **kwargs):
|
||||
self.customer = customer
|
||||
kwargs.setdefault('initial', {})
|
||||
kwargs['initial']['email'] = self.customer.email
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
password1 = self.cleaned_data.get('password', '')
|
||||
password2 = self.cleaned_data.get('password_repeat')
|
||||
|
||||
if password1 and password1 != password2:
|
||||
raise forms.ValidationError({
|
||||
'password_repeat': self.error_messages['pw_mismatch'],
|
||||
}, code='pw_mismatch')
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
def clean_password(self):
|
||||
password1 = self.cleaned_data.get('password', '')
|
||||
if validate_password(password1, user=self.customer) is not None:
|
||||
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
|
||||
return password1
|
||||
|
||||
|
||||
class ResetPasswordForm(forms.Form):
|
||||
required_css_class = 'required'
|
||||
error_messages = {
|
||||
'rate_limit': _("For security reasons, please wait 10 minutes before you try again."),
|
||||
'unknown': _("A user with this email address is not known in our system."),
|
||||
}
|
||||
email = forms.EmailField(
|
||||
label=_('E-mail'),
|
||||
)
|
||||
|
||||
def __init__(self, request=None, *args, **kwargs):
|
||||
self.request = request
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_email(self):
|
||||
if 'email' not in self.cleaned_data:
|
||||
return
|
||||
try:
|
||||
self.customer = self.request.organizer.customers.get(email=self.cleaned_data['email'])
|
||||
return self.customer.email
|
||||
except Customer.DoesNotExist:
|
||||
# Yup, this is an information leak. But it prevents dozens of support requests – and even if we didn't
|
||||
# have it, there'd be an info leak in the registration flow (trying to sign up for an account, which fails
|
||||
# if the email address already exists).
|
||||
raise forms.ValidationError(self.error_messages['unknown'], code='unknown')
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
if d.get('email') and settings.HAS_REDIS:
|
||||
from django_redis import get_redis_connection
|
||||
|
||||
rc = get_redis_connection("redis")
|
||||
cnt = rc.incr('pretix_pwreset_customer_%s' % self.customer.pk)
|
||||
rc.expire('pretix_pwreset_customer_%s' % self.customer.pk, 600)
|
||||
if cnt > 2:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['rate_limit'],
|
||||
code='rate_limit',
|
||||
)
|
||||
return d
|
||||
|
||||
|
||||
class ChangePasswordForm(forms.Form):
|
||||
required_css_class = 'required'
|
||||
error_messages = {
|
||||
'pw_current_wrong': _("The current password you entered was not correct."),
|
||||
'pw_mismatch': _("Please enter the same password twice"),
|
||||
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
||||
}
|
||||
email = forms.EmailField(
|
||||
label=_('E-mail'),
|
||||
disabled=True
|
||||
)
|
||||
password_current = forms.CharField(
|
||||
label=_('Your current password'),
|
||||
widget=forms.PasswordInput,
|
||||
required=True
|
||||
)
|
||||
password = forms.CharField(
|
||||
label=_('New password'),
|
||||
widget=forms.PasswordInput,
|
||||
required=True
|
||||
)
|
||||
password_repeat = forms.CharField(
|
||||
label=_('Repeat password'),
|
||||
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
|
||||
)
|
||||
|
||||
def __init__(self, customer, *args, **kwargs):
|
||||
self.customer = customer
|
||||
kwargs.setdefault('initial', {})
|
||||
kwargs['initial']['email'] = self.customer.email
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
password1 = self.cleaned_data.get('password', '')
|
||||
password2 = self.cleaned_data.get('password_repeat')
|
||||
|
||||
if password1 and password1 != password2:
|
||||
raise forms.ValidationError({
|
||||
'password_repeat': self.error_messages['pw_mismatch'],
|
||||
}, code='pw_mismatch')
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
def clean_password(self):
|
||||
password1 = self.cleaned_data.get('password', '')
|
||||
if validate_password(password1, user=self.customer) is not None:
|
||||
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
|
||||
return password1
|
||||
|
||||
def clean_password_current(self):
|
||||
old_pw = self.cleaned_data.get('password_current')
|
||||
|
||||
if old_pw and settings.HAS_REDIS:
|
||||
from django_redis import get_redis_connection
|
||||
|
||||
rc = get_redis_connection("redis")
|
||||
cnt = rc.incr('pretix_pwchange_customer_%s' % self.customer.pk)
|
||||
rc.expire('pretix_pwchange_customer_%s' % self.customer.pk, 300)
|
||||
if cnt > 10:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['rate_limit'],
|
||||
code='rate_limit',
|
||||
)
|
||||
|
||||
if old_pw and not check_password(old_pw, self.customer.password):
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_current_wrong'],
|
||||
code='pw_current_wrong',
|
||||
)
|
||||
|
||||
|
||||
class ChangeInfoForm(forms.ModelForm):
|
||||
required_css_class = 'required'
|
||||
error_messages = {
|
||||
'pw_current_wrong': _("The current password you entered was not correct."),
|
||||
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
||||
'duplicate': _("An account with this email address is already registered."),
|
||||
}
|
||||
password_current = forms.CharField(
|
||||
label=_('Your current password'),
|
||||
widget=forms.PasswordInput,
|
||||
help_text=_('Only required if you change your email address'),
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Customer
|
||||
fields = ('name_parts', 'email')
|
||||
|
||||
def __init__(self, request=None, *args, **kwargs):
|
||||
self.request = request
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=True,
|
||||
scheme=request.organizer.settings.name_scheme,
|
||||
titles=request.organizer.settings.name_scheme_titles,
|
||||
label=_('Name'),
|
||||
)
|
||||
|
||||
def clean_password_current(self):
|
||||
old_pw = self.cleaned_data.get('password_current')
|
||||
|
||||
if old_pw:
|
||||
if settings.HAS_REDIS:
|
||||
from django_redis import get_redis_connection
|
||||
|
||||
rc = get_redis_connection("redis")
|
||||
cnt = rc.incr('pretix_pwchange_customer_%s' % self.instance.pk)
|
||||
rc.expire('pretix_pwchange_customer_%s' % self.instance.pk, 300)
|
||||
if cnt > 10:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['rate_limit'],
|
||||
code='rate_limit',
|
||||
)
|
||||
|
||||
if not check_password(old_pw, self.instance.password):
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_current_wrong'],
|
||||
code='pw_current_wrong',
|
||||
)
|
||||
|
||||
return "***valid***"
|
||||
|
||||
def clean(self):
|
||||
email = self.cleaned_data.get('email')
|
||||
password_current = self.cleaned_data.get('password_current')
|
||||
|
||||
if email != self.instance.email and not password_current:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_current_wrong'],
|
||||
code='pw_current_wrong',
|
||||
)
|
||||
|
||||
if email is not None:
|
||||
try:
|
||||
self.request.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
|
||||
@@ -120,7 +120,10 @@ class CheckoutFieldRenderer(FieldRenderer):
|
||||
def add_label(self, html):
|
||||
label = self.get_label()
|
||||
|
||||
if hasattr(self.field.field, '_required'):
|
||||
if hasattr(self.field.field, '_show_required'):
|
||||
# e.g. payment settings forms where a field is only required if the payment provider is active
|
||||
required = self.field.field._show_required
|
||||
elif hasattr(self.field.field, '_required'):
|
||||
# e.g. payment settings forms where a field is only required if the payment provider is active
|
||||
required = self.field.field._required
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user