Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Connexion: Utilisation du modèle EmailAddress et refonte du parcours de modification d'adresse mail [GEN-2216] #5088

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@
# We don't want any user to be able to signup using the default allauth `signup` url
# because we have multiple specific signup processes for different kind of users.
re_path(r"^accounts/signup/$", signup_views.signup),
# Override allauth `account_confirm_email` URL
# We want to control how EmailAddress is managed during this view
re_path(
r"^accounts/confirm-email/(?P<key>[-:\w]+)/$",
signup_views.ItouConfirmEmailView.as_view(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pourquoi ne pas garder celle de django-allauth ?

name="account_confirm_email",
),
# --------------------------------------------------------------------------------------
# Override allauth `account_login` URL.
# /accounts/login/ <=> account_login
Expand Down
10 changes: 6 additions & 4 deletions itou/openid_connect/inclusion_connect/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import httpx
from allauth.account.adapter import get_adapter
from allauth.account.models import EmailAddress
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login
Expand All @@ -14,7 +15,6 @@

from itou.prescribers.models import PrescriberOrganization
from itou.users.enums import KIND_EMPLOYER, KIND_PRESCRIBER, IdentityProvider, UserKind
from itou.users.models import User
from itou.utils import constants as global_constants
from itou.utils.constants import ITOU_HELP_CENTER_URL
from itou.utils.urls import add_url_params, get_absolute_url
Expand Down Expand Up @@ -173,13 +173,15 @@ def inclusion_connect_activate_account(request):
return HttpResponseRedirect(params.get("previous_url", reverse("search:employers_home")))

user_kind = params.get("user_kind")
user = User.objects.filter(email=email).first()
email_address = EmailAddress.objects.filter(email__iexact=email).first()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
email_address = EmailAddress.objects.filter(email__iexact=email).first()
email_address = EmailAddress.objects.filter(email__iexact=email).select_related("user").first()

Et a priori à chaque fois qu'on utilise EmailAddress pour récupérer un User.


if not user:
if not email_address:
params["register"] = True
request.GET = params
return inclusion_connect_authorize(request)

user = email_address.user

if user.kind != user_kind:
_add_user_kind_error_message(request, user, request.GET.get("user_kind"))
return HttpResponseRedirect(params.get("previous_url", reverse("search:employers_home")))
Expand Down Expand Up @@ -289,7 +291,7 @@ def inclusion_connect_callback(request):
try:
user, _ = ic_user_data.create_or_update_user(is_login=ic_state.data.get("is_login"))
except InvalidKindException:
existing_user = User.objects.get(email=user_data["email"])
existing_user = EmailAddress.objects.get(email__iexact=user_data["email"]).user
_add_user_kind_error_message(request, existing_user, user_kind)
is_successful = False
except MultipleSubSameEmailException as e:
Expand Down
10 changes: 7 additions & 3 deletions itou/openid_connect/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import ClassVar
from urllib.parse import unquote

from allauth.account.models import EmailAddress
from django.core import signing
from django.db import models
from django.utils import crypto, timezone
Expand Down Expand Up @@ -162,14 +163,14 @@ def create_or_update_user(self, is_login=False):
except User.DoesNotExist:
try:
# A different user has already claimed this email address (we require emails to be unique)
user = User.objects.get(email=self.email)
user = EmailAddress.objects.get(email__iexact=self.email).user
if user.identity_provider == self.identity_provider:
if not self.allow_sub_update:
raise MultipleSubSameEmailException(user)
elif user.identity_provider not in self.allowed_identity_provider_migration:
self.check_valid_kind(user, user_data_dict, is_login)
raise EmailInUseException(user)
except User.DoesNotExist:
except EmailAddress.DoesNotExist:
# User.objects.create_user does the following:
# - set User.is_active to true,
# - call User.set_unusable_password() if no password is given.
Expand All @@ -183,7 +184,7 @@ def create_or_update_user(self, is_login=False):
user.jobseeker_profile.birthdate = birthdate
user.jobseeker_profile.save(update_fields={"birthdate"})
else:
other_user = User.objects.exclude(pk=user.pk).filter(email=self.email).first()
other_user = EmailAddress.objects.exclude(user=user).filter(email__iexact=self.email).first()
if other_user:
# We found a user with its sub, but there's another user using its email.
# This happens when the user tried to update its email with one already used by another account.
Expand All @@ -206,6 +207,9 @@ def create_or_update_user(self, is_login=False):
user.update_external_data_source_history_field(provider=self.identity_provider, field=key, value=value)
user.save()

# Cancel any ongoing email modifications for the user
EmailAddress.objects.filter(user=user).exclude(email__iexact=self.email).delete()

return user, created

@staticmethod
Expand Down
4 changes: 2 additions & 2 deletions itou/openid_connect/pro_connect/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import httpx
import jwt
from allauth.account.adapter import get_adapter
from allauth.account.models import EmailAddress
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login
Expand All @@ -15,7 +16,6 @@

from itou.prescribers.models import PrescriberOrganization
from itou.users.enums import KIND_EMPLOYER, KIND_PRESCRIBER, IdentityProvider, UserKind
from itou.users.models import User
from itou.utils import constants as global_constants
from itou.utils.constants import ITOU_HELP_CENTER_URL
from itou.utils.urls import add_url_params, get_absolute_url
Expand Down Expand Up @@ -242,7 +242,7 @@ def pro_connect_callback(request):
try:
user, _ = pc_user_data.create_or_update_user(is_login=pro_connect_state.data.get("is_login"))
except InvalidKindException:
existing_user = User.objects.get(email=user_data["email"])
existing_user = EmailAddress.objects.get(email__iexact=user_data["email"]).user
_add_user_kind_error_message(request, existing_user, user_kind)
is_successful = False
except EmailInUseException as e:
Expand Down
4 changes: 2 additions & 2 deletions itou/templates/account/email/email_confirmation_message.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{% autoescape off %}
Nous avons bien enregistré votre demande d'inscription et vous remercions de votre confiance.
Nous avons bien reçu votre demande de modification d'adresse e-mail de connexion aux Emplois de l'inclusion.

Afin de finaliser votre inscription, cliquez sur le lien suivant :
Pour finaliser cette modification, veuillez cliquer sur le lien suivant :

{{ activate_url }}

Expand Down
10 changes: 10 additions & 0 deletions itou/templates/account/email/email_confirmation_signup_message.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{% autoescape off %}
Nous avons bien enregistré votre demande d'inscription et vous remercions de votre confiance.

Afin de finaliser votre inscription, cliquez sur le lien suivant :

{{ activate_url }}

Si vous n'êtes pas à l'origine de cette demande, merci de ne pas prendre en compte ce message.
{% include "layout/base_email_signature.txt" %}
{% endautoescape %}
Comment on lines +1 to +10
Copy link
Contributor Author

@calummackervoy calummackervoy Nov 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fonctionnalité de django-allauth, ce gabarit sera utilisé lorsque signup=True (docs)

38 changes: 38 additions & 0 deletions itou/users/migrations/0015_ensure_emailaddress_model_use.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from django.db import migrations
from django.db.models import OuterRef, Subquery


def create_email_addresses_for_users(apps, schema_editor):
User = apps.get_model("users", "User")
EmailAddress = apps.get_model("account", "EmailAddress")

# Get all those values of User.email where there is no corresponding EmailAddresses instance
users_missing_addresses = (
User.objects.prefetch_related("emailaddress_set")
.annotate(email_exists=Subquery(EmailAddress.objects.filter(email=OuterRef("email")).values("id")[:1]))
.filter(email_exists__isnull=True)
.values("id", "email")
)

EmailAddress.objects.bulk_create(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Il y en a beaucoup ? Ça vaudrait ptet le coup de faire un migration atomic = False.

EmailAddress(user_id=x["id"], email=x["email"], primary=True, verified=False) for x in users_missing_addresses
)


class Migration(migrations.Migration):
"""
This migration was created at a time when not all User email addresses had an associated EmailAddress.
It ensures that EmailAddress instances are created where they are not existing.
Of course this means that the migration can be squashed later.
"""

dependencies = [
("users", "0014_alter_jobseekerprofile_birthdate__add_index"),
]

operations = [
migrations.RunPython(
create_email_addresses_for_users,
reverse_code=migrations.RunPython.noop,
),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tu peux rajouter elidable=True si elle peut être squashée.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bien vu merci !

]
18 changes: 17 additions & 1 deletion itou/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from collections import Counter

from allauth.account.forms import default_token_generator
from allauth.account.models import EmailAddress
from allauth.account.utils import user_pk_to_url_str
from citext import CIEmailField
from django.conf import settings
Expand Down Expand Up @@ -75,6 +76,11 @@ def autocomplete(self, search_string, current_user):
)
return queryset[:10]

