web push notifications : added new templatetag, javascript static, model, a mgmt command and tests (#22727)

This commit is contained in:
Elias Showk 2018-04-04 20:53:14 +02:00
parent 266ede325f
commit 3efc1a387d
17 changed files with 667 additions and 2 deletions

View File

@ -20,7 +20,7 @@ from django.utils.translation import ugettext_lazy as _
class Plugin(object):
def get_apps(self):
return [__name__]
return [__name__, 'webpush']
class AppConfig(django.apps.AppConfig):

View File

@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
# combo-plugin-gnm - Combo WebPush App
# Copyright (C) 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 dateutil.parser import parse
from datetime import timedelta, datetime
import argparse
from django.core.management.base import BaseCommand
from django.utils.timezone import make_aware, now
from combo.apps.notifications.models import Notification
from combo_plugin_gnm.push import send_web_push
def to_datetime_argument(string):
try:
return make_aware(parse(string))
except ValueError as verr:
raise argparse.ArgumentTypeError(verr)
class Command(BaseCommand):
help = u'''Send push notification for the last notifications, for example :
> python manage.py send_webpush --since=2018-03-03T00:00:00
will all waiting web push notifications from march 3rd 2018 until now
'''
def add_arguments(self, parser):
# Named (optional) arguments
parser.add_argument(
'--since',
dest='since',
default=None,
type=to_datetime_argument,
help='Send push notification created since this datetime',
)
def handle(self, *args, **options):
''' Send visible and new web-push notifications
'''
since = options.get('since')
if since is None:
since = now() - timedelta(days=1)
verbosity = options.get('verbosity')
notif_query = Notification.objects.new().visible().filter(start_timestamp__gte=since)
for notif in notif_query:
if verbosity > 0:
print('Pushing notification %s' % str(notif).decode('utf-8'))
answer = [str(response_obj) for response_obj in send_web_push(notif, since=since)]
if verbosity > 0:
print('Web Push Responses : %s' % " | ".join(answer))

View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('webpush', '0001_initial'),
('notifications', '0004_auto_20180316_1026'),
]
operations = [
migrations.CreateModel(
name='WebPushRecord',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('creation_date', models.DateTimeField(auto_now_add=True)),
('status', models.CharField(default=b'NEW', max_length=4, blank=True, choices=[(b'NEW', b'Not-sent notification'), (b'SENT', b'Sent notification'), (b'ERR', b'Invalid notification')])),
('notification', models.ForeignKey(to='notifications.Notification')),
('subscription', models.ForeignKey(to='webpush.PushInformation')),
],
),
migrations.AlterUniqueTogether(
name='webpushrecord',
unique_together=set([('subscription', 'notification')]),
),
]

View File

View File

@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
# combo-plugin-gnm - Combo WebPush App
# Copyright (C) 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.db import models
# TODO use signals to hook Notification model create or update ?
from combo.apps.notifications.models import Notification
from webpush.models import PushInformation
class WebPushRecord(models.Model):
DEFAULT_STATUS = 'NEW'
ERR_STATUS = 'ERR'
OK_STATUS = 'SENT'
STATUS = (
(DEFAULT_STATUS, 'Not-sent notification'),
(OK_STATUS, 'Sent notification'),
(ERR_STATUS, 'Invalid notification'),
)
subscription = models.ForeignKey(PushInformation, null=False)
notification = models.ForeignKey(Notification, null=False)
creation_date = models.DateTimeField(blank=True, auto_now_add=True)
status = models.CharField(blank=True, max_length=4,
choices=STATUS, default=DEFAULT_STATUS)
class Meta:
unique_together = ('subscription', 'notification')
@property
def ttl(self):
delta = self.notification.end_timestamp - self.notification.start_timestamp
return int(delta.total_seconds())
@property
def payload(self):
'''
For example, we could extend later this data to such JSON supported by Chrome for Android
{
"body": "Did you make a $1,000,000 purchase at Dr. Evil...",
"icon": "images/ccard.png",
"vibrate": [200, 100, 200, 100, 200, 100, 400],
"tag": "request",
"actions": [
{ "action": "yes", "title": "Yes", "icon": "images/yes.png" },
{ "action": "no", "title": "No", "icon": "images/no.png" }
]
}
'''
return {
'head': self.notification.summary,
'body': self.notification.body,
'url': self.notification.url,
}
def set_status_ok(self):
self.status = self.OK_STATUS
self.save()
def set_status_err(self):
self.status = self.ERR_STATUS
self.save()

67
combo_plugin_gnm/push.py Normal file
View File

@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
# combo-plugin-gnm - Combo WebPush App
# Copyright (C) 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 transaction
from django.utils.timezone import is_naive, make_aware
from pywebpush import WebPushException
from retrying import retry
from webpush.utils import _send_notification
from .models import WebPushRecord
def retry_if_webpush_exception(exception):
'''Return True if we should retry'''
return isinstance(exception, WebPushException)
@retry(wait_exponential_multiplier=1000,
wait_exponential_max=getattr(settings, 'WEBPUSH_BACKOFF_MAXMILLISECS', 10000),
retry_on_exception=retry_if_webpush_exception)
def send_web_push(notification, since=None):
'''Get all the user subscriptions (every browsers with authoorized notifications)
and send a push mesg to each one
'''
if is_naive(since):
since = make_aware(since)
user_subscription_list = notification.user.webpush_info.select_related("subscription")
responses = []
for sub_info in user_subscription_list:
with transaction.atomic():
web_push_record, created = WebPushRecord.objects.select_for_update().get_or_create(
notification=notification,
subscription=sub_info)
if web_push_record.status == WebPushRecord.DEFAULT_STATUS and \
web_push_record.creation_date >= since:
# check the user's subscription and send the request to the endpoint
req = _send_notification(sub_info, web_push_record.payload, web_push_record.ttl)
# requests.Response object
if req.status_code <= 201:
web_push_record.status = web_push_record.OK_STATUS
web_push_record.save()
responses += [req]
else:
web_push_record.status = web_push_record.ERR_STATUS
web_push_record.save()
else:
web_push_record.set_status_err()
return responses

View File

@ -0,0 +1,238 @@
// Based On https://raw.githubusercontent.com/safwanrahman/django-webpush/aed8d5213d76857bfb3a020c1c6b8af18cad0f12/webpush/static/webpush/webpush.js
// combo-plugin-gnm - Combo GNM plugin
// Copyright (C) 2018 Entr'ouvert
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
var subBtn,
messageBox,
registration;
var activateTextMessage = 'Activez les notifications';
var stopTextMessage = 'Stoppez les notifications';
var incompatibleMessage = 'Ce navigateur n&#39;est pas compatible avec les notifications push.';
var browserShortName = navigator.userAgent.match(/(firefox|msie|chrome|safari|trident)/ig)[0].toLowerCase();
$(window).load(function () {
subBtn = $('#webpush-subscribe-checkbox');
messageBox = $('#webpush-subscribe-message');
function set_btn_activate() {
subBtn.attr('disabled', false);
subBtn.attr('checked', false);
subBtn.removeClass("checked");
messageBox.text(activateTextMessage);
}
function set_btn_cancel() {
subBtn.attr('disabled', false);
subBtn.attr('checked', true);
subBtn.addClass("checked");
messageBox.text(stopTextMessage);
}
var user_subscription_browser_list = subBtn.data('userSubscriptionBrowserList').split("=#=#");
if (user_subscription_browser_list.indexOf(browserShortName) >= 0) {
set_btn_cancel();
} else {
set_btn_activate();
}
// show warning to the message box and disabled the input
if (!('serviceWorker' in navigator)) {
messageBox.text(incompatibleMessage);
subBtn.attr('checked', false);
subBtn.attr('disabled', true);
return;
}
subBtn.click(
function (evt) {
subBtn.attr('disabled', true);
// if the Browser Supports Service Worker
// registered the worker and the click event hanlder
if ('serviceWorker' in navigator) {
var serviceWorker = document.getElementById('service-worker-js').src;
navigator.serviceWorker.register(serviceWorker)
.then(
function (reg) {
messageBox.text('Connexion au serveur en cours...');
registration = reg;
initialiseState(reg);
}
);
}
}
);
// Once the service worker is registered set the initial state
function initialiseState(reg) {
// Check if PushManager, Notification is supported in the browser
if (!('PushManager' in window) || !(reg.showNotification)) {
messageBox.text(incompatibleMessage);
subBtn.attr('checked', false);
subBtn.attr('disabled', true);
return;
}
// Check the current Notification permission.
// If its denied, it's a permanent block until the
// user changes the permission
if (Notification.permission === 'denied') {
// Show a message and activate the button
set_btn_activate();
return;
}
if (subBtn.filter(':checked')
.length > 0) {
return subscribe(reg);
} else {
return unsubscribe(reg);
}
}
function subscribe(reg) {
// Get the Subscription or register one
getSubscription(reg)
.then(
function (subscription) {
postSubscribeObj('subscribe', subscription);
}
)
.catch(
function (error) {
messageBox.text('Impossible de communiquer avec le serveur, veuillez r&eacute;essayer dans quelques minutes (Debug = '+ error +')');
}
);
}
function urlB64ToUint8Array(base64String) {
var b64padding = '='.repeat((4 - base64String.length % 4) % 4);
var base64 = (base64String + b64padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
var rawData = window.atob(base64);
var outputArray = new Uint8Array(rawData.length);
for (var i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function getSubscription(reg) {
return reg.pushManager.getSubscription()
.then(
function (subscription) {
var metaObj, applicationServerKey, options;
// Check if Subscription is available
if (subscription) {
return subscription;
}
metaObj = document.querySelector('meta[name="django-webpush-vapid-key"]');
applicationServerKey = metaObj.content;
options = {
userVisibleOnly: true
};
if (applicationServerKey) {
options.applicationServerKey = urlB64ToUint8Array(applicationServerKey)
}
// If not, register one
return registration.pushManager.subscribe(options)
}
)
}
function unsubscribe() {
// Get the Subscription to unregister
registration.pushManager.getSubscription()
.then(
function (subscription) {
// Check we have a subscription to unsubscribe
if (!subscription) {
// No subscription object, so set the state
// to allow the user to subscribe to push
set_btn_activate();
return;
}
postSubscribeObj('unsubscribe', subscription);
}
)
}
function postSubscribeObj(statusType, subscription) {
// Send the information to the server with fetch API.
// the type of the request, the name of the user subscribing,
// and the push subscription endpoint + key the server needs
// to send push messages
// Each subscription is different for each of these navigator userAgent short name
var data = {
status_type: statusType,
subscription: subscription.toJSON(),
browser: browserShortName
// group: subBtn.dataset.group
};
fetch(subBtn.data('url'), {
method: 'post',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data),
credentials: 'include'
})
.then(
function (response) {
// Check the information is saved successfully into server
if ((response.status == 201) && (statusType == 'subscribe')) {
// Show unsubscribe button instead
set_btn_cancel();
}
// Check if the information is deleted from server
if ((response.status == 202) && (statusType == 'unsubscribe')) {
// Get the Subscription
getSubscription(registration)
.then(
function (subscription) {
// Remove the subscription
subscription.unsubscribe()
.then(
function () {
set_btn_activate();
}
)
}
)
.catch(
function (error) {
subBtn.attr('disabled', false);
subBtn.attr('checked', false);
subBtn.removeClass("checked");
messageBox.text('Erreur lors de la requête, veuillez r&eacute;essayer dans quelques minutes (Debug = '+ error +')');
}
);
}
}
)
}
});

View File

@ -0,0 +1,16 @@
{% load i18n static gnm gnm_notifications %}
<div class="notification-dialog">
{% if request.user.is_authenticated or group %}
{% webpush_scripts %}
<label class="checkbox">
<input id="webpush-subscribe-checkbox" class="checkbox_input" type="checkbox"
{% if group %}data-group="{{ group }}"{% endif %} data-url="{{ url }}"
data-user-subscription-browser-list="{{ user_subscription_browser_list|join:'=#=#'}}">
<p id="webpush-subscribe-message" class="checkbox_text"></p>
</label>
{% else %}
<label class="checkbox">{% trans "Login to receive push notifications" %}</label>
{% endif %}
</div>

View File

@ -0,0 +1,4 @@
{% load static %}
<script id="service-worker-js" type="text/javascript" src="{{site_base}}{% static 'webpush/webpush_serviceworker.js' %}?{{statics_hash}}"></script>
<script type="text/javascript" src="{{site_base}}{% static 'combo_plugin_gnm/webpush.js' %}?{{statics_hash}}"></script>

View File

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
# combo-plugin-gnm - Combo GNM plugin
# Copyright (C) 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 import template
from django.conf import settings
from django.core.urlresolvers import reverse
from webpush.models import PushInformation
register = template.Library()
@register.inclusion_tag('webpush_checkbox.html', takes_context=True)
def webpush_checkbox(context):
group = context.get('webpush', {}).get('group')
url = reverse('save_webpush_info')
request = context['request']
user_subscription_browser_list = [info.subscription.browser for info in PushInformation.objects.filter(user=request.user)]
print user_subscription_browser_list
return {
'group': group,
'url': url,
'request': request,
'user_subscription_browser_list': user_subscription_browser_list
}
@register.inclusion_tag('webpush_scripts.html', takes_context=True)
def webpush_scripts(context):
return
@register.assignment_tag(takes_context=True)
def get_webpush_vars(context):
vapid_public_key = getattr(settings, 'WEBPUSH_SETTINGS', {}).get('VAPID_PUBLIC_KEY', '')
return {'vapid_public_key': vapid_public_key}

View File

@ -15,10 +15,12 @@
# 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.urls import url
from django.conf.urls import url, include
from . import views
urlpatterns = [
url(r'^gnm/plusone/$', views.plusone, name='gnm-plus-one'),
url(r'^gnm/webpush/backend/', include('webpush.urls')),
]

View File

@ -22,6 +22,7 @@ from django.template import RequestContext
from combo.utils import requests, get_templated_url
def plusone(request, *args, **kwargs):
# add reference to a jsondatastore with slug "plus1"
reference = request.GET.get('ref')

3
debian/50gnm.py vendored
View File

@ -279,3 +279,6 @@ JSON_CELL_TYPES = {
import memcache
memcache.SERVER_MAX_VALUE_LENGTH = 10 * 1024 * 1024
# Exponential backoff maximum milliseconds spent retrying
WEBPUSH_BACKOFF_MAXMILLISECS = 10000

View File

@ -97,6 +97,11 @@ setup(
install_requires=[
'django>=1.8, <1.12',
'python-dateutil',
'retrying',
'django-webpush>=0.2.2.3'
],
dependency_links=[
'https://github.com/elishowk/django-webpush/tarball/master#egg=django-webpush-0.2.2.3',
],
zip_safe=False,
entry_points={

10
tests/conftest.py Normal file
View File

@ -0,0 +1,10 @@
import pytest
import django_webtest
@pytest.fixture
def app(request):
wtm = django_webtest.WebTestMixin()
wtm._patch_settings()
request.addfinalizer(wtm._unpatch_settings)
return django_webtest.DjangoTestApp()

2
tests/settings.py Normal file
View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
USE_TZ = True

94
tests/test_webpush.py Normal file
View File

@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
# combo-plugin-gnm - Combo WebPush App
# Copyright (C) 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 os
import base64
from datetime import datetime
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.backends import default_backend
import pytest
from mock import patch
from django.contrib.auth.models import User
from django.core.management import call_command
from django.utils.timezone import make_aware
from webpush.models import SubscriptionInfo, PushInformation
from combo.apps.notifications.models import Notification
import combo_plugin_gnm
from combo_plugin_gnm.models import WebPushRecord
pytestmark = pytest.mark.django_db
@pytest.fixture
def user1():
user1 = User.objects.create_user('user1', email='user1@example.net', password='user1')
user1.save()
return user1
def _get_pubkey_str(priv_key):
'''From pywebpush tests'''
return base64.urlsafe_b64encode(
priv_key.public_key().public_numbers().encode_point()
).strip(b'=')
def _gen_subscription_info(recv_key=None,
endpoint="https://example.com/"):
'''From pywebpush tests'''
if not recv_key:
recv_key = ec.generate_private_key(ec.SECP256R1, default_backend())
return {
"endpoint": endpoint,
"keys": {
'auth': base64.urlsafe_b64encode(os.urandom(16)).strip(b'='),
'p256dh': _get_pubkey_str(recv_key),
}
}
@patch('pywebpush.WebPusher.send')
def test_command_send_webpush(mock_send, user1):
mock_send.return_value.status_code = 201
recv_key = ec.generate_private_key(ec.SECP256R1, default_backend())
subscription_keys_dict = _gen_subscription_info(recv_key)
subscription_record = SubscriptionInfo.objects.create(
browser='test browser',
endpoint=subscription_keys_dict['endpoint'],
auth=subscription_keys_dict['keys']['auth'],
p256dh=subscription_keys_dict['keys']['p256dh'],
)
push_info = PushInformation.objects.create(
user=user1,
subscription=subscription_record
)
notification = Notification.notify(user1, u'tèst headér', body=u'test uniçode bodéï')
call_command('send_webpush', '--verbosity=1')
assert WebPushRecord.objects.filter(status=WebPushRecord.ERR_STATUS).count() == 0
assert WebPushRecord.objects.filter(status=WebPushRecord.DEFAULT_STATUS).count() == 0
ok_push = WebPushRecord.objects.filter(status=WebPushRecord.OK_STATUS)
assert ok_push.count() == 1
ok_push = ok_push.first()
assert ok_push.subscription == push_info
assert ok_push.notification == notification
assert ok_push.creation_date < make_aware(datetime.now())