diff --git a/ChangeLog.rst b/ChangeLog.rst index ff32790a22..b6e4e9de0b 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -27,7 +27,8 @@ Note worthy changes installed. Therefore, enabling or disabling ``sites`` is not something you can do on the fly. - +- Added database-level unique constraint for a single primary email address + per user for Django 2.2 and above (supported on PostgreSQL and SQLite). Backwards incompatible changes ------------------------------ diff --git a/allauth/account/migrations/0003_unique_primary_email.py b/allauth/account/migrations/0003_unique_primary_email.py new file mode 100644 index 0000000000..edc9aa516d --- /dev/null +++ b/allauth/account/migrations/0003_unique_primary_email.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import django +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("account", "0002_email_max_length"), + ] + + if django.VERSION >= (2, 2): + operations = [ + migrations.AddConstraint( + model_name="emailaddress", + constraint=models.UniqueConstraint( + condition=models.Q(primary=True), + fields=("user", "primary"), + name="only_one_primary_for_user", + ), + ), + ] + else: + operations = [] diff --git a/allauth/account/models.py b/allauth/account/models.py index 0c3fe0c8d0..2c7c391393 100644 --- a/allauth/account/models.py +++ b/allauth/account/models.py @@ -2,6 +2,7 @@ import datetime +import django from django.core import signing from django.db import models, transaction from django.utils import timezone @@ -37,6 +38,14 @@ class Meta: verbose_name_plural = _("email addresses") if not app_settings.UNIQUE_EMAIL: unique_together = [("user", "email")] + if django.VERSION >= (2, 2): + constraints = [ + models.UniqueConstraint( + fields=["user", "primary"], + condition=models.Q(primary=True), + name="only_one_primary_for_user", + ) + ] def __str__(self): return self.email diff --git a/allauth/account/tests.py b/allauth/account/tests.py index c8137a9f6c..25c05a1582 100644 --- a/allauth/account/tests.py +++ b/allauth/account/tests.py @@ -3,7 +3,9 @@ import json import uuid from datetime import timedelta +from unittest import skipIf +import django from django import forms from django.conf import settings from django.contrib import messages @@ -14,6 +16,7 @@ from django.core import mail, validators from django.core.exceptions import ValidationError from django.db import models +from django.db.utils import IntegrityError from django.http import HttpResponseRedirect from django.template import Context, Template from django.test.client import Client, RequestFactory @@ -684,6 +687,27 @@ def test_login_failed_attempts_exceeded_cleared_on_password_reset(self): resp, settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False ) + @skipIf( + django.VERSION < (2, 2), + "Django only supports conditional indexes since version 2.2", + ) + def test_only_one_primary_email_per_user(self): + user = get_user_model().objects.create( + username="john", email="john@example.org", is_active=True + ) + user2 = self._create_user(username="john2", email="john2@example.com") + + EmailAddress.objects.create( + user=user, email="john@example.org", primary=True, verified=True + ) + EmailAddress.objects.create( + user=user2, email="john2@example.com", primary=True, verified=True + ) + with self.assertRaisesMessage(IntegrityError, "UNIQUE constraint failed"): + EmailAddress.objects.create( + user=user, email="john@example.com", primary=True, verified=False + ) + @override_settings( ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.EMAIL, ACCOUNT_EMAIL_VERIFICATION=app_settings.EmailVerificationMethod.MANDATORY, @@ -700,7 +724,7 @@ def test_login_using_unverified_email_address_is_prohibited(self): user=user, email="john@example.org", primary=True, verified=True ) EmailAddress.objects.create( - user=user, email="john@example.com", primary=True, verified=False + user=user, email="john@example.com", primary=False, verified=False ) resp = self.client.post(