Skip to content

Commit 0867265

Browse files
committed
Merge branch 'hotfix/26.1.5'
2 parents 6e204bb + cd5aec3 commit 0867265

File tree

12 files changed

+144
-25
lines changed

12 files changed

+144
-25
lines changed

CHANGELOG

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

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

5+
26.1.5 (2026-01-12)
6+
===================
7+
8+
- Fix authenticated ORCID is not shown on user profile page (BE part)
9+
- Fix some links in preprint & registration moderation digest emails
10+
- Enable admin/admin read/write access for NR tables
11+
- Handle multiple notification subscriptions gracefully in emit()
12+
- Remove subscription when moderators/admins are removed
13+
- Set proper default message frequency when adding moderator/admins
14+
- Add a management command to remove duplicated notifications
15+
- Fix subscription population script's digest option
16+
517
26.1.4 (2026-01-06)
618
===================
719

api/providers/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ def update(self, instance, validated_data):
401401
return instance
402402

403403
try:
404-
provider.remove_from_group(instance, instance.permission_group, unsubscribe=False)
404+
provider.remove_from_group(instance, instance.permission_group, unsubscribe=True)
405405
except ValueError as e:
406406
raise ValidationError(str(e))
407407
provider.add_to_group(instance, perm_group)

api/users/serializers.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,26 @@ def to_representation(self, value):
6363
value = social
6464
return super().to_representation(value)
6565

66+
class ExternalIdentityField(ser.DictField):
67+
def __init__(self, **kwargs):
68+
super().__init__(**kwargs)
69+
70+
def to_representation(self, value):
71+
if not value or not isinstance(value, dict):
72+
return value
73+
result = {}
74+
for provider, identities in value.items():
75+
if not identities or not isinstance(identities, dict):
76+
result[provider] = identities
77+
continue
78+
identity_id, status = next(iter(identities.items()))
79+
if status != 'VERIFIED':
80+
continue
81+
result[provider] = {
82+
'id': identity_id,
83+
'status': status,
84+
}
85+
return result
6686

6787
class UserSerializer(JSONAPISerializer):
6888
filterable_fields = frozenset([
@@ -97,6 +117,7 @@ class UserSerializer(JSONAPISerializer):
97117
allow_indexing = ShowIfCurrentUser(ser.BooleanField(required=False, allow_null=True))
98118
can_view_reviews = ShowIfCurrentUser(ser.SerializerMethodField(help_text='Whether the current user has the `view_submissions` permission to ANY reviews provider.'))
99119
accepted_terms_of_service = ShowIfCurrentUser(ser.SerializerMethodField())
120+
external_identity = HideIfDisabled(ExternalIdentityField(required=False))
100121

101122
links = HideIfDisabled(
102123
LinksField(

notifications/listeners.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def reviews_withdraw_requests_notification_moderators(self, timestamp, context,
9797
# Set message
9898
context['message'] = f'has requested withdrawal of "{resource.title}".'
9999
# Set submission url
100-
context['reviews_submission_url'] = f'{DOMAIN}reviews/registries/{provider._id}/{resource._id}'
100+
context['reviews_submission_url'] = f'{DOMAIN}{resource._id}?mode=moderator'
101101
context['localized_timestamp'] = str(timestamp)
102102
NotificationType.Type.PROVIDER_NEW_PENDING_WITHDRAW_REQUESTS.instance.emit(
103103
subscribed_object=provider,

notifications/tasks.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import itertools
22
from calendar import monthrange
3-
from datetime import date
3+
from datetime import date, datetime
44
from django.contrib.contenttypes.models import ContentType
55
from django.db import connection
66
from django.utils import timezone
@@ -205,10 +205,11 @@ def send_moderator_email_task(self, user_id, notification_ids, provider_content_
205205
if current_moderators is None or not current_moderators.user_set.filter(id=user.id).exists():
206206
current_admins = provider.get_group('admin')
207207
if current_admins is None or not current_admins.user_set.filter(id=user.id).exists():
208-
log_message(f"User is not a moderator for provider {provider._id} - skipping email")
208+
log_message(f"User is not a moderator for provider {provider._id} - notifications will be marked as sent.")
209209
email_task.status = 'FAILURE'
210210
email_task.error_message = f'User is not a moderator for provider {provider._id}'
211211
email_task.save()
212+
notifications_qs.update(sent=datetime(1000, 1, 1))
212213
return
213214

214215
additional_context = {}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from django.core.management.base import BaseCommand
2+
from django.db import transaction
3+
from django.db.models import OuterRef, Exists
4+
5+
from osf.models import NotificationSubscription
6+
7+
8+
class Command(BaseCommand):
9+
help = (
10+
'Remove duplicate NotificationSubscription records, keeping only '
11+
'the highest-id record per (user, content_type, object_id, notification_type).'
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+
self.stdout.write('Finding duplicate NotificationSubscription records…')
23+
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
33+
)
34+
)
35+
)
36+
37+
count = to_remove.count()
38+
self.stdout.write(f"Duplicates to remove: {count}")
39+
40+
if options['dry']:
41+
self.stdout.write(
42+
self.style.WARNING('Dry run enabled — no records were deleted.')
43+
)
44+
return
45+
46+
if count == 0:
47+
self.stdout.write(self.style.SUCCESS('No duplicates found.'))
48+
return
49+
50+
with transaction.atomic():
51+
deleted, _ = to_remove.delete()
52+
53+
self.stdout.write(
54+
self.style.SUCCESS(f"Successfully removed {deleted} duplicate records.")
55+
)

osf/migrations/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ def get_admin_read_permissions():
6161
'view_registration',
6262
'view_registrationapproval',
6363
'view_registrationschema',
64+
'view_notification',
65+
'view_notificationtype',
66+
'view_notificationsubscription',
67+
'view_emailtask',
6468
])
6569

6670

@@ -103,7 +107,15 @@ def get_admin_write_permissions():
103107
'change_notabledomain',
104108
'delete_notabledomain',
105109
'change_cedarmetadatatemplate',
106-
'change_registrationapproval'
110+
'change_registrationapproval',
111+
'change_notification',
112+
'delete_notification',
113+
'change_notificationtype',
114+
'delete_notificationtype',
115+
'change_notificationsubscription',
116+
'delete_notificationsubscription',
117+
'change_emailtask',
118+
'delete_emailtask',
107119
])
108120

109121

osf/models/mixins.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,6 +1087,7 @@ def add_to_group(self, user, group):
10871087
content_type=ContentType.objects.get_for_model(self, for_concrete_model=False),
10881088
object_id=self.id,
10891089
notification_type=subscription.instance,
1090+
message_frequency='instantly',
10901091
_is_digest=True
10911092
)
10921093

