diff --git a/account/auth_backends.py b/account/auth_backends.py index d3777edb..d5f02681 100644 --- a/account/auth_backends.py +++ b/account/auth_backends.py @@ -7,6 +7,10 @@ class UsernameAuthenticationBackend(ModelBackend): + """ + When the email model is the User, this authentication works just fine + for authenticating the user through email + """ def authenticate(self, request, username=None, password=None, **kwargs): if username is None or password is None: @@ -28,6 +32,8 @@ def authenticate(self, request, username=None, password=None, **kwargs): class EmailAuthenticationBackend(ModelBackend): def authenticate(self, request, username=None, password=None, **kwargs): + # TODO, remove primary true, username shouldn't be necessary either? + # should use email model. This probably doesn't need to be modified qs = EmailAddress.objects.filter(Q(primary=True) | Q(verified=True)) if username is None or password is None: diff --git a/account/conf.py b/account/conf.py index f36e90db..d2f312f9 100644 --- a/account/conf.py +++ b/account/conf.py @@ -1,5 +1,6 @@ import importlib +from django.apps import apps as django_apps from django.conf import settings # noqa from django.core.exceptions import ImproperlyConfigured @@ -22,8 +23,29 @@ def load_path_attr(path): return attr -class AccountAppConf(AppConf): +def get_email_model(): + """ + Return the Email model that is active in this project. + This should either be the user model, or the email model provided + in the project + """ + try: + return django_apps.get_model(settings.ACCOUNT_EMAIL_MODEL, require_ready=False) + except ValueError: + raise ImproperlyConfigured("ACCOUNT_EMAIL_MODEL must be of the form 'app_label.model_name'") + except LookupError: + raise ImproperlyConfigured( + "ACCOUNT_EMAIL_MODEL refers to model '%s' that has not been installed" % settings.ACCOUNT_EMAIL_MODEL + ) + +def user_as_email(): + return settings.ACCOUNT_EMAIL_MODEL == settings.AUTH_USER_MODEL + + +class AccountAppConf(AppConf): + # TODO can we make a check that it's easier the user model or this model? + EMAIL_MODEL = "account.EmailAddress" OPEN_SIGNUP = True LOGIN_URL = "account_login" LOGOUT_URL = "account_logout" diff --git a/account/forms.py b/account/forms.py index b093df75..c057ec43 100644 --- a/account/forms.py +++ b/account/forms.py @@ -7,12 +7,14 @@ from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ -from account.conf import settings +from account.conf import settings, get_email_model from account.hooks import hookset from account.models import EmailAddress from account.utils import get_user_lookup_kwargs -alnum_re = re.compile(r"^\w+$") +alnum_re = re.compile(r"^[\w+.@]+$") + +EmailModel = get_email_model() class PasswordField(forms.CharField): @@ -32,6 +34,7 @@ def to_python(self, value): class SignupForm(forms.Form): + # TODO make it possible to strip out the username field username = forms.CharField( label=_("Username"), @@ -179,7 +182,7 @@ class PasswordResetForm(forms.Form): def clean_email(self): value = self.cleaned_data["email"] - if not EmailAddress.objects.filter(email__iexact=value).exists(): + if not EmailModel.objects.filter(email__iexact=value).exists(): raise forms.ValidationError(_("Email address can not be found.")) return value @@ -221,7 +224,7 @@ def clean_email(self): value = self.cleaned_data["email"] if self.initial.get("email") == value: return value - qs = EmailAddress.objects.filter(email__iexact=value) + qs = EmailModel.objects.filter(email__iexact=value) if not qs.exists() or not settings.ACCOUNT_EMAIL_UNIQUE: return value raise forms.ValidationError(_("A user is registered with this email address.")) diff --git a/account/managers.py b/account/managers.py index ec30d4d4..fb2a4818 100644 --- a/account/managers.py +++ b/account/managers.py @@ -1,5 +1,7 @@ from django.db import models +from account.hooks import hookset + class EmailAddressManager(models.Manager): @@ -7,7 +9,7 @@ def add_email(self, user, email, **kwargs): confirm = kwargs.pop("confirm", False) email_address = self.create(user=user, email=email, **kwargs) if confirm and not email_address.verified: - email_address.send_confirmation() + self.send_confirmation(email=email) return email_address def get_primary(self, user): @@ -21,6 +23,12 @@ def get_users_for(self, email): # do a len() on it right away return [address.user for address in self.filter(verified=True, email=email)] + def send_confirmation(self, **kwargs): + from account.models import EmailConfirmation + confirmation = EmailConfirmation.create(kwargs['email']) + confirmation.send(**kwargs) + return confirmation + class EmailConfirmationManager(models.Manager): @@ -28,3 +36,15 @@ def delete_expired_confirmations(self): for confirmation in self.all(): if confirmation.key_expired(): confirmation.delete() + + +class UserEmailManager(models.Manager): + """This manager needs to be inherited if you use the User + model as the email model. + """ + + def send_confirmation(self, **kwargs): + from account.models import EmailConfirmation + confirmation = EmailConfirmation.create(self) + confirmation.send(**kwargs) + return confirmation diff --git a/account/migrations/0001_initial.py b/account/migrations/0001_initial.py index 90682dcb..f3c02519 100644 --- a/account/migrations/0001_initial.py +++ b/account/migrations/0001_initial.py @@ -59,7 +59,7 @@ class Migration(migrations.Migration): ('created', models.DateTimeField(default=django.utils.timezone.now)), ('sent', models.DateTimeField(null=True)), ('key', models.CharField(unique=True, max_length=64)), - ('email_address', models.ForeignKey(to='account.EmailAddress', on_delete=models.CASCADE)), + ('email_address', models.ForeignKey(to=settings.ACCOUNT_EMAIL_MODEL, on_delete=models.CASCADE)), ], options={ 'verbose_name': 'email confirmation', diff --git a/account/models.py b/account/models.py index 7d479a11..d169938c 100644 --- a/account/models.py +++ b/account/models.py @@ -15,7 +15,7 @@ import pytz from account import signals -from account.conf import settings +from account.conf import settings, user_as_email from account.fields import TimeZoneField from account.hooks import hookset from account.languages import DEFAULT_LANGUAGE @@ -272,11 +272,6 @@ def set_as_primary(self, conditional=False): self.user.save() return True - def send_confirmation(self, **kwargs): - confirmation = EmailConfirmation.create(self) - confirmation.send(**kwargs) - return confirmation - def change(self, new_email, confirm=True): """ Given a new email address, change self and re-confirm. @@ -288,12 +283,12 @@ def change(self, new_email, confirm=True): self.verified = False self.save() if confirm: - self.send_confirmation() + EmailAddress.objects.send_confirmation(email=self) class EmailConfirmation(models.Model): - email_address = models.ForeignKey(EmailAddress, on_delete=models.CASCADE) + email_address = models.ForeignKey(settings.ACCOUNT_EMAIL_MODEL, on_delete=models.CASCADE) created = models.DateTimeField(default=timezone.now) sent = models.DateTimeField(null=True) key = models.CharField(max_length=64, unique=True) @@ -321,7 +316,8 @@ def confirm(self): if not self.key_expired() and not self.email_address.verified: email_address = self.email_address email_address.verified = True - email_address.set_as_primary(conditional=True) + if not user_as_email(): + email_address.set_as_primary(conditional=True) email_address.save() signals.email_confirmed.send(sender=self.__class__, email_address=email_address) return email_address @@ -334,9 +330,11 @@ def send(self, **kwargs): current_site.domain, reverse(settings.ACCOUNT_EMAIL_CONFIRMATION_URL, args=[self.key]) ) + + user = self.email_address.user if not user_as_email() else self ctx = { "email_address": self.email_address, - "user": self.email_address.user, + "user": user, "activate_url": activate_url, "current_site": current_site, "key": self.key, diff --git a/account/views.py b/account/views.py index 8f86749a..c097ea11 100644 --- a/account/views.py +++ b/account/views.py @@ -16,10 +16,11 @@ from django.views.generic.edit import FormView from account import signals -from account.conf import settings +from account.conf import settings, user_as_email, get_email_model from account.forms import ( ChangePasswordForm, LoginUsernameForm, + LoginEmailForm, PasswordResetForm, PasswordResetTokenForm, SettingsForm, @@ -37,6 +38,13 @@ ) from account.utils import default_redirect, get_form_data +EmailModel = get_email_model() + +if user_as_email(): + LoginForm = LoginEmailForm +else: + LoginForm = LoginUsernameForm + class PasswordMixin(object): """ @@ -120,7 +128,7 @@ def create_password_history(self, form, user): class SignupView(PasswordMixin, FormView): - + # TODO need to closely go through this. template_name = "account/signup.html" template_name_ajax = "account/ajax/signup.html" template_name_email_confirmation_sent = "account/email_confirmation_sent.html" @@ -284,7 +292,12 @@ def create_email_address(self, form, **kwargs): kwargs.setdefault("primary", True) kwargs.setdefault("verified", False) if self.signup_code: - kwargs["verified"] = self.created_user.email == self.signup_code.email if self.signup_code.email else False + verified = self.created_user.email == self.signup_code.email if self.signup_code.email else False + kwargs["verified"] = verified + + if user_as_email(): + self.created_user = kwargs['verified'] + return self.created_user return EmailAddress.objects.add_email(self.created_user, self.created_user.email, **kwargs) def use_signup_code(self, user): @@ -292,7 +305,7 @@ def use_signup_code(self, user): self.signup_code.use(user) def send_email_confirmation(self, email_address): - email_address.send_confirmation(site=get_current_site(self.request)) + EmailModel.objects.send_confirmation(email=email_address, site=get_current_site(self.request)) def after_signup(self, form): signals.user_signed_up.send(sender=SignupForm, user=self.created_user, form=form) @@ -354,7 +367,7 @@ class LoginView(FormView): template_name = "account/login.html" template_name_ajax = "account/ajax/login.html" - form_class = LoginUsernameForm + form_class = LoginForm form_kwargs = {} redirect_field_name = "next" @@ -774,6 +787,10 @@ def update_email(self, form, confirm=None): confirm = settings.ACCOUNT_EMAIL_CONFIRMATION_EMAIL # @@@ handle multiple emails per user email = form.cleaned_data["email"].strip() + if user_as_email(): + # When using the user as email we don't need to care about primary emails + return + if not self.primary_email_address: user.email = email EmailAddress.objects.add_email(self.request.user, email, primary=True, confirm=confirm) diff --git a/docs/usage.rst b/docs/usage.rst index b0abb043..3a1e3465 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -293,3 +293,12 @@ are saved forever, allowing password history checking for new passwords. For an authenticated user, ``ExpiredPasswordMiddleware`` prevents retrieving or posting to any page except the password change page and log out page when the user password is expired. However, if the user is "staff" (can access the Django admin site), the password check is skipped. + + +Using email from User model +============================ + +TODO + +The user model needs a field `verified`, it should also have a property `email`, which is used +as an alias for the username which is the email.