From 0903ef7bb2117623cf6cbbd2375f56ca91babe6a Mon Sep 17 00:00:00 2001 From: Romeo Fragomeli Date: Thu, 20 Jul 2023 14:36:30 +0200 Subject: [PATCH] [MOV] *: move all PWA to community MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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@d0ae70103dd1c169debe8fcb0918f80310c85e42 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) Co-authored-by: Romeo Fragomeli Co-authored-by: Romain Estievenart Co-authored-by: Pierre Paridans --- .../views/res_config_settings_views.xml | 6 + addons/mail/controllers/__init__.py | 1 + addons/mail/controllers/webmanifest.py | 19 + addons/mail/data/ir_cron_data.xml | 12 + addons/mail/data/neutralize.sql | 9 + addons/mail/models/__init__.py | 2 + addons/mail/models/discuss/discuss_channel.py | 40 +++ addons/mail/models/mail_thread.py | 208 +++++++++++ addons/mail/models/partner_devices.py | 88 +++++ addons/mail/models/web_push.py | 67 ++++ addons/mail/security/ir.model.access.csv | 2 + .../src/core/common/out_of_focus_service.js | 21 +- addons/mail/static/src/service_worker.js | 45 +++ .../static/src/webclient/web/webclient.js | 169 +++++++++ addons/mail/web_push.py | 206 +++++++++++ addons/test_mail_full/tests/__init__.py | 1 + addons/test_mail_full/tests/test_web_push.py | 326 ++++++++++++++++++ addons/web/__manifest__.py | 1 + addons/web/controllers/__init__.py | 2 + addons/web/controllers/vcard.py | 20 ++ addons/web/controllers/webmanifest.py | 97 ++++++ addons/web/models/__init__.py | 2 + addons/web/models/res_config_settings.py | 10 + addons/web/models/res_partner.py | 79 +++++ addons/web/static/img/odoo-icon-192x192.png | Bin 0 -> 6016 bytes addons/web/static/img/odoo-icon-512x512.png | Bin 0 -> 17285 bytes addons/web/static/img/odoo-icon-ios.png | Bin 0 -> 27552 bytes addons/web/static/img/odoo-icon.svg | 160 +++++++++ addons/web/static/src/service_worker.js | 37 ++ addons/web/static/src/webclient/webclient.js | 16 +- .../static/tests/webclient/webclient_tests.js | 2 + addons/web/tests/__init__.py | 2 + addons/web/tests/test_partner.py | 63 ++++ addons/web/tests/test_webmanifest.py | 90 +++++ addons/web/views/partner_view.xml | 17 + addons/web/views/webclient_templates.xml | 54 +++ 36 files changed, 1872 insertions(+), 2 deletions(-) create mode 100644 addons/mail/controllers/webmanifest.py create mode 100644 addons/mail/models/partner_devices.py create mode 100644 addons/mail/models/web_push.py create mode 100644 addons/mail/static/src/service_worker.js create mode 100644 addons/mail/static/src/webclient/web/webclient.js create mode 100644 addons/mail/web_push.py create mode 100644 addons/test_mail_full/tests/test_web_push.py create mode 100644 addons/web/controllers/vcard.py create mode 100644 addons/web/controllers/webmanifest.py create mode 100644 addons/web/models/res_config_settings.py create mode 100644 addons/web/models/res_partner.py create mode 100644 addons/web/static/img/odoo-icon-192x192.png create mode 100644 addons/web/static/img/odoo-icon-512x512.png create mode 100644 addons/web/static/img/odoo-icon-ios.png create mode 100644 addons/web/static/img/odoo-icon.svg create mode 100644 addons/web/static/src/service_worker.js create mode 100644 addons/web/tests/test_partner.py create mode 100644 addons/web/tests/test_webmanifest.py create mode 100644 addons/web/views/partner_view.xml diff --git a/addons/base_setup/views/res_config_settings_views.xml b/addons/base_setup/views/res_config_settings_views.xml index b0ac1fe680f..76adcdea10e 100644 --- a/addons/base_setup/views/res_config_settings_views.xml +++ b/addons/base_setup/views/res_config_settings_views.xml @@ -130,6 +130,12 @@ + + + + + + diff --git a/addons/mail/controllers/__init__.py b/addons/mail/controllers/__init__.py index d45073201db..17a0f967a9d 100644 --- a/addons/mail/controllers/__init__.py +++ b/addons/mail/controllers/__init__.py @@ -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 diff --git a/addons/mail/controllers/webmanifest.py b/addons/mail/controllers/webmanifest.py new file mode 100644 index 00000000000..0a849deb0a9 --- /dev/null +++ b/addons/mail/controllers/webmanifest.py @@ -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 diff --git a/addons/mail/data/ir_cron_data.xml b/addons/mail/data/ir_cron_data.xml index 5bc687622f1..9ca540b6f9b 100644 --- a/addons/mail/data/ir_cron_data.xml +++ b/addons/mail/data/ir_cron_data.xml @@ -65,5 +65,17 @@ model._send_notifications_cron() code + + + Mail: send web push notification + + code + model._push_notification_to_endpoint() + + 1 + days + -1 + + diff --git a/addons/mail/data/neutralize.sql b/addons/mail/data/neutralize.sql index c40a0156507..f0bc18bdf6e 100644 --- a/addons/mail/data/neutralize.sql +++ b/addons/mail/data/neutralize.sql @@ -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; diff --git a/addons/mail/models/__init__.py b/addons/mail/models/__init__.py index 92146602a08..71bbe20e3ab 100644 --- a/addons/mail/models/__init__.py +++ b/addons/mail/models/__init__.py @@ -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 diff --git a/addons/mail/models/discuss/discuss_channel.py b/addons/mail/models/discuss/discuss_channel.py index 992e242dc2e..4e7d0afab43 100644 --- a/addons/mail/models/discuss/discuss_channel.py +++ b/addons/mail/models/discuss/discuss_channel.py @@ -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 diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index e6dcd73c5a5..991f5bdb956 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -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"[\w.]+).+res_id=(?P\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 diff --git a/addons/mail/models/partner_devices.py b/addons/mail/models/partner_devices.py new file mode 100644 index 00000000000..e739181289c --- /dev/null +++ b/addons/mail/models/partner_devices.py @@ -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 diff --git a/addons/mail/models/web_push.py b/addons/mail/models/web_push.py new file mode 100644 index 00000000000..aedeaf989ca --- /dev/null +++ b/addons/mail/models/web_push.py @@ -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() diff --git a/addons/mail/security/ir.model.access.csv b/addons/mail/security/ir.model.access.csv index 6b8fd1b9e50..60fd3cef3c8 100644 --- a/addons/mail/security/ir.model.access.csv +++ b/addons/mail/security/ir.model.access.csv @@ -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 diff --git a/addons/mail/static/src/core/common/out_of_focus_service.js b/addons/mail/static/src/core/common/out_of_focus_service.js index dc57092bad2..c28303f127a 100644 --- a/addons/mail/static/src/core/common/out_of_focus_service.js +++ b/addons/mail/static/src/core/common/out_of_focus_service.js @@ -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 diff --git a/addons/mail/static/src/service_worker.js b/addons/mail/static/src/service_worker.js new file mode 100644 index 00000000000..a1631a3effd --- /dev/null +++ b/addons/mail/static/src/service_worker.js @@ -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", + }); +}); diff --git a/addons/mail/static/src/webclient/web/webclient.js b/addons/mail/static/src/webclient/web/webclient.js new file mode 100644 index 00000000000..fecbd25ad37 --- /dev/null +++ b/addons/mail/static/src/webclient/web/webclient.js @@ -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} + */ + 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} + */ + 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} + */ + 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("=", ""); + }, +}); diff --git a/addons/mail/web_push.py b/addons/mail/web_push.py new file mode 100644 index 00000000000..417b99f0196 --- /dev/null +++ b/addons/mail/web_push.py @@ -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 diff --git a/addons/test_mail_full/tests/__init__.py b/addons/test_mail_full/tests/__init__.py index 283b523e43c..589c9e20c5d 100644 --- a/addons/test_mail_full/tests/__init__.py +++ b/addons/test_mail_full/tests/__init__.py @@ -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 diff --git a/addons/test_mail_full/tests/test_web_push.py b/addons/test_mail_full/tests/test_web_push.py new file mode 100644 index 00000000000..daff60ed6e6 --- /dev/null +++ b/addons/test_mail_full/tests/test_web_push.py @@ -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('@user') % + 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, + ) diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index 6a825d0d021..28ad4477d56 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -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', diff --git a/addons/web/controllers/__init__.py b/addons/web/controllers/__init__.py index ceb32b0072c..73d97efdbf8 100644 --- a/addons/web/controllers/__init__.py +++ b/addons/web/controllers/__init__.py @@ -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 diff --git a/addons/web/controllers/vcard.py b/addons/web/controllers/vcard.py new file mode 100644 index 00000000000..d0b2d4f6986 --- /dev/null +++ b/addons/web/controllers/vcard.py @@ -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//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)) + ]) diff --git a/addons/web/controllers/webmanifest.py b/addons/web/controllers/webmanifest.py new file mode 100644 index 00000000000..01bc4a7ce43 --- /dev/null +++ b/addons/web/controllers/webmanifest.py @@ -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()) + }) diff --git a/addons/web/models/__init__.py b/addons/web/models/__init__.py index c011109670b..8a1845070fa 100644 --- a/addons/web/models/__init__.py +++ b/addons/web/models/__init__.py @@ -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 diff --git a/addons/web/models/res_config_settings.py b/addons/web/models/res_config_settings.py new file mode 100644 index 00000000000..e8e0ee36cbd --- /dev/null +++ b/addons/web/models/res_config_settings.py @@ -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') diff --git a/addons/web/models/res_partner.py b/addons/web/models/res_partner.py new file mode 100644 index 00000000000..f7d42f5ea42 --- /dev/null +++ b/addons/web/models/res_partner.py @@ -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 diff --git a/addons/web/static/img/odoo-icon-192x192.png b/addons/web/static/img/odoo-icon-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..c79f5477c7b28e22945bca3b727eea80fa2253e2 GIT binary patch literal 6016 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 QO#lD@07*qoM6N<$f{fpL%K!iX literal 0 HcmV?d00001 diff --git a/addons/web/static/img/odoo-icon-ios.png b/addons/web/static/img/odoo-icon-ios.png new file mode 100644 index 0000000000000000000000000000000000000000..e16cb2c9ea246316c379c0b9b3ab491591f3c836 GIT binary patch literal 27552 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 XJ@tv&l30>A&rnj3Rh21~G7J14aM + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/addons/web/static/src/service_worker.js b/addons/web/static/src/service_worker.js new file mode 100644 index 00000000000..da03c832c93 --- /dev/null +++ b/addons/web/static/src/service_worker.js @@ -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)); + } +}); diff --git a/addons/web/static/src/webclient/webclient.js b/addons/web/static/src/webclient/webclient.js index 6c3662f8a0d..630d6833053 100644 --- a/addons/web/static/src/webclient/webclient.js +++ b/addons/web/static/src/webclient/webclient.js @@ -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, diff --git a/addons/web/static/tests/webclient/webclient_tests.js b/addons/web/static/tests/webclient/webclient_tests.js index 2e3b12d5823..da125f16af5 100644 --- a/addons/web/static/tests/webclient/webclient_tests.js +++ b/addons/web/static/tests/webclient/webclient_tests.js @@ -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) diff --git a/addons/web/tests/__init__.py b/addons/web/tests/__init__.py index 7420ed38091..51cb07d6920 100644 --- a/addons/web/tests/__init__.py +++ b/addons/web/tests/__init__.py @@ -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 diff --git a/addons/web/tests/test_partner.py b/addons/web/tests/test_partner.py new file mode 100644 index 00000000000..8263266dd6e --- /dev/null +++ b/addons/web/tests/test_partner.py @@ -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) diff --git a/addons/web/tests/test_webmanifest.py b/addons/web/tests/test_webmanifest.py new file mode 100644 index 00000000000..fac7a53eeb1 --- /dev/null +++ b/addons/web/tests/test_webmanifest.py @@ -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( + '', document.text, + "Icon for iOS is present in the head of the document.", + ) diff --git a/addons/web/views/partner_view.xml b/addons/web/views/partner_view.xml new file mode 100644 index 00000000000..314960f307e --- /dev/null +++ b/addons/web/views/partner_view.xml @@ -0,0 +1,17 @@ + + + + Download (vCard) + + + form + code + + action = { + 'type': 'ir.actions.act_url', + 'url': '/web/partner/%d/vcard' % record.id, + 'target': 'self', + } + + + diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 83d4e828126..fa76ed1a162 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -253,6 +253,8 @@ + + + + + +
+ Odoo logo +

You are offline

+

Check your network connection and come back here. Odoo will load as soon as you're back online.

+ +
+ +