osf/models/notification_type.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from website import settings
66
from enum import Enum
77
from osf.utils.caching import ttl_cached_property
8+
from framework import sentry
89

910

1011
def get_default_frequency_choices():
@@ -222,14 +223,31 @@ def emit(
222223
_is_digest=is_digest,
223224
)
224225
else:
225-
subscription, created = NotificationSubscription.objects.get_or_create(
226-
notification_type=self,
227-
user=user,
228-
content_type=content_type,
229-
object_id=subscribed_object.pk if subscribed_object else None,
230-
defaults={'message_frequency': message_frequency},
231-
_is_digest=is_digest,
232-
)
226+
try:
227+
subscription, created = NotificationSubscription.objects.get_or_create(
228+
notification_type=self,
229+
user=user,
230+
content_type=content_type,
231+
object_id=subscribed_object.pk if subscribed_object else None,
232+
defaults={'message_frequency': message_frequency},
233+
_is_digest=is_digest,
234+
)
235+
except NotificationSubscription.MultipleObjectsReturned as e:
236+
# Temporary fix before we deduplicate and add constraints: use the last created one if duplicates found
237+
subscriptions_qs = NotificationSubscription.objects.filter(
238+
notification_type=self,
239+
user=user,
240+
content_type=content_type,
241+
object_id=subscribed_object.pk if subscribed_object else None,
242+
defaults={'message_frequency': message_frequency},
243+
_is_digest=is_digest,
244+
).order_by('id')
245+
sentry.log_exception(e)
246+
count = subscriptions_qs.count()
247+
subscription = subscriptions_qs.last()
248+
sentry.log_message(f'Multiple notification subscriptions found, using the last created one: '
249+
f'[count={count}, user={user._id}, type={self.name}, last={subscription.id}]')
250+
233251
subscription.emit(
234252
destination_address=destination_address,
235253
event_context=event_context,

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.4",
3+
"version": "26.1.5",
44
"description": "Facilitating Open Science",
55
"repository": "https://github.com/CenterForOpenScience/osf.io",
66
"author": "Center for Open Science",

0 commit comments

Comments
 (0)