pwa: add option to enable support for push notifications (#31388)
This commit is contained in:
parent
9784211b5a
commit
73648052ce
|
@ -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):
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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__)
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
1
setup.py
1
setup.py
|
@ -165,6 +165,7 @@ setup(
|
||||||
'sorl-thumbnail',
|
'sorl-thumbnail',
|
||||||
'Pillow',
|
'Pillow',
|
||||||
'pyproj',
|
'pyproj',
|
||||||
|
'pywebpush',
|
||||||
],
|
],
|
||||||
zip_safe=False,
|
zip_safe=False,
|
||||||
cmdclass={
|
cmdclass={
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue