Skip to content

Commit f87fdad

Browse files
authored
Merge pull request #11556 from cslzchen/feature/create-missing-subscriptions-otf
[ENG-10080] Create global_file_updated, global_reviews and node_file_updated subscriptions if missing
2 parents da90148 + b7b3243 commit f87fdad

File tree

8 files changed

+405
-106
lines changed

8 files changed

+405
-106
lines changed

api/subscriptions/utils.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from django.contrib.contenttypes.models import ContentType
2+
from django.core.exceptions import PermissionDenied
3+
4+
from rest_framework.exceptions import NotFound
5+
6+
from framework import sentry
7+
8+
from osf.models import AbstractNode, OSFUser
9+
from osf.models.notification_type import NotificationType
10+
from osf.models.notification_subscription import NotificationSubscription
11+
12+
13+
def create_missing_notification_from_legacy_id(legacy_id, user):
14+
"""
15+
`global_file_updated` and `global_reviews` should exist by default for every user, and `<node_id>_files_update`
16+
should exist by default if user is a contributor of the node. If not found, create them with `none` frequency
17+
and `_is_digest=True` as default. Raise error if not found, not authorized or permission denied.
18+
"""
19+
20+
node_ct = ContentType.objects.get_for_model(AbstractNode)
21+
user_ct = ContentType.objects.get_for_model(OSFUser)
22+
23+
user_file_updated_nt = NotificationType.Type.USER_FILE_UPDATED.instance
24+
reviews_submission_status_nt = NotificationType.Type.REVIEWS_SUBMISSION_STATUS.instance
25+
node_file_updated_nt = NotificationType.Type.NODE_FILE_UPDATED.instance
26+
27+
node_guid = 'n/a'
28+
29+
if legacy_id == f'{user._id}_global_file_updated':
30+
notification_type = user_file_updated_nt
31+
content_type = user_ct
32+
object_id = user.id
33+
elif legacy_id == f'{user._id}_global_reviews':
34+
notification_type = reviews_submission_status_nt
35+
content_type = user_ct
36+
object_id = user.id
37+
elif legacy_id.endswith('_global_file_updated') or legacy_id.endswith('_global_reviews'):
38+
# Mismatched request user and subscription user
39+
sentry.log_message(f'Permission denied: [user={user._id}, legacy_id={legacy_id}]')
40+
raise PermissionDenied
41+
# `<node_id>_files_update` should exist by default if user is a contributor of the node.
42+
# If not found, create them with `none` frequency and `_is_digest=True` as default.
43+
elif legacy_id.endswith('_file_updated'):
44+
notification_type = node_file_updated_nt
45+
content_type = node_ct
46+
node_guid = legacy_id[:-len('_file_updated')]
47+
node = AbstractNode.objects.filter(guids___id=node_guid, is_deleted=False, type='osf.node').first()
48+
if not node:
49+
# The node in the legacy subscription ID does not exist or is invalid
50+
sentry.log_message(
51+
f'Node not found in legacy subscription ID: [user={user._id}, legacy_id={legacy_id}]',
52+
)
53+
raise NotFound
54+
if not node.is_contributor(user):
55+
# The request user is not a contributor of the node
56+
sentry.log_message(
57+
f'Permission denied: [user={user._id}], node={node_guid}, legacy_id={legacy_id}]',
58+
)
59+
raise PermissionDenied
60+
object_id = node.id
61+
else:
62+
sentry.log_message(f'Subscription not found: [user={user._id}, legacy_id={legacy_id}]')
63+
raise NotFound
64+
missing_subscription_created = NotificationSubscription.objects.create(
65+
notification_type=notification_type,
66+
user=user,
67+
content_type=content_type,
68+
object_id=object_id,
69+
_is_digest=True,
70+
message_frequency='none',
71+
)
72+
sentry.log_message(
73+
f'Missing default subscription has been created: [user={user._id}], node={node_guid} type={notification_type}, legacy_id={legacy_id}]',
74+
)
75+
return missing_subscription_created
76+
77+
def create_missing_notifications_from_event_name(filter_event_names, user):
78+
# Note: this may not be needed since 1) missing node subscriptions are created in the LIST view when filter by
79+
# legacy ID, and 2) missing user global subscriptions are created in DETAILS view with legacy ID. However, log
80+
# this message to sentry for tracking how often this happens.
81+
sentry.log_message(f'Detected empty subscription list when filter by event names: [event={filter_event_names}, user={user._id}]')
82+
return None

api/subscriptions/views.py

