pwa: add settings page with offline parameters (#25496)

This commit is contained in:
Frédéric Péters 2018-12-27 09:44:21 +01:00
parent 00b6001be7
commit c26b473c78
16 changed files with 512 additions and 6 deletions

View File

@ -15,6 +15,9 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import django.apps
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
class AppConfig(django.apps.AppConfig):
name = 'combo.apps.pwa'
@ -26,4 +29,11 @@ class AppConfig(django.apps.AppConfig):
from . import urls
return urls.urlpatterns
def get_extra_manager_actions(self):
from django.conf import settings
if settings.TEMPLATE_VARS.get('pwa_display') in ('standalone', 'fullscreen'):
return [{'href': reverse('pwa-manager-homepage'),
'text': _('Mobile Application (PWA)')}]
return []
default_app_config = 'combo.apps.pwa.AppConfig'

View File

@ -0,0 +1,30 @@
# combo - content management system
# Copyright (C) 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.core.urlresolvers import reverse_lazy
from django.views.generic import UpdateView
from .models import PwaSettings
class ManagerHomeView(UpdateView):
template_name = 'combo/pwa/manager_home.html'
model = PwaSettings
fields = '__all__'
success_url = reverse_lazy('pwa-manager-homepage')
def get_object(self):
return PwaSettings.singleton()

View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-12-27 08:44
from __future__ import unicode_literals
import combo.data.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pwa', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='PwaSettings',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('offline_text', combo.data.fields.RichTextField(default='You are currently offline.', verbose_name='Offline Information Text')),
('offline_retry_button', models.BooleanField(default=True, verbose_name='Include Retry Button')),
('last_update_timestamp', models.DateTimeField(auto_now=True)),
],
),
]

View File

@ -14,10 +14,46 @@
# 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 json
from django.conf import settings
from django.core import serializers
from django.db import models
from django.utils.translation import ugettext_lazy as _
from jsonfield import JSONField
from combo.data.fields import RichTextField
class PwaSettings(models.Model):
offline_text = RichTextField(
verbose_name=_('Offline Information Text'),
default=_('You are currently offline.'),
config_name='small')
offline_retry_button = models.BooleanField(_('Include Retry Button'), default=True)
last_update_timestamp = models.DateTimeField(auto_now=True)
@classmethod
def singleton(cls):
return cls.objects.first() or cls()
@classmethod
def export_for_json(cls):
obj = cls.singleton()
if not obj.id:
return {}
serialized_settings = json.loads(serializers.serialize('json', [obj]))
return serialized_settings[0].get('fields')
@classmethod
def load_serialized_settings(cls, json_settings):
if not json_settings:
return
obj = cls.singleton()
for attr in json_settings:
setattr(obj, attr, json_settings[attr])
obj.save()
class PushSubscription(models.Model):

View File

@ -0,0 +1,84 @@
.manager-mobile-home-layout {
display: flex;
div.sections {
flex: 1;
}
}
div#mobile-case {
background: url(../img/mobile-case.svg) top left no-repeat;
width: 400px;
height: 720px;
position: relative;
overflow: hidden;
div.screen {
position: absolute;
overflow: hidden;
left: 12px;
top: 52px;
bottom: 67px;
right: 28px;
div.mobile-top-bar {
position: absolute;
top: 0;
left: 0;
background: rgba(0, 0, 0, 0.7);
width: 100%;
text-align: right;
color: white;
box-sizing: border-box;
padding-right: 5px;
height: 20px;
}
div.mobile-app-content {
position: absolute;
top: 20px;
left: 0;
width: 100%;
bottom: 0;
div.splash,
iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
opacity: 0;
transition: all ease-out 0.4s;
}
div.splash {
z-index: 100;
opacity: 1;
transform: scale(1);
}
&.splash-off {
div.splash {
pointer-events: none;
opacity: 0;
transform: scale(10);
}
iframe {
opacity: 1;
}
}
}
div.appicon {
position: absolute;
top: 40%;
text-align: center;
img {
width: 50%;
}
}
div.applabel {
position: absolute;
bottom: 50px;
left: 0;
width: 100%;
text-align: center;
font-size: 30px;
color: white;
}
}
}

