Skip to content

Commit e80049e

Browse files
authored
Merge pull request #825 from ubyssey/492-push-notifications
492 push notifications
2 parents 1f21ab2 + 136153d commit e80049e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+8497
-26
lines changed

dispatch/api/exceptions.py

+4
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ class InvalidPoll(APIException):
2525
status_code = status.HTTP_400_BAD_REQUEST
2626
default_detail = 'Invalid poll'
2727

28+
class AlreadySubscribed(APIException):
29+
status_code = status.HTTP_202_ACCEPTED
30+
default_detail = 'User is already subscribed'
31+
2832
class UnpermittedActionError(APIException):
2933
status_code = status.HTTP_401_UNAUTHORIZED
3034
default_detail = 'You do not have permission to perform this action'

dispatch/api/mixins.py

+49
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
import json
2+
from pywebpush import webpush, WebPushException
3+
from django.utils import timezone
4+
15
from rest_framework.response import Response
26
from rest_framework.decorators import detail_route
37
from rest_framework.viewsets import ModelViewSet
48
from rest_framework.serializers import HyperlinkedModelSerializer
9+
from django.conf import settings
510
from django.db.models import F
611

712
from dispatch.core.signals import post_create, post_update, post_publish, post_unpublish
13+
from dispatch.models import Subscription, Notification, Article
814

915
class DispatchModelViewSet(ModelViewSet):
1016
"""Custom viewset to add Dispatch signals to default ModelViewSet"""
@@ -70,6 +76,13 @@ def publish(self, request, parent_id=None):
7076

7177
self.perform_publish(serializer)
7278

79+
if isinstance(instance, Article):
80+
if instance.is_breaking:
81+
self.push_notification(instance)
82+
elif instance.scheduled_notification is not None and instance.scheduled_notification > timezone.now():
83+
Notification.objects.filter(article__parent_id=instance.parent_id).delete()
84+
Notification.objects.create(article=instance, scheduled_push_time=instance.scheduled_notification)
85+
7386
return Response(serializer.data)
7487

7588
@detail_route(methods=['post'])
@@ -83,6 +96,42 @@ def unpublish(self, request, parent_id=None):
8396

8497
return Response(serializer.data)
8598

99+
@classmethod
100+
def push_notification(self, article):
101+
# grab each endpoint from list in database and make a push
102+
data = {
103+
'headline': article.headline,
104+
'url': article.get_absolute_url(),
105+
'snippet': article.snippet,
106+
'tag': 'ubyssey'
107+
}
108+
109+
if article.is_breaking:
110+
data['tag'] = 'breaking'
111+
if article.featured_image is not None:
112+
data['image'] = article.featured_image.image.get_thumbnail_url()
113+
114+
subscriptions = Subscription.objects.all()
115+
for sub in subscriptions:
116+
try:
117+
webpush(
118+
subscription_info={
119+
"endpoint": sub.endpoint,
120+
"keys": {
121+
"p256dh": sub.p256dh,
122+
"auth": sub.auth
123+
}},
124+
data=json.dumps(data),
125+
vapid_private_key=settings.NOTIFICATION_KEY,
126+
vapid_claims={
127+
"sub": "mailto:[email protected]===",
128+
}
129+
)
130+
except WebPushException as ex:
131+
if ex.response.status_code == 410:
132+
sub.delete()
133+
134+
86135
class DispatchModelSerializer(HyperlinkedModelSerializer):
87136
def __init__(self, *args, **kwargs):
88137
# Override default constructor to call hide_authenticated_fields

dispatch/api/serializers.py

+54-3
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
from rest_framework.validators import UniqueValidator
44

55
from dispatch.modules.content.models import (
6-
Article, Image, ImageAttachment, ImageGallery, Issue,
7-
File, Page, Author, Section, Tag, Topic, Video, VideoAttachment, Poll, PollAnswer, PollVote)
6+
Article, Image, ImageAttachment, ImageGallery, Issue, Subscription,
7+
File, Page, Author, Section, Tag, Topic, Video, VideoAttachment,
8+
Poll, PollAnswer, PollVote, Notification, SubscriptionCount)
89
from dispatch.modules.auth.models import Person, User, Invite
910
from dispatch.admin.registration import send_invitation
1011
from dispatch.theme.exceptions import WidgetNotFound, InvalidField
@@ -563,6 +564,7 @@ class ArticleSerializer(DispatchModelSerializer, DispatchPublishableSerializer):
563564

564565
integrations = JSONField(required=False)
565566

567+
scheduled_notification = serializers.DateTimeField(required=False, allow_null=True)
566568
currently_breaking = serializers.BooleanField(source='is_currently_breaking', read_only=True)
567569

568570
class Meta:
@@ -578,6 +580,7 @@ class Meta:
578580
'content',
579581
'authors',
580582
'author_ids',
583+
'scheduled_notification',
581584
'tags',
582585
'tag_ids',
583586
'topic',
@@ -630,6 +633,7 @@ def update(self, instance, validated_data):
630633