Lines changed: 84 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1+
from django.contrib.contenttypes.models import ContentType
2+
from django.core.exceptions import PermissionDenied
13
from django.db.models import Value, When, Case, OuterRef, Subquery, F
24
from django.db.models.fields import CharField, IntegerField
35
from django.db.models.functions import Concat, Cast
4-
from django.contrib.contenttypes.models import ContentType
6+
57
from rest_framework import generics
68
from rest_framework import permissions as drf_permissions
7-
from rest_framework.exceptions import NotFound
8-
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
99
from rest_framework.response import Response
10+
1011
from framework.auth.oauth_scopes import CoreScopes
12+
1113
from api.base.views import JSONAPIBaseView
1214
from api.base.filters import ListFilterMixin
1315
from api.base import permissions as base_permissions
@@ -18,13 +20,16 @@
1820
RegistrationSubscriptionSerializer,
1921
)
2022
from api.subscriptions.permissions import IsSubscriptionOwner
23+
from api.subscriptions import utils
24+
2125
from osf.models import (
2226
CollectionProvider,
2327
PreprintProvider,
2428
RegistrationProvider,
2529
AbstractProvider,
2630
AbstractNode,
2731
Guid,
32+
OSFUser,
2833
)
2934
from osf.models.notification_type import NotificationType
3035
from osf.models.notification_subscription import NotificationSubscription
@@ -44,11 +49,16 @@ class SubscriptionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin):
4449
required_write_scopes = [CoreScopes.NULL]
4550

4651
def get_queryset(self):
52+
53+
user = self.request.user
4754
user_guid = self.request.user._id
4855

49-
provider_ct = ContentType.objects.get_by_natural_key(app_label='osf', model='abstractprovider')
50-
node_ct = ContentType.objects.get_by_natural_key(app_label='osf', model='abstractnode')
51-
user_ct = ContentType.objects.get_by_natural_key(app_label='osf', model='osfuser')
56+
filter_id = self.request.query_params.get('filter[id]')
57+
filter_event_name = self.request.query_params.get('filter[event_name]')
58+
59+
provider_ct = ContentType.objects.get_for_model(AbstractProvider)
60+
node_ct = ContentType.objects.get_for_model(AbstractNode)
61+
user_ct = ContentType.objects.get_for_model(OSFUser)
5262

5363
node_subquery = AbstractNode.objects.filter(
5464
id=Cast(OuterRef('object_id'), IntegerField()),
@@ -85,15 +95,16 @@ def get_queryset(self):
8595
NotificationType.Type.FILE_UPDATED.value,
8696
]
8797

88-
qs = NotificationSubscription.objects.filter(
89-
notification_type__name__in=_global_reviews_provider + _global_reviews_user + _global_file_updated + _node_file_updated,
90-
user=self.request.user,
98+
full_set_of_types = _global_reviews_provider + _global_reviews_user + _global_file_updated + _node_file_updated
99+
annotated_qs = NotificationSubscription.objects.filter(
100+
notification_type__name__in=full_set_of_types,
101+
user=user,
91102
).annotate(
92103
event_name=Case(
93104
When(
94105
notification_type__name__in=_node_file_updated,
95106
content_type=node_ct,
96-
then=Value('files_updated'),
107+
then=Value('file_updated'),
97108
),
98109
When(
99110
notification_type__name__in=_global_file_updated,
@@ -135,17 +146,31 @@ def get_queryset(self):
135146
),
136147
).distinct('legacy_id')
137148

149+
return_qs = annotated_qs
150+
138151
# Apply manual filter for legacy_id if requested
139-
filter_id = self.request.query_params.get('filter[id]')
140152
if filter_id:
141-
qs = qs.filter(legacy_id=filter_id)
142-
# convert to list comprehension because legacy_id is an annotation, not in DB
153+
return_qs = annotated_qs.filter(legacy_id=filter_id)
154+
# TODO: Rework missing subscription fix after fully populating the OSF DB with all missing notifications
155+
# NOTE: `.exists()` errors for unknown reason, possibly due to complex annotation with `.distinct()`
156+
if return_qs.count() == 0:
157+
missing_subscription_created = utils.create_missing_notification_from_legacy_id(filter_id, user)
158+
if missing_subscription_created:
159+
return_qs = annotated_qs.filter(legacy_id=filter_id)
160+
# `filter_id` takes priority over `filter_event_name`
161+
return return_qs
162+
143163
# Apply manual filter for event_name if requested
144-
filter_event_name = self.request.query_params.get('filter[event_name]')
145164
if filter_event_name:
146-
qs = qs.filter(event_name__in=filter_event_name.split(','))
165+
filter_event_names = filter_event_name.split(',')
166+
return_qs = annotated_qs.filter(event_name__in=filter_event_names)
167+
# TODO: Rework missing subscription fix after fully populating the OSF DB with all missing notifications
168+
# NOTE: `.exists()` errors for unknown reason, possibly due to complex annotation with `.distinct()`
169+
if return_qs.count() == 0:
170+
utils.create_missing_notifications_from_event_name(filter_event_names, user)
171+
172+
return return_qs
147173

148-
return qs
149174

150175
class AbstractProviderSubscriptionList(SubscriptionList):
151176
def get_queryset(self):
@@ -171,52 +196,60 @@ class SubscriptionDetail(JSONAPIBaseView, generics.RetrieveUpdateAPIView):
171196

172197
def get_object(self):
173198
subscription_id = self.kwargs['subscription_id']
199+
user = self.request.user
174200
user_guid = self.request.user._id
175-
176-
provider_ct = ContentType.objects.get(app_label='osf', model='abstractprovider')
177-
node_ct = ContentType.objects.get(app_label='osf', model='abstractnode')
201+
user_ct = ContentType.objects.get_for_model(OSFUser)
202+
node_ct = ContentType.objects.get_for_model(AbstractNode)
178203

179204
node_subquery = AbstractNode.objects.filter(
180205
id=Cast(OuterRef('object_id'), IntegerField()),
181206
).values('guids___id')[:1]
182207

183-
try:
184-
annotated_obj_qs = NotificationSubscription.objects.filter(user=self.request.user).annotate(
185-
legacy_id=Case(
186-
When(
187-
notification_type__name=NotificationType.Type.NODE_FILE_UPDATED.value,
188-
content_type=node_ct,
189-
then=Concat(Subquery(node_subquery), Value('_files_updated')),
190-
),
191-
When(
192-
notification_type__name=NotificationType.Type.USER_FILE_UPDATED.value,
193-
then=Value(f'{user_guid}_global_file_updated'),
194-
),
195-
When(
196-
notification_type__name=NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS.value,
197-
content_type=provider_ct,
198-
then=Value(f'{user_guid}_global_reviews'),
199-
),
200-
default=Value(f'{user_guid}_global'),
201-
output_field=CharField(),
208+
missing_subscription_created = None
209+
annotated_obj_qs = NotificationSubscription.objects.filter(user=user).annotate(
210+
legacy_id=Case(
211+
When(
212+
notification_type__name=NotificationType.Type.NODE_FILE_UPDATED.value,
213+
content_type=node_ct,
214+
then=Concat(Subquery(node_subquery), Value('_file_updated')),
202215
),
203-
)
204-
obj = annotated_obj_qs.filter(legacy_id=subscription_id)
205-
206-
except ObjectDoesNotExist:
207-
raise NotFound
208-
209-
obj = obj.filter(user=self.request.user).first()
210-
if not obj:
216+
When(
217+
notification_type__name=NotificationType.Type.USER_FILE_UPDATED.value,
218+
then=Value(f'{user_guid}_global_file_updated'),
219+
),
220+
When(
221+
notification_type__name=NotificationType.Type.REVIEWS_SUBMISSION_STATUS.value,
222+
content_type=user_ct,
223+
then=Value(f'{user_guid}_global_reviews'),
224+
),
225+
default=Value(f'{user_guid}_global'),
226+
output_field=CharField(),
227+
),
228+
)
229+
existing_subscriptions = annotated_obj_qs.filter(legacy_id=subscription_id)
230+
231+
# TODO: Rework missing subscription fix after fully populating the OSF DB with all missing notifications
232+
if not existing_subscriptions.exists():
233+
missing_subscription_created = utils.create_missing_notification_from_legacy_id(subscription_id, user)
234+
if missing_subscription_created:
235+
# Note: must use `annotated_obj_qs` to insert `legacy_id` so that `SubscriptionSerializer` can build data
236+
# properly; in addition, there should be only one result
237+
subscription = annotated_obj_qs.get(legacy_id=subscription_id)
238+
else:
239+
# TODO: Use `get()` and fails/warns on multiple objects after fully de-duplicating the OSF DB
240+
subscription = existing_subscriptions.order_by('id').last()
241+
if not subscription:
211242
raise PermissionDenied
212243

213-
self.check_object_permissions(self.request, obj)
214-
return obj
244+
self.check_object_permissions(self.request, subscription)
245+
return subscription
215246

216247
def update(self, request, *args, **kwargs):
217248
"""
218249
Update a notification subscription
219250
"""
251+
self.get_object()
252+
220253
if '_global_file_updated' in self.kwargs['subscription_id']:
221254
# Copy _global_file_updated subscription changes to all file subscriptions
222255
qs = NotificationSubscription.objects.filter(
@@ -261,9 +294,9 @@ def update(self, request, *args, **kwargs):
261294
serializer.is_valid(raise_exception=True)
262295
self.perform_update(serializer)
263296
return Response(serializer.data)
264-
elif '_files_updated' in self.kwargs['subscription_id']:
265-
# Copy _files_updated subscription changes to all node file subscriptions
266-
node_id = Guid.load(self.kwargs['subscription_id'].split('_files_updated')[0]).object_id
297+
elif '_file_updated' in self.kwargs['subscription_id']:
298+
# Copy _file_updated subscription changes to all node file subscriptions
299+
node_id = Guid.load(self.kwargs['subscription_id'].split('_file_updated')[0]).object_id
267300

268301
qs = NotificationSubscription.objects.filter(
269302
user=self.request.user,

0 commit comments

Comments
 (0)