Repository: tournesol-app/tournesol

Issue:
Disallow creating multiple accounts with the same email
Currently it is possible to create multiple accounts with the same email address. This should not be possible.


Discussion:
- [x] Update admin UI configuration, to allow search by email address 
#254
- [x] Reject existing email on user creation
#255
- [ ] Clean up the existing databases, including the dump used by "dev-env"
- [ ] Enforce `unique=True` in the `User` model
We are two weeks past the deadline given to users for updating their non-unique emails. I propose that we move on to close this ticket. Probably adding a PR that finds and update duplicated emails, then enforce the unique option. 

Resolving patch:
diff --git a/backend/core/migrations/0005_email_unique_in_db.py b/backend/core/migrations/0005_email_unique_in_db.py
new file mode 100644
--- /dev/null
+++ b/backend/core/migrations/0005_email_unique_in_db.py
@@ -0,0 +1,63 @@
+import logging
+
+from django.db import migrations, models
+from django.db.models import Count, Window, F
+from django.db.models.functions.window import RowNumber
+
+
+def remove_duplicated_email(apps, schema_editor):
+    """
+    Find users with duplicated emails, and update email of the accounts associated
+    with the fewer comparisons (to preserve only 1 per original address).
+    Their 'email' value will be replaced with an invalid address derived
+    from the username to guarantee uniqueness in the database.
+    """
+    User = apps.get_model("core", "User")
+
+    duplicated_emails = (
+        User.objects.values("email").alias(count=Count("id")).filter(count__gte=2)
+    )
+    users_to_update = [
+        user
+        for user in User.objects.filter(email__in=duplicated_emails).annotate(
+            n_comparisons=Count("comparisons"),
+            rank_comparisons=Window(
+                expression=RowNumber(),
+                partition_by=[F("email")],
+                order_by=F("n_comparisons").desc(),
+            ),
+        )
+        if user.rank_comparisons > 1
+    ]
+
+    for u in users_to_update:
+        new_email = f"{u.username}@invalid"
+        logging.info(
+            'Updating email for user "%s", from "%s" to "%s"',
+            u.username,
+            u.email,
+            new_email,
+        )
+        u.email = new_email
+        u.save(update_fields=["email"])
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("core", "0004_rename_userpreferences_userpreference"),
+        ("tournesol", "0015_remove_obsolete_fields"),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=remove_duplicated_email, reverse_code=migrations.RunPython.noop
+        ),
+        migrations.AlterField(
+            model_name="user",
+            name="email",
+            field=models.EmailField(
+                max_length=254, unique=True, verbose_name="email address"
+            ),
+        ),
+    ]
diff --git a/backend/core/models/user.py b/backend/core/models/user.py
--- a/backend/core/models/user.py
+++ b/backend/core/models/user.py
@@ -10,6 +10,7 @@
 from django.contrib.auth.models import AbstractUser
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db.models.query import QuerySet
+from django.utils.translation import gettext_lazy as _
 from django_countries import countries
 
 from settings.settings import MAX_VALUE, CRITERIAS, CRITERIAS_DICT
@@ -23,6 +24,7 @@
 
 class User(AbstractUser):
 
+    email = models.EmailField(_('email address'), unique=True)
     is_demo = models.BooleanField(default=False, help_text="Is a demo account?")
     first_name = models.CharField(
         max_length=100, blank=True, null=True, help_text="First name"
diff --git a/backend/core/serializers/user.py b/backend/core/serializers/user.py
--- a/backend/core/serializers/user.py
+++ b/backend/core/serializers/user.py
@@ -13,17 +13,6 @@
 
 
 class RegisterUserSerializer(DefaultRegisterUserSerializer):
-    # Temporary validator, to reject existing email on account creation.
-    # The uniquessness will be enforced in the model, after the database
-    # (and the dev datasets) have been cleaned up, and the migration can
-    # be executed safely.
-    email = EmailField(validators=[
-        UniqueValidator(
-            queryset=User.objects.all(),
-            message="A user with this email address already exists."
-        ),
-    ])
-
     def validate_username(self, value):
         if value in RESERVED_USERNAMES:
             raise ValidationError(f'"{value}" is a reserved username')


Test patch:
diff --git a/backend/tournesol/tests/test_api_comparison.py b/backend/tournesol/tests/test_api_comparison.py
--- a/backend/tournesol/tests/test_api_comparison.py
+++ b/backend/tournesol/tests/test_api_comparison.py
@@ -57,9 +57,9 @@ def setUp(self):
 
         At least 4 videos and 2 users with 2 comparisons each are required.
         """
-        user = User.objects.create(username=self._user)
-        user2 = User.objects.create(username=self._user2)
-        other = User.objects.create(username=self._other)
+        user = User.objects.create(username=self._user, email="user@test")
+        user2 = User.objects.create(username=self._user2, email="user2@test")
+        other = User.objects.create(username=self._other, email="other@test")
         now = datetime.datetime.now()
 
         self.videos = Video.objects.bulk_create([
diff --git a/backend/tournesol/tests/test_api_ratings.py b/backend/tournesol/tests/test_api_ratings.py
--- a/backend/tournesol/tests/test_api_ratings.py
+++ b/backend/tournesol/tests/test_api_ratings.py
@@ -15,8 +15,8 @@ class RatingApi(TestCase):
     _other_user = "random_user"
 
     def setUp(self):
-        self.user1 = User.objects.create(username=self._user)
-        self.user2 = User.objects.create(username=self._other_user)
+        self.user1 = User.objects.create(username=self._user, email="user1@test")
+        self.user2 = User.objects.create(username=self._other_user, email="user2@test")
         video1 = Video.objects.create(video_id='4g4XLGFTDG8')
         video2 = Video.objects.create(video_id='4g4XLGFTDG9')
         video3 = Video.objects.create(video_id='video-id-03')
diff --git a/backend/tournesol/tests/test_api_video_rate_later.py b/backend/tournesol/tests/test_api_video_rate_later.py
--- a/backend/tournesol/tests/test_api_video_rate_later.py
+++ b/backend/tournesol/tests/test_api_video_rate_later.py
@@ -34,8 +34,8 @@ def setUp(self):
         At least two users are required, each of them having a distinct rate
         later list.
         """
-        user1 = User.objects.create(username=self._user)
-        user2 = User.objects.create(username=self._other)
+        user1 = User.objects.create(username=self._user, email="user1@test")
+        user2 = User.objects.create(username=self._other, email="user2@test")
 
         video1 = Video.objects.create(
             video_id="test_video_id_1", name=self._users_video