631634
# Update basic fields
632635
instance.headline = validated_data.get('headline', instance.headline)
636+
instance.scheduled_notification = validated_data.get('scheduled_notification', instance.scheduled_notification)
633637
instance.section_id = validated_data.get('section_id', instance.section_id)
634638
instance.slug = validated_data.get('slug', instance.slug)
635639
instance.snippet = validated_data.get('snippet', instance.snippet)
@@ -642,7 +646,7 @@ def update(self, instance, validated_data):
642646
instance.integrations = validated_data.get('integrations', instance.integrations)
643647
instance.template = template
644648
instance.template_data = template_data
645-
649+
646650
instance.save()
647651

648652
instance.content = validated_data.get('content', instance.content)
@@ -926,3 +930,50 @@ def update(self, instance, validated_data, is_new=False):
926930
instance.save_answers(answers, is_new)
927931

928932
return instance
933+
934+
class SubscriptionSerializer(DispatchModelSerializer):
935+
"""Serializes the Subscription model."""
936+
937+
endpoint = serializers.CharField(required=True, write_only=True)
938+
auth = serializers.CharField(required=True, write_only=True)
939+
p256dh = serializers.CharField(required=True, write_only=True)
940+
941+
class Meta:
942+
model = Subscription
943+
fields = (
944+
'id',
945+
'endpoint',
946+
'auth',
947+
'p256dh'
948+
)
949+
950+
class NotificationSerializer(DispatchModelSerializer):
951+
"""Serializes the Notification model."""
952+
953+
article_id = serializers.IntegerField()
954+
created_at = serializers.DateTimeField(read_only=True)
955+
scheduled_push_time = serializers.DateTimeField(required=True)
956+
article_headline = serializers.CharField(source='get_article_headline', read_only=True)
957+
958+
class Meta:
959+
model = Notification
960+
fields = (
961+
'id',
962+
'created_at',
963+
'article_id',
964+
'article_headline',
965+
'scheduled_push_time'
966+
)
967+
968+
class SubscriptionCountSerializer(DispatchModelSerializer):
969+
"""Serializes the SubscriptionCount model."""
970+
971+
date = serializers.DateTimeField(read_only=True)
972+
973+
class Meta:
974+
model = SubscriptionCount
975+
fields = (
976+
'id',
977+
'count',
978+
'date'
979+
)

dispatch/api/urls.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@
2626
router.register(r'videos', views.VideoViewSet, base_name='api-videos')
2727
router.register(r'invites', views.InviteViewSet, base_name='api-invites')
2828
router.register(r'polls', views.PollViewSet, base_name='api-polls')
29+
router.register(r'notifications', views.NotificationsViewSet, base_name='api-notifications')
30+
router.register(r'subscriptioncount', views.SubscriptionCountViewSet, base_name='api-subscriptioncount')
2931

3032
dashboard_recent_articles = views.DashboardViewSet.as_view({'get': 'list_recent_articles'})
3133
dashboard_user_actions = views.DashboardViewSet.as_view({'get': 'list_actions'})
3234

3335
urlpatterns = format_suffix_patterns([
3436
# Dashboard routes
3537
url(r'^dashboard/recent', dashboard_recent_articles, name='dashboard_recent_articles'),
36-
url(r'^dashboard/actions', dashboard_user_actions, name='dashboard_user_actions')
38+
url(r'^dashboard/actions', dashboard_user_actions, name='dashboard_user_actions'),
3739
]) + router.urls

dispatch/api/views.py

+87-7
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1+
import json
2+
import datetime
3+
4+
from pywebpush import webpush, WebPushException
5+
16
from django.db.models import Q, ProtectedError, Prefetch
2-
from django.contrib.auth import authenticate
37
from django.conf import settings
8+
from django.contrib.auth import authenticate
49
from django.db import IntegrityError
10+
from django.utils import timezone
511

612
from rest_framework import viewsets, mixins, filters, status
713
from rest_framework.response import Response
814
from rest_framework.permissions import (
915
AllowAny, IsAuthenticated, DjangoModelPermissions)
1016
from rest_framework.decorators import (
11-
detail_route, api_view, authentication_classes, permission_classes)
17+
list_route, detail_route, api_view, authentication_classes, permission_classes)
1218
from rest_framework.generics import get_object_or_404
1319
from rest_framework.exceptions import APIException, NotFound
1420
from rest_framework.authtoken.models import Token
@@ -20,7 +26,8 @@
2026
from dispatch.models import (
2127
Article, File, Image, ImageAttachment, ImageGallery, Issue,
2228
Page, Author, Person, Section, Tag, Topic, User, Video,
23-
Poll, PollAnswer, PollVote, Invite)
29+
Poll, PollAnswer, PollVote, Invite, Notification, Subscription,
30+
SubscriptionCount)
2431

