From 3de959da4a67899ed45ef3055412a2529d2b1d15 Mon Sep 17 00:00:00 2001 From: Ostap Zherebetskyi Date: Fri, 30 Jan 2026 11:41:27 +0200 Subject: [PATCH 1/3] Refactor duplicate notification subscription removal logic --- ...ve_duplicate_notification_subscriptions.py | 88 ++++++++++++------- 1 file changed, 54 insertions(+), 34 deletions(-) diff --git a/osf/management/commands/remove_duplicate_notification_subscriptions.py b/osf/management/commands/remove_duplicate_notification_subscriptions.py index 2c7b7775748..275d5d2c98b 100644 --- a/osf/management/commands/remove_duplicate_notification_subscriptions.py +++ b/osf/management/commands/remove_duplicate_notification_subscriptions.py @@ -2,14 +2,13 @@ from django.db import transaction from django.db.models import OuterRef, Exists -from osf.models import NotificationSubscription +from osf.models import NotificationSubscription, NotificationType class Command(BaseCommand): help = ( 'Remove duplicate NotificationSubscription records, keeping only the highest-id record: ' 'Default uniqueness: (user, content_type, object_id, notification_type, is_digest); ' - 'Optional uniqueness with --exclude-is-digest: (user, content_type, object_id, notification_type).' ) def add_arguments(self, parser): @@ -18,53 +17,74 @@ def add_arguments(self, parser): action='store_true', help='Show how many rows would be deleted without deleting anything.', ) - parser.add_argument( - '--exclude-is-digest', - action='store_true', - default=False, - help='Whether to exclude _is_digest field in unique_together') def handle(self, *args, **options): self.stdout.write('Finding duplicate NotificationSubscription records…') + digest_type_names = { + # User types + NotificationType.Type.USER_NO_ADDON.value, + # File types + NotificationType.Type.ADDON_FILE_COPIED.value, + NotificationType.Type.ADDON_FILE_MOVED.value, + NotificationType.Type.ADDON_FILE_RENAMED.value, + NotificationType.Type.FILE_ADDED.value, + NotificationType.Type.FILE_REMOVED.value, + NotificationType.Type.FILE_UPDATED.value, + NotificationType.Type.FOLDER_CREATED.value, + NotificationType.Type.NODE_FILE_UPDATED.value, + NotificationType.Type.USER_FILE_UPDATED.value, + # Review types + NotificationType.Type.COLLECTION_SUBMISSION_SUBMITTED.value, + NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS.value, + NotificationType.Type.PROVIDER_NEW_PENDING_WITHDRAW_REQUESTS.value, + NotificationType.Type.REVIEWS_SUBMISSION_STATUS.value, + } - if options['exclude_is_digest']: - to_remove = NotificationSubscription.objects.filter( - Exists( - NotificationSubscription.objects.filter( - user_id=OuterRef('user_id'), - content_type_id=OuterRef('content_type_id'), - object_id=OuterRef('object_id'), - notification_type_id=OuterRef('notification_type_id'), - id__gt=OuterRef('id'), # keep most recent record - ) - ) - ) - else: - to_remove = NotificationSubscription.objects.filter( - Exists( - NotificationSubscription.objects.filter( - user_id=OuterRef('user_id'), - content_type_id=OuterRef('content_type_id'), - object_id=OuterRef('object_id'), - notification_type_id=OuterRef('notification_type_id'), - _is_digest=OuterRef('_is_digest'), - id__gt=OuterRef('id'), # keep most recent record - ) + digest_type_ids = NotificationType.objects.filter( + name__in=digest_type_names + ).values_list('id', flat=True) + + invalid_non_digest = NotificationSubscription.objects.filter( + notification_type_id__in=digest_type_ids, + _is_digest=False, + ) + + invalid_digest = NotificationSubscription.objects.filter( + notification_type_id__notin=digest_type_ids, + _is_digest=True, + ) + + duplicate_same_kind = NotificationSubscription.objects.filter( + Exists( + NotificationSubscription.objects.filter( + user_id=OuterRef('user_id'), + content_type_id=OuterRef('content_type_id'), + object_id=OuterRef('object_id'), + notification_type_id=OuterRef('notification_type_id'), + _is_digest=OuterRef('_is_digest'), + id__gt=OuterRef('id'), # keep most recent record ) ) + ) + + to_remove = ( + invalid_non_digest + | invalid_digest + | duplicate_same_kind + ) count = to_remove.count() self.stdout.write(f"Duplicates to remove: {count}") if count == 0: self.stdout.write(self.style.SUCCESS('No duplicates found.')) + return if options['dry']: self.stdout.write(self.style.WARNING('Dry run enabled — no records were deleted.')) return - if count > 0: - with transaction.atomic(): - deleted, _ = to_remove.delete() - self.stdout.write(self.style.SUCCESS(f"Successfully removed {deleted} duplicate records.")) + with transaction.atomic(): + deleted, _ = to_remove.delete() + self.stdout.write(self.style.SUCCESS(f"Successfully removed {deleted} duplicate records.")) From acc5818f81f0ca4bb362040e548de59f4070135c Mon Sep 17 00:00:00 2001 From: Ostap Zherebetskyi Date: Fri, 30 Jan 2026 17:11:50 +0200 Subject: [PATCH 2/3] fix typo --- .../commands/remove_duplicate_notification_subscriptions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osf/management/commands/remove_duplicate_notification_subscriptions.py b/osf/management/commands/remove_duplicate_notification_subscriptions.py index 275d5d2c98b..d16ddba61e6 100644 --- a/osf/management/commands/remove_duplicate_notification_subscriptions.py +++ b/osf/management/commands/remove_duplicate_notification_subscriptions.py @@ -1,6 +1,6 @@ from django.core.management.base import BaseCommand from django.db import transaction -from django.db.models import OuterRef, Exists +from django.db.models import OuterRef, Exists, Q from osf.models import NotificationSubscription, NotificationType @@ -51,7 +51,7 @@ def handle(self, *args, **options): ) invalid_digest = NotificationSubscription.objects.filter( - notification_type_id__notin=digest_type_ids, + ~Q(notification_type_id__in=digest_type_ids), _is_digest=True, ) From a5b99abdf4571ffcb033766e780e07f65a5c6ec4 Mon Sep 17 00:00:00 2001 From: Ostap Zherebetskyi Date: Tue, 3 Feb 2026 12:11:52 +0200 Subject: [PATCH 3/3] Refactor duplicate notification subscription removal logic to include counts of invalid records and update handling for non-digest and digest subscriptions. --- ...ve_duplicate_notification_subscriptions.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/osf/management/commands/remove_duplicate_notification_subscriptions.py b/osf/management/commands/remove_duplicate_notification_subscriptions.py index d16ddba61e6..7e84b52a99b 100644 --- a/osf/management/commands/remove_duplicate_notification_subscriptions.py +++ b/osf/management/commands/remove_duplicate_notification_subscriptions.py @@ -49,13 +49,19 @@ def handle(self, *args, **options): notification_type_id__in=digest_type_ids, _is_digest=False, ) + invalid_non_digest_count = invalid_non_digest.count() invalid_digest = NotificationSubscription.objects.filter( ~Q(notification_type_id__in=digest_type_ids), _is_digest=True, ) + invalid_digest_count = invalid_digest.count() - duplicate_same_kind = NotificationSubscription.objects.filter( + if not options['dry']: + invalid_non_digest.update(_is_digest=True) + invalid_digest.update(_is_digest=False) + + to_remove = NotificationSubscription.objects.filter( Exists( NotificationSubscription.objects.filter( user_id=OuterRef('user_id'), @@ -68,17 +74,13 @@ def handle(self, *args, **options): ) ) - to_remove = ( - invalid_non_digest - | invalid_digest - | duplicate_same_kind - ) - count = to_remove.count() self.stdout.write(f"Duplicates to remove: {count}") + self.stdout.write(f"Invalid non-digest records: {invalid_non_digest_count}") + self.stdout.write(f"Invalid digest records: {invalid_digest_count}") - if count == 0: - self.stdout.write(self.style.SUCCESS('No duplicates found.')) + if count == 0 and invalid_non_digest_count == 0 and invalid_digest_count == 0: + self.stdout.write(self.style.SUCCESS('No duplicates or invalid records found.')) return if options['dry']: @@ -88,3 +90,5 @@ def handle(self, *args, **options): with transaction.atomic(): deleted, _ = to_remove.delete() self.stdout.write(self.style.SUCCESS(f"Successfully removed {deleted} duplicate records.")) + self.stdout.write(self.style.SUCCESS(f"Successfully updated {invalid_non_digest_count} non-digest records.")) + self.stdout.write(self.style.SUCCESS(f"Successfully updated {invalid_digest_count} digest records."))