diff --git a/combo/apps/pwa/manager_views.py b/combo/apps/pwa/manager_views.py index 4ff9328b..9b0d1161 100644 --- a/combo/apps/pwa/manager_views.py +++ b/combo/apps/pwa/manager_views.py @@ -15,9 +15,15 @@ # along with this program. If not, see . from django.core.urlresolvers import reverse_lazy -from django.views.generic import UpdateView +from django.db.models import Max +from django import forms +from django.http import JsonResponse +from django.utils.translation import ugettext_lazy as _ +from django.views.generic import CreateView, UpdateView, DeleteView -from .models import PwaSettings +from combo.data.forms import get_page_choices + +from .models import PwaSettings, PwaNavigationEntry class ManagerHomeView(UpdateView): @@ -28,3 +34,59 @@ class ManagerHomeView(UpdateView): def get_object(self): return PwaSettings.singleton() + + def get_context_data(self, **kwargs): + context = super(ManagerHomeView, self).get_context_data(**kwargs) + context['navigation_entries'] = PwaNavigationEntry.objects.all() + return context + + +class ManagerNavigationEntryMixin(object): + model = PwaNavigationEntry + fields = ['label', 'url', 'link_page', 'icon', 'extra_css_class', + 'notification_count', 'use_user_name_as_label'] + template_name = 'combo/pwa/manager_form.html' + success_url = reverse_lazy('pwa-manager-homepage') + + def get_form_class(self): + form_class = forms.models.modelform_factory(self.model, + fields=self.fields) + form_class.base_fields['link_page'].choices = [(None, '-----')] + get_page_choices() + return form_class + + def form_valid(self, form): + if form.instance.order is None: + max_order = self.model.objects.all().aggregate(Max('order')) + form.instance.order = (max_order['order__max'] or 0) + 1 + if form.instance.link_page_id is None: + if not form.instance.label: + form.add_error('label', _('A label is required when no page is selected.')) + if not form.instance.url: + form.add_error('url', _('An URL is required when no page is selected.')) + elif form.instance.url: + form.add_error('url', _('An URL cannot be specified when a page is selected.')) + if form.errors: + return super(ManagerNavigationEntryMixin, self).form_invalid(form) + return super(ManagerNavigationEntryMixin, self).form_valid(form) + + +class ManagerAddNavigationEntry(ManagerNavigationEntryMixin, CreateView): + pass + + +class ManagerEditNavigationEntry(ManagerNavigationEntryMixin, UpdateView): + pass + + +class ManagerDeleteNavigationEntry(DeleteView): + model = PwaNavigationEntry + success_url = reverse_lazy('pwa-manager-homepage') + template_name = 'combo/generic_confirm_delete.html' + + +def manager_navigation_order(request, *args, **kwargs): + new_order = {int(x): i for i, x in enumerate(request.GET['new-order'].split(','))} + for entry in PwaNavigationEntry.objects.all(): + entry.order = new_order[entry.id] + entry.save() + return JsonResponse({'err': 0}) diff --git a/combo/apps/pwa/migrations/0003_pwanavigationentry.py b/combo/apps/pwa/migrations/0003_pwanavigationentry.py new file mode 100644 index 00000000..c8c81faf --- /dev/null +++ b/combo/apps/pwa/migrations/0003_pwanavigationentry.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-12-27 14:27 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('data', '0036_page_sub_slug'), + ('pwa', '0002_pwasettings'), + ] + + operations = [ + migrations.CreateModel( + name='PwaNavigationEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('label', models.CharField(blank=True, max_length=150, verbose_name='Label')), + ('url', models.CharField(blank=True, max_length=200, verbose_name='URL')), + ('icon', models.FileField(blank=True, null=True, upload_to=b'pwa', verbose_name='Icon')), + ('extra_css_class', models.CharField(blank=True, max_length=100, verbose_name='Extra classes for CSS styling')), + ('order', models.PositiveIntegerField()), + ('notification_count', models.BooleanField(default=False, verbose_name='Display notification count')), + ('use_user_name_as_label', models.BooleanField(default=False, verbose_name='Use user name as label')), + ('link_page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='data.Page', verbose_name='Internal link')), + ], + options={ + 'ordering': ('order',), + }, + ), + ] diff --git a/combo/apps/pwa/models.py b/combo/apps/pwa/models.py index 36802001..3e3d0bb4 100644 --- a/combo/apps/pwa/models.py +++ b/combo/apps/pwa/models.py @@ -14,15 +14,21 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import base64 import json from django.conf import settings from django.core import serializers +from django.core.files.storage import default_storage from django.db import models +from django.utils import six +from django.utils.encoding import force_text, force_bytes +from django.utils.six import BytesIO from django.utils.translation import ugettext_lazy as _ from jsonfield import JSONField from combo.data.fields import RichTextField +from combo import utils class PwaSettings(models.Model): @@ -56,6 +62,80 @@ class PwaSettings(models.Model): obj.save() +class PwaNavigationEntry(models.Model): + label = models.CharField(verbose_name=_('Label'), max_length=150, blank=True) + url = models.CharField(verbose_name=_('External URL'), max_length=200, blank=True) + link_page = models.ForeignKey('data.Page', blank=True, + null=True, verbose_name=_('Internal link')) + icon = models.FileField(_('Icon'), upload_to='pwa', blank=True, null=True) + extra_css_class = models.CharField(_('Extra classes for CSS styling'), max_length=100, blank=True) + order = models.PositiveIntegerField() + + notification_count = models.BooleanField( + verbose_name=_('Display notification count'), + default=False) + use_user_name_as_label = models.BooleanField( + verbose_name=_('Use user name as label'), + default=False) + + class Meta: + ordering = ('order',) + + def get_label(self): + return self.label or self.link_page.title + + def get_url(self): + if self.link_page: + return self.link_page.get_online_url() + else: + return utils.get_templated_url(self.url) + + def css_class_names(self): + css_class_names = self.extra_css_class or '' + if self.link_page: + css_class_names += ' page-%s' % self.link_page.slug + return css_class_names + + @classmethod + def export_all_for_json(cls): + return [x.get_as_serialized_object() for x in cls.objects.all()] + + def get_as_serialized_object(self): + serialized_entry = json.loads(serializers.serialize('json', [self], + use_natural_foreign_keys=True, use_natural_primary_keys=True))[0] + if self.icon: + encode = base64.encodestring if six.PY2 else base64.encodebytes + serialized_entry['icon:base64'] = force_text(encode(self.icon.read())) + del serialized_entry['model'] + del serialized_entry['pk'] + return serialized_entry + + @classmethod + def load_serialized_objects(cls, json_site): + for json_entry in json_site: + cls.load_serialized_object(json_entry) + + @classmethod + def load_serialized_object(cls, json_entry): + json_entry['model'] = 'pwa.pwanavigationentry' + # deserialize once to get link_page by natural key + fake_entry = [x for x in serializers.deserialize('json', json.dumps([json_entry]))][0] + entry, created = cls.objects.get_or_create( + label=json_entry['fields']['label'], + url=json_entry['fields']['url'], + link_page=fake_entry.object.link_page, + defaults={'order': 0}) + json_entry['pk'] = entry.id + entry = [x for x in serializers.deserialize('json', json.dumps([json_entry]))][0] + entry.save() + if json_entry.get('icon:base64'): + decode = base64.decodestring if six.PY2 else base64.decodebytes + decoded_icon = decode(force_bytes(json_entry['icon:base64'])) + if not default_storage.exists(entry.object.icon.name) or entry.object.icon.read() != decoded_icon: + # save new file + entry.object.icon.save(entry.object.icon.name, BytesIO(decoded_icon)) + + class PushSubscription(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) subscription_info = JSONField() diff --git a/combo/apps/pwa/static/css/combo.manager.pwa.scss b/combo/apps/pwa/static/css/combo.manager.pwa.scss index ae2c51aa..1cd1d1d0 100644 --- a/combo/apps/pwa/static/css/combo.manager.pwa.scss +++ b/combo/apps/pwa/static/css/combo.manager.pwa.scss @@ -82,3 +82,30 @@ div#mobile-case { } } } + +div.section.navigation { + ul.navigation-entries { + margin-bottom: 0; + + ul { + margin-top: 0; + } + span.handle { + padding: 0; + position: absolute; + width: 2em; + + a { + padding-left: 4ex; + } + } + } + li a.add { + padding-left: 0; + &::before { + content: "\f055"; // circle-plus + font-family: FontAwesome; + width: 2.2em; + display: inline-block; + text-align: center; + } + } +} diff --git a/combo/apps/pwa/templates/combo/pwa/manager_form.html b/combo/apps/pwa/templates/combo/pwa/manager_form.html new file mode 100644 index 00000000..4cd24686 --- /dev/null +++ b/combo/apps/pwa/templates/combo/pwa/manager_form.html @@ -0,0 +1,17 @@ +{% extends "combo/pwa/manager_base.html" %} +{% load i18n %} + +{% block appbar %} +

