Repository: pennersr/django-allauth (python)

Issue:
Feature Request: Prefill option for E-Mail on signup form
Hi,

would be nice if I could send a link to a customer with a prefilled version of the registration form.

For example:
https://www.exmaple.com/accounts/signup/?email=cumter@email.de

Pull request:
feat(accounts): Added prefilling of forms with GET query strings
This pr adds email prefilling suport in signup form, closes #2861 


Patch:
diff --git a/allauth/account/forms.py b/allauth/account/forms.py
index e0c4f26a..8f595e9c 100644
--- a/allauth/account/forms.py
+++ b/allauth/account/forms.py
@@ -20,6 +20,7 @@ from .adapter import get_adapter
 from .app_settings import AuthenticationMethod
 from .models import EmailAddress
 from .utils import (
+    assess_unique_email,
     filter_users_by_email,
     get_user_model,
     perform_login,
@@ -347,34 +348,16 @@ class BaseSignupForm(_base_signup_form_class()):
 
     def validate_unique_email(self, value):
         adapter = get_adapter()
-        if not EmailAddress.objects.lookup([value]).exists():
+        assessment = assess_unique_email(value)
+        if assessment is True:
             # All good.
             pass
-        elif not app_settings.PREVENT_ENUMERATION:
+        elif assessment is False:
             # Fail right away.
             raise forms.ValidationError(adapter.error_messages["email_taken"])
-        elif (
-            app_settings.EMAIL_VERIFICATION
-            == app_settings.EmailVerificationMethod.MANDATORY
-        ):
-            # In case of mandatory verification and enumeration prevention,
-            # we can avoid creating a new account with the same (unverified)
-            # email address, because we are going to send an email anyway.
-            assert app_settings.PREVENT_ENUMERATION
-            self.account_already_exists = True
-        elif app_settings.PREVENT_ENUMERATION == "strict":
-            # We're going to be strict on enumeration prevention, and allow for
-            # this email address to pass even though it already exists. In this
-            # scenario, you can signup multiple times using the same email
-            # address resulting in multiple accounts with an unverified email.
-            pass
         else:
-            assert app_settings.PREVENT_ENUMERATION is True
-            # Conflict. We're supposed to prevent enumeration, but we can't
-            # because that means letting the user in, while emails are required
-            # to be unique. In this case, uniqueness takes precedence over
-            # enumeration prevention.
-            raise forms.ValidationError(adapter.error_messages["email_taken"])
+            assert assessment is None
+            self.account_already_exists = True
         return adapter.validate_unique_email(value)
 
     def clean(self):
diff --git a/allauth/account/utils.py b/allauth/account/utils.py
index f6090592..b896c253 100644
--- a/allauth/account/utils.py
+++ b/allauth/account/utils.py
@@ -1,6 +1,7 @@
 import time
 import unicodedata
 from collections import OrderedDict
+from typing import Optional
 
 from django.conf import settings
 from django.contrib import messages
@@ -273,9 +274,18 @@ def cleanup_email_addresses(request, addresses):
         # ... and non-conflicting ones...
         if (
             app_settings.UNIQUE_EMAIL
+            and app_settings.PREVENT_ENUMERATION != "strict"
+            and EmailAddress.objects.lookup([email])
+        ):
+            # Email address already exists.
+            continue
+        if (
+            app_settings.UNIQUE_EMAIL
+            and app_settings.PREVENT_ENUMERATION == "strict"
             and address.verified
             and EmailAddress.objects.is_verified(email)
         ):
+            # Email address already exists, and is verified as well.
             continue
         a = e2a.get(email.lower())
         if a:
@@ -537,3 +547,41 @@ def did_recently_authenticate(request):
     if not authenticated_at:
         return False
     return time.time() - authenticated_at < app_settings.REAUTHENTICATION_TIMEOUT
+
+
+def assess_unique_email(email) -> Optional[bool]:
+    """
+    True -- email is unique
+    False -- email is already in use
+    None -- email is in use, but we should hide that using email verification.
+    """
+    from .models import EmailAddress
+
+    if not EmailAddress.objects.lookup([email]).exists():
+        # All good.
+        return True
+    elif not app_settings.PREVENT_ENUMERATION:
+        # Fail right away.
+        return False
+    elif (
+        app_settings.EMAIL_VERIFICATION
+        == app_settings.EmailVerificationMethod.MANDATORY
+    ):
+        # In case of mandatory verification and enumeration prevention,
+        # we can avoid creating a new account with the same (unverified)
+        # email address, because we are going to send an email anyway.
+        assert app_settings.PREVENT_ENUMERATION
+        return None
+    elif app_settings.PREVENT_ENUMERATION == "strict":
+        # We're going to be strict on enumeration prevention, and allow for
+        # this email address to pass even though it already exists. In this
+        # scenario, you can signup multiple times using the same email
+        # address resulting in multiple accounts with an unverified email.
+        return True
+    else:
+        assert app_settings.PREVENT_ENUMERATION is True
+        # Conflict. We're supposed to prevent enumeration, but we can't
+        # because that means letting the user in, while emails are required
+        # to be unique. In this case, uniqueness takes precedence over
+        # enumeration prevention.
+        return False
diff --git a/allauth/account/views.py b/allauth/account/views.py
index d2ab9163..c610b593 100644
--- a/allauth/account/views.py
+++ b/allauth/account/views.py
@@ -2,6 +2,8 @@ from django.contrib import messages
 from django.contrib.auth import REDIRECT_FIELD_NAME
 from django.contrib.auth.decorators import login_required
 from django.contrib.sites.shortcuts import get_current_site
+from django.core.validators import validate_email
+from django.forms import ValidationError
 from django.http import (
     Http404,
     HttpResponsePermanentRedirect,
@@ -290,6 +292,19 @@ class SignupView(
         )
         return ret
 
+    def get_initial(self):
+        initial = super().get_initial()
+        email = self.request.GET.get("email")
+        if email:
+            try:
+                validate_email(email)
+            except ValidationError:
+                return initial
+            initial["email"] = email
+            if app_settings.SIGNUP_EMAIL_ENTER_TWICE:
+                initial["email2"] = email
+        return initial
+
 
 signup = SignupView.as_view()
 
diff --git a/allauth/socialaccount/helpers.py b/allauth/socialaccount/helpers.py
index b2d30612..d0a8473a 100644
--- a/allauth/socialaccount/helpers.py
+++ b/allauth/socialaccount/helpers.py
@@ -6,8 +6,8 @@ from django.urls import reverse
 
 from allauth.account import app_settings as account_settings
 from allauth.account.adapter import get_adapter as get_account_adapter
-from allauth.account.models import EmailAddress
 from allauth.account.utils import (
+    assess_unique_email,
     complete_signup,
     perform_login,
     user_display,
@@ -29,33 +29,33 @@ def _process_auto_signup(request, sociallogin):
     email = user_email(sociallogin.user)
     # Let's check if auto_signup is really possible...
     if email:
-        if account_settings.UNIQUE_EMAIL and EmailAddress.objects.is_verified(email):
-            if (
-                account_settings.PREVENT_ENUMERATION
-                and account_settings.EMAIL_VERIFICATION
-                == account_settings.EmailVerificationMethod.MANDATORY
-            ):
-                # Prevent enumeration is properly turned on, meaning, we cannot
-                # show the signup form to allow the user to input another email
-                # address. Instead, we're going to send the user an email that
-                # the account already exists, and on the outside make it appear
-                # as if an email verification mail was sent.
-                account_adapter = get_account_adapter(request)
-                account_adapter.send_account_already_exists_mail(email)
-                resp = account_adapter.respond_email_verification_sent(request, None)
-                return False, resp
-            else:
-                # Oops, another user already has this address.  We cannot simply
-                # connect this social account to the existing user. Reason is
-                # that the email address may not be verified, meaning, the user
-                # may be a hacker that has added your email address to their
-                # account in the hope that you fall in their trap.  We cannot
-                # check on 'email_address.verified' either, because
-                # 'email_address' is not guaranteed to be verified.
-                auto_signup = False
-                # TODO: We redirect to signup form -- user will see email
-                # address conflict only after posting whereas we detected it
-                # here already.
+        assessment = assess_unique_email(email)
+        if assessment is True:
+            # Auto signup is fine.
+            pass
+        elif assessment is False:
+            # Oops, another user already has this address.  We cannot simply
+            # connect this social account to the existing user. Reason is
+            # that the email address may not be verified, meaning, the user
+            # may be a hacker that has added your email address to their
+            # account in the hope that you fall in their trap.  We cannot
+            # check on 'email_address.verified' either, because
+            # 'email_address' is not guaranteed to be verified.
+            auto_signup = False
+            # TODO: We redirect to signup form -- user will see email
+            # address conflict only after posting whereas we detected it
+            # here already.
+        else:
+            assert assessment is None
+            # Prevent enumeration is properly turned on, meaning, we cannot
+            # show the signup form to allow the user to input another email
+            # address. Instead, we're going to send the user an email that
+            # the account already exists, and on the outside make it appear
+            # as if an email verification mail was sent.
+            account_adapter = get_account_adapter(request)
+            account_adapter.send_account_already_exists_mail(email)
+            resp = account_adapter.respond_email_verification_sent(request, None)
+            return False, resp
     elif app_settings.EMAIL_REQUIRED:
         # Nope, email is required and we don't have it yet...
         auto_signup = False


Test patch:
diff --git a/allauth/account/tests/test_signup.py b/allauth/account/tests/test_signup.py
index 0619539a..79408515 100644
--- a/allauth/account/tests/test_signup.py
+++ b/allauth/account/tests/test_signup.py
@@ -10,6 +10,7 @@ from django.test.client import Client, RequestFactory
 from django.test.utils import override_settings
 from django.urls import reverse
 
+import pytest
 from pytest_django.asserts import assertTemplateUsed
 
 from allauth.account import app_settings
@@ -371,3 +372,16 @@ def test_prevent_enumeration_on(settings, user_factory):
     assert resp.context["form"].errors == {
         "email": ["A user is already registered with this email address."]
     }
+
+
+@pytest.mark.django_db
+def test_get_initial_with_valid_email():
+    """Test that the email field is populated with a valid email."""
+    request = RequestFactory().get("/signup/?email=test@example.com")
+    from allauth.account.views import signup
+
+    SessionMiddleware(lambda request: None).process_request(request)
+    request.user = AnonymousUser()
+    with context.request_context(request):
+        view = signup(request)
+    assert view.context_data["view"].get_initial()["email"] == "test@example.com"
diff --git a/allauth/socialaccount/conftest.py b/allauth/socialaccount/conftest.py
index a3bf68f8..43109a36 100644
--- a/allauth/socialaccount/conftest.py
+++ b/allauth/socialaccount/conftest.py
@@ -6,13 +6,19 @@ from allauth.socialaccount.models import SocialAccount, SocialLogin
 
 @pytest.fixture
 def sociallogin_factory(user_factory):
-    def factory(email=None, with_email=True, provider="unittest-server", uid="123"):
+    def factory(
+        email=None,
+        with_email=True,
+        provider="unittest-server",
+        uid="123",
+        email_verified=True,
+    ):
         user = user_factory(email=email, commit=False, with_email=with_email)
         account = SocialAccount(provider=provider, uid=uid)
         sociallogin = SocialLogin(user=user, account=account)
         if with_email:
             sociallogin.email_addresses = [
-                EmailAddress(email=user.email, verified=True, primary=True)
+                EmailAddress(email=user.email, verified=email_verified, primary=True)
             ]
         return sociallogin
 
diff --git a/allauth/socialaccount/tests/test_signup.py b/allauth/socialaccount/tests/test_signup.py
index ccba05c1..28390a50 100644
--- a/allauth/socialaccount/tests/test_signup.py
+++ b/allauth/socialaccount/tests/test_signup.py
@@ -489,3 +489,38 @@ def test_email_address_conflict_during_auto_signup(
     resp = complete_social_login(request, sociallogin)
     assert resp["location"] == reverse("account_email_verification_sent")
     assert mailoutbox[0].subject == "[example.com] Account Already Exists"
+
+
+def test_email_address_conflict_removes_conflicting_email(
+    db, settings, user_factory, sociallogin_factory, client, rf, mailoutbox
+):
+    """Tests that when an already existing email is given at the social signup
+    form, enumeration preventation kicks in.
+    """
+    settings.ACCOUNT_EMAIL_REQUIRED = True
+    settings.ACCOUNT_UNIQUE_EMAIL = True
+    settings.ACCOUNT_USERNAME_REQUIRED = False
+    settings.ACCOUNT_AUTHENTICATION_METHOD = "email"
+    settings.ACCOUNT_EMAIL_VERIFICATION = "optional"
+    settings.SOCIALACCOUNT_AUTO_SIGNUP = True
+    settings.SOCIALACCOUNT_EMAIL_AUTHENTICATION = False
+
+    user = user_factory(email_verified=False)
+    sociallogin = sociallogin_factory(email=user.email, email_verified=False)
+
+    request = rf.get("/")
+    request.session = {}
+    request.user = AnonymousUser()
+
+    resp = complete_social_login(request, sociallogin)
+    # Auto signup does not kick in as the `sociallogin` has a conflicting email.
+    assert resp["location"] == reverse("socialaccount_signup")
+
+    session = client.session
+    session["socialaccount_sociallogin"] = sociallogin.serialize()
+    session.save()
+    # Here, we input the already existing email.
+    resp = client.post(reverse("socialaccount_signup"), {"email": "other@email.org"})
+    assert mailoutbox[0].subject == "[example.com] Please Confirm Your Email Address"
+    assert resp["location"] == settings.LOGIN_REDIRECT_URL
+    assert EmailAddress.objects.filter(email=user.email).count() == 1