[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:
Romeo Fragomeli
2023-09-11 16:32:30 +00:00
co-authored by Romain Estievenart Pierre Paridans
parent 9d5bc32036
commit 0903ef7bb2
36 changed files with 1872 additions and 2 deletions
@@ -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" />
+1
View File
@@ -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
+19
View File
@@ -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
+12
View File
@@ -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>
+9
View File
@@ -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;
+2
View File
@@ -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
+208
View File
@@ -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
+88
View File
@@ -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
+67
View File
@@ -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()
+2
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
69 access_mail_link_preview_admin mail.link.preview.admin model_mail_link_preview base.group_erp_manager 1 1 1 1
70 access_discuss_gif_favorite discuss.gif.favorite model_discuss_gif_favorite base.group_user 1 1 1 1
71 access_discuss_voice_metadata_user discuss.voice.metadata.user model_discuss_voice_metadata 0 0 0 0
72 access_mail_partner_device access_mail_partner_device mail.model_mail_partner_device base.group_user 0 0 0 0
73 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
+45
View File
@@ -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("=", "");
},
});
+206
View File
@@ -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
+1
View File
@@ -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,
)
+1
View File
@@ -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',
+2
View File
@@ -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
+20
View File
@@ -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))
])
+97
View File
@@ -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())
})
+2
View File
@@ -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
+10
View File
@@ -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')
+79
View File
@@ -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

+160
View File
@@ -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

+37
View File
@@ -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));
}
});
+15 -1
View File
@@ -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)
+2
View File
@@ -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
+63
View File
@@ -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)
+90
View File
@@ -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.",
)
+17
View File
@@ -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>
+54
View File
@@ -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>