Skip to content

Commit

Permalink
feat(account): Add unique constraint for a user's primary email
Browse files Browse the repository at this point in the history
Since Django 2.2 the UniqueConstraint supports conditions.
This allows enforcing only one primary email per user on the database
level.

The docs [1] mention that Oracle databases don't support partial
indexes. I'm not sure if that means that the index will silently be
ignored (just as MariaDB and MySQL do).

Closes #225.

[1] https://docs.djangoproject.com/en/2.2/ref/models/indexes/#django.db.models.Index.condition
  • Loading branch information
jnns committed Dec 17, 2021
1 parent 4f6ccb7 commit 7cf5f5e
Show file tree
Hide file tree
Showing 4 changed files with 62 additions and 2 deletions.
3 changes: 2 additions & 1 deletion ChangeLog.rst
Expand Up @@ -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
------------------------------
Expand Down
26 changes: 26 additions & 0 deletions 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"),
]

operations = [
migrations.AddConstraint(
model_name="emailaddress",
constraint=models.UniqueConstraint(
condition=models.Q(primary=True),
fields=("user", "primary"),
name="only_one_primary_for_user",
),
),
]

if django.VERSION < (2, 2):
operations = []
9 changes: 9 additions & 0 deletions allauth/account/models.py
Expand Up @@ -2,6 +2,7 @@

import datetime

import django
from django.core import signing
from django.db import models, transaction
from django.utils import timezone
Expand Down Expand Up @@ -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
Expand Down
26 changes: 25 additions & 1 deletion allauth/account/tests.py
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down

0 comments on commit 7cf5f5e

Please sign in to comment.