diff --git a/combo/apps/pwa/__init__.py b/combo/apps/pwa/__init__.py
index 8fec7cf3..ade658df 100644
--- a/combo/apps/pwa/__init__.py
+++ b/combo/apps/pwa/__init__.py
@@ -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
diff --git a/combo/apps/pwa/migrations/0001_initial.py b/combo/apps/pwa/migrations/0001_initial.py
new file mode 100644
index 00000000..a2dd96f1
--- /dev/null
+++ b/combo/apps/pwa/migrations/0001_initial.py
@@ -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)),
+ ],
+ ),
+ ]
diff --git a/combo/apps/pwa/migrations/__init__.py b/combo/apps/pwa/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/combo/apps/pwa/models.py b/combo/apps/pwa/models.py
new file mode 100644
index 00000000..2ac31cb6
--- /dev/null
+++ b/combo/apps/pwa/models.py
@@ -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 .
+
+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)
diff --git a/combo/apps/pwa/signals.py b/combo/apps/pwa/signals.py
new file mode 100644
index 00000000..2b7f2b3a
--- /dev/null
+++ b/combo/apps/pwa/signals.py
@@ -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 .
+
+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)
diff --git a/combo/apps/pwa/templates/combo/service-worker-registration.js b/combo/apps/pwa/templates/combo/service-worker-registration.js
index ad7a7968..a9dbba1f 100644
--- a/combo/apps/pwa/templates/combo/service-worker-registration.js
+++ b/combo/apps/pwa/templates/combo/service-worker-registration.js
@@ -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) {
+ }
+ });
+}
diff --git a/combo/apps/pwa/templates/combo/service-worker.js b/combo/apps/pwa/templates/combo/service-worker.js
index 56c58244..d813302f 100644
--- a/combo/apps/pwa/templates/combo/service-worker.js
+++ b/combo/apps/pwa/templates/combo/service-worker.js
@@ -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);
+ })
+ );
+});
diff --git a/combo/apps/pwa/urls.py b/combo/apps/pwa/urls.py
index 392c9d24..d8357820 100644
--- a/combo/apps/pwa/urls.py
+++ b/combo/apps/pwa/urls.py
@@ -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'),
]
diff --git a/combo/apps/pwa/views.py b/combo/apps/pwa/views.py
index 5e11d7e8..fbd775b9 100644
--- a/combo/apps/pwa/views.py
+++ b/combo/apps/pwa/views.py
@@ -14,8 +14,15 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-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})
diff --git a/combo/public/templatetags/combo.py b/combo/public/templatetags/combo.py
index 6d2c5f3e..cf52d9e7 100644
--- a/combo/public/templatetags/combo.py
+++ b/combo/public/templatetags/combo.py
@@ -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)
diff --git a/combo/settings.py b/combo/settings.py
index 3792b5f7..1262fee8 100644
--- a/combo/settings.py
+++ b/combo/settings.py
@@ -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
diff --git a/debian/control b/debian/control
index 7f499925..849a2c57 100644
--- a/debian/control
+++ b/debian/control
@@ -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)
diff --git a/tests/test_pwa.py b/tests/test_pwa.py
index 07ebaf78..ea24da22 100644
--- a/tests/test_pwa.py
+++ b/tests/test_pwa.py
@@ -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'}
diff --git a/tox.ini b/tox.ini
index b9765f1e..1ce3287e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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