def create_user(self, username, email=None, password=None, **extra_fields):
if email is not None and EmailAddress.objects.filter(email__icontains=email).exists():
raise ValidationError(User.ERROR_EMAIL_ALREADY_EXISTS)
return super().create_user(username, email, password, **extra_fields)

def get_duplicated_pole_emploi_ids(self):
"""
Returns an array of `pole_emploi_id` used more than once:
Expand Down Expand Up @@ -350,6 +356,13 @@ def save(self, *args, **kwargs):

self.set_old_values()

# Ensure EmailAddress consistency
if self.email is None:
self.emailaddress_set.all().delete()
elif self.email not in self.emailaddress_set.values_list("email", flat=True):
self.emailaddress_set.all().delete()
self.emailaddress_set.create(email=self.email, primary=True)

def get_full_name(self):
"""
Return the first_name plus the last_name, with a space in between.
Expand Down Expand Up @@ -706,7 +719,10 @@ def email_already_exists(cls, email, exclude_pk=None):
is case-insensitive. Consider [email protected] and [email protected] as
the same email.
"""
queryset = cls.objects.filter(email__iexact=email)
# Uses EmailAddress to capture emails in-use but thus far unverified.
queryset = cls.objects.filter(
id__in=EmailAddress.objects.filter(email__iexact=email).values_list("user_id", flat=True)
)
if exclude_pk:
queryset = queryset.exclude(pk=exclude_pk)
Comment on lines +723 to 727
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
queryset = cls.objects.filter(
id__in=EmailAddress.objects.filter(email__iexact=email).values_list("user_id", flat=True)
)
if exclude_pk:
queryset = queryset.exclude(pk=exclude_pk)
queryset = EmailAddress.objects.filter(email__iexact=email)
if exclude_pk:
queryset = queryset.exclude(user_id=exclude_pk)

return queryset.exists()
Expand Down
7 changes: 4 additions & 3 deletions itou/www/dashboard/forms.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from allauth.account.models import EmailAddress
from django import forms
from django.core.exceptions import ValidationError
from django.utils import timezone
Expand Down Expand Up @@ -134,7 +135,7 @@ class EditUserEmailForm(forms.Form):
)

def __init__(self, *args, **kwargs):
self.user_email = kwargs.pop("user_email")
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)

def clean(self):
Expand All @@ -147,9 +148,9 @@ def clean(self):

def clean_email(self):
email = self.cleaned_data["email"]
if email == self.user_email:
if email == self.user.email:
raise ValidationError("Veuillez indiquer une adresse différente de l'actuelle.")
if User.objects.filter(email=email):
if EmailAddress.objects.exclude(user=self.user).filter(email__iexact=email).exists():
raise ValidationError("Cette adresse est déjà utilisée par un autre utilisateur.")
return email

Expand Down
15 changes: 10 additions & 5 deletions itou/www/dashboard/views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from allauth.account.utils import send_email_confirmation
from allauth.account.views import PasswordChangeView
from django.conf import settings
from django.contrib import auth, messages
Expand Down Expand Up @@ -213,13 +214,17 @@ class ItouPasswordChangeView(PasswordChangeView):
def edit_user_email(request, template_name="dashboard/edit_user_email.html"):
if request.user.has_sso_provider:
return HttpResponseForbidden()
form = EditUserEmailForm(data=request.POST or None, user_email=request.user.email)
form = EditUserEmailForm(data=request.POST or None, user=request.user)
if request.method == "POST" and form.is_valid():
request.user.email = form.cleaned_data["email"]
request.user.save()
request.user.emailaddress_set.all().delete()
# User can only request one email modification at a time
request.user.emailaddress_set.filter(verified=False, primary=False).delete()

email = form.cleaned_data["email"]
request.user.emailaddress_set.create(email=email)

send_email_confirmation(request, request.user, email=email)
auth.logout(request)
return HttpResponseRedirect(reverse("account_logout"))
return HttpResponseRedirect(reverse("account_email_verification_sent"))

context = {
"form": form,
Expand Down
8 changes: 4 additions & 4 deletions itou/www/itou_staff_views/forms.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import datetime

from allauth.account.models import EmailAddress
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator
from django.utils import timezone

from itou.common_apps.address.departments import DEPARTMENTS
from itou.users.models import User
from itou.utils.widgets import DuetDatePickerWidget


Expand Down Expand Up @@ -58,10 +58,10 @@ class MergeUserForm(forms.Form):
)

def _check_email_exists(self, email):
user = User.objects.filter(email=email).first()
if not user:
email_address = EmailAddress.objects.filter(email__iexact=email).first()
if not email_address:
raise ValidationError("Cet utilisateur n'existe pas.")
return user
return email_address.user

def clean_email_1(self):
self.user_1 = self._check_email_exists(self.cleaned_data["email_1"])
Expand Down
6 changes: 4 additions & 2 deletions itou/www/job_seekers_views/forms.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from allauth.account.models import EmailAddress
from django import forms
from django.forms import ValidationError
from django.utils.html import format_html
Expand Down Expand Up @@ -118,8 +119,9 @@ def clean_email(self):
raise ValidationError("Vous ne pouvez pas utiliser un e-mail Pôle emploi pour un candidat.")
if email.endswith(global_constants.FRANCE_TRAVAIL_EMAIL_SUFFIX):
raise ValidationError("Vous ne pouvez pas utiliser un e-mail France Travail pour un candidat.")
self.user = User.objects.filter(email__iexact=email).first()
if self.user:
email_address = EmailAddress.objects.filter(email__iexact=email).first()
if email_address:
self.user = email_address.user
if not self.user.is_active:
error = "Vous ne pouvez pas postuler pour cet utilisateur car son compte a été désactivé."
raise forms.ValidationError(error)
Expand Down
2 changes: 1 addition & 1 deletion itou/www/login/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def __init__(self, *args, **kwargs):

def clean(self):
# Parent method performs authentication on form success.
user = User.objects.filter(email=self.data["login"]).first()
user = User.objects.filter(email__iexact=self.data["login"]).first()
if (
user
and user.has_sso_provider
Expand Down
14 changes: 12 additions & 2 deletions itou/www/signup/forms.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from allauth.account.adapter import get_adapter
from allauth.account.forms import SignupForm
from allauth.account.models import EmailAddress
from django import forms
from django.core.exceptions import ValidationError
from django.db.models.fields import BLANK_CHOICE_DASH
Expand Down Expand Up @@ -146,7 +148,7 @@ def clean_email(self):
raise ValidationError("Vous ne pouvez pas utiliser un e-mail Pôle emploi pour un candidat.")
if email.endswith(global_constants.FRANCE_TRAVAIL_EMAIL_SUFFIX):
raise ValidationError("Vous ne pouvez pas utiliser un e-mail France Travail pour un candidat.")
if User.objects.filter(email=email).exists():
if EmailAddress.objects.filter(email__iexact=email).exists():
raise ValidationError("Un autre utilisateur utilise déjà cette adresse e-mail.")
return email

Expand All @@ -156,7 +158,15 @@ def save(self, request):
self.cleaned_data["username"] = User.generate_unique_username()
# Create the user.
self.user_kind = UserKind.JOB_SEEKER
user = super().save(request)

# Below code is taken from allauth SignupForm
# We don't call super().save() because there are some behaviours we want to avoid
# (calls to setup_user_email) and their comments suggest their code might be reorganized in future
adapter = get_adapter()
user = adapter.new_user(request)
adapter.save_user(request, user, self)
self.custom_signup(request, user)

user.title = self.cleaned_data["title"]
user.first_name = self.cleaned_data["first_name"]
user.last_name = self.cleaned_data["last_name"]
Expand Down
Loading
Loading