2532
from dispatch.core.settings import get_settings
2633
from dispatch.admin.registration import reset_password
@@ -31,10 +38,11 @@
3138
FileSerializer, IssueSerializer, ImageGallerySerializer, TagSerializer,
3239
TopicSerializer, PersonSerializer, UserSerializer, IntegrationSerializer,
3340
ZoneSerializer, WidgetSerializer, TemplateSerializer, VideoSerializer,
34-
PollSerializer, PollVoteSerializer, InviteSerializer)
41+
PollSerializer, PollVoteSerializer, NotificationSerializer, InviteSerializer,
42+
SubscriptionSerializer, SubscriptionCountSerializer)
3543
from dispatch.api.exceptions import (
3644
ProtectedResourceError, BadCredentials, PollClosed, InvalidPoll,
37-
UnpermittedActionError)
45+
UnpermittedActionError, AlreadySubscribed)
3846

3947
from dispatch.theme import ThemeManager
4048
from dispatch.theme.exceptions import ZoneNotFound, TemplateNotFound
@@ -87,7 +95,7 @@ def get_queryset(self):
8795
'authors'
8896
)
8997

90-
queryset = queryset.order_by('-updated_at')
98+
queryset = queryset.order_by('-updated_at')
9199

92100
q = self.request.query_params.get('q', None)
93101
section = self.request.query_params.get('section', None)
@@ -99,7 +107,7 @@ def get_queryset(self):
99107

100108
if section is not None:
101109
queryset = queryset.filter(section_id=section)
102-
110+
103111
if tags is not None:
104112
for tag in tags:
105113
queryset = queryset.filter(tags__id=tag)
@@ -387,6 +395,78 @@ def vote(self, request, pk=None):
387395

388396
return Response(serializer.data)
389397

398+
class SubscriptionCountViewSet(DispatchModelViewSet):
399+
"""Viewset for the SubscriptionCount model views."""
400+
401+
model = SubscriptionCount
402+
serializer_class = SubscriptionCountSerializer
403+
queryset = SubscriptionCount.objects.filter(date__gte=timezone.now() - datetime.timedelta(days=90)).order_by('-date')
404+
405+
def get_permissions(self):
406+
"""
407+
Instantiates and returns the list of permissions that this view requires.
408+
"""
409+
if self.action == 'create':
410+
permission_classes = [AllowAny,]
411+
else:
412+
permission_classes = [IsAuthenticated,]
413+
return [permission() for permission in permission_classes]
414+
415+
def create(self, request):
416+
try:
417+
subscription_count = Subscription.objects.all().count()
418+
data = {
419+
'count': subscription_count
420+
}
421+
serializer = SubscriptionCountSerializer(data=data)
422+
serializer.is_valid(raise_exception=True)
423+
serializer.save()
424+
425+
return Response({ 'detail': 'Subscriber count recorded' })
426+
except:
427+
return Response({ 'detail': 'Subscriber count for today has already been created' }, status.HTTP_400_BAD_REQUEST)
428+
429+
class NotificationsViewSet(DispatchModelViewSet):
430+
"""Viewset for the Poll model views."""
431+
432+
model = Notification
433+
serializer_class = NotificationSerializer
434+
permission_classes = (IsAuthenticated,)
435+
queryset = Notification.objects.all().order_by('scheduled_push_time')
436+
437+
@detail_route(permission_classes=[AllowAny], methods=['post', 'patch'],)
438+
def subscribe(self, request, pk=None):
439+
data = {
440+
'endpoint': request.data['endpoint'],
441+
'auth': request.data['keys']['auth'],
442+
'p256dh': request.data['keys']['p256dh'],
443+
}
444+
445+
try:
446+
serializer = SubscriptionSerializer(data=data)
447+
serializer.is_valid(raise_exception=True)
448+
serializer.save()
449+
return Response(serializer.data)
450+
except:
451+
raise AlreadySubscribed()
452+
453+
return Response(serializer.data)
454+
455+
@list_route(permission_classes=[AllowAny], methods=['post'],)
456+
def push(self, request):
457+
notification = Notification.objects \
458+
.filter(scheduled_push_time__lte=timezone.now()) \
459+
.order_by('scheduled_push_time') \
460+
.first()
461+
462+
if notification is not None:
463+
article = Article.objects.get(parent__id=notification.article.parent_id, is_published=True)
464+
if article is not None:
465+
DispatchPublishableMixin.push_notification(article)
466+
notification.delete()
467+
468+
return Response(status=status.HTTP_200_OK)
469+
390470
class TemplateViewSet(viewsets.GenericViewSet):
391471
"""Viewset for Template views."""
392472
permission_classes = (IsAuthenticated,)

0 commit comments

Comments
 (0)