pwa: add option to enable support for push notifications (#31388)

This commit is contained in:
Frédéric Péters 2019-06-30 09:43:16 +02:00
parent 9784211b5a
commit 73648052ce
11 changed files with 136 additions and 21 deletions

View File

@ -27,6 +27,8 @@ from django.db.models.query import QuerySet
from combo.data.models import CellBase from combo.data.models import CellBase
from combo.data.library import register_cell_class from combo.data.library import register_cell_class
from combo.apps.pwa.models import PwaSettings
class NotificationQuerySet(QuerySet): class NotificationQuerySet(QuerySet):
def namespace(self, namespace): def namespace(self, namespace):
@ -181,6 +183,8 @@ class NotificationsCell(CellBase):
qs = Notification.objects.visible(user) qs = Notification.objects.visible(user)
extra_context['notifications'] = qs extra_context['notifications'] = qs
extra_context['new_notifications'] = qs.new() extra_context['new_notifications'] = qs.new()
pwa_settings = PwaSettings.singleton()
extra_context['push_notifications_enabled'] = pwa_settings.push_notifications
return extra_context return extra_context
def get_badge(self, context): def get_badge(self, context):

View File

@ -20,4 +20,37 @@
<p>{% trans 'No notifications.' %}</p> <p>{% trans 'No notifications.' %}</p>
</div> </div>
{% endif %} {% endif %}
{% if push_notifications_enabled %}
<div class="notification-buttons">
<div class="notification-push-on" style="display: none"><a href="#" class="pk-button">Activer les notifications</a></div>
<div class="notification-push-off" style="display: none"><a href="#" class="pk-button">Désactiver les notifications</a></div>
</div>
<script>
$(function() {
$('.notification-push-on a').on('click', function() {
$('.notification-push-on').hide();
$('.notification-push-off').hide();
combo_pwa_subscribe_user();
return false;
});
$('.notification-push-off a').on('click', function() {
$('.notification-push-on').hide();
$('.notification-push-off').hide();
combo_pwa_unsubscribe_user();
return false;
});
$(document).on('combo:pwa-user-info', function() {
if (COMBO_PWA_USER_SUBSCRIPTION) {
$('.notification-push-off').show();
} else {
$('.notification-push-on').show();
}
});
}
);
</script>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -24,12 +24,13 @@ from django.views.generic import CreateView, UpdateView, DeleteView
from combo.data.forms import get_page_choices from combo.data.forms import get_page_choices
from .models import PwaSettings, PwaNavigationEntry from .models import PwaSettings, PwaNavigationEntry
from .forms import PwaSettingsForm
class ManagerHomeView(UpdateView): class ManagerHomeView(UpdateView):
template_name = 'combo/pwa/manager_home.html' template_name = 'combo/pwa/manager_home.html'
model = PwaSettings model = PwaSettings
fields = '__all__' form_class = PwaSettingsForm
success_url = reverse_lazy('pwa-manager-homepage') success_url = reverse_lazy('pwa-manager-homepage')
def get_initial(self): def get_initial(self):

View File

@ -29,6 +29,8 @@ from django.utils.encoding import force_text, force_bytes
from django.utils.six import BytesIO from django.utils.six import BytesIO
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from py_vapid import Vapid
from jsonfield import JSONField from jsonfield import JSONField
from combo.data.fields import RichTextField from combo.data.fields import RichTextField
from combo import utils from combo import utils
@ -51,8 +53,24 @@ class PwaSettings(models.Model):
default=_('You are currently offline.'), default=_('You are currently offline.'),
config_name='small') config_name='small')
offline_retry_button = models.BooleanField(_('Include Retry Button'), default=True) offline_retry_button = models.BooleanField(_('Include Retry Button'), default=True)
push_notifications = models.BooleanField(
verbose_name=_('Enable subscription to push notifications'),
default=False)
push_notifications_infos = JSONField(blank=True)
last_update_timestamp = models.DateTimeField(auto_now=True) last_update_timestamp = models.DateTimeField(auto_now=True)
def save(self, **kwargs):
if self.push_notifications and not self.push_notifications_infos:
# generate VAPID keys
vapid = Vapid()
vapid.generate_keys()
self.push_notifications_infos = {
'private_key': vapid.private_pem(),
}
elif not self.push_notifications:
self.push_notifications_infos = {}
return super(PwaSettings, self).save(**kwargs)
@classmethod @classmethod
def singleton(cls): def singleton(cls):
return cls.objects.first() or cls() return cls.objects.first() or cls()

View File

