pwa: add basic support for push notifications (#25462)

This commit is contained in:
Frédéric Péters 2018-11-26 17:14:05 +01:00
parent 88baf45c71
commit e039b655e1
14 changed files with 354 additions and 9 deletions

View File

@ -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

View File

@ -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)),
],
),
]

View File

26
combo/apps/pwa/models.py Normal file
View File

@ -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)

56
combo/apps/pwa/signals.py Normal file
View File

@ -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)

View File

@ -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) {
}
});
}

View File

@ -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);
})
);
});

View File

@ -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'),
]

View File

@ -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})

View File

@ -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)

View File

@ -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

2
debian/control vendored
View File

@ -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)

View File

@ -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'}

View File

@ -12,6 +12,7 @@ setenv =
deps =
django18: django>=1.8,<1.9
django111: django>=1.11,<1.12
django111: pywebpush
pytest-cov
pytest-django
pytest-freezegun