Skip to content

Commit 376c53c

Browse files
committed
Merge branch 'hotfix/26.1.6'
2 parents 0867265 + 5a07c47 commit 376c53c

File tree

6 files changed

+107
-56
lines changed

6 files changed

+107
-56
lines changed

CHANGELOG

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO.
44

5+
26.1.6 (2026-01-14)
6+
===================
7+
8+
- Rework NR verification and de-duplication command
9+
- Fix the rest of regsitration links in moderator digest email
10+
- Mark fail-to-render notifications as fake sent
11+
512
26.1.5 (2026-01-12)
613
===================
714

api/providers/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -657,7 +657,7 @@ def bulk_upload_finish_job(upload, row_count, success_count, draft_errors, appro
657657
'auto_approval': auto_approval,
658658
'count': row_count,
659659
'total': row_count,
660-
'pending_submissions_url': get_registration_provider_submissions_url(provider),
660+
'pending_submissions_url': f'{get_registration_provider_submissions_url(provider)}?status=pending',
661661
'draft_errors': draft_errors,
662662
'approval_errors': approval_errors,
663663
'successes': success_count,

notifications/tasks.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ def safe_render_notification(notifications, email_task):
3333
email_task.error_message = f'Error rendering notification {notification.id}: {str(e)} \n'
3434
email_task.save()
3535
failed_notifications.append(notification.id)
36+
# Mark notifications that failed to render as fake sent
37+
# Use 1000/12/31 to distinguish itself from another type of fake sent 1000/1/1
38+
log_message(f'Error rendering notification, mark as fake sent: [notification_id={notification.id}]')
39+
notification.sent = datetime(1000, 12, 31)
40+
notification.save()
3641
continue
3742

3843
rendered_notifications.append(rendered)
@@ -216,8 +221,9 @@ def send_moderator_email_task(self, user_id, notification_ids, provider_content_
216221
logo = None
217222
if isinstance(provider, RegistrationProvider):
218223
provider_type = 'registration'
219-
submissions_url = get_registration_provider_submissions_url(provider)
220-
withdrawals_url = f'{submissions_url}?state=pending_withdraw'
224+
base_submissions_url = get_registration_provider_submissions_url(provider)
225+
submissions_url = f'{base_submissions_url}?status=pending'
226+
withdrawals_url = f'{base_submissions_url}?status=pending_withdraw'
221227
notification_settings_url = f'{settings.DOMAIN}registries/{provider._id}/moderation/notifications'
222228
if provider.brand:
223229
additional_context = {
Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,85 @@
11
import time
2+
23
from django.core.management.base import BaseCommand
34
from django.db.models import Count
45
from django.db import connection
6+
57
from osf.models import NotificationSubscription, NotificationSubscriptionLegacy
68

79

810
class Command(BaseCommand):
9-
help = 'Verify notification migration integrity (duplicates, invalid frequencies, counts, distribution)'
10-
'''
11+
"""
1112
Usage example:
1213
python manage.py migrate_notifications_verification
13-
python manage.py migrate_notifications_verification --duplicates --counts
14-
'''
14+
python manage.py migrate_notifications_verification --duplicates --distribution
15+
python manage.py migrate_notifications_verification --duplicates --unique-digest --output-size=100
16+
"""
17+
18+
help = 'Verify notification migration integrity (duplicates, invalid frequencies, counts and distribution)'
1519

1620
def add_arguments(self, parser):
21+
parser.add_argument('--all', action='store_true', default=False, help='Run all checks')
1722
parser.add_argument('--duplicates', action='store_true', help='Check for duplicate NotificationSubscription entries')
1823
parser.add_argument('--frequencies', action='store_true', help='Check message_frequency values for invalid ones')
1924
parser.add_argument('--counts', action='store_true', help='Compare legacy M2M total with migrated count')
2025
parser.add_argument('--distribution', action='store_true', help='Print breakdown summary')
21-
parser.add_argument('--all', action='store_true', help='Run all checks')
26+
parser.add_argument('--exclude-is-digest', action='store_true', default=False, help='Used along with --duplicates to exclude _is_digest field in unique_together')
27+
parser.add_argument('--output-size', type=int, default=10, help='Used along with other options to set the number of found duplicates for output')
2228

2329
def handle(self, *args, **options):
30+
2431
start = time.time()
2532
flags = {k for k, v in options.items() if v and k in ['duplicates', 'frequencies', 'counts', 'distribution']}
33+
run_all = options['all']
34+
output_size = options['output_size']
2635

27-
run_all = options['all'] or not flags
2836
print('\n================ Notification Migration Verification ================\n')
2937

38+
if not run_all and not flags:
39+
print('\n⚠ No options selected, command will exit ... \n')
40+
41+
# 1. Detect duplicates
3042
if run_all or 'duplicates' in flags:
31-
# 1. Detect duplicates
32-
print('1) Checking duplicate NotificationSubscription entries...')
33-
duplicates = (
34-
NotificationSubscription.objects.values(
35-
'user_id', 'content_type_id', 'object_id', 'notification_type_id'
43+
action_word = 'excludes' if options['exclude_is_digest'] else 'includes'
44+
print(f'1) Checking duplicate NotificationSubscription entries (unique_together {action_word} _is_digest)...')
45+
if options['exclude_is_digest']:
46+
duplicates = (
47+
NotificationSubscription.objects.values(
48+
'user_id', 'content_type_id', 'object_id', 'notification_type_id',
49+
)
50+
.annotate(count=Count('id'))
51+
.filter(count__gt=1)
3652
)
37-
.annotate(count=Count('id'))
38-
.filter(count__gt=1)
39-
)
40-
print(f" → Duplicates found: {duplicates.count()}")
53+
else:
54+
duplicates = (
55+
NotificationSubscription.objects.values(
56+
'user_id', 'content_type_id', 'object_id', 'notification_type_id', '_is_digest',
57+
)
58+
.annotate(count=Count('id'))
59+
.filter(count__gt=1)
60+
)
61+
print(f' → Duplicates found: {duplicates.count()}.')
4162
if duplicates.exists():
42-
print(' Sample (up to 10):')
43-
for d in duplicates[:10]:
63+
print(f' Sample (up to {output_size}):')
64+
for d in duplicates.order_by('-count')[:output_size]:
4465
print(' ', d)
4566
print(' ✔ OK' if not duplicates.exists() else ' ⚠ Needs review')
4667

68+
# 2. Invalid frequencies
4769
if run_all or 'frequencies' in flags:
48-
# 2. Invalid frequencies
4970
print('\n2) Checking invalid message_frequency values...')
5071
valid = {'none', 'daily', 'instantly'}
5172
invalid_freq = NotificationSubscription.objects.exclude(message_frequency__in=valid)
5273

53-
print(f" → Invalid frequency rows: {invalid_freq.count()}")
74+
print(f' → Invalid frequency rows: {invalid_freq.count()}')
5475
if invalid_freq.exists():
5576
print(' Sample (id, freq):')
56-
for row in invalid_freq[:10]:
57-
print(f" {row.id}{row.message_frequency}")
77+
for row in invalid_freq[:output_size]:
78+
print(f' {row.id}{row.message_frequency}')
5879
print(' ✔ OK' if not invalid_freq.exists() else ' ⚠ Needs cleanup')
5980

81+
# 3. Compare legacy frequency-based totals vs new subscription count
6082
if run_all or 'counts' in flags:
61-
# 3. Compare legacy frequency-based totals vs new subscription count
6283
print('\n3) Validating total count migrated...')
6384
valid_subscription_ids = NotificationSubscriptionLegacy.objects.filter(event_name__in=['global_reviews', 'global_file_updated', 'file_updated']).values_list('id', flat=True)
6485
with connection.cursor() as cursor:
@@ -72,26 +93,28 @@ def handle(self, *args, **options):
7293
legacy_total_expanded = none_count + digest_count + transactional_count
7394
new_total = NotificationSubscription.objects.count()
7495

75-
print(f" Legacy M2M total: {legacy_total_expanded}")
76-
print(f" New subscriptions: {new_total}")
96+
print(f' Legacy M2M total: {legacy_total_expanded}')
97+
print(f' New subscriptions: {new_total}')
7798

7899
if legacy_total_expanded == new_total:
79100
print(' ✔ Counts match')
80101
else:
81102
diff = new_total - legacy_total_expanded
82-
print(f" ⚠ Mismatch: difference = {diff} (possibly skipped or duplicates removed)")
103+
print(f' ⚠ Mismatch: difference = {diff} (possibly skipped, duplicates removed or newly created)')
104+
105+
print(' ⚠ Note: this is accurate only right after migration and before any new subscriptions are created.)')
83106

84107
if run_all or 'distribution' in flags:
85108
# 4. Distribution summary
86-
print('\n4) Subscription distribution breakdown (top 30):\n')
109+
print(f'\n4) Subscription distribution breakdown (top {output_size}):\n')
87110
dist = (
88111
NotificationSubscription.objects
89112
.values('notification_type_id', 'message_frequency')
90113
.annotate(total=Count('id'))
91-
.order_by('-total')[:30]
114+
.order_by('-total')[:output_size]
92115
)
93116
for row in dist:
94117
print(' ', row)
95118

96119
elapsed = time.time() - start
97-
print(f"\n================ Verification complete in {elapsed:.2f}s ================\n")
120+
print(f'\n================ Verification complete in {elapsed:.2f}s ================\n')

osf/management/commands/remove_duplicate_notification_subscriptions.py

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77

88
class Command(BaseCommand):
99
help = (
10-
'Remove duplicate NotificationSubscription records, keeping only '
11-
'the highest-id record per (user, content_type, object_id, notification_type).'
10+
'Remove duplicate NotificationSubscription records, keeping only the highest-id record: '
11+
'Default uniqueness: (user, content_type, object_id, notification_type, is_digest); '
12+
'Optional uniqueness with --exclude-is-digest: (user, content_type, object_id, notification_type).'
1213
)
1314

1415
def add_arguments(self, parser):
@@ -17,39 +18,53 @@ def add_arguments(self, parser):
1718
action='store_true',
1819
help='Show how many rows would be deleted without deleting anything.',
1920
)
21+
parser.add_argument(
22+
'--exclude-is-digest',
23+
action='store_true',
24+
default=False,
25+
help='Whether to exclude _is_digest field in unique_together')
2026

2127
def handle(self, *args, **options):
28+
2229
self.stdout.write('Finding duplicate NotificationSubscription records…')
2330

24-
to_remove = NotificationSubscription.objects.filter(
25-
Exists(
26-
NotificationSubscription.objects.filter(
27-
user_id=OuterRef('user_id'),
28-
content_type_id=OuterRef('content_type_id'),
29-
object_id=OuterRef('object_id'),
30-
notification_type_id=OuterRef('notification_type_id'),
31-
_is_digest=OuterRef('_is_digest'),
32-
id__gt=OuterRef('id'), # keep most recent record
31+
if options['exclude_is_digest']:
32+
to_remove = NotificationSubscription.objects.filter(
33+
Exists(
34+
NotificationSubscription.objects.filter(
35+
user_id=OuterRef('user_id'),
36+
content_type_id=OuterRef('content_type_id'),
37+
object_id=OuterRef('object_id'),
38+
notification_type_id=OuterRef('notification_type_id'),
39+
id__gt=OuterRef('id'), # keep most recent record
40+
)
41+
)
42+
)
43+
else:
44+
to_remove = NotificationSubscription.objects.filter(
45+
Exists(
46+
NotificationSubscription.objects.filter(
47+
user_id=OuterRef('user_id'),
48+
content_type_id=OuterRef('content_type_id'),
49+
object_id=OuterRef('object_id'),
50+
notification_type_id=OuterRef('notification_type_id'),
51+
_is_digest=OuterRef('_is_digest'),
52+
id__gt=OuterRef('id'), # keep most recent record
53+
)
3354
)
3455
)
35-
)
3656

3757
count = to_remove.count()
3858
self.stdout.write(f"Duplicates to remove: {count}")
3959

40-
if options['dry']:
41-
self.stdout.write(
42-
self.style.WARNING('Dry run enabled — no records were deleted.')
43-
)
44-
return
45-
4660
if count == 0:
4761
self.stdout.write(self.style.SUCCESS('No duplicates found.'))
48-
return
4962

50-
with transaction.atomic():
51-
deleted, _ = to_remove.delete()
63+
if options['dry']:
64+
self.stdout.write(self.style.WARNING('Dry run enabled — no records were deleted.'))
65+
return
5266

53-
self.stdout.write(
54-
self.style.SUCCESS(f"Successfully removed {deleted} duplicate records.")
55-
)
67+
if count > 0:
68+
with transaction.atomic():
69+
deleted, _ = to_remove.delete()
70+
self.stdout.write(self.style.SUCCESS(f"Successfully removed {deleted} duplicate records."))

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "OSF",
3-
"version": "26.1.5",
3+
"version": "26.1.6",
44
"description": "Facilitating Open Science",
55
"repository": "https://github.com/CenterForOpenScience/osf.io",
66
"author": "Center for Open Science",

0 commit comments

Comments
 (0)