pwa: add basic support for push notifications (#25462)
This commit is contained in:
parent
88baf45c71
commit
e039b655e1
|
@ -19,6 +19,9 @@ import django.apps
|
|||
class AppConfig(django.apps.AppConfig):
|
||||
name = 'combo.apps.pwa'
|
||||
|
||||
def ready(self):
|
||||
from . import signals
|
||||
|
||||
def get_before_urls(self):
|
||||
from . import urls
|
||||
return urls.urlpatterns
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.12 on 2018-11-29 09:45
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import jsonfield.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PushSubscription',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('subscription_info', jsonfield.fields.JSONField(default=dict)),
|
||||
('creation_timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1,26 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2015-2018 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from jsonfield import JSONField
|
||||
|
||||
|
||||
class PushSubscription(models.Model):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
subscription_info = JSONField()
|
||||
creation_timestamp = models.DateTimeField(auto_now_add=True)
|
|
@ -0,0 +1,56 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2015-2018 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
try:
|
||||
import pywebpush
|
||||
except ImportError:
|
||||
pywebpush = None
|
||||
|
||||
from combo.apps.notifications.models import Notification
|
||||
|
||||
from .models import PushSubscription
|
||||
|
||||
|
||||
@receiver(post_save, sender=Notification)
|
||||
def notification(sender, instance=None, created=False, **kwargs):
|
||||
if not pywebpush:
|
||||
return
|
||||
if not created:
|
||||
return
|
||||
message = json.dumps({
|
||||
'summary': instance.summary,
|
||||
'body': instance.body,
|
||||
'url': instance.url,
|
||||
})
|
||||
|
||||
for subscription in PushSubscription.objects.filter(user_id=instance.user_id):
|
||||
try:
|
||||
pywebpush.webpush(
|
||||
subscription_info=subscription.subscription_info,
|
||||
data=message,
|
||||
vapid_private_key=settings.PWA_VAPID_PRIVATE_KEY,
|
||||
vapid_claims=settings.PWA_VAPID_CLAIMS
|
||||
)
|
||||
except pywebpush.WebPushException as e:
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.exception('webpush error (%r)', e)
|
|
@ -1,10 +1,90 @@
|
|||
var applicationServerPublicKey = {% if pwa_vapid_publik_key %}'{{ pwa_vapid_publik_key }}'{% else %}null{% endif %};
|
||||
var COMBO_PWA_USER_SUBSCRIPTION = false;
|
||||
|
||||
function urlB64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/\-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/service-worker.js', {scope: '/'}).then(function(registration) {
|
||||
// Registration was successful
|
||||
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
||||
swRegistration = registration;
|
||||
combo_pwa_initialize();
|
||||
}).catch(function(err) {
|
||||
// registration failed :(
|
||||
console.log('ServiceWorker registration failed: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
function combo_pwa_initialize() {
|
||||
if (applicationServerPublicKey !== null) {
|
||||
swRegistration.pushManager.getSubscription()
|
||||
.then(function(subscription) {
|
||||
if (subscription !== null) {
|
||||
COMBO_PWA_USER_SUBSCRIPTION = true;
|
||||
} else {
|
||||
COMBO_PWA_USER_SUBSCRIPTION = false;
|
||||
}
|
||||
$(document).trigger('combo:pwa-user-info');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function combo_pwa_subscribe_user() {
|
||||
const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey);
|
||||
swRegistration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey
|
||||
})
|
||||
.then(function(subscription) {
|
||||
console.log('User is subscribed.');
|
||||
COMBO_PWA_USER_SUBSCRIPTION = true;
|
||||
$(document).trigger('combo:pwa-user-info');
|
||||
combo_pwa_update_subscription_on_server(subscription);
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.log('Failed to subscribe the user: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
function combo_pwa_unsubscribe_user() {
|
||||
swRegistration.pushManager.getSubscription()
|
||||
.then(function(subscription) {
|
||||
if (subscription) {
|
||||
return subscription.unsubscribe();
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.log('Error unsubscribing', error);
|
||||
})
|
||||
.then(function() {
|
||||
combo_pwa_update_subscription_on_server(null);
|
||||
console.log('User is unsubscribed.');
|
||||
COMBO_PWA_USER_SUBSCRIPTION = false;
|
||||
$(document).trigger('combo:pwa-user-info');
|
||||
});
|
||||
}
|
||||
|
||||
function combo_pwa_update_subscription_on_server(subscription) {
|
||||
$.ajax({
|
||||
url: '{% url "pwa-subscribe-push" %}',
|
||||
data: JSON.stringify(subscription),
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
success: function(response) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,7 +1,27 @@
|
|||
{% load gadjo %}
|
||||
{% load combo gadjo %}
|
||||
|
||||
/* global self, caches, fetch, URL, Response */
|
||||
'use strict';
|
||||
|
||||
const applicationServerPublicKey = {{ pwa_vapid_publik_key|as_json|safe }};
|
||||
|
||||
function urlB64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/\-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
|
||||
|
||||
var config = {
|
||||
version: 'v{% start_timestamp %}',
|
||||
staticCacheItems: [
|
||||
|
@ -115,3 +135,44 @@ self.addEventListener('fetch', event => {
|
|||
onFetch(event, config);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('push', event => {
|
||||
console.log('[Service Worker] Push Received.');
|
||||
console.log(`[Service Worker] Push had this data: "${event.data.text()}"`);
|
||||
var message = JSON.parse(event.data.text());
|
||||
|
||||
const title = message.summary;
|
||||
const options = {
|
||||
body: message.body,
|
||||
data: {"url": message.url},
|
||||
badge: {{ pwa_notification_badge_url|as_json|safe }},
|
||||
icon: {{ pwa_notification_icon_url|as_json|safe }}
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', function(event) {
|
||||
var url = event.notification.data.url;
|
||||
event.notification.close();
|
||||
if (url) {
|
||||
event.waitUntil(
|
||||
clients.openWindow(url)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('pushsubscriptionchange', event => {
|
||||
console.log('[Service Worker]: \'pushsubscriptionchange\' event fired.');
|
||||
const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey);
|
||||
event.waitUntil(
|
||||
self.registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey
|
||||
})
|
||||
.then(function(newSubscription) {
|
||||
console.log('[Service Worker] New subscription: ', newSubscription);
|
||||
combo_pwa_update_subscription_on_server(newSubscription);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
|
@ -20,10 +20,12 @@ from .views import (
|
|||
manifest_json,
|
||||
service_worker_js,
|
||||
service_worker_registration_js,
|
||||
subscribe_push,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
url('^manifest.json$', manifest_json),
|
||||
url('^service-worker.js$', service_worker_js),
|
||||
url('^service-worker-registration.js$', service_worker_registration_js),
|
||||
url('^api/pwa/push/subscribe$', subscribe_push, name='pwa-subscribe-push'),
|
||||
]
|
||||
|
|
|
@ -14,8 +14,15 @@
|
|||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.http import HttpResponse, Http404
|
||||
import json
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from django.http import HttpResponse, HttpResponseForbidden, Http404, JsonResponse
|
||||
from django.template.loader import get_template, TemplateDoesNotExist
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from .models import PushSubscription
|
||||
|
||||
|
||||
def manifest_json(request, *args, **kwargs):
|
||||
|
@ -26,13 +33,37 @@ def manifest_json(request, *args, **kwargs):
|
|||
return HttpResponse(template.render({}, request), content_type='application/json')
|
||||
|
||||
|
||||
def service_worker_js(request, *args, **kwargs):
|
||||
template = get_template('combo/service-worker.js')
|
||||
return HttpResponse(template.render({}, request),
|
||||
def js_response(request, template_name):
|
||||
template = get_template(template_name)
|
||||
context = {
|
||||
'pwa_vapid_publik_key': settings.PWA_VAPID_PUBLIK_KEY,
|
||||
'pwa_notification_badge_url': settings.PWA_NOTIFICATION_BADGE_URL,
|
||||
'pwa_notification_icon_url': settings.PWA_NOTIFICATION_ICON_URL,
|
||||
}
|
||||
return HttpResponse(template.render(context, request),
|
||||
content_type='application/javascript; charset=utf-8')
|
||||
|
||||
|
||||
def service_worker_js(request, *args, **kwargs):
|
||||
return js_response(request, 'combo/service-worker.js')
|
||||
|
||||
|
||||
def service_worker_registration_js(request, *args, **kwargs):
|
||||
template = get_template('combo/service-worker-registration.js')
|
||||
return HttpResponse(template.render({}, request),
|
||||
content_type='application/javascript; charset=utf-8')
|
||||
return js_response(request, 'combo/service-worker-registration.js')
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def subscribe_push(request, *args, **kwargs):
|
||||
if not (request.user and request.user.is_authenticated()):
|
||||
return HttpResponseForbidden()
|
||||
if request.method != 'POST':
|
||||
return HttpResponseForbidden()
|
||||
subscription_data = json.loads(request.body)
|
||||
if subscription_data is None:
|
||||
PushSubscription.objects.filter(user=request.user).delete()
|
||||
else:
|
||||
subscription, created = PushSubscription.objects.get_or_create(
|
||||
user=request.user,
|
||||
subscription_info=subscription_data)
|
||||
subscription.save()
|
||||
return JsonResponse({'err': 0})
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
from __future__ import absolute_import
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import time
|
||||
|
||||
from django import template
|
||||
|
@ -236,6 +237,10 @@ def is_empty_placeholder(page, placeholder_name):
|
|||
def as_list(obj):
|
||||
return list(obj)
|
||||
|
||||
@register.filter(name='as_json')
|
||||
def as_json(obj):
|
||||
return json.dumps(obj)
|
||||
|
||||
@register.filter
|
||||
def signed(obj):
|
||||
return signing.dumps(obj)
|
||||
|
|
|
@ -315,6 +315,14 @@ WCS_CATEGORY_ASSET_SLOTS = {
|
|||
|
||||
WCS_FORM_ASSET_SLOTS = {}
|
||||
|
||||
# PWA Settings
|
||||
PWA_VAPID_PUBLIK_KEY = None
|
||||
PWA_VAPID_PRIVATE_KEY = None
|
||||
PWA_VAPID_CLAIMS = None
|
||||
PWA_NOTIFICATION_BADGE_URL = None
|
||||
PWA_NOTIFICATION_ICON_URL = None
|
||||
|
||||
|
||||
# hide work-in-progress/experimental/broken/legacy/whatever cells for now
|
||||
BOOKING_CALENDAR_CELL_ENABLED = False
|
||||
NEWSLETTERS_CELL_ENABLED = False
|
||||
|
|
|
@ -24,7 +24,7 @@ Depends: ${misc:Depends}, ${python:Depends},
|
|||
python-django-haystack (>= 2.4.0),
|
||||
python-sorl-thumbnail,
|
||||
python-pil
|
||||
Recommends: python-django-mellon, python-whoosh
|
||||
Recommends: python-django-mellon, python-whoosh, python-pywebpush
|
||||
Conflicts: python-lingo
|
||||
Description: Portal Management System (Python module)
|
||||
|
||||
|
|
|
@ -1,11 +1,25 @@
|
|||
import os
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
|
||||
try:
|
||||
import pywebpush
|
||||
except ImportError:
|
||||
pywebpush = None
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test import override_settings
|
||||
|
||||
from combo.apps.notifications.models import Notification
|
||||
from combo.apps.pwa.models import PushSubscription
|
||||
|
||||
from .test_manager import login
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_manifest_json(app):
|
||||
app.get('/manifest.json', status=404)
|
||||
|
||||
|
@ -16,3 +30,32 @@ def test_manifest_json(app):
|
|||
|
||||
def test_service_worker(app):
|
||||
app.get('/service-worker.js', status=200)
|
||||
app.get('/service-worker-registration.js', status=200)
|
||||
|
||||
def test_webpush_subscription(app, john_doe, jane_doe):
|
||||
app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content'}, status=403)
|
||||
app.get(reverse('pwa-subscribe-push'), status=403)
|
||||
app = login(app, john_doe.username, john_doe.username)
|
||||
app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content'}, status=200)
|
||||
assert PushSubscription.objects.count() == 1
|
||||
app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content2'}, status=200)
|
||||
assert PushSubscription.objects.count() == 2
|
||||
|
||||
app = login(app, jane_doe.username, jane_doe.username)
|
||||
app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content'}, status=200)
|
||||
assert PushSubscription.objects.count() == 3
|
||||
|
||||
app = login(app, john_doe.username, john_doe.username)
|
||||
app.post_json(reverse('pwa-subscribe-push'), params=None, status=200)
|
||||
assert PushSubscription.objects.count() == 1
|
||||
|
||||
@pytest.mark.skipif('pywebpush is None')
|
||||
def test_webpush_notification(app, john_doe):
|
||||
PushSubscription.objects.all().delete()
|
||||
app = login(app, john_doe.username, john_doe.username)
|
||||
app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content'}, status=200)
|
||||
|
||||
with mock.patch('pywebpush.webpush') as webpush:
|
||||
notification = Notification.notify(john_doe, 'test', body='hello world')
|
||||
assert webpush.call_count == 1
|
||||
assert webpush.call_args[1]['subscription_info'] == {'sample': 'content'}
|
||||
|
|
Loading…
Reference in New Issue