View File

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="svg2"
xml:space="preserve"
width="386.93579"
height="707.42499"
viewBox="0 0 386.93579 707.42499"
sodipodi:docname="mobile-case.svg"
inkscape:version="0.92.3 (2405546, 2018-03-11)"><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs6"><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath18"><path
d="M 0,410 H 1028 V 0 H 0 Z"
id="path16"
inkscape:connector-curvature="0" /></clipPath></defs><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1043"
id="namedview4"
showgrid="false"
inkscape:zoom="0.5"
inkscape:cx="-114.43322"
inkscape:cy="238.84637"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g10"
inkscape:measure-start="27,428"
inkscape:measure-end="387,428"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0" /><g
id="g10"
inkscape:groupmode="layer"
inkscape:label="x"
transform="matrix(1.3333333,0,0,-1.3333333,-3.6475299,539.99784)"><g
id="g20"
transform="matrix(1.8601685,0,0,1.8601685,309.66345,-151.14733)"
style="stroke-width:0.5"><path
d="m -10.548622,33.67008 c 0,-16.568 -13.431,-19.920268 -29.999,-19.920268 H -134.999 c -16.569,0 -30.001,3.352268 -30.001,19.920268 V 280.665 c 0,9.72565 13.432,18.31099 30.001,18.31099 h 94.451378 c 16.568,0 29.999,-9.44063 29.999,-18.31099 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.5"
id="path22"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssss" /></g><g
id="g24"
transform="matrix(1.8601685,0,0,1.8601685,300.67326,-142.46964)"
style="stroke-width:0.5"><path
d="m -10.120975,32.690552 c 0,-16.568 -13.432,-20.726646 -30,-20.726646 H -125.333 c -16.569,0 -30,4.158646 -30,20.726646 V 271.334 c 0,16.569 13.431,20.71282 30,20.71282 h 85.212025 c 16.568,0 30,-4.14382 30,-20.71282 z"
style="fill:#232323;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.5"
id="path26"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sssssssss" /></g><g
id="g30"
transform="matrix(1.8601685,0,0,1.8601685,189.06408,381.94033)"
style="stroke-width:0.5"><path
d="m 0,0 c 0,-1 -1,-2 -2,-2 h -30 c -1,0 -2,1 -2,2 0,1 1,2 2,2 H -2 C -1,2 0,1 0,0"
style="fill:#0e0e0e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.5"
id="path32"
inkscape:connector-curvature="0" /></g><g
id="g38"
transform="matrix(1.8601685,0,0,1.8601685,111.24393,381.94125)"
style="stroke-width:0.5"><path
d="m 0,0 c 0,-2 -1,-4 -4,-4 -2,0 -4,1 -4,4 0,2 1,4 4,4 2,0 4,-1 4,-4"
style="fill:#0e0e0e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.5"
id="path40"
inkscape:connector-curvature="0" /></g><g
id="g42"
transform="matrix(1.8601685,0,0,1.8601685,108.45368,381.94125)"
style="stroke-width:0.5"><path
d="m 0,0 c 0,-1 -1,-2 -2,-2 -1,0 -3,1 -3,2 0,1 2,2 3,2 1,0 2,-1 2,-2"
style="fill:#2c2c2c;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.5"
id="path44"
inkscape:connector-curvature="0" /></g><g
id="g46"
transform="matrix(1.8601685,0,0,1.8601685,106.43911,381.94125)"
style="stroke-width:0.5"><path
d="m 0,0 c 0,0 0,-1 -1,-1 0,0 -1,0 -1,1 0,0 0,1 1,1 1,0 1,-1 1,-1"
style="fill:#373737;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.5"
id="path48"
inkscape:connector-curvature="0" /></g><path
d="m 292.9375,309.4375 h -3 v 41 h 3 z"
style="fill:#1a1a1a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1"
id="path50"
inkscape:connector-curvature="0" /><rect
style="color:#000000;display:inline;overflow:visible;visibility:visible;opacity:0.8;fill:#00ffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.28346458;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;enable-background:accumulate"
id="rect831"
width="270"
height="450"
x="11.735635"
y="-365.1778"
transform="scale(1,-1)" /></g></svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -0,0 +1,16 @@
{% extends "combo/manager_base.html" %}
{% load i18n %}
{% block css %}
{{ block.super }}
<link rel="stylesheet" type="text/css" media="all" href="{{ STATIC_URL }}css/combo.manager.pwa.css"/>
{% endblock %}
{% block appbar %}
<h2>{% trans 'Mobile Application' %}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'pwa-manager-homepage' %}">{% trans 'Mobile Application' %}</a>
{% endblock %}

