pwa: add management of navigation entries (#29362)

This commit is contained in:
Frédéric Péters 2018-12-27 15:26:21 +01:00
parent 1ebadd603c
commit d9367275de
13 changed files with 487 additions and 8 deletions

View File

@ -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,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})

View File

@ -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',),
},
),
]

View File

@ -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,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()

View File

@ -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;
}
}
}

View File

@ -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 %}

View File

@ -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>

View File

@ -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>

View File

View File

@ -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)

View File

@ -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 = [

View File

@ -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'))

View File

@ -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'

View File

@ -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 '<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