[IMP] website_event_track_online: add PWA support

RATIONALE

Events are sometimes held online, gathering a community. In this merge we
improve Event application to better support full-online events with improved
tracks, wishlists, chat rooms, ...

PURPOSE

Add PWA support. This will improve adoption and use of our Events application.

LINKS

Task ID-2252655 (Main Online Event task)
Task ID-2283796 (Event B2Basics / Registration Flow)
Task ID-1896623 (PWA Support)
This commit is contained in:
Pierre Paridans
2020-08-04 14:28:29 +00:00
committed by Thibault Delavallée
parent 0ba681f974
commit bf2aead36f
11 changed files with 590 additions and 2 deletions
@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from werkzeug.exceptions import Forbidden, NotFound
from datetime import timedelta
import pytz
@@ -8,6 +10,9 @@ import pytz
from odoo import exceptions, http, fields
from odoo.addons.website_event_track.controllers.main import WebsiteEventTrackController
from odoo.http import request
from odoo.modules.module import get_module_resource
from odoo.tools import ustr
from odoo.tools.translate import _
class EventTrackOnlineController(WebsiteEventTrackController):
@@ -225,3 +230,46 @@ class EventTrackOnlineController(WebsiteEventTrackController):
result['visitor_uuid'] = visitor_sudo.access_token
return result
@http.route('/event/manifest.webmanifest', type='http', auth='public', methods=['GET'], website=True, sitemap=False)
def webmanifest(self):
""" Returns a WebManifest describing the metadata associated with a web application.
Using this metadata, user agents can provide developers with means to create user
experiences that are more comparable to that of a native application.
"""
company = request.env.company
website = request.website
manifest = {
'name': _('%s Online Events') % company.name,
'short_name': company.name,
'description': _('%s Online Events') % company.name,
'scope': '/event',
'start_url': '/event',
'display': 'standalone',
'background_color': '#ffffff',
'theme_color': '#875A7B',
}
icon_sizes = ['192x192', '512x512']
manifest['icons'] = [{
'src': website.image_url(website, 'app_icon', size=size),
'sizes': size,
'type': 'image/png',
} for size in icon_sizes]
body = json.dumps(manifest, default=ustr)
response = request.make_response(body, [
('Content-Type', 'application/manifest+json'),
])
return response
@http.route('/event/service-worker.js', type='http', auth='public', methods=['GET'], website=True, sitemap=False)
def service_worker(self):
""" Returns a ServiceWorker javascript file scoped for website_event
"""
sw_file = get_module_resource('website_event_track_online', 'static/src/js/service_worker.js')
with open(sw_file, 'rb') as fp:
body = fp.read()
response = request.make_response(body, [
('Content-Type', 'text/javascript'),
('Service-Worker-Allowed', '/event'),
])
return response
@@ -9,5 +9,6 @@ from . import event_track
from . import event_track_stage
from . import event_track_tag
from . import event_type
from . import website
from . import website_event_menu
from . import website_visitor
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from PIL import Image
from odoo import api, fields, models
from odoo.tools import ImageProcess
class Website(models.Model):
_inherit = "website"
app_icon = fields.Image(string='Website App Icon', compute='_compute_app_icon', stored=True, readonly=True, help='This field holds the image used as mobile app icon on the website (PNG format).')
@api.depends('favicon')
def _compute_app_icon(self):
""" Computes a squared image based on the favicon to be used as mobile webapp icon.
App Icon should be in PNG format and size of at least 512x512.
"""
for website in self:
image = ImageProcess(website.favicon)
w, h = image.image.size
square_size = w if w > h else h
image.crop_resize(square_size, square_size)
image.image = image.image.resize((512, 512))
image.operationsCount += 1
website.app_icon = image.image_base64(output_format='PNG')
@@ -0,0 +1,81 @@
// idb-keyval.js 3.2.0
// https://github.com/jakearchibald/idb-keyval
// Copyright 2016, Jake Archibald
// Licensed under the Apache License, Version 2.0
var idbKeyval = (function (exports) {
'use strict';
class Store {
constructor(dbName = 'keyval-store', storeName = 'keyval') {
this.storeName = storeName;
this._dbp = new Promise((resolve, reject) => {
const openreq = indexedDB.open(dbName, 1);
openreq.onerror = () => reject(openreq.error);
openreq.onsuccess = () => resolve(openreq.result);
// First time setup: create an empty object store
openreq.onupgradeneeded = () => {
openreq.result.createObjectStore(storeName);
};
});
}
_withIDBStore(type, callback) {
return this._dbp.then(db => new Promise((resolve, reject) => {
const transaction = db.transaction(this.storeName, type);
transaction.oncomplete = () => resolve();
transaction.onabort = transaction.onerror = () => reject(transaction.error);
callback(transaction.objectStore(this.storeName));
}));
}
}
let store;
function getDefaultStore() {
if (!store)
store = new Store();
return store;
}
function get(key, store = getDefaultStore()) {
let req;
return store._withIDBStore('readonly', store => {
req = store.get(key);
}).then(() => req.result);
}
function set(key, value, store = getDefaultStore()) {
return store._withIDBStore('readwrite', store => {
store.put(value, key);
});
}
function del(key, store = getDefaultStore()) {
return store._withIDBStore('readwrite', store => {
store.delete(key);
});
}
function clear(store = getDefaultStore()) {
return store._withIDBStore('readwrite', store => {
store.clear();
});
}
function keys(store = getDefaultStore()) {
const keys = [];
return store._withIDBStore('readonly', store => {
// This would be store.getAllKeys(), but it isn't supported by Edge or Safari.
// And openKeyCursor isn't supported by Safari.
(store.openKeyCursor || store.openCursor).call(store).onsuccess = function () {
if (!this.result)
return;
keys.push(this.result.key);
this.result.continue();
};
}).then(() => keys);
}
exports.Store = Store;
exports.get = get;
exports.set = set;
exports.del = del;
exports.clear = clear;
exports.keys = keys;
return exports;
}({}));
@@ -38,11 +38,13 @@ publicWidget.registry.websiteEventTrackReminder = publicWidget.Widget.extend({
this.reminderOn = $trackLink.data('reminderOn');
}
var reminderOnValue = !this.reminderOn;
this._rpc({
route: '/event/track/toggle_reminder',
params: {
track_id: $trackLink.data('trackId'),
set_reminder_on: !this.reminderOn
set_reminder_on: reminderOnValue,
},
}).then(function (result) {
if (result.error && result.error === 'ignored') {
@@ -52,7 +54,7 @@ publicWidget.registry.websiteEventTrackReminder = publicWidget.Widget.extend({
message: _.str.sprintf(_t('Talk already in your Favorites')),
});
} else {
self.reminderOn = result.reminderOn;
self.reminderOn = reminderOnValue;
var reminderText = self.reminderOn ? _t('Reminder On') : _t('Set Reminder');
self.$('.o_wetrack_js_reminder_text').text(reminderText);
self._updateDisplay();
@@ -0,0 +1,228 @@
importScripts("/website_event_track_online/static/lib/idb-keyval/idb-keyval.js");
const PREFIX = "odoo-event";
const SYNCABLE_ROUTES = ["/event/track/toggle_reminder"];
const CACHABLE_ROUTES = ["/web/webclient/version_info"];
const { Store, set, get, del } = idbKeyval;
const pendingRequestsQueueName = `${PREFIX}-pending-requests`;
const cacheName = `${PREFIX}-cache`;
const syncStore = new Store(`${PREFIX}-sync-db`, `${PREFIX}-sync-store`);
const cacheStore = new Store(`${PREFIX}-cache-db`, `${PREFIX}-cache-store`);
/**
*
* @param {string} url
* @returns {string}
*/
const urlPathname = (url) => new URL(url).pathname;
/**
*
* @param {Array} whitelist
* @returns {Function}
*/
const canHandleRoutes = (whitelist) => (url) => whitelist.includes(urlPathname(url));
/**
*
* @param {Request} request
* @returns {boolean}
*/
const isGET = (request) => request.method === "GET";
/**
*
* @returns {Function}
*/
const isSyncableURL = canHandleRoutes(SYNCABLE_ROUTES);
/**
*
* @returns {Function}
*/
const isCachableURL = canHandleRoutes(CACHABLE_ROUTES);
/**
*
* @param {Request} req
* @returns {Promise<Object>}
*/
const serializeRequest = async (req) => ({
url: req.url,
method: req.method,
headers: Object.fromEntries(req.headers.entries()),
body: await req.text(),
mode: req.mode,
credentials: req.credentials,
cache: req.cache,
redirect: req.redirect,
referrer: req.referrer,
integrity: req.integrity,
});
/**
*
* @param {Object} requestData
* @returns {Request}
*/
const deserializeRequest = (requestData) => {
const { url } = requestData;
delete requestData.url;
return new Request(url, requestData);
};
/**
*
* @param {Response} res
* @returns {Promise<Object>}
*/
const serializeResponse = async (res) => ({
body: await res.text(),
status: res.status,
statusText: res.statusText,
headers: Object.fromEntries(res.headers.entries()),
});
/**
*
* @param {Object} responseData
* @returns {Response}
*/
const deserializeResponse = (responseData) => {
const { body } = responseData;
delete responseData.body;
return new Response(body, responseData);
};
/**
*
* @param {Object} serializedRequest
* @returns {string}
*/
const buildCacheKey = ({ url, body: { method, params } }) =>
JSON.stringify({
url,
method,
params,
});
/**
*
* @returns {int}
*/
const uniqueRequestId = () => Math.floor(Math.random() * 1000 * 1000 * 1000);
/**
*
* @returns {Response}
*/
const buildEmptyResponse = () => new Response(JSON.stringify({ jsonrpc: "2.0", id: uniqueRequestId(), result: {} }));
/**
*
* @param {Request} request
* @param {Response} response
* @returns {Promise}
*/
const cacheRequest = async (request, response) => {
if (isGET(request)) {
const cache = await caches.open(cacheName);
await cache.put(request, response.clone());
} else if (isCachableURL(request.url)) {
const serializedResponse = await serializeResponse(response.clone());
await set(buildCacheKey(serializedRequest), serializedResponse, cacheStore);
}
};
/**
*
* @param {Request} request
* @returns {boolean}
*/
const isCachableRequest = (request) => isGET(request) || isCachableURL(request.url);
/**
*
* @param {Request} request
* @returns {Promise<Response|null>}
*/
const matchCache = async (request) => {
if (isGET(request)) {
const cache = await caches.open(cacheName);
return await cache.match(request);
}
if (isCachableURL(request.url)) {
const serializedRequest = await serializeRequest(requestCopy);
const cachedResponse = await get(buildCacheKey(serializedRequest), cacheStore);
if (cachedResponse) {
return deserializeResponse(cachedResponse);
}
}
return null;
};
/**
*
* @param {FetchEvent} param0
* @returns {Promise<Response>}
*/
const processFetchEvent = async ({ request }) => {
const requestCopy = request.clone();
let response;
try {
response = await fetch(request);
await cacheRequest(request, response);
} catch (requestError) {
if (isCachableRequest(requestCopy)) {
response = await matchCache(requestCopy);
} else if (isSyncableURL(requestCopy.url)) {
const pendingRequests = (await get(pendingRequestsQueueName, syncStore)) || [];
const serializedRequest = await serializeRequest(requestCopy);
await set(pendingRequestsQueueName, [...pendingRequests, serializedRequest], syncStore);
if (self.registration.sync) {
await self.registration.sync.register(pendingRequestsQueueName).catch((err) => {
console.warn("Cannot use BackgroundSync", err);
throw requestError;
});
}
return buildEmptyResponse();
} else {
console.warn(`Offline ${requestCopy.method} request currently not supported`, requestCopy);
}
if (!response) {
throw requestError;
}
}
return response;
};
/**
*
* @returns {Promise}
*/
const processPendingRequests = async () => {
const pendingRequests = (await get(pendingRequestsQueueName, syncStore)) || [];
if (!pendingRequests.length) {
console.info("Nothing to sync!");
return;
}
let pendingRequest;
while ((pendingRequest = pendingRequests.shift())) {
const request = deserializeRequest(pendingRequest);
await fetch(request);
await set(pendingRequestsQueueName, pendingRequests, syncStore);
}
};
self.addEventListener("fetch", (event) => {
event.respondWith(processFetchEvent(event));
});
self.addEventListener("sync", (event) => {
console.info(`Syncing pending requests...`);
if (event.tag === pendingRequestsQueueName) {
event.waitUntil(processPendingRequests());
}
});
@@ -0,0 +1,132 @@
odoo.define("website_event_track_online.website_event_pwa_widget", function (require) {
"use strict";
var config = require("web.config");
var publicWidget = require("web.public.widget");
var PWAInstallBanner = publicWidget.Widget.extend({
xmlDependencies: ["/website_event_track_online/static/src/xml/website_event_pwa.xml"],
template: "pwa_install_banner",
events: {
"click .o_btn_install": "_onClickInstall",
},
/**
* @private
*/
_onClickInstall: function () {
this.trigger_up("prompt_install");
},
});
publicWidget.registry.WebsiteEventPWAWidget = publicWidget.Widget.extend({
selector: "#wrapwrap.event",
custom_events: {
prompt_install: "_onPromptInstall",
},
/**
*
* @override
*/
init: function () {
this._super.apply(this, arguments);
this.beforeInstallPromptHandler = this._onBeforeInstallPrompt.bind(this);
},
/**
*
* @override
*/
start: function () {
var superProm = this._super.apply(this, arguments);
window.addEventListener("beforeinstallprompt", this.beforeInstallPromptHandler);
return superProm.then(this._registerServiceWorker);
},
/**
*
* @override
*/
destroy: function () {
window.removeEventListener("beforeinstallprompt", this.beforeInstallPromptHandler);
this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_hideInstallBanner: function () {
this.installBanner ? this.installBanner.destroy() : undefined;
},
/**
*
* @private
*/
_registerServiceWorker: function () {
if (!("serviceWorker" in navigator)) {
return;
}
navigator.serviceWorker
.register("/event/service-worker.js", { scope: "/event" })
.then((registration) => {
console.info("Registration successful, scope is:", registration.scope);
})
.catch((error) => {
console.error("Service worker registration failed, error:", error);
});
},
/**
* @private
*/
_showInstallBanner: function () {
this.installBanner = new PWAInstallBanner(this);
this.installBanner.appendTo(this.$el);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param ev {Event}
*/
_onBeforeInstallPrompt: function (ev) {
if (!config.device.isMobile) {
return;
}
ev.preventDefault();
this.deferredPrompt = ev;
this._showInstallBanner();
},
/**
* @private
* @param ev {Event}
*/
_onPromptInstall: function (ev) {
ev.stopPropagation();
this.deferredPrompt.prompt();
this._hideInstallBanner();
this.deferredPrompt.userChoice.then((choiceResult) => {
if (choiceResult.outcome === "accepted") {
console.log("User accepted the install prompt");
} else {
console.log("User dismissed the install prompt");
}
});
},
});
return {
PWAInstallBanner: PWAInstallBanner,
WebsiteEventPWAWidget: publicWidget.registry.WebsiteEventPWAWidget,
};
});
@@ -0,0 +1,29 @@
@media all and (display-mode: standalone) {
#wrapwrap.event {
#top,
#bottom {
display: none;
}
}
}
#wrapwrap.event {
.o_pwa_install_banner {
position: fixed;
display: flex;
bottom: 0;
right: 0;
left: 0;
margin: 0;
justify-content: space-between;
align-items: center;
background-color: rgba(255, 255, 255, 1);
border-top: 1px solid $gray-200;
box-shadow: 0 -0.125rem 0.25rem rgba(0, 0, 0, 0.075);
font-size: 1rem;
.o_btn_install {
margin-left: auto;
}
}
}
@@ -0,0 +1,10 @@
<templates>
<t t-name="pwa_install_banner">
<div class="alert alert-default o_pwa_install_banner">
<strong>Install Application</strong>
<button class="btn btn-primary o_btn_install">Install</button>
</div>
</t>
</templates>
@@ -4,10 +4,13 @@
<template id="assets_frontend" inherit_id="website.assets_frontend" name="Event Track Online Assets Frontend">
<xpath expr="//link[last()]" position="after">
<link rel="stylesheet" type="text/scss" href="/website_event_track_online/static/src/scss/event_track_templates.scss"/>
<link rel="stylesheet" type="text/scss" href="/website_event_track_online/static/src/scss/pwa_frontend.scss"/>
</xpath>
<xpath expr="//script[last()]" position="after">
<script type="text/javascript" src="/website_event_track_online/static/src/js/event_track_reminder.js"></script>
<script type="text/javascript" src="/website_event_track_online/static/src/js/website_event_customize_options.js"></script>
<script type="text/javascript" src="/website_event_track_online/static/lib/idb-keyval/idb-keyval.js"/>
<script type="text/javascript" src="/website_event_track_online/static/src/js/website_event_pwa_widget.js"/>
</xpath>
</template>
@@ -15,4 +15,30 @@
</xpath>
</template>
<template id="layout" inherit_id="website_event.layout">
<xpath expr='//t[@t-call="website.layout"]' position="inside">
<t t-set="pageName" t-value="'event'"/>
<t t-set="head">
<t t-raw="head"/>
<t t-call="website_event_track_online.pwa_manifest"/>
</t>
</xpath>
</template>
<template id="index" inherit_id="website_event.index">
<xpath expr='//t[@t-call="website.layout"]' position="inside">
<t t-set="pageName" t-value="'event'"/>
<t t-set="head">
<t t-raw="head"/>
<t t-call="website_event_track_online.pwa_manifest"/>
</t>
</xpath>
</template>
<template id="pwa_manifest">
<link rel="manifest" href="/event/manifest.webmanifest"/>
<link rel="apple-touch-icon" t-att-href="website.image_url(website, 'app_icon', size='192x192')"/>
<meta name="theme-color" content="#875A7B"/>
</template>
</odoo>