Skip to content

Commit 5de4a03

Browse files
committed
New Notification system proof of concept
1 parent c43cc32 commit 5de4a03

30 files changed

+719
-242
lines changed

admin/notifications/views.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from osf.models.notifications import NotificationSubscription
1+
from osf.models.notification import NotificationSubscription
22
from django.db.models import Count
33

44
def delete_selected_notifications(selected_ids):

api/base/views.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -143,14 +143,14 @@ def get_serializer_context(self):
143143
multiple levels of nesting.
144144
"""
145145
context = super().get_serializer_context()
146-
if self.kwargs.get('is_embedded'):
146+
if self.kwargs.get('is_embedded') or not self.request:
147147
embeds = []
148148
else:
149149
embeds = self.request.query_params.getlist('embed') or self.request.query_params.getlist('embed[]')
150150

151151
fields_check = self.get_serializer_class()._declared_fields.copy()
152152
serializer_class_type = get_meta_type(self.serializer_class, self.request)
153-
if f'fields[{serializer_class_type}]' in self.request.query_params:
153+
if self.request and f'fields[{serializer_class_type}]' in self.request.query_params:
154154
# Check only requested and mandatory fields
155155
sparse_fields = self.request.query_params[f'fields[{serializer_class_type}]']
156156
for field in list(fields_check.copy().keys()):

api/subscriptions/fields.py

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from rest_framework import serializers as ser
2+
from osf.models import NotificationSubscription
3+
4+
class FrequencyField(ser.ChoiceField):
5+
def __init__(self, **kwargs):
6+
super().__init__(choices=['none', 'instantly', 'daily', 'weekly', 'monthly'], **kwargs)
7+
8+
def to_representation(self, obj: NotificationSubscription):
9+
return obj.message_frequency
10+
11+
def to_internal_value(self, freq):
12+
return super().to_internal_value(freq)

api/subscriptions/permissions.py

+3-9
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
from rest_framework import permissions
2-
3-
from osf.models.notifications import NotificationSubscription
4-
2+
from osf.models import NotificationSubscription
53

64
class IsSubscriptionOwner(permissions.BasePermission):
7-
85
def has_object_permission(self, request, view, obj):
9-
assert isinstance(obj, NotificationSubscription), f'obj must be a NotificationSubscription; got {obj}'
10-
user_id = request.user.id
11-
return obj.none.filter(id=user_id).exists() \
12-
or obj.email_transactional.filter(id=user_id).exists() \
13-
or obj.email_digest.filter(id=user_id).exists()
6+
assert isinstance(obj, NotificationSubscription), f'obj must be NotificationSubscription; got {obj}'
7+
return obj.user == request.user

api/subscriptions/serializers.py

+24-55
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,69 @@
11
from rest_framework import serializers as ser
2-
from rest_framework.exceptions import ValidationError
2+
from api.base.serializers import JSONAPISerializer, LinksField
3+
from website.util import api_v2_url
4+
from .fields import FrequencyField
35
from api.nodes.serializers import RegistrationProviderRelationshipField
46
from api.collections_providers.fields import CollectionProviderRelationshipField
57
from api.preprints.serializers import PreprintProviderRelationshipField
6-
from website.util import api_v2_url
7-
8-
9-
from api.base.serializers import JSONAPISerializer, LinksField
10-
11-
NOTIFICATION_TYPES = {
12-
'none': 'none',
13-
'instant': 'email_transactional',
14-
'daily': 'email_digest',
15-
}
168

179

18-
class FrequencyField(ser.Field):
19-
def to_representation(self, obj):
20-
user_id = self.context['request'].user.id
21-
if obj.email_transactional.filter(id=user_id).exists():
22-
return 'instant'
23-
if obj.email_digest.filter(id=user_id).exists():
24-
return 'daily'
25-
return 'none'
26-
27-
def to_internal_value(self, frequency):
28-
notification_type = NOTIFICATION_TYPES.get(frequency)
29-
if notification_type:
30-
return {'notification_type': notification_type}
31-
raise ValidationError(f'Invalid frequency "{frequency}"')
32-
3310
class SubscriptionSerializer(JSONAPISerializer):
34-
filterable_fields = frozenset([
35-
'id',
36-
'event_name',
37-
])
38-
39-
id = ser.CharField(source='_id', read_only=True)
40-
event_name = ser.CharField(read_only=True)
41-
frequency = FrequencyField(source='*', required=True)
42-
links = LinksField({
43-
'self': 'get_absolute_url',
44-
})
11+
filterable_fields = frozenset(['id', 'type'])
12+
13+
id = ser.CharField(read_only=True)
14+
type = ser.SerializerMethodField()
15+
email_freq = FrequencyField(required=True)
16+
links = LinksField({'self': 'get_absolute_url'})
4517

4618
class Meta:
4719
type_ = 'subscription'
4820

21+
def get_type(self, obj):
22+
return self.Meta.type_
23+
4924
def get_absolute_url(self, obj):
50-
return obj.absolute_api_v2_url
25+
return api_v2_url(f'subscriptions/{obj.pk}')
5126

5227
def update(self, instance, validated_data):
53-
user = self.context['request'].user
54-
notification_type = validated_data.get('notification_type')
55-
instance.add_user_to_subscription(user, notification_type, save=True)
28+
instance.message_frequency = validated_data.get('message_frequency', instance.message_frequency)
29+
instance.save(update_fields=['message_frequency'])
5630
return instance
5731

58-
5932
class RegistrationSubscriptionSerializer(SubscriptionSerializer):
6033
provider = RegistrationProviderRelationshipField(
6134
related_view='providers:registration-providers:registration-provider-detail',
62-
related_view_kwargs={'provider_id': '<provider._id>'},
63-
read_only=False,
64-
required=False,
35+
related_view_kwargs={'provider_id': '<subscribed_object._id>'},
36+
read_only=True, # We're not modifying the relationship from here
6537
)
6638

6739
def get_absolute_url(self, obj):
68-
return api_v2_url(f'registration_subscriptions/{obj._id}')
40+
return api_v2_url(f'registration_subscriptions/{obj.pk}')
6941

7042
class Meta:
7143
type_ = 'registration-subscription'
7244

73-
7445
class CollectionSubscriptionSerializer(SubscriptionSerializer):
7546
provider = CollectionProviderRelationshipField(
7647
related_view='providers:collection-providers:collection-provider-detail',
77-
related_view_kwargs={'provider_id': '<provider._id>'},
78-
read_only=False,
79-
required=False,
48+
related_view_kwargs={'provider_id': '<subscribed_object._id>'},
49+
read_only=True,
8050
)
8151

8252
def get_absolute_url(self, obj):
83-
return api_v2_url(f'collection_subscriptions/{obj._id}')
53+
return api_v2_url(f'collection_subscriptions/{obj.pk}')
8454

8555
class Meta:
8656
type_ = 'collection-subscription'
8757

88-
8958
class PreprintSubscriptionSerializer(SubscriptionSerializer):
9059
provider = PreprintProviderRelationshipField(
9160
related_view='providers:preprint-providers:preprint-provider-detail',
92-
related_view_kwargs={'provider_id': '<provider._id>'},
93-
read_only=False,
61+
related_view_kwargs={'provider_id': '<subscribed_object._id>'},
62+
read_only=True,
9463
)
9564

9665
def get_absolute_url(self, obj):
97-
return api_v2_url(f'preprints_subscriptions/{obj._id}')
66+
return api_v2_url(f'preprints_subscriptions/{obj.pk}')
9867

9968
class Meta:
10069
type_ = 'preprint-subscription'

api/subscriptions/views.py

+70-78
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,27 @@
1-
from rest_framework import generics
2-
from rest_framework import permissions as drf_permissions
1+
from api.base.filters import ListFilterMixin
2+
from api.subscriptions.serializers import SubscriptionSerializer
3+
from osf.models.notification import NotificationSubscription
4+
from django.contrib.contenttypes.models import ContentType
5+
from rest_framework import generics, permissions as drf_permissions
36
from rest_framework.exceptions import NotFound
4-
from django.core.exceptions import ObjectDoesNotExist
5-
from django.db.models import Q
7+
from django.shortcuts import get_object_or_404
8+
9+
from osf.models import AbstractProvider, CollectionProvider, PreprintProvider, RegistrationProvider
610

7-
from framework.auth.oauth_scopes import CoreScopes
811
from api.base.views import JSONAPIBaseView
9-
from api.base.filters import ListFilterMixin
1012
from api.base import permissions as base_permissions
13+
from api.subscriptions.permissions import IsSubscriptionOwner
1114
from api.subscriptions.serializers import (
12-
SubscriptionSerializer,
1315
CollectionSubscriptionSerializer,
1416
PreprintSubscriptionSerializer,
1517
RegistrationSubscriptionSerializer,
1618
)
17-
from api.subscriptions.permissions import IsSubscriptionOwner
18-
from osf.models import (
19-
NotificationSubscription,
20-
CollectionProvider,
21-
PreprintProvider,
22-
RegistrationProvider,
23-
AbstractProvider,
24-
)
25-
19+
from framework.auth.oauth_scopes import CoreScopes
2620

2721
class SubscriptionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin):
2822
view_name = 'notification-subscription-list'
2923
view_category = 'notification-subscriptions'
3024
serializer_class = SubscriptionSerializer
31-
model_class = NotificationSubscription
3225
permission_classes = (
3326
drf_permissions.IsAuthenticated,
3427
base_permissions.TokenHasScope,
@@ -37,31 +30,8 @@ class SubscriptionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin):
3730
required_read_scopes = [CoreScopes.SUBSCRIPTIONS_READ]
3831
required_write_scopes = [CoreScopes.NULL]
3932

40-
def get_default_queryset(self):
41-
user = self.request.user
42-
return NotificationSubscription.objects.filter(
43-
Q(none=user) |
44-
Q(email_digest=user) |
45-
Q(
46-
email_transactional=user,
47-
),
48-
).distinct()
49-
5033
def get_queryset(self):
51-
return self.get_queryset_from_request()
52-
53-
54-
class AbstractProviderSubscriptionList(SubscriptionList):
55-
def get_default_queryset(self):
56-
user = self.request.user
57-
return NotificationSubscription.objects.filter(
58-
provider___id=self.kwargs['provider_id'],
59-
provider__type=self.provider_class._typedmodels_type,
60-
).filter(
61-
Q(none=user) |
62-
Q(email_digest=user) |
63-
Q(email_transactional=user),
64-
).distinct()
34+
return NotificationSubscription.objects.filter(user=self.request.user)
6535

6636

6737
class SubscriptionDetail(JSONAPIBaseView, generics.RetrieveUpdateAPIView):
@@ -78,80 +48,102 @@ class SubscriptionDetail(JSONAPIBaseView, generics.RetrieveUpdateAPIView):
7848
required_write_scopes = [CoreScopes.SUBSCRIPTIONS_WRITE]
7949

8050
def get_object(self):
81-
subscription_id = self.kwargs['subscription_id']
8251
try:
83-
obj = NotificationSubscription.objects.get(_id=subscription_id)
84-
except ObjectDoesNotExist:
52+
sub = NotificationSubscription.objects.get(pk=self.kwargs['pk'])
53+
except NotificationSubscription.DoesNotExist:
8554
raise NotFound
86-
self.check_object_permissions(self.request, obj)
87-
return obj
55+
self.check_object_permissions(self.request, sub)
56+
return sub
8857

8958

90-
class AbstractProviderSubscriptionDetail(SubscriptionDetail):
59+
class AbstractProviderSubscriptionDetail(JSONAPIBaseView, generics.RetrieveUpdateAPIView):
9160
view_name = 'provider-notification-subscription-detail'
9261
view_category = 'notification-subscriptions'
9362
permission_classes = (
9463
drf_permissions.IsAuthenticated,
9564
base_permissions.TokenHasScope,
9665
IsSubscriptionOwner,
9766
)
98-
9967
required_read_scopes = [CoreScopes.SUBSCRIPTIONS_READ]
10068
required_write_scopes = [CoreScopes.SUBSCRIPTIONS_WRITE]
101-
provider_class = None
102-
103-
def __init__(self, *args, **kwargs):
104-
assert issubclass(self.provider_class, AbstractProvider), 'Class must be subclass of AbstractProvider'
105-
super().__init__(*args, **kwargs)
69+
provider_class = None # Must be set in subclass
70+
serializer_class = None # Must be set in subclass
10671

10772
def get_object(self):
108-
subscription_id = self.kwargs['subscription_id']
109-
if self.kwargs.get('provider_id'):
110-
provider = self.provider_class.objects.get(_id=self.kwargs.get('provider_id'))
111-
try:
112-
obj = NotificationSubscription.objects.get(
113-
_id=subscription_id,
114-
provider_id=provider.id,
115-
)
116-
except ObjectDoesNotExist:
117-
raise NotFound
118-
else:
119-
try:
120-
obj = NotificationSubscription.objects.get(
121-
_id=subscription_id,
122-
provider__type=self.provider_class._typedmodels_type,
123-
)
124-
except ObjectDoesNotExist:
125-
raise NotFound
126-
self.check_object_permissions(self.request, obj)
127-
return obj
73+
assert issubclass(self.provider_class, AbstractProvider), 'Must set provider_class to an AbstractProvider subclass'
12874

75+
subscription_id = self.kwargs.get('pk')
76+
provider_id = self.kwargs.get('provider_id')
77+
78+
# Get provider
79+
provider = get_object_or_404(self.provider_class, _id=provider_id)
80+
content_type = ContentType.objects.get_for_model(self.provider_class)
81+
82+
try:
83+
sub = NotificationSubscription.objects.get(
84+
pk=subscription_id,
85+
content_type=content_type,
86+
object_id=provider.id,
87+
)
88+
except NotificationSubscription.DoesNotExist:
89+
raise NotFound
90+
91+
self.check_object_permissions(self.request, sub)
92+
return sub
93+
94+
95+
class AbstractProviderSubscriptionList(JSONAPIBaseView, generics.ListAPIView):
96+
permission_classes = (
97+
drf_permissions.IsAuthenticated,
98+
base_permissions.TokenHasScope,
99+
)
100+
required_read_scopes = [CoreScopes.SUBSCRIPTIONS_READ]
101+
provider_class = None
102+
serializer_class = None
103+
104+
def get_queryset(self):
105+
assert issubclass(self.provider_class, AbstractProvider), 'Must set provider_class to an AbstractProvider subclass'
106+
provider_id = self.kwargs.get('provider_id')
107+
provider = get_object_or_404(self.provider_class, _id=provider_id)
108+
109+
return NotificationSubscription.objects.filter(
110+
user=self.request.user,
111+
content_type=ContentType.objects.get_for_model(self.provider_class),
112+
object_id=provider.id,
113+
)
129114

130115
class CollectionProviderSubscriptionDetail(AbstractProviderSubscriptionDetail):
116+
view_name = 'provider-notification-subscription-detail'
117+
view_category = 'notification-subscriptions'
131118
provider_class = CollectionProvider
132119
serializer_class = CollectionSubscriptionSerializer
133120

134-
135121
class PreprintProviderSubscriptionDetail(AbstractProviderSubscriptionDetail):
122+
view_name = 'provider-notification-subscription-detail'
123+
view_category = 'notification-subscriptions'
136124
provider_class = PreprintProvider
137125
serializer_class = PreprintSubscriptionSerializer
138126

139-
140127
class RegistrationProviderSubscriptionDetail(AbstractProviderSubscriptionDetail):
128+
view_name = 'provider-notification-subscription-detail'
129+
view_category = 'notification-subscriptions'
141130
provider_class = RegistrationProvider
142131
serializer_class = RegistrationSubscriptionSerializer
143132

144-
145133
class CollectionProviderSubscriptionList(AbstractProviderSubscriptionList):
134+
view_name = 'provider-notification-subscription-detail'
135+
view_category = 'notification-subscriptions'
146136
provider_class = CollectionProvider
147137
serializer_class = CollectionSubscriptionSerializer
148138

149-
150139
class PreprintProviderSubscriptionList(AbstractProviderSubscriptionList):
140+
view_name = 'provider-notification-subscription-detail'
141+
view_category = 'notification-subscriptions'
151142
provider_class = PreprintProvider
152143
serializer_class = PreprintSubscriptionSerializer
153144

154-
155145
class RegistrationProviderSubscriptionList(AbstractProviderSubscriptionList):
146+
view_name = 'provider-notification-subscription-detail'
147+
view_category = 'notification-subscriptions'
156148
provider_class = RegistrationProvider
157149
serializer_class = RegistrationSubscriptionSerializer

0 commit comments

Comments
 (0)