+{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {{ form.as_p }} +
+ + {% trans 'Cancel' %} +
+
+{% endblock %} diff --git a/combo/apps/pwa/templates/combo/pwa/manager_home.html b/combo/apps/pwa/templates/combo/pwa/manager_home.html index eb6d7d44..1d709b41 100644 --- a/combo/apps/pwa/templates/combo/pwa/manager_home.html +++ b/combo/apps/pwa/templates/combo/pwa/manager_home.html @@ -20,6 +20,36 @@
+ +

{% trans "Settings" %}

@@ -48,6 +78,17 @@ $(function() { $('.mobile-app-content iframe').attr('src', '/'); $('.mobile-app-content').addClass('splash-off'); }); + + $('.navigation-entries').sortable({ + handle: '.handle', + update: function(event, ui) { + var new_order = $('.navigation-entries li').map(function() { return $(this).data('pk'); }).get().join(); + $.ajax({ + url: $('.navigation-entries').data('order-url'), + data: {'new-order': new_order} + }); + } + }); }); diff --git a/combo/apps/pwa/templates/combo/pwa/navigation.html b/combo/apps/pwa/templates/combo/pwa/navigation.html new file mode 100644 index 00000000..4d7a7187 --- /dev/null +++ b/combo/apps/pwa/templates/combo/pwa/navigation.html @@ -0,0 +1,37 @@ +{% load combo %} +
+
+ +
+
+ diff --git a/combo/apps/pwa/templatetags/__init__.py b/combo/apps/pwa/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/combo/apps/pwa/templatetags/pwa.py b/combo/apps/pwa/templatetags/pwa.py new file mode 100644 index 00000000..d7a9cfdd --- /dev/null +++ b/combo/apps/pwa/templatetags/pwa.py @@ -0,0 +1,36 @@ +# 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 import template +from django.conf import settings + +from combo.apps.pwa.models import PwaNavigationEntry + + +register = template.Library() + +@register.simple_tag(takes_context=True) +def pwa_navigation(context): + if settings.TEMPLATE_VARS.get('pwa_display') not in ('standalone', 'fullscreen'): + return '' + pwa_navigation_template = template.loader.get_template('combo/pwa/navigation.html') + context = { + 'entries': PwaNavigationEntry.objects.all(), + 'user': context.get('user'), + 'render_skeleton': context.get('render_skeleton'), + 'site_base': context['request'].build_absolute_uri('/')[:-1], + } + return pwa_navigation_template.render(context) diff --git a/combo/apps/pwa/urls.py b/combo/apps/pwa/urls.py index 766c8f2e..0c9850b3 100644 --- a/combo/apps/pwa/urls.py +++ b/combo/apps/pwa/urls.py @@ -20,6 +20,10 @@ from combo.urls_utils import decorated_includes, manager_required from .manager_views import ( ManagerHomeView, + ManagerAddNavigationEntry, + ManagerEditNavigationEntry, + ManagerDeleteNavigationEntry, + manager_navigation_order, ) from .views import ( manifest_json, @@ -32,6 +36,18 @@ from .views import ( pwa_manager_urls = [ url('^$', ManagerHomeView.as_view(), name='pwa-manager-homepage'), + url('^navigation/add/$', + ManagerAddNavigationEntry.as_view(), + name='pwa-manager-navigation-add'), + url('^navigation/edit/(?P\w+)/$', + ManagerEditNavigationEntry.as_view(), + name='pwa-manager-navigation-edit'), + url('^navigation/delete/(?P\w+)/$', + ManagerDeleteNavigationEntry.as_view(), + name='pwa-manager-navigation-delete'), + url('^navigation/order/$', + manager_navigation_order, + name='pwa-manager-navigation-order'), ] urlpatterns = [ diff --git a/combo/data/utils.py b/combo/data/utils.py index 669a9ad7..0765d8bc 100644 --- a/combo/data/utils.py +++ b/combo/data/utils.py @@ -22,7 +22,7 @@ from django.utils.translation import ugettext_lazy as _ from combo.apps.assets.models import Asset from combo.apps.maps.models import MapLayer -from combo.apps.pwa.models import PwaSettings +from combo.apps.pwa.models import PwaSettings, PwaNavigationEntry from .models import Page @@ -42,6 +42,7 @@ def export_site(): 'assets': Asset.export_all_for_json(), 'pwa': { 'settings': PwaSettings.export_for_json(), + 'navigation': PwaNavigationEntry.export_all_for_json(), } } @@ -74,6 +75,7 @@ def import_site(data, if_empty=False, clean=False): Asset.objects.all().delete() Page.objects.all().delete() PwaSettings.objects.all().delete() + PwaNavigationEntry.objects.all().delete() with transaction.atomic(): MapLayer.load_serialized_objects(data.get('map-layers') or []) @@ -84,5 +86,8 @@ def import_site(data, if_empty=False, clean=False): with transaction.atomic(): Page.load_serialized_pages(data.get('pages') or []) - with transaction.atomic(): - PwaSettings.load_serialized_settings((data.get('pwa') or {}).get('settings')) + if data.get('pwa'): + with transaction.atomic(): + PwaSettings.load_serialized_settings(data['pwa'].get('settings')) + with transaction.atomic(): + PwaNavigationEntry.load_serialized_objects(data['pwa'].get('navigation')) diff --git a/tests/test_import_export.py b/tests/test_import_export.py index 6f90bc36..668d3d3f 100644 --- a/tests/test_import_export.py +++ b/tests/test_import_export.py @@ -1,3 +1,4 @@ +import base64 import datetime import json import os @@ -9,12 +10,12 @@ import pytest from django.contrib.auth.models import Group from django.core.files import File from django.core.management import call_command -from django.utils.encoding import force_bytes +from django.utils.encoding import force_bytes, force_text from django.utils.six import BytesIO, StringIO from combo.apps.assets.models import Asset from combo.apps.maps.models import MapLayer, Map -from combo.apps.pwa.models import PwaSettings +from combo.apps.pwa.models import PwaSettings, PwaNavigationEntry from combo.data.models import Page, TextCell from combo.data.utils import export_site, import_site, MissingGroups @@ -213,3 +214,39 @@ def test_import_export_pwa_settings(app): import_site(data=json.loads(output)) assert PwaSettings.singleton().offline_retry_button is False assert PwaSettings.singleton().offline_text == 'Hello world' + +def test_import_export_pwa_navigation(app, some_data): + page = Page.objects.get(slug='one') + entry1 = PwaNavigationEntry(label='a', url='/', order=0) + entry2 = PwaNavigationEntry(link_page=page, order=1, icon=File(BytesIO(b'te\30st'), 'test.png')) + entry1.save() + entry2.save() + output = get_output_of_command('export_site') + import_site(data={}, clean=True) + assert PwaNavigationEntry.objects.all().count() == 0 + + import_site(data=json.loads(output)) + assert PwaNavigationEntry.objects.all().count() == 2 + # check identical file was not touched + assert os.path.basename(PwaNavigationEntry.objects.get(order=1).icon.file.name) == 'test.png' + assert PwaNavigationEntry.objects.get(order=1).icon.read() == b'te\30st' + + # check a second import doesn't create additional entries + import_site(data=json.loads(output)) + assert PwaNavigationEntry.objects.all().count() == 2 + + # check with a change in icon file content + data = json.loads(output) + data['pwa']['navigation'][1]['icon:base64'] = force_text(base64.encodestring(b'TEST')) + import_site(data=data) + assert PwaNavigationEntry.objects.all().count() == 2 + assert PwaNavigationEntry.objects.get(order=1).icon.read() == b'TEST' + + # check with a change in icon file name + data = json.loads(output) + data['pwa']['navigation'][1]['fields']['icon'] = 'pwa/test2.png' + data['pwa']['navigation'][1]['icon:base64'] = force_text(base64.encodestring(b'TEST2')) + import_site(data=data) + assert PwaNavigationEntry.objects.all().count() == 2 + assert os.path.basename(PwaNavigationEntry.objects.get(order=1).icon.file.name) == 'test2.png' + assert PwaNavigationEntry.objects.get(order=1).icon.read() == b'TEST2' diff --git a/tests/test_pwa.py b/tests/test_pwa.py index f4fbae36..c5fc11a1 100644 --- a/tests/test_pwa.py +++ b/tests/test_pwa.py @@ -9,11 +9,16 @@ except ImportError: pywebpush = None from django.conf import settings +from django.core.files import File from django.core.urlresolvers import reverse +from django.template import Context, Template from django.test import override_settings +from django.test.client import RequestFactory +from django.utils.six import BytesIO from combo.apps.notifications.models import Notification -from combo.apps.pwa.models import PushSubscription, PwaSettings +from combo.apps.pwa.models import PushSubscription, PwaSettings, PwaNavigationEntry +from combo.data.models import Page from .test_manager import login @@ -78,6 +83,64 @@ def test_pwa_manager(app, admin_user): assert resp.form['offline_text'].value == 'You are offline.' assert resp.form['offline_retry_button'].checked is False + resp = app.get('/manage/pwa/') + resp = resp.click('Add a navigation entry') + resp.form['label'] = 'Hello' + resp.form['url'] = 'https://www.example.net' + resp = resp.form.submit().follow() + assert PwaNavigationEntry.objects.all().count() == 1 + + page = Page(title='test', slug='test') + page.save() + + resp = resp.click('Add a navigation entry') + resp.form['link_page'] = page.id + resp = resp.form.submit().follow() + assert PwaNavigationEntry.objects.all().count() == 2 + + for i in range(3): + resp = resp.click('Add a navigation entry') + resp.form['label'] = 'Hello %s' % i + resp.form['url'] = 'https://www.example.net' + resp = resp.form.submit().follow() + + # max 5 items + assert 'Add a navigation entry' not in resp.text + + # reorder items, reverse them all + entries = PwaNavigationEntry.objects.all() + app.get('/manage/pwa/navigation/order/?new-order=%s' % + ','.join(reversed([str(x.id) for x in entries]))) + entries = PwaNavigationEntry.objects.all() + assert entries[0].label == 'Hello 2' + + # remove first item + resp = app.get('/manage/pwa/') + resp = resp.click(href='delete', index=0) + resp = resp.form.submit().follow() + assert 'Hello 2' not in resp.text + assert 'Add a navigation entry' in resp.text + + # rename item + resp = resp.click('Hello 1') + resp.form['label'] = 'Hello 12' + resp = resp.form.submit().follow() + assert PwaNavigationEntry.objects.all()[0].label == 'Hello 12' + + # check error handling + resp = resp.click('Hello 12') + resp.form['label'] = '' + resp.form['url'] = '' + resp = resp.form.submit() + assert 'A label is required' in resp.text + assert 'An URL is required' in resp.text + + resp.form['url'] = 'foobar' + resp.form['link_page'] = page.id + resp = resp.form.submit() + assert 'An URL cannot be specified' in resp.text + + def test_pwa_offline_page(app): PwaSettings.objects.all().delete() resp = app.get('/__pwa__/offline/') @@ -91,3 +154,27 @@ def test_pwa_offline_page(app): resp = app.get('/__pwa__/offline/') assert 'You are offline.' in resp.text assert 'Retry' not in resp.text + + +def test_pwa_navigation_templatetag(app): + page = Page(title='One', slug='one') + page.save() + entry1 = PwaNavigationEntry(label='a', url='/', notification_count=True, + use_user_name_as_label=True, order=0) + entry2 = PwaNavigationEntry(link_page=page, order=1, icon=File(BytesIO(b'te\30st'), 'test.png')) + entry1.save() + entry2.save() + t = Template('{% load pwa %}{% pwa_navigation %}') + assert t.render(Context({})) == '' + + with override_settings(TEMPLATE_VARS={'pwa_display': 'standalone'}): + request = RequestFactory().get('/') + nav = t.render(Context({'request': request})) + assert 'a' in nav + assert 'One' in nav + assert nav.count('background-image') == 1 + assert nav.count('data-notification-count-url=') == 1 + assert nav.count('data-pwa-user-name=""') == 1 + + nav = t.render(Context({'request': request, 'render_skeleton': True})) + assert 'data-pwa-user-name="{% block placeholder-user-name %}' in nav