diff --git a/generic/migrations/0028_normalize_supervisor_emails.py b/generic/migrations/0028_normalize_supervisor_emails.py new file mode 100644 index 00000000..4bf9a054 --- /dev/null +++ b/generic/migrations/0028_normalize_supervisor_emails.py @@ -0,0 +1,51 @@ +from django.db import migrations +# no additional imports required + + +def normalize_and_dedupe(apps, schema_editor): + Supervisor = apps.get_model('generic', 'Supervisor') + UserProfile = apps.get_model('generic', 'UserProfile') + CidUser = apps.get_model('generic', 'CidUser') + # Build groups by normalized email without saving changes yet to avoid unique constraint errors + supervisors = list(Supervisor.objects.all()) + groups = {} + + for s in supervisors: + norm_email = (s.email or "").strip().lower() + norm_name = (s.name or "").strip() + groups.setdefault(norm_email, []).append((s, norm_name)) + + # For each group, pick a keeper (smallest PK), reassign FKs from duplicates to keeper, delete duplicates, + # then update the keeper's normalized email/name. + for norm_email, entries in groups.items(): + # pick keeper by lowest id + entries_sorted = sorted(entries, key=lambda e: e[0].pk) + keeper_obj, keeper_name = entries_sorted[0] + + # reassign duplicates + for dup_obj, dup_name in entries_sorted[1:]: + UserProfile.objects.filter(supervisor_id=dup_obj.pk).update(supervisor_id=keeper_obj.pk) + CidUser.objects.filter(supervisor_id=dup_obj.pk).update(supervisor_id=keeper_obj.pk) + dup_obj.delete() + + # update keeper's email and name to normalized values if needed + changed = False + if norm_email and keeper_obj.email != norm_email: + keeper_obj.email = norm_email + changed = True + if keeper_name and keeper_obj.name != keeper_name: + keeper_obj.name = keeper_name + changed = True + if changed: + keeper_obj.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('generic', '0027_alter_questionreview_status'), + ] + + operations = [ + migrations.RunPython(normalize_and_dedupe, reverse_code=migrations.RunPython.noop), + ] diff --git a/generic/migrations/0029_add_unique_constraint_supervisor_email.py b/generic/migrations/0029_add_unique_constraint_supervisor_email.py new file mode 100644 index 00000000..4f506206 --- /dev/null +++ b/generic/migrations/0029_add_unique_constraint_supervisor_email.py @@ -0,0 +1,17 @@ +from django.db import migrations +from django.db.models import UniqueConstraint +from django.db.models.functions import Lower + + +class Migration(migrations.Migration): + + dependencies = [ + ('generic', '0028_normalize_supervisor_emails'), + ] + + operations = [ + migrations.AddConstraint( + model_name='supervisor', + constraint=UniqueConstraint(Lower('email'), name='unique_supervisor_email_ci'), + ), + ] diff --git a/generic/models.py b/generic/models.py index 87218d1b..d254ba8e 100644 --- a/generic/models.py +++ b/generic/models.py @@ -5,6 +5,8 @@ import os from typing import Optional, Self, Tuple from django.contrib.auth.mixins import LoginRequiredMixin from django.db import models +from django.db.models import UniqueConstraint +from django.db.models.functions import Lower from django.forms import ValidationError from django.http import Http404, HttpRequest from django.shortcuts import render @@ -1859,8 +1861,16 @@ class Supervisor(models.Model): return reverse("generic:supervisor_detail", kwargs={"pk": self.pk}) def clean(self): - self.email = self.email.strip() - self.name = self.name.strip() + if self.email: + # normalize email: strip and lowercase for consistency + self.email = self.email.strip().lower() + if self.name: + self.name = self.name.strip() + + class Meta: + constraints = [ + UniqueConstraint(Lower('email'), name='unique_supervisor_email_ci') + ] @receiver(post_save, sender=User)