🧑💻(backend) add management command to merge duplicate users

Add a management command to merge duplicate users and reassign all
granted resources to the most recent user account in the database.

Support a dry-run mode to estimate the impact of the operation before
applying any changes. This helps validate the command and identify
potential issues in environments where realistic testing is difficult.

Add unit tests to verify the command behavior and ensure database
integrity is preserved during the merge process.
This commit is contained in:
lebaudantoine
2026-06-02 18:50:37 +02:00
committed by aleb_the_flash
parent 3554b2eb53
commit 29b0a6fcb4
6 changed files with 588 additions and 1 deletions
@@ -0,0 +1,157 @@
"""Management command to merge duplicate users based on their email address."""
# pylint: disable=too-many-locals
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import Count
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
User = get_user_model()
ROLE_PRIORITY = {
RoleChoices.OWNER: 3,
RoleChoices.ADMIN: 2,
RoleChoices.MEMBER: 1,
}
class Command(BaseCommand):
"""
Merge duplicate users sharing the same email into the most recently created one.
The KEPT user is the most recently created. All room memberships, recording
accesses and files are transferred to it. When a conflict exists, the
higher-privilege role wins. Stale users are then deleted.
Each email group is processed inside a single database transaction.
"""
help = __doc__
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Simulate the merge without writing any changes to the database.",
)
def handle(self, *args, **options):
"""Execute the management command."""
dry_run = options["dry_run"]
if dry_run:
self.stdout.write("[DRY-RUN] No changes will be written.\n")
duplicate_emails = (
User.objects.all()
.exclude(email__isnull=True)
.exclude(email="")
.values("email")
.annotate(cnt=Count("id"))
.filter(cnt__gt=1)
.values_list("email", flat=True)
)
if not duplicate_emails:
self.stdout.write("[INFO] No duplicate users found. Nothing to do.")
return
self.stdout.write(
f"[INFO] Found {len(duplicate_emails)} email(s) with duplicate users."
)
total_merged = 0
total_deleted = 0
failed_emails = []
for email in duplicate_emails:
# Secondary sort by id ensures a stable, deterministic order when
# created_at timestamps are equal (common in tests and bulk imports).
users = list(User.objects.filter(email=email).order_by("created_at", "id"))
kept_user = users[-1]
stale_users = users[:-1]
self.stdout.write(
f"\n[INFO] Email '{email}': {len(users)} users — "
f"keeping {kept_user.id} (created {kept_user.created_at.date()})."
)
for u in stale_users:
self.stdout.write(
f" stale: {u.id} (created {u.created_at.date()})"
)
if dry_run:
ra_count = ResourceAccess.objects.filter(user__in=stale_users).count()
rca_count = RecordingAccess.objects.filter(user__in=stale_users).count()
f_count = File.objects.filter(creator__in=stale_users).count()
self.stdout.write(
f" [DRY-RUN] Would migrate: {ra_count} ResourceAccess, "
f"{rca_count} RecordingAccess, {f_count} File(s)."
)
continue
try:
group_deleted = 0
with transaction.atomic():
for stale_user in stale_users:
self._merge_resource_accesses(stale_user, kept_user)
self._merge_recording_accesses(stale_user, kept_user)
self._merge_files(stale_user, kept_user)
stale_user.delete()
group_deleted += 1
total_deleted += group_deleted
total_merged += 1
except Exception as exc: # noqa: BLE001 #pylint: disable=broad-exception-caught
failed_emails.append(email)
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
if failed_emails:
raise CommandError(
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
)
self.stdout.write(
self.style.SUCCESS(
f"\n[DONE] Merged {total_merged} group(s), deleted {total_deleted} user(s)."
)
)
def _merge_resource_accesses(self, stale_user, kept_user):
"""Transfer room memberships from stale_user to kept_user."""
for ra in ResourceAccess.objects.filter(user=stale_user):
existing = ResourceAccess.objects.filter(
user=kept_user, resource=ra.resource
).first()
if existing is None:
ra.user = kept_user
ra.save(update_fields=["user"])
else:
if ROLE_PRIORITY.get(ra.role, 0) > ROLE_PRIORITY.get(existing.role, 0):
existing.role = ra.role
existing.save(update_fields=["role"])
ra.delete()
def _merge_recording_accesses(self, stale_user, kept_user):
"""Transfer recording accesses from stale_user to kept_user."""
for rca in RecordingAccess.objects.filter(user=stale_user):
existing = RecordingAccess.objects.filter(
user=kept_user, recording=rca.recording
).first()
if existing is None:
rca.user = kept_user
rca.save(update_fields=["user"])
else:
if ROLE_PRIORITY.get(rca.role, 0) > ROLE_PRIORITY.get(existing.role, 0):
existing.role = rca.role
existing.save(update_fields=["role"])
rca.delete()
def _merge_files(self, stale_user, kept_user):
"""Re-assign files created by stale_user to kept_user."""
File.objects.filter(creator=stale_user).update(creator=kept_user)