Skip to content

Commit 47e26fd

Browse files
Add notification subscriptions de-duplication v2 command (#11576)
Co-authored-by: Ostap Zherebetskyi <ozherebetskyi@exoft.net>
1 parent 88103af commit 47e26fd

1 file changed

Lines changed: 94 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from django.core.management.base import BaseCommand
2+
from django.db import transaction
3+
from django.db.models import OuterRef, Exists, Q
4+
5+
from osf.models import NotificationSubscription, NotificationType
6+
7+
8+
class Command(BaseCommand):
9+
help = (
10+
'Remove duplicate NotificationSubscription records, keeping only the highest-id record: '
11+
'Default uniqueness: (user, content_type, object_id, notification_type, is_digest); '
12+
)
13+
14+
def add_arguments(self, parser):
15+
parser.add_argument(
16+
'--dry',
17+
action='store_true',
18+
help='Show how many rows would be deleted without deleting anything.',
19+
)
20+
21+
def handle(self, *args, **options):
22+
23+
self.stdout.write('Finding duplicate NotificationSubscription records…')
24+
digest_type_names = {
25+
# User types
26+
NotificationType.Type.USER_NO_ADDON.value,
27+
# File types
28+
NotificationType.Type.ADDON_FILE_COPIED.value,
29+
NotificationType.Type.ADDON_FILE_MOVED.value,
30+
NotificationType.Type.ADDON_FILE_RENAMED.value,
31+
NotificationType.Type.FILE_ADDED.value,
32+
NotificationType.Type.FILE_REMOVED.value,
33+
NotificationType.Type.FILE_UPDATED.value,
34+
NotificationType.Type.FOLDER_CREATED.value,
35+
NotificationType.Type.NODE_FILE_UPDATED.value,
36+
NotificationType.Type.USER_FILE_UPDATED.value,
37+
# Review types
38+
NotificationType.Type.COLLECTION_SUBMISSION_SUBMITTED.value,
39+
NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS.value,
40+
NotificationType.Type.PROVIDER_NEW_PENDING_WITHDRAW_REQUESTS.value,
41+
NotificationType.Type.REVIEWS_SUBMISSION_STATUS.value,
42+
}
43+
44+
digest_type_ids = NotificationType.objects.filter(
45+
name__in=digest_type_names
46+
).values_list('id', flat=True)
47+
48+
invalid_non_digest = NotificationSubscription.objects.filter(
49+
notification_type_id__in=digest_type_ids,
50+
_is_digest=False,
51+
)
52+
invalid_non_digest_count = invalid_non_digest.count()
53+
54+
invalid_digest = NotificationSubscription.objects.filter(
55+
~Q(notification_type_id__in=digest_type_ids),
56+
_is_digest=True,
57+
)
58+
invalid_digest_count = invalid_digest.count()
59+
60+
if not options['dry']:
61+
invalid_non_digest.update(_is_digest=True)
62+
invalid_digest.update(_is_digest=False)
63+
64+
to_remove = NotificationSubscription.objects.filter(
65+
Exists(
66+
NotificationSubscription.objects.filter(
67+
user_id=OuterRef('user_id'),
68+
content_type_id=OuterRef('content_type_id'),
69+
object_id=OuterRef('object_id'),
70+
notification_type_id=OuterRef('notification_type_id'),
71+
_is_digest=OuterRef('_is_digest'),
72+
id__gt=OuterRef('id'), # keep most recent record
73+
)
74+
)
75+
)
76+
77+
count = to_remove.count()
78+
self.stdout.write(f"Duplicates to remove: {count}")
79+
self.stdout.write(f"Invalid non-digest records: {invalid_non_digest_count}")
80+
self.stdout.write(f"Invalid digest records: {invalid_digest_count}")
81+
82+
if count == 0 and invalid_non_digest_count == 0 and invalid_digest_count == 0:
83+
self.stdout.write(self.style.SUCCESS('No duplicates or invalid records found.'))
84+
return
85+
86+
if options['dry']:
87+
self.stdout.write(self.style.WARNING('Dry run enabled — no records were deleted.'))
88+
return
89+
90+
with transaction.atomic():
91+
deleted, _ = to_remove.delete()
92+
self.stdout.write(self.style.SUCCESS(f"Successfully removed {deleted} duplicate records."))
93+
self.stdout.write(self.style.SUCCESS(f"Successfully updated {invalid_non_digest_count} non-digest records."))
94+
self.stdout.write(self.style.SUCCESS(f"Successfully updated {invalid_digest_count} digest records."))

0 commit comments

Comments
 (0)