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 %}
+
+{% 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 "Navigation" %}
+
+
+{% if navigation_entries|length %}
+
+{% blocktrans %}
+Use drag and drop with the ⣿ handles to reorder navigation entries.
+{% endblocktrans %}
+
+{% endif %}
+
+
+{% if navigation_entries|length < 5 %}
+
+{% endif %}
+
+
+
+
{% 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