@ -21,22 +21,29 @@ from django.conf import settings
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
try: from py_vapid import Vapid
import pywebpush import pywebpush
except ImportError:
pywebpush = None
from combo.apps.notifications.models import Notification from combo.apps.notifications.models import Notification
from .models import PushSubscription from .models import PushSubscription, PwaSettings
@receiver(post_save, sender=Notification) @receiver(post_save, sender=Notification)
def notification(sender, instance=None, created=False, **kwargs): def notification(sender, instance=None, created=False, **kwargs):
if not pywebpush:
return
if not created: if not created:
return return
pwa_settings = PwaSettings.singleton()
if not pwa_settings.push_notifications:
return
if settings.PWA_VAPID_PRIVATE_KEY: # legacy
pwa_vapid_private_key = settings.PWA_VAPID_PRIVATE_KEY
else:
pwa_vapid_private_key = Vapid.from_pem(pwa_settings.push_notifications_infos['private_key'].encode('ascii'))
if settings.PWA_VAPID_CLAIMS: # legacy
claims = settings.PWA_VAPID_CLAIMS
else:
claims = {'sub': 'mailto:%s' % settings.DEFAULT_FROM_EMAIL}
message = json.dumps({ message = json.dumps({
'summary': instance.summary, 'summary': instance.summary,
'body': instance.body, 'body': instance.body,
@ -48,8 +55,8 @@ def notification(sender, instance=None, created=False, **kwargs):
pywebpush.webpush( pywebpush.webpush(
subscription_info=subscription.subscription_info, subscription_info=subscription.subscription_info,
data=message, data=message,
vapid_private_key=settings.PWA_VAPID_PRIVATE_KEY, vapid_private_key=pwa_vapid_private_key,
vapid_claims=settings.PWA_VAPID_CLAIMS vapid_claims=claims,
) )
except pywebpush.WebPushException as e: except pywebpush.WebPushException as e:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -1,4 +1,6 @@
var applicationServerPublicKey = {% if pwa_vapid_publik_key %}'{{ pwa_vapid_publik_key }}'{% else %}null{% endif %}; {% load combo %}
var applicationServerPublicKey = {{ pwa_vapid_public_key|as_json|safe }};
var COMBO_PWA_USER_SUBSCRIPTION = false; var COMBO_PWA_USER_SUBSCRIPTION = false;
function urlB64ToUint8Array(base64String) { function urlB64ToUint8Array(base64String) {

View File

@ -3,7 +3,7 @@
/* global self, caches, fetch, URL, Response */ /* global self, caches, fetch, URL, Response */
'use strict'; 'use strict';
const applicationServerPublicKey = {{ pwa_vapid_publik_key|as_json|safe }}; const applicationServerPublicKey = {{ pwa_vapid_public_key|as_json|safe }};
function urlB64ToUint8Array(base64String) { function urlB64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4); const padding = '='.repeat((4 - base64String.length % 4) % 4);

View File

@ -14,6 +14,7 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64
import json import json
from django.conf import settings from django.conf import settings
@ -23,6 +24,9 @@ from django.template.loader import get_template, TemplateDoesNotExist
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.generic import TemplateView from django.views.generic import TemplateView
from cryptography.hazmat.primitives import serialization
from py_vapid import Vapid
from .models import PushSubscription, PwaSettings from .models import PushSubscription, PwaSettings
from combo import VERSION from combo import VERSION
@ -40,8 +44,19 @@ def manifest_json(request, *args, **kwargs):
def js_response(request, template_name, **kwargs): def js_response(request, template_name, **kwargs):
template = get_template(template_name) template = get_template(template_name)
pwa_vapid_public_key = None
pwa_settings = PwaSettings.singleton()
if pwa_settings.push_notifications:
if settings.PWA_VAPID_PUBLIK_KEY: # legacy
pwa_vapid_public_key = settings.PWA_VAPID_PUBLIK_KEY
else:
pwa_vapid_public_key = base64.urlsafe_b64encode(
Vapid.from_pem(pwa_settings.push_notifications_infos['private_key'].encode('ascii')
).private_key.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint)).strip('=')
context = { context = {
'pwa_vapid_publik_key': settings.PWA_VAPID_PUBLIK_KEY, 'pwa_vapid_public_key': pwa_vapid_public_key,
'pwa_notification_badge_url': settings.PWA_NOTIFICATION_BADGE_URL, 'pwa_notification_badge_url': settings.PWA_NOTIFICATION_BADGE_URL,
'pwa_notification_icon_url': settings.PWA_NOTIFICATION_ICON_URL, 'pwa_notification_icon_url': settings.PWA_NOTIFICATION_ICON_URL,
} }

5
debian/control vendored
View File

@ -23,8 +23,9 @@ Depends: ${misc:Depends}, ${python:Depends},
python-eopayment (>= 1.35), python-eopayment (>= 1.35),
python-django-haystack (>= 2.4.0), python-django-haystack (>= 2.4.0),
python-sorl-thumbnail, python-sorl-thumbnail,
python-pil python-pil,
Recommends: python-django-mellon, python-whoosh, python-pywebpush python-pywebpush
Recommends: python-django-mellon, python-whoosh
Conflicts: python-lingo Conflicts: python-lingo
Description: Portal Management System (Python module) Description: Portal Management System (Python module)

View File

@ -165,6 +165,7 @@ setup(
'sorl-thumbnail', 'sorl-thumbnail',
'Pillow', 'Pillow',
'pyproj', 'pyproj',
'pywebpush',
], ],
zip_safe=False, zip_safe=False,
cmdclass={ cmdclass={

View File

@ -5,10 +5,7 @@ import mock
import pytest import pytest
from webtest import Upload from webtest import Upload
try: import pywebpush
import pywebpush
except ImportError:
pywebpush = None
from django.conf import settings from django.conf import settings
from django.core.files import File from django.core.files import File
@ -34,7 +31,23 @@ def test_manifest_json(app):
def test_service_worker(app): def test_service_worker(app):
app.get('/service-worker.js', status=200) app.get('/service-worker.js', status=200)
app.get('/service-worker-registration.js', status=200) resp = app.get('/service-worker-registration.js', status=200)
assert 'applicationServerPublicKey = null' in resp.text
pwa_settings = PwaSettings.singleton()
pwa_settings.push_notifications = True
pwa_settings.save()
resp = app.get('/service-worker-registration.js', status=200)
assert 'applicationServerPublicKey = "' in resp.text
# check legacy settings are still supported
with override_settings(
PWA_VAPID_PUBLIK_KEY="BFzvUdXB...",
PWA_VAPID_PRIVATE_KEY="4WbCnBF...",
PWA_VAPID_CLAIMS = {'sub': 'mailto:admin@entrouvert.com'}):
resp = app.get('/service-worker-registration.js', status=200)
assert 'applicationServerPublicKey = "BFzvUdXB..."' in resp.text
def test_webpush_subscription(app, john_doe, jane_doe): def test_webpush_subscription(app, john_doe, jane_doe):
app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content'}, status=403) app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content'}, status=403)
@ -53,17 +66,37 @@ def test_webpush_subscription(app, john_doe, jane_doe):
app.post_json(reverse('pwa-subscribe-push'), params=None, status=200) app.post_json(reverse('pwa-subscribe-push'), params=None, status=200)
assert PushSubscription.objects.count() == 1 assert PushSubscription.objects.count() == 1
@pytest.mark.skipif('pywebpush is None')
def test_webpush_notification(app, john_doe): def test_webpush_notification(app, john_doe):
PushSubscription.objects.all().delete() PushSubscription.objects.all().delete()
app = login(app, john_doe.username, john_doe.username) app = login(app, john_doe.username, john_doe.username)
app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content'}, status=200) app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content'}, status=200)
pwa_settings = PwaSettings.singleton()
pwa_settings.push_notifications = False
pwa_settings.save()
with mock.patch('pywebpush.webpush') as webpush:
notification = Notification.notify(john_doe, 'test', body='hello world')
assert webpush.call_count == 0
pwa_settings.push_notifications = True
pwa_settings.save()
with mock.patch('pywebpush.webpush') as webpush: with mock.patch('pywebpush.webpush') as webpush:
notification = Notification.notify(john_doe, 'test', body='hello world') notification = Notification.notify(john_doe, 'test', body='hello world')
assert webpush.call_count == 1 assert webpush.call_count == 1
assert webpush.call_args[1]['subscription_info'] == {'sample': 'content'} assert webpush.call_args[1]['subscription_info'] == {'sample': 'content'}
# check legacy settings are still supported
with override_settings(
PWA_VAPID_PUBLIK_KEY="BFzvUdXB...",
PWA_VAPID_PRIVATE_KEY="4WbCnBF...",
PWA_VAPID_CLAIMS = {'sub': 'mailto:admin@entrouvert.com'}):
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'}
assert webpush.call_args[1]['vapid_private_key'] == settings.PWA_VAPID_PRIVATE_KEY
assert webpush.call_args[1]['vapid_claims'] == settings.PWA_VAPID_CLAIMS
def test_no_pwa_manager(app, admin_user): def test_no_pwa_manager(app, admin_user):
app = login(app) app = login(app)
resp = app.get('/manage/', status=200) resp = app.get('/manage/', status=200)