[MOV] *: move all PWA to community
In this commit, we moved all features related to the PWA and the PWA itself to the community. This includes: * PWA * Web Push Notification * VCARD Note from original commits: =========================== PWA (part 1) ------------ This commit adds a ServiceWorker to complement the WebManifest to complete the setup of the backend as a Progressive Web App. More precisely, it adds the route, registration and the most basic ServiceWorker to allow the backend to be recognized as an installable PWA. References: - https://web.dev/install-criteria/ - https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Installable_PWAs - https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers Task ID: 3063485 PWA (part 2) ------------ This commit adds a WebManifest as a first step toward setuping the backend as a Progressive Web App. In a nutshell: - the web app's name is configurable through a config parameter (available in the Settings, in debug); defaulting to "Odoo". - the web app's icon has been revamped to accommodate the required sizes; also its design matches the one from the Android app. - "theme-color" is used to color part of the browser/system UI to match Enterprise brand color; also supports the dark mode. References: - https://web.dev/learn/pwa/web-app-manifest/ - https://web.dev/install-criteria/ - https://developer.mozilla.org/en-US/docs/Web/Manifest Task ID: 3063485 PWA shortcuts ------------- The main goal of this commit is like we did inside the `Android Odoo Mobile App`, allowing users to have some Odoo application shortcuts. We added the following apps in the key `shortcuts` on `web.manifest` in these orders: `Discuss`, `CRM`, `Project`, `To-Do` (old `Notes`). Links: - https://w3c.github.io/manifest/#shortcuts-member - https://developer.mozilla.org/en-US/docs/Web/Manifest/shortcuts Task ID: 3123607 Offline mode ------------ This commit introduces a way to notify the user that he's "offline" (aka. cannot reach its Odoo server) and that Odoo doesn't work in a graceful way in this circumstance. To do so, the Service-Worker will return the response of the ´web/offline´ route, which is cached at its setup. Note: this screen is only show when launched while "offline" and fails to load the requested page. It does not "interrupt" the WebClient to show this screen when the connection drops off (cf. not a replacement for the existing notification). Task ID: 3203639 WebPush ------- WebPush allows sending data to the user browser/app(PWA) even when tab/app is closed. Web push is a "constant" link between the ServiceWorker of browser/app and a WebPush server. Note that each browser has its own custom WebPush server. e.g.: Chrome: https://fcm.googleapis.com/ Firefox: https://updates.push.services.mozilla.com/ Safari: https://web.push.apple.com/ Edge: https://wns2-ln2p.notify.windows.com/ WebPush introduces some cryptographic notion to ensure some the reliability of the data sent: VAPID: "Voluntary Application Server Identification" is the standard used to generate the public and the private to sign the message between the browser and the WebPush server JWT: "JSON Web Token" is the standard used to sign the payload to the WebPush server ECE: "Encrypted Content-Encoding" is the standard used by WebPush to encrypt the data of the payload to avoid sending RAW data outside trusted network. Simplified steps how to WebPush works: The Javascript code of a web page subscribes to the WebPush server (using the VAPID key generated at mail_entreprise install). The WebPush server replay with a subscription (and some other info like the unique URL endpoint per subscription where to send a notification) The application (odoo-bin in our case) sends a post request to the WebPush server using the specific URL endpoint of the user (using JWT and ECE). The WebPush server sends back to the browser the encrypted payload. The browser decrypts the payload and sends it to the ServiceWorker linked to the subscription. Here is a Sequence diagram of all interactions to process a web push notification. In Odoo, we use WebPush to send Notification to the user. This commit aims to have a parity with the Android/iOS Mobile App at the notification level. Notes: There are some ways to encrypt (ECE) the message for WebPush: AESGCM128: this is a draft AESGCM: very well documented AES128GCM: RFC8188 Standard encoding We implement only the RFC one as it is the only one implemented in all major updated browsers (Chrome, Firefox, Safari, Edge, ...) You need to allow the desktop "Notification" and "Push" inside your browser. For iOS Devices, it only works on iOS 16.4+ and it's requiring Odoo to first be added to the Home Screen. It's delivered silently, meaning no sound, vibration, haptics or screen wake. Note: Notifications are sent directly if there are less than five notifications, otherwise we use a cron triggered immediately. Also, we have changed the value of the "QueryCount" as mail_enterprise executes a new query to search the devices associated with the partner. We have added a "try/except" for any Exception before the push_to_end_point method as we want to avoid blocking a normal flow just for a not mandatory push notification if something happens during the push to the endpoint. See: odoo/enterprise@d0ae70103d Links: https://www.rfc-editor.org/rfc/rfc8030 https://www.rfc-editor.org/rfc/rfc8188 https://www.rfc-editor.org/rfc/rfc8291 https://www.rfc-editor.org/rfc/rfc8292 https://w3c.github.io/push-api/index.html https://autopush.readthedocs.io/en/latest/http.html https://web.dev/push-notifications-web-push-protocol/ https://github.com/web-push-libs/encrypted-content-encoding https://github.com/web-push-libs/pywebpush https://github.com/web-push-libs/vapid https://caniuse.com/push-api Task ID: 3123678 VCARD ----- In the process of replacing the native methods exposed in the mobile apps, this commit implements the download of a vCard containing a partner's information. By using this standard format, both regular web users and mobile ones are now able to save the partner's details to use them with their usual address book software. On a mobile device, the actual import of those informations is delegated to the operating system. References: - https://datatracker.ietf.org/doc/html/rfc6350 - https://en.wikipedia.org/wiki/VCard - https://github.com/eventable/vobject#vcards Task ID: 2583916 =========== End of note =========== Task ID: 3478014 closes odoo/odoo#133560 Related: odoo/enterprise#46530 Related: odoo/upgrade#5086 Signed-off-by: Adrien Dieudonné (adr) <adr@odoo.com> Co-authored-by: Romeo Fragomeli <rfr@odoo.com> Co-authored-by: Romain Estievenart <res@odoo.com> Co-authored-by: Pierre Paridans <app@odoo.com>
This commit is contained in:
co-authored by
Romain Estievenart
Pierre Paridans
parent
9d5bc32036
commit
0903ef7bb2
@@ -130,6 +130,12 @@
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<block title="Progressive Web App" id="pwa_settings" groups="base.group_no_one">
|
||||
<setting help="This name will be used for the application when Odoo is installed through the browser.">
|
||||
<field name="web_app_name" placeholder="Odoo"/>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<block title="Integrations" name="integration">
|
||||
<setting string="Mail Plugin" documentation="/applications/productivity/mail_plugins.html" help="Integrate with mail client plugins" id="mail_pluggin_setting">
|
||||
<field name="module_mail_plugin" />
|
||||
|
||||
@@ -8,6 +8,7 @@ from . import mailbox
|
||||
from . import message_reaction
|
||||
from . import thread
|
||||
from . import webclient
|
||||
from . import webmanifest
|
||||
|
||||
# after mail specifically as discuss module depends on mail
|
||||
from . import discuss
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.http import request
|
||||
from odoo.tools import file_open
|
||||
from odoo.addons.web.controllers.webmanifest import WebManifest as WebWebManifest
|
||||
|
||||
|
||||
class WebManifest(WebWebManifest):
|
||||
|
||||
def _get_service_worker_content(self):
|
||||
body = super()._get_service_worker_content()
|
||||
|
||||
# Add notification support to the service worker if user but no public
|
||||
if request.env.user.has_group('base.group_user'):
|
||||
with file_open('mail/static/src/service_worker.js') as f:
|
||||
body += f.read()
|
||||
|
||||
return body
|
||||
@@ -65,5 +65,17 @@
|
||||
<field name="code">model._send_notifications_cron()</field>
|
||||
<field name="state">code</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_cron_web_push_notification" model="ir.cron">
|
||||
<field name="name">Mail: send web push notification</field>
|
||||
<field name="model_id" ref="model_mail_notification_web_push"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._push_notification_to_endpoint()</field>
|
||||
<field name="active" eval="True"/>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -4,3 +4,12 @@ UPDATE mail_template
|
||||
-- deactivate fetchmail server
|
||||
UPDATE fetchmail_server
|
||||
SET active = false;
|
||||
|
||||
-- reset WEB Push Notification:
|
||||
-- * delete VAPID keys
|
||||
DELETE FROM ir_config_parameter
|
||||
WHERE key IN ('mail.web_push_vapid_private_key', 'mail.web_push_vapid_public_key');
|
||||
-- * delete delayed messages (CRON)
|
||||
TRUNCATE mail_notification_web_push;
|
||||
-- * delete Devices for each partners
|
||||
TRUNCATE mail_partner_device CASCADE;
|
||||
|
||||
@@ -57,6 +57,8 @@ from . import res_company
|
||||
from . import res_config_settings
|
||||
from . import res_users
|
||||
from . import update
|
||||
from . import web_push
|
||||
from . import partner_devices
|
||||
|
||||
# after mail specifically as discuss module depends on mail
|
||||
from . import discuss
|
||||
|
||||
@@ -591,6 +591,8 @@ class Channel(models.Model):
|
||||
'last_interest_dt': member.last_interest_dt,
|
||||
}])
|
||||
self.env['bus.bus'].sudo()._sendmany(bus_notifications)
|
||||
if self.is_chat or self.channel_type == 'group':
|
||||
self._notify_thread_by_web_push(message, rdata, msg_vals, **kwargs)
|
||||
return rdata
|
||||
|
||||
def _message_receive_bounce(self, email, partner):
|
||||
@@ -1296,3 +1298,41 @@ class Channel(models.Model):
|
||||
msg = _("Users in this channel: %(members)s %(dots)s and you.", members=", ".join(members), dots=dots)
|
||||
|
||||
self._send_transient_message(self.env.user.partner_id, msg)
|
||||
|
||||
def _notify_thread_by_web_push(self, message, recipients_data, msg_vals=False, **kwargs):
|
||||
""" Specifically handle channel members. """
|
||||
chat_channels = self.filtered(lambda channel: channel.channel_type == 'chat')
|
||||
if chat_channels:
|
||||
# modify rdata only for calling super. Do not deep copy as we only
|
||||
# add data into list but we do not modify item content
|
||||
channel_rdata = recipients_data.copy()
|
||||
channel_rdata += [
|
||||
{'id': partner.id,
|
||||
'share': partner.partner_share,
|
||||
'active': partner.active,
|
||||
'notif': 'web_push',
|
||||
'type': 'customer',
|
||||
'groups': [],
|
||||
}
|
||||
for partner in chat_channels.mapped("channel_partner_ids")
|
||||
]
|
||||
else:
|
||||
channel_rdata = recipients_data
|
||||
|
||||
return super()._notify_thread_by_web_push(message, channel_rdata, msg_vals=msg_vals, **kwargs)
|
||||
|
||||
def _notify_by_web_push_prepare_payload(self, message, msg_vals=False):
|
||||
payload = super()._notify_by_web_push_prepare_payload(message, msg_vals=msg_vals)
|
||||
payload['options']['data']['action'] = 'mail.action_discuss'
|
||||
record_name = msg_vals.get('record_name') if msg_vals and 'record_name' in msg_vals else message.record_name
|
||||
if self.channel_type == 'chat':
|
||||
author_id = [msg_vals.get('author_id')] if 'author_id' in msg_vals else message.author_id.ids
|
||||
payload['title'] = self.env['res.partner'].browse(author_id).name
|
||||
payload['options']['icon'] = '/discuss/channel/%d/partner/%d/avatar_128' % (message.res_id, author_id[0])
|
||||
elif self.channel_type == 'channel':
|
||||
author_id = [msg_vals.get('author_id')] if 'author_id' in msg_vals else message.author_id.ids
|
||||
author_name = self.env['res.partner'].browse(author_id).name
|
||||
payload['title'] = "#%s - %s" % (record_name, author_name)
|
||||
else:
|
||||
payload['title'] = "#%s" % (record_name)
|
||||
return payload
|
||||
|
||||
@@ -31,6 +31,11 @@ from odoo.osv import expression
|
||||
from odoo.tools import is_html_empty, html_escape, html2plaintext, parse_contact_from_email
|
||||
from odoo.tools.misc import clean_context, split_every
|
||||
|
||||
from requests import Session
|
||||
from ..web_push import push_to_end_point, DeviceUnreachableError
|
||||
|
||||
MAX_DIRECT_PUSH = 5
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -2961,6 +2966,7 @@ class MailThread(models.AbstractModel):
|
||||
self._notify_thread_by_inbox(message, recipients_data, msg_vals=msg_vals, **kwargs)
|
||||
self._notify_thread_by_email(message, recipients_data, msg_vals=msg_vals, **kwargs)
|
||||
|
||||
self._notify_thread_by_web_push(message, recipients_data, msg_vals, **kwargs)
|
||||
return recipients_data
|
||||
|
||||
def _notify_thread_by_inbox(self, message, recipients_data, msg_vals=False, **kwargs):
|
||||
@@ -4179,3 +4185,205 @@ class MailThread(models.AbstractModel):
|
||||
if 'suggestedRecipients' in request_list:
|
||||
res['suggestedRecipients'] = self._message_get_suggested_recipients()[self.id]
|
||||
return res
|
||||
|
||||
def _extract_partner_ids_for_notifications(self, message, msg_vals, recipients_data):
|
||||
notif_pids = []
|
||||
no_inbox_pids = []
|
||||
for recipient in recipients_data:
|
||||
if recipient['active']:
|
||||
notif_pids.append(recipient['id'])
|
||||
if recipient['notif'] != 'inbox':
|
||||
no_inbox_pids.append(recipient['id'])
|
||||
|
||||
if not notif_pids:
|
||||
return []
|
||||
|
||||
msg_sudo = message.sudo()
|
||||
msg_type = msg_vals.get('message_type') or msg_sudo.message_type
|
||||
author_id = [msg_vals.get('author_id')] if 'author_id' in msg_vals else msg_sudo.author_id.ids
|
||||
# never send to author and to people outside Odoo (email), except comments
|
||||
pids = set()
|
||||
if msg_type == 'comment':
|
||||
pids = set(notif_pids) - set(author_id)
|
||||
elif msg_type in ('notification', 'user_notification', 'email'):
|
||||
pids = (set(notif_pids) - set(author_id) - set(no_inbox_pids))
|
||||
return list(pids)
|
||||
|
||||
def _truncate_payload(self, payload):
|
||||
"""
|
||||
Check the payload limit of 4096 bytes to avoid 413 error return code.
|
||||
If the payload is too big, we trunc the body value.
|
||||
:param dict payload: Current payload to trunc
|
||||
:return: The truncate payload;
|
||||
"""
|
||||
payload_length = len(str(payload).encode())
|
||||
body = payload['options']['body']
|
||||
body_length = len(body)
|
||||
if payload_length > 4096:
|
||||
body_max_length = 4096 - payload_length - body_length
|
||||
payload['options']['body'] = body.encode()[:body_max_length].decode(errors="ignore")
|
||||
return payload
|
||||
|
||||
def _notify_thread_by_web_push(self, message, recipients_data, msg_vals=False, **kwargs):
|
||||
""" Method to send cloud notifications for every mention of a partner
|
||||
and every direct message. We have to take into account the risk of
|
||||
duplicated notifications in case of a mention in a channel of `chat` type.
|
||||
|
||||
:param message: ``mail.message`` record to notify;
|
||||
:param recipients_data: list of recipients information (based on res.partner
|
||||
records), formatted like
|
||||
[{'active': partner.active;
|
||||
'id': id of the res.partner being recipient to notify;
|
||||
'groups': res.group IDs if linked to a user;
|
||||
'notif': 'inbox', 'email', 'sms' (SMS App);
|
||||
'share': partner.partner_share;
|
||||
'type': 'customer', 'portal', 'user;'
|
||||
}, {...}].
|
||||
See ``MailThread._notify_get_recipients``;
|
||||
:param msg_vals: dictionary of values used to create the message. If given it
|
||||
may be used to access values related to ``message`` without accessing it
|
||||
directly. It lessens query count in some optimized use cases by avoiding
|
||||
access message content in db;
|
||||
"""
|
||||
|
||||
msg_vals = dict(msg_vals or {})
|
||||
partner_ids = self._extract_partner_ids_for_notifications(message, msg_vals, recipients_data)
|
||||
if not partner_ids:
|
||||
return
|
||||
|
||||
partner_devices_sudo = self.env['mail.partner.device'].sudo()
|
||||
devices = partner_devices_sudo.search([
|
||||
('partner_id', 'in', partner_ids)
|
||||
])
|
||||
if not devices:
|
||||
return
|
||||
|
||||
ir_parameter_sudo = self.env['ir.config_parameter'].sudo()
|
||||
vapid_private_key = ir_parameter_sudo.get_param('mail.web_push_vapid_private_key')
|
||||
vapid_public_key = ir_parameter_sudo.get_param('mail.web_push_vapid_public_key')
|
||||
if not vapid_private_key or not vapid_public_key:
|
||||
_logger.warning("Missing web push vapid keys !")
|
||||
return
|
||||
|
||||
payload = self._notify_by_web_push_prepare_payload(message, msg_vals=msg_vals)
|
||||
payload = self._truncate_payload(payload)
|
||||
if len(devices) < MAX_DIRECT_PUSH:
|
||||
session = Session()
|
||||
devices_to_unlink = set()
|
||||
for device in devices:
|
||||
try:
|
||||
push_to_end_point(
|
||||
base_url=self.get_base_url(),
|
||||
device={
|
||||
'id': device.id,
|
||||
'endpoint': device.endpoint,
|
||||
'keys': device.keys
|
||||
},
|
||||
payload=json.dumps(payload),
|
||||
vapid_private_key=vapid_private_key,
|
||||
vapid_public_key=vapid_public_key,
|
||||
session=session,
|
||||
)
|
||||
except DeviceUnreachableError:
|
||||
devices_to_unlink.add(device.id)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
# Avoid blocking the whole request just for a notification
|
||||
_logger.error('An error occurred while contacting the endpoint: %s', e)
|
||||
|
||||
# clean up obsolete devices
|
||||
if devices_to_unlink:
|
||||
devices_list = list(devices_to_unlink)
|
||||
self.env['mail.partner.device'].sudo().browse(devices_list).unlink()
|
||||
|
||||
else:
|
||||
self.env['mail.notification.web.push'].sudo().create([{
|
||||
'user_device': device.id,
|
||||
'payload': json.dumps(payload),
|
||||
} for device in devices])
|
||||
self.env.ref('mail.ir_cron_web_push_notification')._trigger()
|
||||
|
||||
def _notify_by_web_push_prepare_payload(self, message, msg_vals=False):
|
||||
""" Returns dictionary containing message information for a browser device.
|
||||
This info will be delivered to a browser device via its recorded endpoint.
|
||||
REM: It is having a limit of 4000 bytes (4kb)
|
||||
"""
|
||||
if msg_vals:
|
||||
author_id = [msg_vals.get('author_id')]
|
||||
author_name = self.env['res.partner'].browse(author_id).name
|
||||
model = msg_vals.get('model')
|
||||
title = msg_vals.get('record_name') or msg_vals.get('subject')
|
||||
res_id = msg_vals.get('res_id')
|
||||
body = msg_vals.get('body')
|
||||
if not model and body:
|
||||
model, res_id = self._extract_model_and_id(msg_vals)
|
||||
else:
|
||||
author_id = [message.author_id.ids]
|
||||
author_name = self.env['res.partner'].browse(author_id).name
|
||||
model = message.model
|
||||
title = message.record_name or message.subject
|
||||
res_id = message.res_id
|
||||
body = message.body
|
||||
|
||||
icon = '/web/static/img/odoo-icon-192x192.png'
|
||||
|
||||
if author_name:
|
||||
title = "%s: %s" % (author_name, title)
|
||||
icon = "/web/image/res.users/%d/avatar_128" % author_id[0]
|
||||
|
||||
payload = {
|
||||
'title': title,
|
||||
'options': {
|
||||
'icon': icon,
|
||||
'data': {
|
||||
'model': model if model else '',
|
||||
'res_id': res_id if res_id else '',
|
||||
}
|
||||
}
|
||||
}
|
||||
payload['options']['body'] = tools.html2plaintext(body)
|
||||
payload['options']['body'] += self._generate_tracking_message(message)
|
||||
|
||||
return payload
|
||||
|
||||
@api.model
|
||||
def _extract_model_and_id(self, msg_vals):
|
||||
"""
|
||||
Return the model and the id when is present in a link (HTML)
|
||||
|
||||
:param msg_vals: see :meth:`._notify_thread_by_web_push`
|
||||
|
||||
:return: a dict empty if no matches and a dict with these keys if match : model and res_id
|
||||
"""
|
||||
regex = r"<a.+model=(?P<model>[\w.]+).+res_id=(?P<id>\d+).+>[\s\w\/\\.]+<\/a>"
|
||||
matches = re.finditer(regex, msg_vals['body'])
|
||||
|
||||
for match in matches:
|
||||
return match['model'], match['id']
|
||||
return None, None
|
||||
|
||||
@api.model
|
||||
def _generate_tracking_message(self, message, return_line='\n'):
|
||||
"""
|
||||
Format the tracking values like in the chatter
|
||||
:param message: current mail.message record
|
||||
:param return_line: type of return line
|
||||
:return: a string with the new text if there is one or more tracking value
|
||||
"""
|
||||
tracking_message = ''
|
||||
if message.subtype_id and message.subtype_id.description:
|
||||
tracking_message = return_line + message.subtype_id.description + return_line
|
||||
|
||||
for value in message.sudo().tracking_value_ids.filtered(lambda tracking: not tracking.field_groups):
|
||||
if value.field_type == 'boolean':
|
||||
old_value = str(bool(value.old_value_integer))
|
||||
new_value = str(bool(value.new_value_integer))
|
||||
else:
|
||||
old_value = value.old_value_char if value.old_value_char else str(value.old_value_integer)
|
||||
new_value = value.new_value_char if value.new_value_char else str(value.new_value_integer)
|
||||
|
||||
tracking_message += value.field_desc + ': ' + old_value
|
||||
if old_value != new_value:
|
||||
tracking_message += ' → ' + new_value
|
||||
tracking_message += return_line
|
||||
|
||||
return tracking_message
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import json
|
||||
import logging as logger
|
||||
|
||||
from odoo import api, fields, models
|
||||
from ..web_push import generate_web_push_vapid_key
|
||||
|
||||
_logger = logger.getLogger(__name__)
|
||||
|
||||
|
||||
class InvalidVapidError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PartnerDevice(models.Model):
|
||||
_name = 'mail.partner.device'
|
||||
_description = 'Partner Web Push Device'
|
||||
|
||||
partner_id = fields.Many2one('res.partner', string='Partner', index=True, required=True,
|
||||
default=lambda self: self.env.user.partner_id)
|
||||
endpoint = fields.Char(string='Browser endpoint', required=True)
|
||||
keys = fields.Char(string='Browser keys', required=True,
|
||||
help=("It's refer to browser keys used by the notification: \n"
|
||||
"- p256dh: It's the subscription public key generated by the browser. The browser will \n"
|
||||
" keep the private key secret and use it for decrypting the payload\n"
|
||||
"- auth: The auth value should be treated as a secret and not shared outside of Odoo"))
|
||||
expiration_time = fields.Datetime(string='Expiration Token Date')
|
||||
|
||||
_sql_constraints = [('endpoint_unique', 'unique(endpoint)', 'The endpoint must be unique !')]
|
||||
|
||||
@api.model
|
||||
def get_web_push_vapid_public_key(self):
|
||||
ir_params_sudo = self.env['ir.config_parameter'].sudo()
|
||||
public_key = 'mail.web_push_vapid_public_key'
|
||||
public_key_value = ir_params_sudo.get_param(public_key)
|
||||
# Regenerate new Keys if public key not present
|
||||
if not public_key_value:
|
||||
self.sudo().search([]).unlink() # Reset all devices (ServiceWorker)
|
||||
private_key_value, public_key_value = generate_web_push_vapid_key()
|
||||
ir_params_sudo.set_param('mail.web_push_vapid_private_key', private_key_value)
|
||||
ir_params_sudo.set_param(public_key, public_key_value)
|
||||
_logger.info("WebPush: missing public key, new VAPID keys generated")
|
||||
return public_key_value
|
||||
|
||||
@api.model
|
||||
def register_devices(self, **kw):
|
||||
sw_vapid_public_key = kw.get('vapid_public_key')
|
||||
valid_sub = self._verify_vapid_public_key(sw_vapid_public_key)
|
||||
if not valid_sub:
|
||||
raise InvalidVapidError("Invalid VAPID public key")
|
||||
endpoint = kw.get('endpoint')
|
||||
browser_keys = kw.get('keys')
|
||||
if not endpoint or not browser_keys:
|
||||
return
|
||||
search_endpoint = kw.get('previousEndpoint', endpoint)
|
||||
user_device = self.sudo().search([('endpoint', '=', search_endpoint)])
|
||||
if user_device:
|
||||
if user_device.partner_id is not self.env.user.partner_id:
|
||||
user_device.write({
|
||||
'endpoint': endpoint,
|
||||
'expiration_time': kw.get('expirationTime'),
|
||||
'keys': json.dumps(browser_keys),
|
||||
'partner_id': self.env.user.partner_id,
|
||||
})
|
||||
else:
|
||||
self.sudo().create([{
|
||||
'endpoint': endpoint,
|
||||
'expiration_time': kw.get('expirationTime'),
|
||||
'keys': json.dumps(browser_keys),
|
||||
'partner_id': self.env.user.partner_id.id,
|
||||
}])
|
||||
|
||||
@api.model
|
||||
def unregister_devices(self, **kw):
|
||||
endpoint = kw.get('endpoint')
|
||||
if not endpoint:
|
||||
return
|
||||
user_device = self.sudo().search([
|
||||
('endpoint', '=', endpoint)
|
||||
])
|
||||
if user_device:
|
||||
user_device.unlink()
|
||||
|
||||
def _verify_vapid_public_key(self, sw_public_key):
|
||||
ir_params_sudo = self.env['ir.config_parameter'].sudo()
|
||||
db_public_key = ir_params_sudo.get_param('mail.web_push_vapid_public_key')
|
||||
return db_public_key == sw_public_key
|
||||
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import logging
|
||||
from requests import Session
|
||||
|
||||
from ..web_push import push_to_end_point, DeviceUnreachableError
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebPush(models.Model):
|
||||
_name = 'mail.notification.web.push'
|
||||
_description = 'Cron data used for web push notification'
|
||||
|
||||
user_device = fields.Many2one('mail.partner.device', string='devices', required=True, ondelete="cascade")
|
||||
payload = fields.Text()
|
||||
|
||||
@api.model
|
||||
def _push_notification_to_endpoint(self, batch_size=50):
|
||||
"""Send to web browser endpoint computed notification"""
|
||||
web_push_notifications_sudo = self.sudo().search_fetch([], ['user_device', 'payload'], limit=batch_size)
|
||||
if not web_push_notifications_sudo:
|
||||
return
|
||||
|
||||
ir_parameter_sudo = self.env['ir.config_parameter'].sudo()
|
||||
vapid_private_key = ir_parameter_sudo.get_param('mail.web_push_vapid_private_key')
|
||||
vapid_public_key = ir_parameter_sudo.get_param('mail.web_push_vapid_public_key')
|
||||
if not vapid_private_key or not vapid_public_key:
|
||||
return
|
||||
|
||||
session = Session()
|
||||
devices_to_unlink = set()
|
||||
|
||||
# process send notif
|
||||
devices = web_push_notifications_sudo.user_device.grouped('id')
|
||||
for web_push_notification_sudo in web_push_notifications_sudo:
|
||||
device = devices.get(web_push_notification_sudo.user_device.id)
|
||||
if device.id in devices_to_unlink:
|
||||
continue
|
||||
try:
|
||||
push_to_end_point(
|
||||
base_url=self.get_base_url(),
|
||||
device={
|
||||
'id': device.id,
|
||||
'endpoint': device.endpoint,
|
||||
'keys': device.keys
|
||||
},
|
||||
payload=web_push_notification_sudo.payload,
|
||||
vapid_private_key=vapid_private_key,
|
||||
vapid_public_key=vapid_public_key,
|
||||
session=session,
|
||||
)
|
||||
except DeviceUnreachableError:
|
||||
devices_to_unlink.add(device.id)
|
||||
|
||||
# clean up notif
|
||||
web_push_notifications_sudo.unlink()
|
||||
|
||||
# clean up obsolete devices
|
||||
if devices_to_unlink:
|
||||
self.env['mail.partner.device'].sudo().browse(devices_to_unlink).unlink()
|
||||
|
||||
# restart the cron if needed
|
||||
if self.search_count([]) > 0:
|
||||
self.env.ref('mail.ir_cron_web_push_notification')._trigger()
|
||||
@@ -69,3 +69,5 @@ ir_actions_report_access_user,ir.actions.report.access.user,base.model_ir_action
|
||||
access_mail_link_preview_admin,mail.link.preview.admin,model_mail_link_preview,base.group_erp_manager,1,1,1,1
|
||||
access_discuss_gif_favorite,discuss.gif.favorite,model_discuss_gif_favorite,base.group_user,1,1,1,1
|
||||
access_discuss_voice_metadata_user,discuss.voice.metadata.user,model_discuss_voice_metadata,,0,0,0,0
|
||||
access_mail_partner_device,access_mail_partner_device,mail.model_mail_partner_device,base.group_user,0,0,0,0
|
||||
access_mail_notification_web_push,access_mail_notification_web_push,mail.model_mail_notification_web_push,base.group_user,0,0,0,0
|
||||
|
||||
|
@@ -35,7 +35,14 @@ export class OutOfFocusService {
|
||||
});
|
||||
}
|
||||
|
||||
notify(message, channel) {
|
||||
async notify(message, channel) {
|
||||
const modelsHandleByPush = ["mail.thread", "discuss.channel"];
|
||||
if (
|
||||
modelsHandleByPush.includes(message.resModel) &&
|
||||
(await this.hasServiceWorkInstalledAndPushSubscriptionActive())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const author = message.author;
|
||||
let notificationTitle;
|
||||
if (!author) {
|
||||
@@ -66,6 +73,18 @@ export class OutOfFocusService {
|
||||
});
|
||||
}
|
||||
|
||||
async hasServiceWorkInstalledAndPushSubscriptionActive() {
|
||||
const registration = await browser.navigator.serviceWorker?.getRegistration();
|
||||
if (registration) {
|
||||
const pushManager = await registration.pushManager;
|
||||
if (pushManager) {
|
||||
const subscription = await pushManager.getSubscription();
|
||||
return !!subscription;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification, preferably a native one. If native
|
||||
* notifications are disable or unavailable on the current
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* eslint-env serviceworker */
|
||||
/* eslint-disable no-restricted-globals */
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
if (event.notification.data) {
|
||||
const { action, model, res_id } = event.notification.data;
|
||||
if (model === "discuss.channel") {
|
||||
clients.openWindow(`/web#action=${action}&active_id=${res_id}`);
|
||||
} else {
|
||||
clients.openWindow(`/web#model=${model}&id=${res_id}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
self.addEventListener("push", (event) => {
|
||||
const notification = event.data.json();
|
||||
self.registration.showNotification(notification.title, notification.options || {});
|
||||
});
|
||||
self.addEventListener("pushsubscriptionchange", async (event) => {
|
||||
const subscription = await self.registration.pushManager.subscribe(
|
||||
event.oldSubscription.options
|
||||
);
|
||||
await fetch("/web/dataset/call_kw/mail.partner.device/register_devices", {
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: 1,
|
||||
jsonrpc: "2.0",
|
||||
method: "call",
|
||||
params: {
|
||||
model: "mail.partner.device",
|
||||
method: "register_devices",
|
||||
args: [],
|
||||
kwargs: {
|
||||
...subscription.toJSON(),
|
||||
previousEndpoint: event.oldSubscription.endpoint,
|
||||
},
|
||||
context: {},
|
||||
},
|
||||
}),
|
||||
method: "POST",
|
||||
mode: "cors",
|
||||
credentials: "include",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { WebClient } from "@web/webclient/webclient";
|
||||
import { onWillDestroy } from "@odoo/owl";
|
||||
|
||||
const USER_DEVICES_MODEL = "mail.partner.device";
|
||||
|
||||
patch(WebClient.prototype, {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
this.rpc = useService("rpc");
|
||||
this.orm = useService("orm");
|
||||
if (this._canSendNativeNotification) {
|
||||
this._subscribePush();
|
||||
}
|
||||
if (browser.navigator.permissions) {
|
||||
let notificationPerm;
|
||||
const onPermissionChange = () => {
|
||||
if (this._canSendNativeNotification) {
|
||||
this._subscribePush();
|
||||
} else {
|
||||
this._unsubscribePush();
|
||||
}
|
||||
};
|
||||
browser.navigator.permissions.query({ name: "notifications" }).then((perm) => {
|
||||
notificationPerm = perm;
|
||||
notificationPerm.addEventListener("change", onPermissionChange);
|
||||
});
|
||||
onWillDestroy(() => {
|
||||
notificationPerm?.removeEventListener("change", onPermissionChange);
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
get _canSendNativeNotification() {
|
||||
return browser.Notification?.permission === "granted";
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribe device from push notification
|
||||
*
|
||||
* @private
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async _subscribePush(numberTry = 1) {
|
||||
const pushManager = await this.pushManager();
|
||||
if (!pushManager) {
|
||||
return;
|
||||
}
|
||||
let subscription = await pushManager.getSubscription();
|
||||
const previousEndpoint = browser.localStorage.getItem(`${USER_DEVICES_MODEL}_endpoint`);
|
||||
// This may occur if the subscription was refreshed by the browser,
|
||||
// but it may also happen if the subscription has been revoked or lost.
|
||||
if (!subscription) {
|
||||
subscription = await pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: await this._getApplicationServerKey(),
|
||||
});
|
||||
browser.localStorage.setItem(`${USER_DEVICES_MODEL}_endpoint`, subscription.endpoint);
|
||||
}
|
||||
const kwargs = subscription.toJSON();
|
||||
if (previousEndpoint && subscription.endpoint !== previousEndpoint) {
|
||||
kwargs.previous_endpoint = previousEndpoint;
|
||||
}
|
||||
try {
|
||||
kwargs.vapid_public_key = this._arrayBufferToBase64(
|
||||
subscription.options.applicationServerKey
|
||||
);
|
||||
await this.orm.call(USER_DEVICES_MODEL, "register_devices", [], kwargs);
|
||||
} catch (e) {
|
||||
const invalidVapidErrorClass =
|
||||
"odoo.addons.mail.models.partner_devices.InvalidVapidError";
|
||||
const warningMessage = "Error sending subscription information to the server";
|
||||
if (e.data?.name === invalidVapidErrorClass) {
|
||||
const MAX_TRIES = 2;
|
||||
if (numberTry < MAX_TRIES) {
|
||||
await subscription.unsubscribe();
|
||||
this._subscribePush(numberTry + 1);
|
||||
} else {
|
||||
console.warn(warningMessage);
|
||||
}
|
||||
} else {
|
||||
console.warn(`${warningMessage}: ${e.data?.debug}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Unsubscribe device from push notification
|
||||
*
|
||||
* @private
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async _unsubscribePush() {
|
||||
const pushManager = await this.pushManager();
|
||||
if (!pushManager) {
|
||||
return;
|
||||
}
|
||||
const subscription = await pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
await this.orm.call(USER_DEVICES_MODEL, "unregister_devices", [], {
|
||||
endpoint: subscription.endpoint,
|
||||
});
|
||||
await subscription.unsubscribe();
|
||||
browser.localStorage.removeItem(`${USER_DEVICES_MODEL}_endpoint`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve the PushManager interface of the Push API provides a way to receive notifications from third-party
|
||||
* servers as well as request URLs for push notifications.
|
||||
*
|
||||
* @return {Promise<PushManager>}
|
||||
*/
|
||||
async pushManager() {
|
||||
const registration = await browser.navigator.serviceWorker?.getRegistration();
|
||||
return registration?.pushManager;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* The Application Server Key is need to be an Uint8Array.
|
||||
* This format is used when the exchanging secret key between client and server.
|
||||
* This base64 to Uint8Array implementation is inspired by https://github.com/gbhasha/base64-to-uint8array
|
||||
*
|
||||
* @private
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
async _getApplicationServerKey() {
|
||||
const vapid_public_key_base64 = await this.orm.call(
|
||||
USER_DEVICES_MODEL,
|
||||
"get_web_push_vapid_public_key"
|
||||
);
|
||||
const padding = "=".repeat((4 - (vapid_public_key_base64.length % 4)) % 4);
|
||||
const base64 = (vapid_public_key_base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const rawData = atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert an ArrayBuffer to a base64 string without padding
|
||||
* @param buffer {ArrayBuffer}
|
||||
* @return {string}
|
||||
* @private
|
||||
*/
|
||||
_arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging as logger
|
||||
import os
|
||||
import struct
|
||||
import textwrap
|
||||
import time
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, utils
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
from urllib.parse import urlparse
|
||||
|
||||
MAX_PAYLOAD_SIZE = 4096
|
||||
|
||||
_logger = logger.getLogger(__name__)
|
||||
|
||||
def _base64_decode_with_padding(value):
|
||||
return base64.urlsafe_b64decode(value + '==')
|
||||
|
||||
def generate_web_push_vapid_key():
|
||||
"""
|
||||
Generate the VAPID (Voluntary Application Server Identification) used for the Web Push
|
||||
This function generates a signing key pair usable with the Elliptic Curve Digital
|
||||
Signature Algorithm (ECDSA) over the P-256 curve.
|
||||
These keys will be used during communication with the endpoint/browser
|
||||
https://www.rfc-editor.org/rfc/rfc8292
|
||||
"""
|
||||
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
private_int = private_key.private_numbers().private_value
|
||||
private = private_int.to_bytes(32, 'big')
|
||||
private_string = base64.urlsafe_b64encode(private).decode('ascii').strip('=')
|
||||
|
||||
public_key = private_key.public_key()
|
||||
public = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
public_string = base64.urlsafe_b64encode(public).decode('ascii').strip('=')
|
||||
return private_string, public_string
|
||||
|
||||
def _generate_jwt(endpoint, base_url, vapid_private_key):
|
||||
"""
|
||||
JWT are a pair of JSON objects, turned into base64 strings, and signed with the private ECDH key
|
||||
https://www.rfc-editor.org/rfc/rfc7519
|
||||
https://www.rfc-editor.org/rfc/rfc8291
|
||||
:param endpoint: the browser endpoint
|
||||
:param base_url: the base url
|
||||
:param vapid_private_key: the private ECDH key generate at mail_entreprise install
|
||||
:return:
|
||||
"""
|
||||
url = urlparse(endpoint)
|
||||
|
||||
jwt_info = base64.urlsafe_b64encode(json.dumps({
|
||||
'typ': 'JWT',
|
||||
'alg': 'ES256'
|
||||
}).encode())
|
||||
|
||||
# The expiration is a timestamp in seconds and must be no longer 12 hours.
|
||||
token_validity = 12 * 60 * 60
|
||||
|
||||
jwt_data = base64.urlsafe_b64encode(json.dumps({
|
||||
# aud: The “Audience” is a JWT construct that indicates the recipient scheme and host
|
||||
# e.g. for an endpoint like https://updates.push.services.mozilla.com/wpush/v2/gAAAAABY...,
|
||||
# the “aud” would be https://updates.push.services.mozilla.com
|
||||
'aud': '{}://{}'.format(url.scheme, url.netloc),
|
||||
# sub: the sub value needs to be either a URL address. This is so that if a push service needed to reach out
|
||||
# to sender, it can find contact information from the JWT.
|
||||
'sub': base_url,
|
||||
# exp: It's the expiration of the JWT, this prevents snoopers from being able to re-use a JWT if they intercept it.
|
||||
'exp': int(time.time()) + token_validity
|
||||
}).encode())
|
||||
|
||||
unsigned_token = '{}.{}'.format(jwt_info.decode().strip('='), jwt_data.decode().strip('='))
|
||||
|
||||
# Retrieve the private key using a P256 elliptic curve
|
||||
vapid_private_key_decoded = _base64_decode_with_padding(vapid_private_key)
|
||||
private_key = ec.derive_private_key(int(binascii.hexlify(vapid_private_key_decoded), 16), ec.SECP256R1(), default_backend())
|
||||
|
||||
# sign with ECDSA SHA-256
|
||||
signature = private_key.sign(unsigned_token.encode(), ec.ECDSA(hashes.SHA256()))
|
||||
(r, s) = utils.decode_dss_signature(signature)
|
||||
sig = base64.urlsafe_b64encode(r.to_bytes(32, 'big') + s.to_bytes(32, 'big'))
|
||||
|
||||
return '{}.{}'.format(unsigned_token, sig.decode().strip('='))
|
||||
|
||||
def _iv(base, counter):
|
||||
mask = int.from_bytes(base[4:], 'big')
|
||||
return base[:4] + (counter ^ mask).to_bytes(8, 'big')
|
||||
|
||||
def _derive_key(salt, private_key, device):
|
||||
# browser keys
|
||||
device_keys = json.loads(device["keys"])
|
||||
p256dh = _base64_decode_with_padding(device_keys.get('p256dh'))
|
||||
auth = _base64_decode_with_padding(device_keys.get('auth'))
|
||||
|
||||
# generate a public key derived from the browser public key
|
||||
pub_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), p256dh)
|
||||
sender_pub_key = private_key.public_key().public_bytes(
|
||||
Encoding.X962, PublicFormat.UncompressedPoint
|
||||
)
|
||||
|
||||
context = b"WebPush: info\x00" + p256dh + sender_pub_key
|
||||
key_info = b"Content-Encoding: aes128gcm\x00"
|
||||
nonce_info = b"Content-Encoding: nonce\x00"
|
||||
|
||||
# Create the 3 HKDF keys needed to encrypt the message (auth, key, nonce)
|
||||
hkdf_auth = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=auth,
|
||||
info=context,
|
||||
backend=default_backend(),
|
||||
)
|
||||
hkdf_key = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=16,
|
||||
salt=salt,
|
||||
info=key_info,
|
||||
backend=default_backend(),
|
||||
)
|
||||
hkdf_nonce = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=12,
|
||||
salt=salt,
|
||||
info=nonce_info,
|
||||
backend=default_backend(),
|
||||
)
|
||||
secret = hkdf_auth.derive(private_key.exchange(ec.ECDH(), pub_key))
|
||||
return hkdf_key.derive(secret), hkdf_nonce.derive(secret)
|
||||
|
||||
def _encrypt_payload(content, device, record_size=MAX_PAYLOAD_SIZE):
|
||||
"""
|
||||
Encrypt a payload for Push Notification Endpoint using AES128GCM
|
||||
|
||||
https://www.rfc-editor.org/rfc/rfc7516
|
||||
https://www.rfc-editor.org/rfc/rfc8188
|
||||
:param content: the unencrypted payload
|
||||
:param device: the web push user browser information
|
||||
:param record_size: record size must be bigger than 18
|
||||
:return: the encrypted payload
|
||||
"""
|
||||
# The private_key is an ephemeral ECDH key used only for a transaction
|
||||
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
salt = os.urandom(16)
|
||||
# generate key
|
||||
(key, nonce) = _derive_key(salt=salt, private_key=private_key, device=device)
|
||||
# AEAD_AES_128_GCM produces ciphertext 16 octets longer than its input plaintext.
|
||||
# Therefore, the unencrypted content of each record is shorter than the record size by 16 octets.
|
||||
# Valid records always contain at least a padding delimiter octet and a 16-octet authentication tag.
|
||||
overhead = 1 + 16
|
||||
chunk_size = record_size - overhead
|
||||
|
||||
body = b""
|
||||
end = len(content)
|
||||
aesgcm = AESGCM(key)
|
||||
for i in range(0, end, chunk_size):
|
||||
padding = b"\x02" if (i + chunk_size) >= end else b"\x01"
|
||||
body += aesgcm.encrypt(nonce, content[i: i + chunk_size] + padding, None)
|
||||
|
||||
sender_public_key = private_key.public_key().public_bytes(
|
||||
Encoding.X962, PublicFormat.UncompressedPoint
|
||||
)
|
||||
|
||||
# +-----------+-----------------+---------------------------+-------------------------------------------+
|
||||
# | salt (16) | record_size (4) | sender_public_key.len (1) | sender_public_key (sender_public_key.len) |
|
||||
# +-----------+-----------------+---------------------------+-------------------------------------------+
|
||||
header = struct.pack("!16sLB", salt, record_size, len(sender_public_key))
|
||||
header += sender_public_key
|
||||
return header + body
|
||||
|
||||
def push_to_end_point(base_url, device, payload, vapid_private_key, vapid_public_key, session):
|
||||
endpoint = device["endpoint"]
|
||||
jwt = _generate_jwt(endpoint, base_url, vapid_private_key)
|
||||
body_payload = payload.encode()
|
||||
payload = _encrypt_payload(body_payload, device)
|
||||
headers = {
|
||||
# Authorization header field contains these parameters:
|
||||
# - "t" is the JWT;
|
||||
# - "k" the base64url-encoded key that signed that token.
|
||||
'Authorization': 'vapid t={}, k={}'.format(jwt, vapid_public_key),
|
||||
'Content-Encoding': 'aes128gcm',
|
||||
'TTL': '0',
|
||||
}
|
||||
|
||||
response = session.post(endpoint, headers=headers, data=payload, timeout=5)
|
||||
if response.status_code == 201:
|
||||
_logger.debug('Sent push notification %s', endpoint)
|
||||
else:
|
||||
error_message_shorten = textwrap.shorten(response.text, 100)
|
||||
_logger.warning('Failed push notification %s %d - %s',
|
||||
endpoint, response.status_code, error_message_shorten)
|
||||
|
||||
# Invalid subscription
|
||||
if response.status_code == 404 or response.status_code == 410:
|
||||
raise DeviceUnreachableError("Device Unreachable")
|
||||
|
||||
|
||||
class DeviceUnreachableError(Exception):
|
||||
pass
|
||||
@@ -8,3 +8,4 @@ from . import test_mass_mailing
|
||||
from . import test_portal
|
||||
from . import test_rating
|
||||
from . import test_res_users
|
||||
from . import test_web_push
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import json
|
||||
import socket
|
||||
|
||||
import odoo
|
||||
from odoo.tools.misc import mute_logger
|
||||
from odoo.addons.mail.models.partner_devices import InvalidVapidError
|
||||
from odoo.addons.mail.tests.common import mail_new_test_user
|
||||
from odoo.addons.sms.tests.common import SMSCommon
|
||||
from odoo.addons.test_mail.data.test_mail_data import MAIL_TEMPLATE
|
||||
from odoo.tests import tagged
|
||||
from markupsafe import Markup
|
||||
from unittest.mock import patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestWebPushNotification(SMSCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
channel = cls.env['discuss.channel'].with_context(cls._test_context)
|
||||
|
||||
cls.user_email = cls.user_employee
|
||||
cls.user_email.notification_type = 'email'
|
||||
|
||||
cls.user_inbox = mail_new_test_user(
|
||||
cls.env, login='user_inbox', groups='base.group_user', name='User Inbox',
|
||||
notification_type='inbox'
|
||||
)
|
||||
|
||||
cls.record_simple = cls.env['mail.test.simple'].with_context(cls._test_context).create({
|
||||
'name': 'Test',
|
||||
'email_from': 'ignasse@example.com'
|
||||
})
|
||||
cls.record_simple.message_subscribe(partner_ids=[
|
||||
cls.user_email.partner_id.id,
|
||||
cls.user_inbox.partner_id.id,
|
||||
])
|
||||
|
||||
cls.direct_message_channel = channel.with_user(cls.user_email).create({
|
||||
'channel_partner_ids': [
|
||||
(4, cls.user_email.partner_id.id),
|
||||
(4, cls.user_inbox.partner_id.id),
|
||||
],
|
||||
'channel_type': 'chat',
|
||||
'name': 'Direct Message',
|
||||
})
|
||||
|
||||
cls.group_channel = cls.env['discuss.channel'].browse(cls.env['discuss.channel'].channel_create(name='Channel', group_id=None)['id'])
|
||||
cls.group_channel.add_members((cls.user_email + cls.user_inbox).partner_id.ids)
|
||||
|
||||
cls.env['mail.partner.device'].get_web_push_vapid_public_key()
|
||||
|
||||
cls.vapid_public_key = cls.env['mail.partner.device'].get_web_push_vapid_public_key()
|
||||
|
||||
cls.env['mail.partner.device'].sudo().create([{
|
||||
'endpoint': 'https://test.odoo.com/webpush/user1',
|
||||
'expiration_time': None,
|
||||
'keys': json.dumps({
|
||||
'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A',
|
||||
'auth': 'DJFdtAgZwrT6yYkUMgUqow'
|
||||
}),
|
||||
'partner_id': cls.user_email.partner_id.id,
|
||||
}])
|
||||
|
||||
cls.env['mail.partner.device'].sudo().create([{
|
||||
'endpoint': 'https://test.odoo.com/webpush/user2',
|
||||
'expiration_time': None,
|
||||
'keys': json.dumps({
|
||||
'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A',
|
||||
'auth': 'DJFdtAgZwrT6yYkUMgUqow'
|
||||
}),
|
||||
'partner_id': cls.user_inbox.partner_id.id,
|
||||
}])
|
||||
|
||||
def _trigger_cron_job(self):
|
||||
self.env.ref('mail.ir_cron_web_push_notification').method_direct_trigger()
|
||||
|
||||
def _assert_notification_count_for_cron(self, number_of_notification):
|
||||
notification_count = self.env['mail.notification.web.push'].search_count([])
|
||||
self.assertEqual(notification_count, number_of_notification)
|
||||
|
||||
@patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point')
|
||||
def test_push_notifications(self, push_to_end_point):
|
||||
# Test No Inbox Condition
|
||||
self.record_simple.with_user(self.user_inbox).message_notify(
|
||||
partner_ids=self.user_email.partner_id.ids,
|
||||
body='Test',
|
||||
subject='Test Activity',
|
||||
record_name=self.record_simple._name,
|
||||
)
|
||||
|
||||
self._assert_notification_count_for_cron(0)
|
||||
push_to_end_point.assert_not_called()
|
||||
|
||||
|
||||
self.record_simple.with_user(self.user_email).message_notify(
|
||||
partner_ids=self.user_inbox.partner_id.ids,
|
||||
body='Test message send via Web Push',
|
||||
subject='Test Activity',
|
||||
record_name=self.record_simple._name,
|
||||
)
|
||||
|
||||
self._assert_notification_count_for_cron(0)
|
||||
push_to_end_point.assert_called_once()
|
||||
payload_value = json.loads(push_to_end_point.call_args.kwargs['payload'])
|
||||
self.assertIn(self.record_simple._name, payload_value['title'])
|
||||
self.assertIn(self.user_email.name, payload_value['title'])
|
||||
self.assertEqual(payload_value['options']['body'], 'Test message send via Web Push')
|
||||
self.assertEqual(payload_value['options']['data']['res_id'], self.record_simple.id)
|
||||
self.assertEqual(payload_value['options']['data']['model'], self.record_simple._name)
|
||||
self.assertIn('icon', payload_value['options'])
|
||||
self.assertEqual(push_to_end_point.call_args.kwargs['device']['endpoint'], 'https://test.odoo.com/webpush/user2')
|
||||
self.assertIn('vapid_private_key', push_to_end_point.call_args.kwargs)
|
||||
self.assertIn('vapid_public_key', push_to_end_point.call_args.kwargs)
|
||||
|
||||
# Reset the mock counter
|
||||
push_to_end_point.reset_mock()
|
||||
|
||||
# Test Tracking Message
|
||||
mail_test_ticket = self.env['mail.test.ticket'].with_context(self._test_context)
|
||||
record_full = mail_test_ticket.with_user(self.user_email).create({
|
||||
'name': 'Test',
|
||||
})
|
||||
record_full = record_full.with_context(mail_notrack=False)
|
||||
|
||||
container = self.env['mail.test.container'].create({'name': 'Container'})
|
||||
record_full.message_subscribe(
|
||||
partner_ids=[self.user_email.partner_id.id],
|
||||
subtype_ids=[self.env.ref('test_mail.st_mail_test_ticket_container_upd').id],
|
||||
)
|
||||
record_full.write({
|
||||
'name': 'Test2',
|
||||
'email_from': 'noone@example.com',
|
||||
'container_id': container.id,
|
||||
})
|
||||
self.flush_tracking()
|
||||
self._assert_notification_count_for_cron(0)
|
||||
push_to_end_point.assert_not_called()
|
||||
|
||||
container2 = self.env['mail.test.container'].create({'name': 'Container Two'})
|
||||
record_full.message_subscribe(
|
||||
partner_ids=[self.user_inbox.partner_id.id],
|
||||
subtype_ids=[self.env.ref('test_mail.st_mail_test_ticket_container_upd').id],
|
||||
)
|
||||
record_full.write({
|
||||
'name': 'Test3',
|
||||
'email_from': 'noone@example.com',
|
||||
'container_id': container2.id,
|
||||
})
|
||||
self.flush_tracking()
|
||||
self._assert_notification_count_for_cron(0)
|
||||
push_to_end_point.assert_called_once()
|
||||
payload_value = json.loads(push_to_end_point.call_args.kwargs['payload'])
|
||||
# As the tracking values are converted to text. We check the '→' added by ocn_client.
|
||||
self.assertIn('→', payload_value['options']['body'], 'No Tracking Message found')
|
||||
|
||||
@patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point')
|
||||
def test_push_notifications_all_type(self, push_to_end_point):
|
||||
# Test Direct Message
|
||||
self.direct_message_channel.with_user(self.user_email).message_post(
|
||||
body='Test', message_type='comment', subtype_xmlid='mail.mt_comment')
|
||||
|
||||
self._assert_notification_count_for_cron(0)
|
||||
push_to_end_point.assert_called_once()
|
||||
|
||||
|
||||
# Reset the mock counter
|
||||
push_to_end_point.reset_mock()
|
||||
|
||||
# Test Following Message
|
||||
self.record_simple.with_user(self.user_email).message_post(
|
||||
body='Test', message_type='comment', subtype_xmlid='mail.mt_comment'
|
||||
)
|
||||
self._assert_notification_count_for_cron(0)
|
||||
push_to_end_point.assert_called_once()
|
||||
|
||||
# Reset the mock counter
|
||||
push_to_end_point.reset_mock()
|
||||
|
||||
# Test Channel Message
|
||||
self.group_channel.with_user(self.user_email).message_post(
|
||||
body='Test', partner_ids=self.user_inbox.partner_id.ids,
|
||||
message_type='comment', subtype_xmlid='mail.mt_comment')
|
||||
self._assert_notification_count_for_cron(0)
|
||||
push_to_end_point.assert_called_once()
|
||||
|
||||
# Reset the mock counter
|
||||
push_to_end_point.reset_mock()
|
||||
|
||||
# Test AtMention Message
|
||||
self.record_simple.with_user(self.user_email).message_post(
|
||||
body=Markup('<a href="/web" data-oe-id="%i" data-oe-model="res.partner" >@user</a>') %
|
||||
self.user_inbox.partner_id.id,
|
||||
message_type='comment', subtype_xmlid='mail.mt_comment'
|
||||
)
|
||||
self._assert_notification_count_for_cron(0)
|
||||
push_to_end_point.assert_called_once()
|
||||
|
||||
@patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point')
|
||||
def test_push_notifications_mail_replay(self, push_to_end_point):
|
||||
test_record = self.env['mail.test.gateway'].with_context(self._test_context).create({
|
||||
'name': 'Test',
|
||||
'email_from': 'ignasse@example.com',
|
||||
})
|
||||
test_record.message_subscribe(partner_ids=[self.user_inbox.partner_id.id])
|
||||
|
||||
fake_email = self.env['mail.message'].create({
|
||||
'model': 'mail.test.gateway',
|
||||
'res_id': test_record.id,
|
||||
'subject': 'Public Discussion',
|
||||
'message_type': 'email',
|
||||
'subtype_id': self.env.ref('mail.mt_comment').id,
|
||||
'author_id': self.user_email.partner_id.id,
|
||||
'message_id': '<123456-openerp-%s-mail.test.gateway@%s>' % (test_record.id, socket.gethostname()),
|
||||
})
|
||||
|
||||
self.format_and_process(
|
||||
MAIL_TEMPLATE, self.user_email.email_formatted,
|
||||
self.user_inbox.email_formatted,
|
||||
subject='Test Subject Reply By mail',
|
||||
extra='In-Reply-To:\r\n\t%s\n' % fake_email.message_id,
|
||||
)
|
||||
self._assert_notification_count_for_cron(0)
|
||||
push_to_end_point.assert_called_once()
|
||||
payload_value = json.loads(push_to_end_point.call_args.kwargs['payload'])
|
||||
self.assertIn(self.user_email.name, payload_value['title'])
|
||||
self.assertIn(
|
||||
'Please call me as soon as possible this afternoon!\n\n--\nSylvie',
|
||||
payload_value['options']['body'],
|
||||
'The body must contain the text send by mail'
|
||||
)
|
||||
|
||||
@patch.object(odoo.addons.mail.models.web_push, 'push_to_end_point')
|
||||
def test_push_notifications_cron(self, push_to_end_point):
|
||||
# Add 4 more devices to force sending via cron queue
|
||||
for index in range(10, 14):
|
||||
self.env['mail.partner.device'].sudo().create([{
|
||||
'endpoint': 'https://test.odoo.com/webpush/user%d' % index,
|
||||
'expiration_time': None,
|
||||
'keys': json.dumps({
|
||||
'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A',
|
||||
'auth': 'DJFdtAgZwrT6yYkUMgUqow'
|
||||
}),
|
||||
'partner_id': self.user_inbox.partner_id.id,
|
||||
}])
|
||||
|
||||
self.record_simple.with_user(self.user_email).message_notify(
|
||||
partner_ids=self.user_inbox.partner_id.ids,
|
||||
body='Test message send via Web Push',
|
||||
subject='Test Activity',
|
||||
record_name=self.record_simple._name,
|
||||
)
|
||||
|
||||
self._assert_notification_count_for_cron(5)
|
||||
# Force the execution of the cron
|
||||
self._trigger_cron_job()
|
||||
self.assertEqual(push_to_end_point.call_count, 5)
|
||||
|
||||
@patch.object(odoo.addons.mail.models.mail_thread.Session, 'post',
|
||||
return_value=SimpleNamespace(**{'status_code': 201, 'text': 'Ok'}))
|
||||
def test_push_notifications_encryption_simple(self, post):
|
||||
"""
|
||||
Test to see if all parameters sent to the endpoint are present.
|
||||
This test doesn't test if the cryptographic values are correct.
|
||||
"""
|
||||
self.record_simple.with_user(self.user_email).message_notify(
|
||||
partner_ids=self.user_inbox.partner_id.ids,
|
||||
body='Test message send via Web Push',
|
||||
subject='Test Activity',
|
||||
record_name=self.record_simple._name,
|
||||
)
|
||||
|
||||
self._assert_notification_count_for_cron(0)
|
||||
post.assert_called_once()
|
||||
self.assertEqual(post.call_args.args[0], 'https://test.odoo.com/webpush/user2')
|
||||
self.assertIn('headers', post.call_args.kwargs)
|
||||
self.assertIn('vapid', post.call_args.kwargs['headers']['Authorization'])
|
||||
self.assertIn('t=', post.call_args.kwargs['headers']['Authorization'])
|
||||
self.assertIn('k=', post.call_args.kwargs['headers']['Authorization'])
|
||||
self.assertEqual('aes128gcm', post.call_args.kwargs['headers']['Content-Encoding'])
|
||||
self.assertEqual('0', post.call_args.kwargs['headers']['TTL'])
|
||||
self.assertIn('data', post.call_args.kwargs)
|
||||
self.assertIn('timeout', post.call_args.kwargs)
|
||||
|
||||
@patch.object(odoo.addons.mail.models.mail_thread.Session, 'post',
|
||||
return_value=SimpleNamespace(**{'status_code': 404, 'text': 'Device Unreachable'}))
|
||||
def test_push_notifications_device_unreachable(self, post):
|
||||
with mute_logger('odoo.addons.mail.web_push'):
|
||||
self.record_simple.with_user(self.user_email).message_notify(
|
||||
partner_ids=self.user_inbox.partner_id.ids,
|
||||
body='Test message send via Web Push',
|
||||
subject='Test Activity',
|
||||
record_name=self.record_simple._name,
|
||||
)
|
||||
|
||||
self._assert_notification_count_for_cron(0)
|
||||
post.assert_called_once()
|
||||
# Test that the unreachable device is deleted from the DB
|
||||
notification_count = self.env['mail.partner.device'].search_count([('endpoint', '=', 'https://test.odoo.com/webpush/user2')])
|
||||
self.assertEqual(notification_count, 0)
|
||||
|
||||
|
||||
def test_push_notification_regenerate_vpaid_keys(self):
|
||||
ir_params_sudo = self.env['ir.config_parameter'].sudo()
|
||||
ir_params_sudo.search([('key', 'in', [
|
||||
'mail.web_push_vapid_private_key',
|
||||
'mail.web_push_vapid_public_key'
|
||||
])]).unlink()
|
||||
new_vapid_public_key = self.env['mail.partner.device'].get_web_push_vapid_public_key()
|
||||
self.assertNotEqual(self.vapid_public_key, new_vapid_public_key)
|
||||
with self.assertRaises(InvalidVapidError):
|
||||
self.env['mail.partner.device'].register_devices(
|
||||
endpoint='https://test.odoo.com/webpush/user1',
|
||||
expiration_time=None,
|
||||
keys=json.dumps({
|
||||
'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A',
|
||||
'auth': 'DJFdtAgZwrT6yYkUMgUqow'
|
||||
}),
|
||||
partner_id=self.user_email.partner_id.id,
|
||||
vapid_public_key=self.vapid_public_key,
|
||||
)
|
||||
@@ -18,6 +18,7 @@ This module provides the core of the Odoo Web Client.
|
||||
'views/webclient_templates.xml',
|
||||
'views/report_templates.xml',
|
||||
'views/base_document_layout_views.xml',
|
||||
'views/partner_view.xml',
|
||||
'views/speedscope_template.xml',
|
||||
'views/neutralize_views.xml',
|
||||
'data/ir_attachment.xml',
|
||||
|
||||
@@ -11,7 +11,9 @@ from . import pivot
|
||||
from . import profiling
|
||||
from . import report
|
||||
from . import session
|
||||
from . import vcard
|
||||
from . import view
|
||||
from . import webclient
|
||||
from . import webmanifest
|
||||
|
||||
from . import main # deprecated
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import odoo.http as http
|
||||
|
||||
from odoo.http import request, content_disposition
|
||||
|
||||
|
||||
class Partner(http.Controller):
|
||||
|
||||
@http.route('/web/partner/<model("res.partner"):partner>/vcard', type='http', auth="user")
|
||||
def download_vcard(self, partner, **kwargs):
|
||||
content = partner._get_vcard_file()
|
||||
if not content:
|
||||
return request.not_found()
|
||||
return request.make_response(content, [
|
||||
('Content-Type', 'text/vcard'),
|
||||
('Content-Length', len(content)),
|
||||
('Content-Disposition', content_disposition('%s.vcf' % partner.name))
|
||||
])
|
||||
@@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
|
||||
from odoo import http
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.http import request
|
||||
from odoo.tools import ustr, file_open
|
||||
|
||||
|
||||
class WebManifest(http.Controller):
|
||||
|
||||
def _get_shortcuts(self):
|
||||
module_names = ['mail', 'crm', 'project', 'project_todo']
|
||||
try:
|
||||
module_ids = request.env['ir.module.module'].search([('state', '=', 'installed'), ('name', 'in', module_names)]) \
|
||||
.sorted(key=lambda r: module_names.index(r["name"]))
|
||||
except AccessError:
|
||||
return []
|
||||
menu_roots = request.env['ir.ui.menu'].get_user_roots()
|
||||
datas = request.env['ir.model.data'].sudo().search([('model', '=', 'ir.ui.menu'),
|
||||
('res_id', 'in', menu_roots.ids),
|
||||
('module', 'in', module_names)])
|
||||
shortcuts = []
|
||||
for module in module_ids:
|
||||
data = datas.filtered(lambda res: res.module == module.name)
|
||||
if data:
|
||||
shortcuts.append({
|
||||
'name': module.display_name,
|
||||
'url': '/web#menu_id=%s' % data.mapped('res_id')[0],
|
||||
'description': module.summary,
|
||||
'icons': [{
|
||||
'sizes': '100x100',
|
||||
'src': module.icon,
|
||||
'type': mimetypes.guess_type(module.icon)[0] or 'image/png'
|
||||
}]
|
||||
})
|
||||
return shortcuts
|
||||
|
||||
@http.route('/web/manifest.webmanifest', type='http', auth='public', methods=['GET'])
|
||||
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.
|
||||
"""
|
||||
web_app_name = request.env['ir.config_parameter'].sudo().get_param('web.web_app_name', 'Odoo')
|
||||
manifest = {
|
||||
'name': web_app_name,
|
||||
'scope': '/web',
|
||||
'start_url': '/web',
|
||||
'display': 'standalone',
|
||||
'background_color': '#714B67',
|
||||
'theme_color': '#714B67',
|
||||
'prefer_related_applications': False,
|
||||
}
|
||||
icon_sizes = ['192x192', '512x512']
|
||||
manifest['icons'] = [{
|
||||
'src': '/web/static/img/odoo-icon-%s.png' % size,
|
||||
'sizes': size,
|
||||
'type': 'image/png',
|
||||
} for size in icon_sizes]
|
||||
manifest['shortcuts'] = self._get_shortcuts()
|
||||
body = json.dumps(manifest, default=ustr)
|
||||
response = request.make_response(body, [
|
||||
('Content-Type', 'application/manifest+json'),
|
||||
])
|
||||
return response
|
||||
|
||||
@http.route('/web/service-worker.js', type='http', auth='public', methods=['GET'])
|
||||
def service_worker(self):
|
||||
response = request.make_response(
|
||||
self._get_service_worker_content(),
|
||||
[
|
||||
('Content-Type', 'text/javascript'),
|
||||
('Service-Worker-Allowed', '/web'),
|
||||
]
|
||||
)
|
||||
return response
|
||||
|
||||
def _get_service_worker_content(self):
|
||||
""" Returns a ServiceWorker javascript file scoped for the backend (aka. '/web')
|
||||
"""
|
||||
with file_open('web/static/src/service_worker.js') as f:
|
||||
body = f.read()
|
||||
return body
|
||||
|
||||
def _icon_path(self):
|
||||
return 'web/static/img/odoo-icon-192x192.png'
|
||||
|
||||
@http.route('/web/offline', type='http', auth='public', methods=['GET'])
|
||||
def offline(self):
|
||||
""" Returns the offline page delivered by the service worker """
|
||||
return request.render('web.webclient_offline', {
|
||||
'odoo_icon': base64.b64encode(file_open(self._icon_path(), 'rb').read())
|
||||
})
|
||||
@@ -7,4 +7,6 @@ from . import ir_model
|
||||
from . import ir_ui_menu
|
||||
from . import models
|
||||
from . import base_document_layout
|
||||
from . import res_config_settings
|
||||
from . import res_partner
|
||||
from . import res_users
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
web_app_name = fields.Char('Web App Name', config_parameter='web.web_app_name')
|
||||
@@ -0,0 +1,79 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
from base64 import b64decode
|
||||
|
||||
from odoo import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import vobject
|
||||
except ImportError:
|
||||
_logger.warning("`vobject` Python module not found, vcard file generation disabled. Consider installing this module if you want to generate vcard files")
|
||||
vobject = None
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
def _build_vcard(self):
|
||||
""" Build the partner's vCard.
|
||||
:returns a vobject.vCard object
|
||||
"""
|
||||
if not vobject:
|
||||
return False
|
||||
vcard = vobject.vCard()
|
||||
# Name
|
||||
n = vcard.add('n')
|
||||
n.value = vobject.vcard.Name(family=self.name)
|
||||
if self.title:
|
||||
n.value.prefix = self.title.name
|
||||
# Formatted Name
|
||||
fn = vcard.add('fn')
|
||||
fn.value = self.name
|
||||
# Address
|
||||
adr = vcard.add('adr')
|
||||
adr.value = vobject.vcard.Address(street=self.street or '', city=self.city or '', code=self.zip or '')
|
||||
if self.state_id:
|
||||
adr.value.region = self.state_id.name
|
||||
if self.country_id:
|
||||
adr.value.country = self.country_id.name
|
||||
# Email
|
||||
if self.email:
|
||||
email = vcard.add('email')
|
||||
email.value = self.email
|
||||
email.type_param = 'INTERNET'
|
||||
# Telephone numbers
|
||||
if self.phone:
|
||||
tel = vcard.add('tel')
|
||||
tel.type_param = 'work'
|
||||
tel.value = self.phone
|
||||
if self.mobile:
|
||||
tel = vcard.add('tel')
|
||||
tel.type_param = 'cell'
|
||||
tel.value = self.mobile
|
||||
# URL
|
||||
if self.website:
|
||||
url = vcard.add('url')
|
||||
url.value = self.website
|
||||
# Organisation
|
||||
if self.commercial_company_name:
|
||||
org = vcard.add('org')
|
||||
org.value = [self.commercial_company_name]
|
||||
if self.function:
|
||||
function = vcard.add('title')
|
||||
function.value = self.function
|
||||
# Photo
|
||||
photo = vcard.add('photo')
|
||||
photo.value = b64decode(self.avatar_512)
|
||||
photo.encoding_param = 'B'
|
||||
photo.type_param = 'JPG'
|
||||
return vcard
|
||||
|
||||
def _get_vcard_file(self):
|
||||
vcard = self._build_vcard()
|
||||
if vcard:
|
||||
return vcard.serialize().encode()
|
||||
return False
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,160 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<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"
|
||||
width="512"
|
||||
height="512"
|
||||
viewBox="0 0 135.46667 135.46667"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:export-filename="/home/app/odoo-icon-192x192.png"
|
||||
inkscape:export-xdpi="36"
|
||||
inkscape:export-ydpi="36"
|
||||
inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
|
||||
sodipodi:docname="odoo-icon.svg">
|
||||
<defs
|
||||
id="defs2">
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter2216">
|
||||
<feFlood
|
||||
flood-opacity="1"
|
||||
flood-color="rgb(205,205,205)"
|
||||
result="flood"
|
||||
id="feFlood2206" />
|
||||
<feComposite
|
||||
in="flood"
|
||||
in2="SourceGraphic"
|
||||
operator="in"
|
||||
result="composite1"
|
||||
id="feComposite2208" />
|
||||
<feGaussianBlur
|
||||
in="composite1"
|
||||
stdDeviation="0.6"
|
||||
result="blur"
|
||||
id="feGaussianBlur2210" />
|
||||
<feOffset
|
||||
dx="0"
|
||||
dy="2"
|
||||
result="offset"
|
||||
id="feOffset2212"
|
||||
preserveAlpha="false" />
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="offset"
|
||||
operator="over"
|
||||
result="composite2"
|
||||
id="feComposite2214" />
|
||||
</filter>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter2305">
|
||||
<feFlood
|
||||
flood-opacity="1"
|
||||
flood-color="rgb(84,84,84)"
|
||||
result="flood"
|
||||
id="feFlood2295" />
|
||||
<feComposite
|
||||
in="flood"
|
||||
in2="SourceGraphic"
|
||||
operator="in"
|
||||
result="composite1"
|
||||
id="feComposite2297" />
|
||||
<feGaussianBlur
|
||||
in="composite1"
|
||||
stdDeviation="0.6"
|
||||
result="blur"
|
||||
id="feGaussianBlur2299" />
|
||||
<feOffset
|
||||
dx="0"
|
||||
dy="2"
|
||||
result="offset"
|
||||
id="feOffset2301" />
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="offset"
|
||||
operator="over"
|
||||
result="composite2"
|
||||
id="feComposite2303" />
|
||||
</filter>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1.979899"
|
||||
inkscape:cx="185.89708"
|
||||
inkscape:cy="240.51511"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer2"
|
||||
inkscape:document-rotation="0"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1043"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<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 />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer2"
|
||||
inkscape:label="Layer 2"
|
||||
style="display:inline"
|
||||
transform="translate(-50.440524,-82.082377)">
|
||||
<g
|
||||
id="g2221"
|
||||
inkscape:export-xdpi="335.51001"
|
||||
inkscape:export-ydpi="335.51001"
|
||||
transform="matrix(1.1223551,0,0,1.1223551,-14.032588,-21.732906)">
|
||||
<ellipse
|
||||
style="display:inline;opacity:1;mix-blend-mode:normal;fill:#ffffff;fill-rule:evenodd;stroke-width:0.249932;filter:url(#filter2305)"
|
||||
id="path26"
|
||||
cx="89.202377"
|
||||
cy="121.03871"
|
||||
rx="32.301544"
|
||||
ry="32.463612"
|
||||
inkscape:label="path26"
|
||||
transform="matrix(1.7474279,0,0,1.7378618,-37.731671,-60.534514)" />
|
||||
<ellipse
|
||||
style="display:inline;mix-blend-mode:normal;fill:#a24689;fill-opacity:1;stroke-width:0.470743"
|
||||
id="path28"
|
||||
cx="118.20312"
|
||||
cy="149.9698"
|
||||
rx="49.217548"
|
||||
ry="49.185436" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;fill-opacity:1;stroke-width:0.471569"
|
||||
id="path859"
|
||||
cx="118.23886"
|
||||
cy="149.90324"
|
||||
rx="29.618845"
|
||||
ry="29.795511" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,37 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
const cacheName = "odoo-sw-cache";
|
||||
const cachedRequests = ["/web/offline"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(cacheName).then((cache) => cache.addAll(cachedRequests)));
|
||||
});
|
||||
|
||||
const navigateOrDisplayOfflinePage = async (request) => {
|
||||
try {
|
||||
return await fetch(request);
|
||||
} catch (requestError) {
|
||||
if (
|
||||
request.method === "GET" &&
|
||||
["Failed to fetch", "Load failed"].includes(requestError.message)
|
||||
) {
|
||||
if (cachedRequests.includes("/web/offline")) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cachedResponse = await cache.match("/web/offline");
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw requestError;
|
||||
}
|
||||
};
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (
|
||||
(event.request.mode === "navigate" && event.request.destination === "document") ||
|
||||
// request.mode = navigate isn't supported in all browsers => check for http header accept:text/html
|
||||
event.request.headers.get("accept").includes("text/html")
|
||||
) {
|
||||
event.respondWith(navigateOrDisplayOfflinePage(event.request));
|
||||
}
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { useBus, useService } from "@web/core/utils/hooks";
|
||||
import { ActionContainer } from "./actions/action_container";
|
||||
import { NavBar } from "./navbar/navbar";
|
||||
|
||||
import { Component, onMounted, useExternalListener, useState } from "@odoo/owl";
|
||||
import { Component, onMounted, onWillStart, useExternalListener, useState } from "@odoo/owl";
|
||||
|
||||
export class WebClient extends Component {
|
||||
setup() {
|
||||
@@ -47,6 +47,7 @@ export class WebClient extends Component {
|
||||
this.env.bus.trigger("WEB_CLIENT_READY");
|
||||
});
|
||||
useExternalListener(window, "click", this.onGlobalClick, { capture: true });
|
||||
onWillStart(this.registerServiceWorker);
|
||||
}
|
||||
|
||||
async loadRouterState() {
|
||||
@@ -108,6 +109,19 @@ export class WebClient extends Component {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
registerServiceWorker() {
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker
|
||||
.register("/web/service-worker.js", { scope: "/web" })
|
||||
.then((registration) => {
|
||||
console.info("Registration successful, scope is:", registration.scope);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Service worker registration failed, error:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
WebClient.components = {
|
||||
ActionContainer,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { dialogService } from "@web/core/dialog/dialog_service";
|
||||
import { notificationService } from "@web/core/notifications/notification_service";
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { popoverService } from "@web/core/popover/popover_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
@@ -24,6 +25,7 @@ let target;
|
||||
QUnit.module("WebClient", {
|
||||
async beforeEach() {
|
||||
serviceRegistry
|
||||
.add("orm", ormService)
|
||||
.add("action", actionService)
|
||||
.add("dialog", dialogService)
|
||||
.add("hotkey", hotkeyService)
|
||||
|
||||
@@ -9,6 +9,7 @@ from . import test_menu
|
||||
from . import test_click_everywhere
|
||||
from . import test_base_document_layout
|
||||
from . import test_load_menus
|
||||
from . import test_partner
|
||||
from . import test_profiler
|
||||
from . import test_session_info
|
||||
from . import test_read_progress_bar
|
||||
@@ -19,3 +20,4 @@ from . import test_web_search_read
|
||||
from . import test_domain
|
||||
from . import test_web_redirect
|
||||
from . import test_res_users
|
||||
from . import test_webmanifest
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import logging
|
||||
import unittest
|
||||
|
||||
from odoo.tests.common import HttpCase, tagged
|
||||
from base64 import b64decode
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import vobject
|
||||
except ImportError:
|
||||
_logger.warning("`vobject` Python module not found, vcard file generation disabled. Consider installing this module if you want to generate vcard files")
|
||||
vobject = None
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestPartnerVCard(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
if not vobject:
|
||||
raise unittest.SkipTest("Skip tests when `vobject` Python module is not found.")
|
||||
|
||||
self.partner = self.env['res.partner'].create({
|
||||
'name': 'John Doe',
|
||||
'email': 'john.doe@test.example.com',
|
||||
'mobile': '+1 202 555 0888',
|
||||
'phone': '+1 202 555 0122',
|
||||
'function': 'Painter',
|
||||
'street': 'Cookieville Minimum-Security Orphanarium',
|
||||
'city': 'New York',
|
||||
'country_id': self.env.ref('base.us').id,
|
||||
'zip': '97648',
|
||||
'website': 'https://test.exemple.com',
|
||||
})
|
||||
self.authenticate("admin", "admin")
|
||||
|
||||
def test_fetch_partner_vcard(self):
|
||||
res = self.url_open('/web/partner/%d/vcard' % self.partner.id)
|
||||
vcard = vobject.readOne(res.text)
|
||||
self.assertEqual(vcard.contents["n"][0].value.family, self.partner.name, "Vcard should have the same name")
|
||||
self.assertEqual(vcard.contents["adr"][0].value.street, self.partner.street, "Vcard should have the same street")
|
||||
self.assertEqual(vcard.contents["adr"][0].value.city, self.partner.city, "Vcard should have the same city")
|
||||
self.assertEqual(vcard.contents["adr"][0].value.code, self.partner.zip, "Vcard should have the same zip")
|
||||
self.assertEqual(vcard.contents["adr"][0].value.country, self.env.ref('base.us').name, "Vcard should have the same country")
|
||||
self.assertEqual(vcard.contents["email"][0].value, self.partner.email, "Vcard should have the same email")
|
||||
self.assertEqual(vcard.contents["url"][0].value, self.partner.website, "Vcard should have the same website")
|
||||
self.assertEqual(vcard.contents["tel"][0].params['TYPE'], ["work"], "Vcard should have the same phone")
|
||||
self.assertEqual(vcard.contents["tel"][0].value, self.partner.phone, "Vcard should have the same phone")
|
||||
self.assertEqual(vcard.contents["tel"][1].params['TYPE'], ["cell"], "Vcard should have the same mobile")
|
||||
self.assertEqual(vcard.contents["tel"][1].value, self.partner.mobile, "Vcard should have the same mobile")
|
||||
self.assertEqual(vcard.contents["title"][0].value, self.partner.function, "Vcard should have the same function")
|
||||
self.assertEqual(len(vcard.contents['photo'][0].value), len(b64decode(self.partner.avatar_512)), "Vcard should have the same photo")
|
||||
|
||||
@unittest.skip
|
||||
def test_not_exist_partner_vcard(self):
|
||||
partner_id = self.partner.id
|
||||
self.partner.unlink()
|
||||
res = self.url_open('/web/partner/%d/vcard' % partner_id)
|
||||
self.assertEqual(res.status_code, 404)
|
||||
@@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.tests.common import HttpCase, tagged
|
||||
|
||||
@tagged("-at_install", "post_install")
|
||||
class WebManifestRoutesTest(HttpCase):
|
||||
"""
|
||||
This test suite is used to request the routes used by the PWA backend implementation
|
||||
"""
|
||||
|
||||
def test_webmanifest(self):
|
||||
"""
|
||||
This route returns a well formed backend's WebManifest
|
||||
"""
|
||||
self.authenticate("admin", "admin")
|
||||
response = self.url_open("/web/manifest.webmanifest")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], "application/manifest+json")
|
||||
data = response.json()
|
||||
self.assertEqual(data["name"], "Odoo")
|
||||
self.assertEqual(data["scope"], "/web")
|
||||
self.assertEqual(data["start_url"], "/web")
|
||||
self.assertEqual(data["display"], "standalone")
|
||||
self.assertEqual(data["background_color"], "#714B67")
|
||||
self.assertEqual(data["theme_color"], "#714B67")
|
||||
self.assertEqual(data["prefer_related_applications"], False)
|
||||
self.assertCountEqual(data["icons"], [
|
||||
{'src': '/web/static/img/odoo-icon-192x192.png', 'sizes': '192x192', 'type': 'image/png'},
|
||||
{'src': '/web/static/img/odoo-icon-512x512.png', 'sizes': '512x512', 'type': 'image/png'}
|
||||
])
|
||||
self.assertGreaterEqual(len(data["shortcuts"]), 0)
|
||||
for shortcut in data["shortcuts"]:
|
||||
self.assertGreater(len(shortcut["name"]), 0)
|
||||
self.assertGreater(len(shortcut["description"]), 0)
|
||||
self.assertGreater(len(shortcut["icons"]), 0)
|
||||
self.assertTrue(shortcut["url"].startswith("/web#menu_id="))
|
||||
|
||||
def test_webmanifest_unauthenticated(self):
|
||||
"""
|
||||
This route returns a well formed backend's WebManifest
|
||||
"""
|
||||
response = self.url_open("/web/manifest.webmanifest")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], "application/manifest+json")
|
||||
data = response.json()
|
||||
self.assertEqual(data["name"], "Odoo")
|
||||
self.assertEqual(data["scope"], "/web")
|
||||
self.assertEqual(data["start_url"], "/web")
|
||||
self.assertEqual(data["display"], "standalone")
|
||||
self.assertEqual(data["background_color"], "#714B67")
|
||||
self.assertEqual(data["theme_color"], "#714B67")
|
||||
self.assertEqual(data["prefer_related_applications"], False)
|
||||
self.assertCountEqual(data["icons"], [
|
||||
{'src': '/web/static/img/odoo-icon-192x192.png', 'sizes': '192x192', 'type': 'image/png'},
|
||||
{'src': '/web/static/img/odoo-icon-512x512.png', 'sizes': '512x512', 'type': 'image/png'}
|
||||
])
|
||||
self.assertEqual(len(data["shortcuts"]), 0)
|
||||
|
||||
def test_serviceworker(self):
|
||||
"""
|
||||
This route returns a JavaScript's ServiceWorker
|
||||
"""
|
||||
response = self.url_open("/web/service-worker.js")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], "text/javascript")
|
||||
self.assertEqual(response.headers["Service-Worker-Allowed"], "/web")
|
||||
|
||||
def test_offline_url(self):
|
||||
"""
|
||||
This route returns the offline page
|
||||
"""
|
||||
response = self.url_open("/web/offline")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], "text/html; charset=utf-8")
|
||||
|
||||
def test_apple_touch_icon(self):
|
||||
"""
|
||||
This request tests the presence of an apple-touch-icon image route for the PWA icon and
|
||||
its presence from the head of the document.
|
||||
"""
|
||||
self.authenticate("demo", "demo")
|
||||
response = self.url_open("/web/static/img/odoo-icon-ios.png")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
document = self.url_open("/web")
|
||||
self.assertIn(
|
||||
'<link rel="apple-touch-icon" href="/web/static/img/odoo-icon-ios.png"/>', document.text,
|
||||
"Icon for iOS is present in the head of the document.",
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record model="ir.actions.server" id="download_contact">
|
||||
<field name="name">Download (vCard)</field>
|
||||
<field name="model_id" ref="model_res_partner"/>
|
||||
<field name="binding_model_id" ref="model_res_partner"/>
|
||||
<field name="binding_view_types">form</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
action = {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/partner/%d/vcard' % record.id,
|
||||
'target': 'self',
|
||||
}
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -253,6 +253,8 @@
|
||||
<t t-set="head_web">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>
|
||||
<meta name="theme-color" content="#71639e"/>
|
||||
<link rel="manifest" href="/web/manifest.webmanifest" crossorigin="use-credentials"/>
|
||||
<link rel="apple-touch-icon" href="/web/static/img/odoo-icon-ios.png"/>
|
||||
<script type="text/javascript">
|
||||
// Block to avoid leaking variables in the script scope
|
||||
{
|
||||
@@ -285,4 +287,56 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="webclient_offline">
|
||||
<t t-call="web.layout">
|
||||
<t t-set="html_data" t-value="{'style': 'height: 100%;'}"/>
|
||||
<t t-set="title">Offline</t>
|
||||
<t t-set="head">
|
||||
<script type="text/javascript">
|
||||
window.addEventListener('online', () => location.reload());
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
padding:0;
|
||||
margin:0;
|
||||
background-color: #fff;
|
||||
color: rgb(17, 24, 39);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
user-select: none;
|
||||
}
|
||||
.card {
|
||||
width: 80%;
|
||||
}
|
||||
.card img {
|
||||
width: 96px;
|
||||
height: auto;
|
||||
filter: grayscale(.6);
|
||||
}
|
||||
.card button {
|
||||
background-color: #714B67;
|
||||
color: #FFFFFF;
|
||||
border: 1px solid #714B67;
|
||||
border-radius: .25rem;
|
||||
padding: .5rem 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
</t>
|
||||
<div class="card">
|
||||
<img t-attf-src="data:image/png;base64,{{odoo_icon}}" alt="Odoo logo"/>
|
||||
<h1>You are offline</h1>
|
||||
<p>Check your network connection and come back here. Odoo will load as soon as you're back online.</p>
|
||||
<button onclick="location.reload()">Check again</button>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
|
||||
Reference in New Issue
Block a user