From 166aaf4349e09b13982b78f538747688730e6a03 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Thu, 27 Dec 2018 15:26:21 +0100
Subject: [PATCH] pwa: add management of navigation entries (#29362)

---
 combo/apps/pwa/manager_views.py               | 64 +++++++++++++-
 .../pwa/migrations/0003_pwanavigationentry.py | 34 ++++++++
 combo/apps/pwa/models.py                      | 79 +++++++++++++++++
 .../pwa/static/css/combo.manager.pwa.scss     | 27 ++++++
 .../pwa/templates/combo/pwa/manager_form.html | 17 ++++
 .../pwa/templates/combo/pwa/manager_home.html | 41 +++++++++
 .../pwa/templates/combo/pwa/navigation.html   | 37 ++++++++
 combo/apps/pwa/templatetags/__init__.py       |  0
 combo/apps/pwa/templatetags/pwa.py            | 36 ++++++++
 combo/apps/pwa/urls.py                        | 16 ++++
 combo/data/utils.py                           | 11 ++-
 tests/test_import_export.py                   | 41 ++++++++-
 tests/test_pwa.py                             | 84 ++++++++++++++++++-
 13 files changed, 479 insertions(+), 8 deletions(-)
 create mode 100644 combo/apps/pwa/migrations/0003_pwanavigationentry.py
 create mode 100644 combo/apps/pwa/templates/combo/pwa/manager_form.html
 create mode 100644 combo/apps/pwa/templates/combo/pwa/navigation.html
 create mode 100644 combo/apps/pwa/templatetags/__init__.py
 create mode 100644 combo/apps/pwa/templatetags/pwa.py

diff --git a/combo/apps/pwa/manager_views.py b/combo/apps/pwa/manager_views.py
index 4ff9328b..aed6ddf0 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 <http://www.gnu.org/licenses/>.
 
 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,57 @@ 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.'))
+            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..f2b1407e
--- /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')),
+                ('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..f93d88ba 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 <http://www.gnu.org/licenses/>.
 
+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,79 @@ 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(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.url:
+            return utils.get_templated_url(self.url)
+        return self.link_page.get_online_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 %}
+<h2></h2>
+{% endblock %}
+
+{% block content %}
+<form method="post" enctype="multipart/form-data">
+  {% csrf_token %}
+  {{ form.as_p }}
+  <div class="buttons">
+    <button class="submit-button">{% trans "Save" %}</button>
+    <a class="cancel" href="{% url 'pwa-manager-homepage' %}">{% trans 'Cancel' %}</a>
+  </div>
+</form>
+{% 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 @@
 
 <div class="sections">
 
+<div class="section navigation">
+<h3>{% trans "Navigation" %}</h3>
+<div>
+
+{% if navigation_entries|length %}
+<p class="hint">
+{% blocktrans %}
+Use drag and drop with the ⣿ handles to reorder navigation entries.
+{% endblocktrans %}
+</p>
+{% endif %}
+
+<ul class="objects-list single-links navigation-entries"
+    data-order-url="{% url 'pwa-manager-navigation-order' %}">
+{% for entry in navigation_entries %}
+<li data-pk="{{entry.pk}}"><span class="handle">⣿</span>
+    <a rel="popup" href="{% url 'pwa-manager-navigation-edit' pk=entry.pk %}">{{ entry.get_label }}</a>
+    <a rel="popup" class="delete" href="{% url 'pwa-manager-navigation-delete' pk=entry.pk %}">{% trans "remove" %}</a>
+</li>
+{% endfor %}
+</ul>
+{% if navigation_entries|length < 5 %}
+<ul class="objects-list single-links">
+<li><a class="add" rel="popup" href="{% url 'pwa-manager-navigation-add' %}">{% trans 'Add a navigation entry' %}</a></li>
+</ul>
+{% endif %}
+
+</div>
+</div>
+
 <div class="section settings">
 <h3>{% trans "Settings" %}</h3>
 <div>
@@ -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}
+       });
+    }
+  });
 });
 </script>
 
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 %}
+<div class="pwa-navigation" id="pwa-navigation">
+<div>
+<ul>
+  {% for entry in entries %}
+  <li class="{{ entry.css_class_names }}" data-entry-pk="{{ entry.pk }}"
+      {% if entry.notification_count %}data-notification-count-url="{{site_base}}/api/notification/count/"{% endif %}
+      {% if entry.use_user_name_as_label %}data-pwa-user-name="{% skeleton_extra_placeholder user-name %}{{user.get_full_name}}{% end_skeleton_extra_placeholder %}"{% endif %}>
+      <a href="{{ entry.get_url }}"
+         {% if entry.icon %}style="background-image: url({{site_base}}{{entry.icon.url}});"{% endif %}
+         ><span>{{ entry.get_label }}</span></a></li>
+  {% endfor %}
+</ul>
+</div>
+</div>
+<script>
+$('li[data-pwa-user-name]').each(function(idx, elem) {
+  var user_name = $(this).data('pwa-user-name');
+  if (user_name) {
+    $(this).find('span').text(user_name);
+  }
+});
+$('body.authenticated-user li[data-notification-count-url]').each(function(idx, elem) {
+  var $entry = $(this);
+  $.ajax({
+    url: $entry.data('notification-count-url'),
+    xhrFields: { withCredentials: true },
+    async: true,
+    dataType: 'json',
+    crossDomain: true,
+    success: function(data) {
+      if (data.new) {
+        $entry.find('span').append(' <span class="badge">' + data.new + '</span>');
+      }
+  }});
+});
+</script>
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 <http://www.gnu.org/licenses/>.
+
+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<pk>\w+)/$',
+        ManagerEditNavigationEntry.as_view(),
+        name='pwa-manager-navigation-edit'),
+    url('^navigation/delete/(?P<pk>\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..6f39c072 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,59 @@ 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
+
+
 def test_pwa_offline_page(app):
     PwaSettings.objects.all().delete()
     resp = app.get('/__pwa__/offline/')
@@ -91,3 +149,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 '<span>a</span>' in nav
+        assert '<span>One</span>' 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
-- 
2.20.1