View File

@ -0,0 +1,54 @@
{% extends "combo/pwa/manager_base.html" %}
{% load i18n static %}
{% block content %}
<div class="manager-mobile-home-layout">
<div id="mobile-case">
<div class="screen" style="background: {{ theme_color }};">
<div class="mobile-top-bar"><span class="clock">--:--</span></div>
<div class="mobile-app-content">
<div class="splash">
<div class="appicon">
<img src="{% static "" %}{{ css_variant }}/{{ icon_prefix }}{{icon_sizes|last}}px.png" alt="">
</div>
<div class="applabel">{% firstof global_title "Compte Citoyen" %}</div>
</div>
<iframe scrolling="no"></iframe>
</div>
</div>
</div>
<div class="sections">
<div class="section settings">
<h3>{% trans "Settings" %}</h3>
<div>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
</div>
</form>
</div>
</div>
</div> {# .sections #}
</div> {# .manager-mobile-home-layout #}
<script>
setInterval(function() {
var $clock = $('#mobile-case .clock');
var date = new Date();
$clock.text(('0' + date.getHours()).slice(-2) + ':' + ('0' + date.getMinutes()).slice(-2));
}, 500);
$(function() {
$('.mobile-app-content .splash').on('click', function() {
$('.mobile-app-content iframe').attr('src', '/');
$('.mobile-app-content').addClass('splash-off');
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,53 @@
{% load i18n static %}<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
html, body {
margin: 0; padding: 1rem;
font-family: sans-serif;
background: {{theme_color}};
}
div.info-text {
background: white;
padding: 1rem;
border-radius: 3px;
}
img {
max-width: 100%;
margin: 0 auto;
display: block;
}
p.retry {
margin-top: 2rem;
text-align: center;
}
p.retry a {
border: 1px solid {{theme_color}};
text-decoration: none;
background: white;
padding: 0.5rem 1rem;
border-radius: 3px;
color: inherit;
}
</style>
</head>
<body>
<div class="info-text">
<img src="{% static "" %}{{ css_variant }}/{{ icon_prefix }}{{icon_sizes|last}}px.png" alt="">
{{ pwa_settings.offline_text|safe }}
{% if pwa_settings.offline_retry_button %}
<p class="retry">
<a href=".">{% trans "Retry" %}</a>
</p>
{% endif %}
</div>
</body>
</html>

View File

@ -25,11 +25,11 @@ function urlB64ToUint8Array(base64String) {
var config = {
version: 'v{% start_timestamp %}',
staticCacheItems: [
'/offline/'
'/__pwa__/offline/'
],
cachePathPattern: /^\/static\/.*/,
handleFetchPathPattern: /.*/,
offlinePage: '/offline/'
offlinePage: '/__pwa__/offline/'
};
function cacheName (key, opts) {

View File

@ -14,18 +14,32 @@
# 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.conf.urls import url
from django.conf.urls import url, include
from combo.urls_utils import decorated_includes, manager_required
from .manager_views import (
ManagerHomeView,
)
from .views import (
manifest_json,
service_worker_js,
service_worker_registration_js,
subscribe_push,
offline_page,
)
pwa_manager_urls = [
url('^$', ManagerHomeView.as_view(), name='pwa-manager-homepage'),
]
urlpatterns = [
url('^manifest.json$', manifest_json),
url('^service-worker.js$', service_worker_js),
url('^service-worker-registration.js$', service_worker_registration_js),
url('^api/pwa/push/subscribe$', subscribe_push, name='pwa-subscribe-push'),
url('^__pwa__/offline/$', offline_page),
url(r'^manage/pwa/', decorated_includes(manager_required,
include(pwa_manager_urls))),
]

View File

@ -21,8 +21,9 @@ from django.conf import settings
from django.http import HttpResponse, HttpResponseForbidden, Http404, JsonResponse
from django.template.loader import get_template, TemplateDoesNotExist
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import TemplateView
from .models import PushSubscription
from .models import PushSubscription, PwaSettings
def manifest_json(request, *args, **kwargs):
@ -67,3 +68,14 @@ def subscribe_push(request, *args, **kwargs):
subscription_info=subscription_data)
subscription.save()
return JsonResponse({'err': 0})
class OfflinePage(TemplateView):
template_name = 'combo/pwa/offline.html'
def get_context_data(self, **kwargs):
context = super(OfflinePage, self).get_context_data(**kwargs)
context['pwa_settings'] = PwaSettings.singleton()
return context
offline_page = OfflinePage.as_view()

View File

@ -22,6 +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 .models import Page
@ -38,7 +39,11 @@ def export_site():
'''Dump site objects to JSON-dumpable dictionnary'''
return {'pages': Page.export_all_for_json(),
'map-layers': MapLayer.export_all_for_json(),
'assets': Asset.export_all_for_json(),}
'assets': Asset.export_all_for_json(),
'pwa': {
'settings': PwaSettings.export_for_json(),
}
}
def import_site(data, if_empty=False, clean=False):
@ -68,6 +73,7 @@ def import_site(data, if_empty=False, clean=False):
MapLayer.objects.all().delete()
Asset.objects.all().delete()
Page.objects.all().delete()
PwaSettings.objects.all().delete()
with transaction.atomic():
MapLayer.load_serialized_objects(data.get('map-layers') or [])
@ -77,3 +83,6 @@ 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'))

View File

@ -23,6 +23,7 @@ The local settings file should exist, at least to set a suitable SECRET_KEY,
and to disable DEBUG mode in production.
"""
import copy
import os
from django.conf import global_settings
from django.utils.translation import ugettext_lazy as _
@ -177,6 +178,9 @@ CKEDITOR_CONFIGS = {
},
}
CKEDITOR_CONFIGS['small'] = copy.copy(CKEDITOR_CONFIGS['default'])
CKEDITOR_CONFIGS['small']['height'] = 150
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine',

View File

@ -14,6 +14,7 @@ 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.data.models import Page, TextCell
from combo.data.utils import export_site, import_site, MissingGroups
@ -198,3 +199,17 @@ def test_import_export_assets(app, some_assets):
import_site(data={}, if_empty=True)
assert Asset.objects.count() == 2
def test_import_export_pwa_settings(app):
output = get_output_of_command('export_site')
pwa_settings = PwaSettings.singleton()
pwa_settings.offline_text = 'Hello world'
pwa_settings.offline_retry_button = False
pwa_settings.save()
output = get_output_of_command('export_site')
import_site(data={}, clean=True)
assert PwaSettings.objects.all().count() == 0
import_site(data=json.loads(output))
assert PwaSettings.singleton().offline_retry_button is False
assert PwaSettings.singleton().offline_text == 'Hello world'

View File

@ -13,7 +13,7 @@ from django.core.urlresolvers import reverse
from django.test import override_settings
from combo.apps.notifications.models import Notification
from combo.apps.pwa.models import PushSubscription
from combo.apps.pwa.models import PushSubscription, PwaSettings
from .test_manager import login
@ -59,3 +59,35 @@ def test_webpush_notification(app, john_doe):
notification = Notification.notify(john_doe, 'test', body='hello world')
assert webpush.call_count == 1
assert webpush.call_args[1]['subscription_info'] == {'sample': 'content'}
def test_no_pwa_manager(app, admin_user):
app = login(app)
resp = app.get('/manage/', status=200)
assert not '/manage/pwa/' in resp.text
def test_pwa_manager(app, admin_user):
app = login(app)
with override_settings(TEMPLATE_VARS={'pwa_display': 'standalone'}):
resp = app.get('/manage/', status=200)
assert '/manage/pwa/' in resp.text
resp = app.get('/manage/pwa/')
resp.form['offline_text'] = 'You are offline.'
assert resp.form['offline_retry_button'].checked
resp.form['offline_retry_button'].checked = False
resp = resp.form.submit().follow()
assert resp.form['offline_text'].value == 'You are offline.'
assert resp.form['offline_retry_button'].checked is False
def test_pwa_offline_page(app):
PwaSettings.objects.all().delete()
resp = app.get('/__pwa__/offline/')
assert 'You are currently offline.' in resp.text
assert 'Retry' in resp.text
pwa_settings = PwaSettings.singleton()
pwa_settings.offline_text = 'You are offline.'
pwa_settings.offline_retry_button = False
pwa_settings.save()
resp = app.get('/__pwa__/offline/')
assert 'You are offline.' in resp.text
assert 'Retry' not in resp.text