Merge branch 'develop'
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import controller
|
||||||
|
from . import models
|
||||||
|
from . import tools
|
||||||
|
from . import wizard
|
||||||
|
from . import populate
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
{
|
||||||
|
'name': 'WhatsApp Messaging',
|
||||||
|
'category': 'Marketing/WhatsApp',
|
||||||
|
'summary': 'Text your Contacts on WhatsApp',
|
||||||
|
'version': '1.0',
|
||||||
|
'description': """This module integrates Odoo with WhatsApp to use WhatsApp messaging service""",
|
||||||
|
'depends': ['mail', 'phone_validation'],
|
||||||
|
'data': [
|
||||||
|
'data/ir_actions_server_data.xml',
|
||||||
|
'data/ir_cron_data.xml',
|
||||||
|
'data/ir_module_category_data.xml',
|
||||||
|
'data/whatsapp_templates_preview.xml',
|
||||||
|
'security/res_groups.xml',
|
||||||
|
'security/ir_rules.xml',
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'wizard/whatsapp_preview_views.xml',
|
||||||
|
'wizard/whatsapp_composer_views.xml',
|
||||||
|
'views/discuss_channel_views.xml',
|
||||||
|
'views/ir_actions_server_views.xml',
|
||||||
|
'views/whatsapp_account_views.xml',
|
||||||
|
'views/whatsapp_message_views.xml',
|
||||||
|
'views/whatsapp_template_views.xml',
|
||||||
|
'views/whatsapp_template_button_views.xml',
|
||||||
|
'views/whatsapp_template_variable_views.xml',
|
||||||
|
'views/res_config_settings_views.xml',
|
||||||
|
'views/whatsapp_menus.xml',
|
||||||
|
'views/res_partner_views.xml',
|
||||||
|
],
|
||||||
|
'demo': [
|
||||||
|
'data/whatsapp_demo.xml',
|
||||||
|
],
|
||||||
|
'external_dependencies': {
|
||||||
|
'python': ['phonenumbers'],
|
||||||
|
},
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'whatsapp/static/src/scss/*.scss',
|
||||||
|
'whatsapp/static/src/core/common/**/*',
|
||||||
|
'whatsapp/static/src/core/web/**/*',
|
||||||
|
'whatsapp/static/src/core/public_web/**/*',
|
||||||
|
'whatsapp/static/src/**/common/**/*',
|
||||||
|
'whatsapp/static/src/**/web/**/*',
|
||||||
|
'whatsapp/static/src/components/**/*',
|
||||||
|
'whatsapp/static/src/views/**/*',
|
||||||
|
# Don't include dark mode files in light mode
|
||||||
|
('remove', 'whatsapp/static/src/**/*.dark.scss'),
|
||||||
|
],
|
||||||
|
"web.assets_web_dark": [
|
||||||
|
'whatsapp/static/src/**/*.dark.scss',
|
||||||
|
],
|
||||||
|
'web.assets_unit_tests': [
|
||||||
|
# 'whatsapp/static/tests/**/*',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'license': 'OEEL-1',
|
||||||
|
'application': True,
|
||||||
|
'installable': True,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import main
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from markupsafe import Markup
|
||||||
|
from werkzeug.exceptions import Forbidden
|
||||||
|
|
||||||
|
from http import HTTPStatus
|
||||||
|
from odoo import http, _
|
||||||
|
from odoo.http import request
|
||||||
|
from odoo.tools import consteq
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Webhook(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/whatsapp/webhook/', methods=['POST'], type="json", auth="public")
|
||||||
|
def webhookpost(self):
|
||||||
|
data = json.loads(request.httprequest.data)
|
||||||
|
for entry in data['entry']:
|
||||||
|
account_id = entry['id']
|
||||||
|
account = request.env['whatsapp.account'].sudo().search(
|
||||||
|
[('account_uid', '=', account_id)])
|
||||||
|
if not self._check_signature(account):
|
||||||
|
raise Forbidden()
|
||||||
|
|
||||||
|
for changes in entry.get('changes', []):
|
||||||
|
value = changes['value']
|
||||||
|
phone_number_id = value.get('metadata', {}).get('phone_number_id', {})
|
||||||
|
if not phone_number_id:
|
||||||
|
phone_number_id = value.get('whatsapp_business_api_data', {}).get('phone_number_id', {})
|
||||||
|
if phone_number_id:
|
||||||
|
wa_account_id = request.env['whatsapp.account'].sudo().search([
|
||||||
|
('phone_uid', '=', phone_number_id), ('account_uid', '=', account_id)])
|
||||||
|
if wa_account_id:
|
||||||
|
# Process Messages and Status webhooks
|
||||||
|
if changes['field'] == 'messages':
|
||||||
|
request.env['whatsapp.message']._process_statuses(value)
|
||||||
|
wa_account_id._process_messages(value)
|
||||||
|
else:
|
||||||
|
_logger.warning("There is no phone configured for this whatsapp webhook : %s ", data)
|
||||||
|
|
||||||
|
# Process Template webhooks
|
||||||
|
if value.get('message_template_id'):
|
||||||
|
# There is no user in webhook, so we need to SUPERUSER_ID to write on template object
|
||||||
|
template = request.env['whatsapp.template'].sudo().with_context(active_test=False).search([('wa_template_uid', '=', value['message_template_id'])])
|
||||||
|
if template:
|
||||||
|
if changes['field'] == 'message_template_status_update':
|
||||||
|
template.write({'status': value['event'].lower()})
|
||||||
|
if value['event'].lower() == 'rejected':
|
||||||
|
body = _("Your Template has been rejected.")
|
||||||
|
description = value.get('other_info', {}).get('description') or value.get('reason')
|
||||||
|
if description:
|
||||||
|
body += Markup("<br/>") + _("Reason : %s", description)
|
||||||
|
template.message_post(body=body)
|
||||||
|
continue
|
||||||
|
if changes['field'] == 'message_template_quality_update':
|
||||||
|
new_quality_score = value['new_quality_score'].lower()
|
||||||
|
new_quality_score = {'unknown': 'none'}.get(new_quality_score, new_quality_score)
|
||||||
|
template.write({'quality': new_quality_score})
|
||||||
|
continue
|
||||||
|
if changes['field'] == 'template_category_update':
|
||||||
|
template.write({'template_type': value['new_category'].lower()})
|
||||||
|
continue
|
||||||
|
_logger.warning("Unknown Template webhook : %s ", value)
|
||||||
|
else:
|
||||||
|
_logger.warning("No Template found for this webhook : %s ", value)
|
||||||
|
|
||||||
|
@http.route('/whatsapp/webhook/', methods=['GET'], type="http", auth="public", csrf=False)
|
||||||
|
def webhookget(self, **kwargs):
|
||||||
|
"""
|
||||||
|
This controller is used to verify the webhook.
|
||||||
|
if challenge is matched then it will make response with challenge.
|
||||||
|
once it is verified the webhook will be activated.
|
||||||
|
"""
|
||||||
|
token = kwargs.get('hub.verify_token')
|
||||||
|
mode = kwargs.get('hub.mode')
|
||||||
|
challenge = kwargs.get('hub.challenge')
|
||||||
|
if not (token and mode and challenge):
|
||||||
|
return Forbidden()
|
||||||
|
wa_account = request.env['whatsapp.account'].sudo().search([('webhook_verify_token', '=', token)])
|
||||||
|
if mode == 'subscribe' and wa_account:
|
||||||
|
response = request.make_response(challenge)
|
||||||
|
response.status_code = HTTPStatus.OK
|
||||||
|
return response
|
||||||
|
response = request.make_response({})
|
||||||
|
response.status_code = HTTPStatus.FORBIDDEN
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _check_signature(self, business_account):
|
||||||
|
"""Whatsapp will sign all requests it makes to our endpoint."""
|
||||||
|
signature = request.httprequest.headers.get('X-Hub-Signature-256')
|
||||||
|
if not signature or not signature.startswith('sha256=') or len(signature) != 71:
|
||||||
|
# Signature must be valid SHA-256 (sha256=<64 hex digits>)
|
||||||
|
_logger.warning('Invalid signature header %r', signature)
|
||||||
|
return False
|
||||||
|
if not business_account.app_secret:
|
||||||
|
_logger.warning('App-secret is missing, can not check signature')
|
||||||
|
return False
|
||||||
|
|
||||||
|
expected = hmac.new(
|
||||||
|
business_account.app_secret.encode(),
|
||||||
|
msg=request.httprequest.data,
|
||||||
|
digestmod=hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
return consteq(signature[7:], expected)
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="ir_actions_server_resend_whatsapp_queue" model="ir.actions.server">
|
||||||
|
<field name="name">WhatsApp : Resend failed Messages</field>
|
||||||
|
<field name="model_id" ref="whatsapp.model_whatsapp_message"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="binding_view_types">list</field>
|
||||||
|
<field name="code">action = records._resend_failed()</field>
|
||||||
|
<field name="binding_model_id" eval="ref('whatsapp.model_whatsapp_message')"/>
|
||||||
|
<field name="binding_type">action</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="ir_cron_send_whatsapp_queue" model="ir.cron">
|
||||||
|
<field name="name">WhatsApp : Send In Queue Messages</field>
|
||||||
|
<field name="model_id" ref="whatsapp.model_whatsapp_message"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._send_cron()</field>
|
||||||
|
<field name='interval_number'>1</field>
|
||||||
|
<field name='interval_type'>hours</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="base.module_category_marketing_whatsapp" model="ir.module.category">
|
||||||
|
<field name="name">WhatsApp</field>
|
||||||
|
<field name="description">User access levels for WhatsApp module</field>
|
||||||
|
<field name="sequence">110</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
UPDATE whatsapp_account
|
||||||
|
SET token = 'dummy_token';
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record model="discuss.channel" id="demo_whatsapp_channel">
|
||||||
|
<field name="name">Brandon Freeman (demo)</field>
|
||||||
|
<field name="channel_type">whatsapp</field>
|
||||||
|
<field name="whatsapp_number">(355)-687-3262</field>
|
||||||
|
<field name="whatsapp_partner_id" ref="base.res_partner_address_15"/>
|
||||||
|
<field name="channel_partner_ids" eval="[Command.link(ref('base.res_partner_address_15'))]"/>
|
||||||
|
<field name="group_ids" eval="[Command.link(ref('base.group_system'))]"/>
|
||||||
|
</record>
|
||||||
|
<record model="mail.message" id="welcome_mail_message">
|
||||||
|
<field name="model">discuss.channel</field>
|
||||||
|
<field name="res_id" ref="demo_whatsapp_channel"/>
|
||||||
|
<field name="message_type">whatsapp_message</field>
|
||||||
|
<field name="author_id" ref="base.res_partner_address_15"/>
|
||||||
|
<field name="body"><![CDATA[<p>This channel is for demo purpose only.</p>]]></field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<template id="template_message_preview" t-name="WhatsApp Preview">
|
||||||
|
<div class="o_whatsapp_preview overflow-hidden ps-3 pe-5">
|
||||||
|
<div class="o_whatsapp_message mt-2 mb-1 fs-6 lh-1 float-start text-break text-black position-relative">
|
||||||
|
<div class="o_whatsapp_message_core p-2 position-relative">
|
||||||
|
<div class="o_whatsapp_message_header bg-opacity-50" t-if="header_type != 'none' and header_type != 'text'">
|
||||||
|
<div t-attf-class="d-block bg-400 p-4 text-center {{ 'rounded-top-2' if header_type == 'location' else 'rounded-2' }}">
|
||||||
|
<img class="m-2 img-fluid" t-attf-src="/whatsapp/static/img/{{header_type}}.png" t-att-alt="header_type"/>
|
||||||
|
</div>
|
||||||
|
<div t-if="header_type == 'location'" class="o_whatsapp_location_footer d-flex p-2 bg-200 rounded-bottom-2 flex-column">
|
||||||
|
<span class="o-whatsapp-font-11">{{Location name}}</span><br/>
|
||||||
|
<span class="text-600 o-whatsapp-font-9">{{Address}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="o_whatsapp_message_body px-1 mt-2" t-attf-style="direction:{{language_direction}};">
|
||||||
|
<t t-out="body"/>
|
||||||
|
</div>
|
||||||
|
<div t-if="footer_text" class="o_whatsapp_message_footer px-1">
|
||||||
|
<span class="fs-6 text-400" t-out="footer_text" />
|
||||||
|
<span class="o_whatsapp_msg_space me-5 d-inline-block"/>
|
||||||
|
</div>
|
||||||
|
<span class="position-absolute bottom-0 end-0 o-whatsapp-font-11 py-1 px-2 text-black-50" area-hidden="true">
|
||||||
|
06:00
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="o_whatsapp_message_links cursor-default px-2" t-if="buttons">
|
||||||
|
<hr class="position-relative w-100 m-0"/>
|
||||||
|
<t t-set="filtered_buttons" t-value="buttons[:3] if len(buttons) < 4 else buttons[:2]" />
|
||||||
|
<t t-set="show_all_options_button" t-value="len(buttons) >= 4" />
|
||||||
|
<t t-foreach="filtered_buttons" t-as="button">
|
||||||
|
<span t-attf-class="o_whatsapp_message_link d-block text-center my-3">
|
||||||
|
<t t-if="button.button_type == 'phone_number'">
|
||||||
|
<i t-attf-class="fa fs-5 fa-phone"/>
|
||||||
|
</t>
|
||||||
|
<t t-elif="button.button_type == 'url'">
|
||||||
|
<i t-attf-class="fa fs-5 fa-external-link"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<i t-attf-class="fa fs-5 fa-reply"/>
|
||||||
|
</t>
|
||||||
|
<t t-out="button.name"/>
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
<t t-if="show_all_options_button">
|
||||||
|
<span t-attf-class="o_whatsapp_message_link d-block text-center my-3">
|
||||||
|
<i t-attf-class="fa fs-5 fa-list-ul"/> See all options
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
|
||||||
|
from . import discuss_channel
|
||||||
|
from . import discuss_channel_member
|
||||||
|
from . import ir_actions_server
|
||||||
|
from . import mail_message
|
||||||
|
from . import mail_thread
|
||||||
|
from . import models
|
||||||
|
from . import res_partner
|
||||||
|
from . import res_users_settings
|
||||||
|
from . import whatsapp_account
|
||||||
|
from . import whatsapp_message
|
||||||
|
from . import whatsapp_template
|
||||||
|
from . import whatsapp_template_button
|
||||||
|
from . import whatsapp_template_variable
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
from odoo import api, Command, fields, models, tools, _
|
||||||
|
from odoo.addons.mail.tools.discuss import Store
|
||||||
|
from odoo.addons.whatsapp.tools import phone_validation as wa_phone_validation
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class DiscussChannel(models.Model):
|
||||||
|
""" Support WhatsApp Channels, used for discussion with a specific
|
||||||
|
whasapp number """
|
||||||
|
_inherit = 'discuss.channel'
|
||||||
|
|
||||||
|
channel_type = fields.Selection(
|
||||||
|
selection_add=[('whatsapp', 'WhatsApp Conversation')],
|
||||||
|
ondelete={'whatsapp': 'cascade'})
|
||||||
|
whatsapp_number = fields.Char(string="Phone Number")
|
||||||
|
whatsapp_channel_valid_until = fields.Datetime(string="WhatsApp Channel Valid Until Datetime", compute="_compute_whatsapp_channel_valid_until")
|
||||||
|
last_wa_mail_message_id = fields.Many2one(comodel_name="mail.message", string="Last WA Partner Mail Message", index='btree_not_null')
|
||||||
|
whatsapp_partner_id = fields.Many2one(comodel_name='res.partner', string="WhatsApp Partner", index='btree_not_null')
|
||||||
|
wa_account_id = fields.Many2one(comodel_name='whatsapp.account', string="WhatsApp Business Account")
|
||||||
|
whatsapp_channel_active = fields.Boolean('Is Whatsapp Channel Active', compute="_compute_whatsapp_channel_active")
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('group_public_id_check',
|
||||||
|
"CHECK (channel_type = 'channel' OR channel_type = 'whatsapp' OR group_public_id IS NULL)",
|
||||||
|
'Group authorization and group auto-subscription are only supported on channels and whatsapp.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.constrains('channel_type', 'whatsapp_number')
|
||||||
|
def _check_whatsapp_number(self):
|
||||||
|
# constraint to check the whatsapp number for channel with type 'whatsapp'
|
||||||
|
missing_number = self.filtered(lambda channel: channel.channel_type == 'whatsapp' and not channel.whatsapp_number)
|
||||||
|
if missing_number:
|
||||||
|
raise ValidationError(
|
||||||
|
_("A phone number is required for WhatsApp channels %(channel_names)s",
|
||||||
|
channel_names=', '.join(missing_number)
|
||||||
|
))
|
||||||
|
|
||||||
|
# INHERITED CONSTRAINTS
|
||||||
|
|
||||||
|
@api.constrains('group_public_id', 'group_ids')
|
||||||
|
def _constraint_group_id_channel(self):
|
||||||
|
valid_channels = self.filtered(lambda channel: channel.channel_type == 'whatsapp')
|
||||||
|
super(DiscussChannel, self - valid_channels)._constraint_group_id_channel()
|
||||||
|
|
||||||
|
# NEW COMPUTES
|
||||||
|
|
||||||
|
@api.depends('last_wa_mail_message_id')
|
||||||
|
def _compute_whatsapp_channel_valid_until(self):
|
||||||
|
for channel in self:
|
||||||
|
channel.whatsapp_channel_valid_until = channel.last_wa_mail_message_id.create_date + timedelta(hours=24) \
|
||||||
|
if channel.channel_type == "whatsapp" and channel.last_wa_mail_message_id else False
|
||||||
|
|
||||||
|
@api.depends('whatsapp_channel_valid_until')
|
||||||
|
def _compute_whatsapp_channel_active(self):
|
||||||
|
for channel in self:
|
||||||
|
channel.whatsapp_channel_active = channel.whatsapp_channel_valid_until and \
|
||||||
|
channel.whatsapp_channel_valid_until > fields.Datetime.now()
|
||||||
|
|
||||||
|
# INHERITED COMPUTES
|
||||||
|
|
||||||
|
def _compute_group_public_id(self):
|
||||||
|
wa_channels = self.filtered(lambda channel: channel.channel_type == "whatsapp")
|
||||||
|
wa_channels.filtered(lambda channel: not channel.group_public_id).group_public_id = self.env.ref('base.group_user')
|
||||||
|
super(DiscussChannel, self - wa_channels)._compute_group_public_id()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# MAILING
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_notify_valid_parameters(self):
|
||||||
|
if self.channel_type == 'whatsapp':
|
||||||
|
return super()._get_notify_valid_parameters() | {'whatsapp_inbound_msg_uid'}
|
||||||
|
return super()._get_notify_valid_parameters()
|
||||||
|
|
||||||
|
def _notify_thread(self, message, msg_vals=False, **kwargs):
|
||||||
|
parent_msg_id = kwargs.pop('parent_msg_id') if 'parent_msg_id' in kwargs else False
|
||||||
|
recipients_data = super()._notify_thread(message, msg_vals=msg_vals, **kwargs)
|
||||||
|
if kwargs.get('whatsapp_inbound_msg_uid') and self.channel_type == 'whatsapp':
|
||||||
|
self.env['whatsapp.message'].create({
|
||||||
|
'mail_message_id': message.id,
|
||||||
|
'message_type': 'inbound',
|
||||||
|
'mobile_number': f'+{self.whatsapp_number}',
|
||||||
|
'msg_uid': kwargs['whatsapp_inbound_msg_uid'],
|
||||||
|
'parent_id': parent_msg_id,
|
||||||
|
'state': 'received',
|
||||||
|
'wa_account_id': self.wa_account_id.id,
|
||||||
|
})
|
||||||
|
if parent_msg_id:
|
||||||
|
self.env['whatsapp.message'].browse(parent_msg_id).state = 'replied'
|
||||||
|
return recipients_data
|
||||||
|
|
||||||
|
def message_post(self, *, message_type='notification', **kwargs):
|
||||||
|
new_msg = super().message_post(message_type=message_type, **kwargs)
|
||||||
|
if self.channel_type == 'whatsapp' and message_type == 'whatsapp_message':
|
||||||
|
if new_msg.author_id == self.whatsapp_partner_id:
|
||||||
|
self.last_wa_mail_message_id = new_msg
|
||||||
|
self._bus_send_store(
|
||||||
|
self, {"whatsapp_channel_valid_until": self.whatsapp_channel_valid_until}
|
||||||
|
)
|
||||||
|
if not new_msg.wa_message_ids:
|
||||||
|
whatsapp_message = self.env['whatsapp.message'].create({
|
||||||
|
'body': new_msg.body,
|
||||||
|
'mail_message_id': new_msg.id,
|
||||||
|
'message_type': 'outbound',
|
||||||
|
'mobile_number': f'+{self.whatsapp_number}',
|
||||||
|
'wa_account_id': self.wa_account_id.id,
|
||||||
|
})
|
||||||
|
whatsapp_message._send()
|
||||||
|
return new_msg
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# CONTROLLERS
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
@api.returns('self')
|
||||||
|
def _get_whatsapp_channel(self, whatsapp_number, wa_account_id, sender_name=False, create_if_not_found=False, related_message=False):
|
||||||
|
""" Creates a whatsapp channel.
|
||||||
|
|
||||||
|
:param str whatsapp_number: whatsapp phone number of the customer. It should
|
||||||
|
be formatted according to whatsapp standards, aka {country_code}{national_number}.
|
||||||
|
|
||||||
|
:returns: whatsapp discussion discuss.channel
|
||||||
|
"""
|
||||||
|
# be somewhat defensive with number, as it is used in various flows afterwards
|
||||||
|
# notably in 'message_post' for the number, and called by '_process_messages'
|
||||||
|
base_number = whatsapp_number if whatsapp_number.startswith('+') else f'+{whatsapp_number}'
|
||||||
|
wa_number = base_number.lstrip('+')
|
||||||
|
wa_formatted = wa_phone_validation.wa_phone_format(
|
||||||
|
self.env.company,
|
||||||
|
number=base_number,
|
||||||
|
force_format="WHATSAPP",
|
||||||
|
raise_exception=False,
|
||||||
|
) or wa_number
|
||||||
|
|
||||||
|
related_record = False
|
||||||
|
responsible_partners = self.env['res.partner']
|
||||||
|
channel_domain = [
|
||||||
|
('whatsapp_number', '=', wa_formatted),
|
||||||
|
('wa_account_id', '=', wa_account_id.id)
|
||||||
|
]
|
||||||
|
if related_message:
|
||||||
|
related_record = self.env[related_message.model].browse(related_message.res_id)
|
||||||
|
responsible_partners = related_record._whatsapp_get_responsible(
|
||||||
|
related_message=related_message,
|
||||||
|
related_record=related_record,
|
||||||
|
whatsapp_account=wa_account_id,
|
||||||
|
).partner_id
|
||||||
|
|
||||||
|
channel = self.sudo().search(channel_domain, order='create_date desc', limit=1)
|
||||||
|
if responsible_partners:
|
||||||
|
channel = channel.filtered(lambda c: all(r in c.channel_member_ids.partner_id for r in responsible_partners))
|
||||||
|
|
||||||
|
partners_to_notify = responsible_partners
|
||||||
|
record_name = related_message.record_name
|
||||||
|
if not record_name and related_message.res_id:
|
||||||
|
record_name = self.env[related_message.model].browse(related_message.res_id).display_name
|
||||||
|
if not channel and create_if_not_found:
|
||||||
|
channel = self.sudo().with_context(tools.clean_context(self.env.context)).create({
|
||||||
|
'name': f"{wa_formatted} ({record_name})" if record_name else wa_formatted,
|
||||||
|
'channel_type': 'whatsapp',
|
||||||
|
'whatsapp_number': wa_formatted,
|
||||||
|
'whatsapp_partner_id': self.env['res.partner']._find_or_create_from_number(wa_formatted, sender_name).id,
|
||||||
|
'wa_account_id': wa_account_id.id,
|
||||||
|
})
|
||||||
|
partners_to_notify += channel.whatsapp_partner_id
|
||||||
|
if related_message:
|
||||||
|
# Add message in channel about the related document
|
||||||
|
info = _("Related %(model_name)s: ", model_name=self.env['ir.model']._get(related_message.model).display_name)
|
||||||
|
url = Markup('{base_url}/odoo/{model}/{res_id}').format(
|
||||||
|
base_url=self.get_base_url(), model=related_message.model, res_id=related_message.res_id)
|
||||||
|
related_record_name = related_message.record_name
|
||||||
|
if not related_record_name:
|
||||||
|
related_record_name = self.env[related_message.model].browse(related_message.res_id).display_name
|
||||||
|
channel.message_post(
|
||||||
|
body=Markup('<p>{info}<a target="_blank" href="{url}">{related_record_name}</a></p>').format(
|
||||||
|
info=info, url=url, related_record_name=related_record_name),
|
||||||
|
message_type='comment',
|
||||||
|
author_id=self.env.ref('base.partner_root').id,
|
||||||
|
subtype_xmlid='mail.mt_note',
|
||||||
|
)
|
||||||
|
if hasattr(related_record, 'message_post'):
|
||||||
|
# Add notification in document about the new message and related channel
|
||||||
|
info = _("A new WhatsApp channel is created for this document")
|
||||||
|
url = Markup('{base_url}/odoo/discuss.channel/{channel_id}').format(
|
||||||
|
base_url=self.get_base_url(), channel_id=channel.id)
|
||||||
|
related_record.message_post(
|
||||||
|
author_id=self.env.ref('base.partner_root').id,
|
||||||
|
body=Markup('<p>{info} <a target="_blank" class="o_whatsapp_channel_redirect"'
|
||||||
|
'data-oe-id="{channel_id}" href="{url}">{channel_name}</a></p>').format(
|
||||||
|
info=info, url=url, channel_id=channel.id, channel_name=channel.display_name),
|
||||||
|
message_type='comment',
|
||||||
|
subtype_xmlid='mail.mt_note',
|
||||||
|
)
|
||||||
|
if partners_to_notify == channel.whatsapp_partner_id and wa_account_id.notify_user_ids.partner_id:
|
||||||
|
partners_to_notify += wa_account_id.notify_user_ids.partner_id
|
||||||
|
channel.channel_member_ids = [Command.clear()] + [Command.create({'partner_id': partner.id}) for partner in partners_to_notify]
|
||||||
|
channel._broadcast(partners_to_notify.ids)
|
||||||
|
return channel
|
||||||
|
|
||||||
|
def whatsapp_channel_join_and_pin(self):
|
||||||
|
""" Adds the current partner as a member of self channel and pins them if not already pinned. """
|
||||||
|
self.ensure_one()
|
||||||
|
if self.channel_type != 'whatsapp':
|
||||||
|
raise ValidationError(_('This join method is not possible for regular channels.'))
|
||||||
|
|
||||||
|
self.check_access('write')
|
||||||
|
current_partner = self.env.user.partner_id
|
||||||
|
member = self.channel_member_ids.filtered(lambda m: m.partner_id == current_partner)
|
||||||
|
if member:
|
||||||
|
if not member.is_pinned:
|
||||||
|
member.write({'unpin_dt': False})
|
||||||
|
else:
|
||||||
|
new_member = self.env['discuss.channel.member'].with_context(tools.clean_context(self.env.context)).sudo().create([{
|
||||||
|
'partner_id': current_partner.id,
|
||||||
|
'channel_id': self.id,
|
||||||
|
}])
|
||||||
|
message_body = Markup(f'<div class="o_mail_notification">{_("joined the channel")}</div>')
|
||||||
|
new_member.channel_id.message_post(body=message_body, message_type="notification", subtype_xmlid="mail.mt_comment")
|
||||||
|
self._bus_send_store(Store(new_member).add(self, {"memberCount": self.member_count}))
|
||||||
|
return Store(self).get_result()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# OVERRIDE
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _action_unfollow(self, partner=None, guest=None):
|
||||||
|
if partner and self.channel_type == "whatsapp" \
|
||||||
|
and next(
|
||||||
|
(member.partner_id for member in self.channel_member_ids if not member.partner_id.partner_share),
|
||||||
|
self.env["res.partner"]
|
||||||
|
) == partner:
|
||||||
|
msg = _("You can't leave this channel. As you are the owner of this WhatsApp channel, you can only delete it.")
|
||||||
|
partner._bus_send_transient_message(self, msg)
|
||||||
|
return
|
||||||
|
super()._action_unfollow(partner, guest)
|
||||||
|
|
||||||
|
def _to_store(self, store: Store):
|
||||||
|
super()._to_store(store)
|
||||||
|
for channel in self.filtered(lambda channel: channel.channel_type == "whatsapp"):
|
||||||
|
store.add(channel, {
|
||||||
|
"whatsapp_channel_valid_until": channel.whatsapp_channel_valid_until,
|
||||||
|
"whatsapp_partner_id": Store.one(channel.whatsapp_partner_id, only_id=True),
|
||||||
|
})
|
||||||
|
|
||||||
|
def _types_allowing_seen_infos(self):
|
||||||
|
return super()._types_allowing_seen_infos() + ["whatsapp"]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# COMMANDS
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
def execute_command_leave(self, **kwargs):
|
||||||
|
if self.channel_type == 'whatsapp':
|
||||||
|
self.action_unfollow()
|
||||||
|
else:
|
||||||
|
super().execute_command_leave(**kwargs)
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
|
||||||
|
class DiscussChannelMember(models.Model):
|
||||||
|
_inherit = 'discuss.channel.member'
|
||||||
|
|
||||||
|
@api.autovacuum
|
||||||
|
def _gc_unpin_whatsapp_channels(self):
|
||||||
|
""" Unpin read whatsapp channels with no activity for at least one day to
|
||||||
|
clean the operator's interface """
|
||||||
|
members = self.env['discuss.channel.member'].search([
|
||||||
|
('is_pinned', '=', True),
|
||||||
|
('last_seen_dt', '<=', datetime.now() - timedelta(days=1)),
|
||||||
|
('channel_id.channel_type', '=', 'whatsapp'),
|
||||||
|
])
|
||||||
|
members_to_be_unpinned = members.filtered(lambda m: m.message_unread_counter == 0)
|
||||||
|
members_to_be_unpinned.write({'unpin_dt': datetime.now()})
|
||||||
|
for member in members_to_be_unpinned:
|
||||||
|
member._bus_send("discuss.channel/unpin", {"id": member.channel_id.id})
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class ServerActions(models.Model):
|
||||||
|
""" Add WhatsApp option in server actions. """
|
||||||
|
_name = 'ir.actions.server'
|
||||||
|
_inherit = ['ir.actions.server']
|
||||||
|
|
||||||
|
# force insert before followers option
|
||||||
|
state = fields.Selection(selection_add=[
|
||||||
|
('whatsapp', 'Send WhatsApp'), ('followers',),
|
||||||
|
], ondelete={'whatsapp': 'cascade'})
|
||||||
|
# WhatsApp
|
||||||
|
wa_template_id = fields.Many2one(
|
||||||
|
'whatsapp.template', 'WhatsApp Template',
|
||||||
|
compute='_compute_wa_template_id',
|
||||||
|
ondelete='restrict', readonly=False, store=True,
|
||||||
|
domain="[('model_id', '=', model_id), ('status', '=', 'approved')]",
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('model_id', 'state')
|
||||||
|
def _compute_wa_template_id(self):
|
||||||
|
to_reset = self.filtered(
|
||||||
|
lambda act: act.state != 'whatsapp' or (act.model_id != act.wa_template_id.model_id)
|
||||||
|
)
|
||||||
|
if to_reset:
|
||||||
|
to_reset.wa_template_id = False
|
||||||
|
|
||||||
|
def _run_action_whatsapp_multi(self, eval_context=None):
|
||||||
|
if not self.wa_template_id or self._is_recompute():
|
||||||
|
return False
|
||||||
|
|
||||||
|
records = eval_context.get('records') or eval_context.get('record')
|
||||||
|
if not records:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.env['whatsapp.composer'].create({
|
||||||
|
'res_ids': records.ids,
|
||||||
|
'res_model': records._name,
|
||||||
|
'wa_template_id': self.wa_template_id.id,
|
||||||
|
})._send_whatsapp_template(force_send_by_cron=True)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import models, fields
|
||||||
|
from odoo.addons.mail.tools.discuss import Store
|
||||||
|
|
||||||
|
|
||||||
|
class MailMessage(models.Model):
|
||||||
|
_inherit = 'mail.message'
|
||||||
|
|
||||||
|
message_type = fields.Selection(
|
||||||
|
selection_add=[('whatsapp_message', 'WhatsApp')],
|
||||||
|
ondelete={'whatsapp_message': lambda recs: recs.write({'message_type': 'comment'})},
|
||||||
|
)
|
||||||
|
wa_message_ids = fields.One2many('whatsapp.message', 'mail_message_id', string='Related WhatsApp Messages')
|
||||||
|
|
||||||
|
def _post_whatsapp_reaction(self, reaction_content, partner_id):
|
||||||
|
self.ensure_one()
|
||||||
|
reaction_to_delete = self.reaction_ids.filtered(lambda r: r.partner_id == partner_id)
|
||||||
|
if reaction_to_delete:
|
||||||
|
content = reaction_to_delete.content
|
||||||
|
reaction_to_delete.unlink()
|
||||||
|
self._bus_send_reaction_group(content)
|
||||||
|
if reaction_content and self.id:
|
||||||
|
self.env['mail.message.reaction'].create({
|
||||||
|
'message_id': self.id,
|
||||||
|
'content': reaction_content,
|
||||||
|
'partner_id': partner_id.id,
|
||||||
|
})
|
||||||
|
self._bus_send_reaction_group(reaction_content)
|
||||||
|
|
||||||
|
def _to_store(self, store: Store, **kwargs):
|
||||||
|
super()._to_store(store, **kwargs)
|
||||||
|
if whatsapp_mail_messages := self.filtered(lambda m: m.message_type == "whatsapp_message"):
|
||||||
|
for whatsapp_message in (
|
||||||
|
self.env["whatsapp.message"]
|
||||||
|
.sudo()
|
||||||
|
.search([("mail_message_id", "in", whatsapp_mail_messages.ids)])
|
||||||
|
):
|
||||||
|
store.add(
|
||||||
|
whatsapp_message.mail_message_id, {"whatsappStatus": whatsapp_message.state}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import models
|
||||||
|
from odoo.addons.mail.tools.discuss import Store
|
||||||
|
|
||||||
|
|
||||||
|
class MailThread(models.AbstractModel):
|
||||||
|
_inherit = 'mail.thread'
|
||||||
|
|
||||||
|
def _thread_to_store(self, store: Store, /, *, request_list=None, **kwargs):
|
||||||
|
super()._thread_to_store(store, request_list=request_list, **kwargs)
|
||||||
|
if request_list:
|
||||||
|
store.add(
|
||||||
|
self,
|
||||||
|
{"canSendWhatsapp": self.env["whatsapp.template"]._can_use_whatsapp(self._name)},
|
||||||
|
as_thread=True,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from odoo import exceptions, models, _
|
||||||
|
from odoo.tools import format_datetime
|
||||||
|
|
||||||
|
|
||||||
|
class BaseModel(models.AbstractModel):
|
||||||
|
_inherit = 'base'
|
||||||
|
|
||||||
|
def _whatsapp_get_portal_url(self):
|
||||||
|
""" List is defined here else we need to create bridge modules. """
|
||||||
|
if self._name in {
|
||||||
|
'sale.order',
|
||||||
|
'account.move',
|
||||||
|
'project.project',
|
||||||
|
'project.task',
|
||||||
|
'purchase.order',
|
||||||
|
'helpdesk.ticket',
|
||||||
|
} and hasattr(self, 'get_portal_url'):
|
||||||
|
self.ensure_one()
|
||||||
|
return self.get_portal_url()
|
||||||
|
contactus_page = self.env.ref('website.contactus_page', raise_if_not_found=False)
|
||||||
|
return contactus_page.url if contactus_page else False
|
||||||
|
|
||||||
|
def _whatsapp_get_responsible(self, related_message=False, related_record=False, whatsapp_account=False):
|
||||||
|
""" Try to find suitable responsible users for a record.
|
||||||
|
This is typically used when trying to find who to add to the discuss.channel created when
|
||||||
|
a customer replies to a sent 'whatsapp.template'. In short: who should be notified.
|
||||||
|
|
||||||
|
Heuristic is as follows:
|
||||||
|
- Try to find a 'user_id/user_ids' field on the record, use that as responsible if available;
|
||||||
|
- Always add the author of the original message
|
||||||
|
(If you send a template to a customer, you should be able to reply to his questions.)
|
||||||
|
- If nothing found, fallback on the first available among the following:
|
||||||
|
- The creator of the record
|
||||||
|
- The last editor of the record
|
||||||
|
- Ultimate fallback is the people configured as 'notify_user_ids' on the whatsapp account
|
||||||
|
|
||||||
|
For each of those, we only take into account active internal users, that are not the
|
||||||
|
superuser, to avoid having the responsible set to 'Odoobot' for automated processes.
|
||||||
|
|
||||||
|
This method can be overridden to force specific responsible users. """
|
||||||
|
|
||||||
|
self.ensure_one()
|
||||||
|
responsible_users = self.env['res.users']
|
||||||
|
|
||||||
|
def filter_suitable_users(user):
|
||||||
|
return user.active and user._is_internal() and not user._is_superuser()
|
||||||
|
|
||||||
|
for field in ['user_id', 'user_ids']:
|
||||||
|
if field in self._fields and self[field]:
|
||||||
|
responsible_users = self[field].filtered(filter_suitable_users)
|
||||||
|
|
||||||
|
if related_message:
|
||||||
|
# add the message author even if we already have a responsible
|
||||||
|
responsible_users |= related_message.author_id.user_ids.filtered(filter_suitable_users)
|
||||||
|
|
||||||
|
if responsible_users:
|
||||||
|
# do not go further if we found suitable users
|
||||||
|
return responsible_users
|
||||||
|
|
||||||
|
if related_message and not related_record:
|
||||||
|
related_record = self.env[related_message.model].browse(related_message.res_id)
|
||||||
|
|
||||||
|
if related_record:
|
||||||
|
responsible_users = related_record.create_uid.filtered(filter_suitable_users)
|
||||||
|
|
||||||
|
if not responsible_users:
|
||||||
|
responsible_users = related_record.write_uid.filtered(filter_suitable_users)
|
||||||
|
|
||||||
|
if not responsible_users:
|
||||||
|
if not whatsapp_account:
|
||||||
|
whatsapp_account = self.env['whatsapp.account'].search([], limit=1)
|
||||||
|
|
||||||
|
responsible_users = whatsapp_account.notify_user_ids
|
||||||
|
|
||||||
|
return responsible_users
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from odoo import exceptions, fields, models, _
|
||||||
|
from odoo.addons.phone_validation.tools import phone_validation
|
||||||
|
from odoo.addons.whatsapp.tools import phone_validation as phone_validation_wa
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ResPartner(models.Model):
|
||||||
|
_inherit = 'res.partner'
|
||||||
|
|
||||||
|
wa_channel_count = fields.Integer(string='WhatsApp Channel Count', compute="_compute_wa_channel_count")
|
||||||
|
|
||||||
|
def _compute_wa_channel_count(self):
|
||||||
|
partner_channel_counts = {partner.id: 0 for partner in self}
|
||||||
|
member_count_by_partner = self.env['discuss.channel.member']._read_group(
|
||||||
|
domain=[
|
||||||
|
('channel_id.channel_type', '=', 'whatsapp'),
|
||||||
|
('partner_id', 'in', self.ids)
|
||||||
|
],
|
||||||
|
groupby=['partner_id'],
|
||||||
|
aggregates=['id:count'],
|
||||||
|
)
|
||||||
|
for partner, count in member_count_by_partner:
|
||||||
|
partner_channel_counts[partner.id] += count
|
||||||
|
for partner in self:
|
||||||
|
partner.wa_channel_count = partner_channel_counts[partner.id]
|
||||||
|
|
||||||
|
def _find_or_create_from_number(self, number, name=False):
|
||||||
|
""" Number should come currently from whatsapp and contain country info. """
|
||||||
|
search_number = number if number.startswith('+') else f'+{number}'
|
||||||
|
try:
|
||||||
|
formatted_number = phone_validation_wa.wa_phone_format(
|
||||||
|
self.env.company,
|
||||||
|
number=search_number,
|
||||||
|
force_format='E164',
|
||||||
|
raise_exception=True,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 don't want to crash in that point, whatever the issue
|
||||||
|
_logger.warning('WhatsApp: impossible to format incoming number %s, skipping partner creation', number)
|
||||||
|
formatted_number = False
|
||||||
|
if not number or not formatted_number:
|
||||||
|
return self.env['res.partner']
|
||||||
|
|
||||||
|
# find country / local number based on formatted number to ease future searches
|
||||||
|
region_data = phone_validation.phone_get_region_data_for_number(formatted_number)
|
||||||
|
number_country_code = region_data['code']
|
||||||
|
number_national_number = str(region_data['national_number'])
|
||||||
|
number_phone_code = int(region_data['phone_code'])
|
||||||
|
|
||||||
|
# search partner on INTL number, then fallback on national number
|
||||||
|
partners = self._search_on_phone_mobile("=", formatted_number)
|
||||||
|
if not partners:
|
||||||
|
partners = self._search_on_phone_mobile("=like", number_national_number)
|
||||||
|
|
||||||
|
if not partners:
|
||||||
|
# do not set a country if country code is not unique as we cannot guess
|
||||||
|
country = self.env['res.country'].search([('phone_code', '=', number_phone_code)])
|
||||||
|
if len(country) > 1:
|
||||||
|
country = country.filtered(lambda c: c.code.lower() == number_country_code.lower())
|
||||||
|
|
||||||
|
partners = self.env['res.partner'].create({
|
||||||
|
'country_id': country.id if country and len(country) == 1 else False,
|
||||||
|
'mobile': formatted_number,
|
||||||
|
'name': name or formatted_number,
|
||||||
|
})
|
||||||
|
partners._message_log(
|
||||||
|
body=_("Partner created by incoming WhatsApp message.")
|
||||||
|
)
|
||||||
|
return partners[0]
|
||||||
|
|
||||||
|
def _search_on_phone_mobile(self, operator, number):
|
||||||
|
""" Temporary hackish solution to better find partners based on numbers.
|
||||||
|
It is globally copied from '_search_phone_mobile_search' defined on
|
||||||
|
'mail.thread.phone' mixin. However a design decision led to not using
|
||||||
|
it in base whatsapp module (because stuff), hence not having
|
||||||
|
this search method nor the 'phone_sanitized' field. """
|
||||||
|
assert operator in {'=', '=like'}
|
||||||
|
number = number.strip()
|
||||||
|
if not number:
|
||||||
|
return self.browse()
|
||||||
|
if len(number) < self.env['mail.thread.phone']._phone_search_min_length:
|
||||||
|
raise exceptions.UserError(
|
||||||
|
_('Please enter at least 3 characters when searching a Phone/Mobile number.')
|
||||||
|
)
|
||||||
|
|
||||||
|
phone_fields = ['mobile', 'phone']
|
||||||
|
pattern = r'[\s\\./\(\)\-]'
|
||||||
|
sql_operator = "LIKE" if operator == "=like" else "="
|
||||||
|
|
||||||
|
if number.startswith(('+', '00')):
|
||||||
|
# searching on +32485112233 should also finds 0032485112233 (and vice versa)
|
||||||
|
# we therefore remove it from input value and search for both of them in db
|
||||||
|
where_str = ' OR '.join(
|
||||||
|
f"""partner.{phone_field} IS NOT NULL AND (
|
||||||
|
REGEXP_REPLACE(partner.{phone_field}, %s, '', 'g') {sql_operator} %s OR
|
||||||
|
REGEXP_REPLACE(partner.{phone_field}, %s, '', 'g') {sql_operator} %s
|
||||||
|
)"""
|
||||||
|
for phone_field in phone_fields
|
||||||
|
)
|
||||||
|
query = f"SELECT partner.id FROM {self._table} partner WHERE {where_str};"
|
||||||
|
|
||||||
|
term = re.sub(pattern, '', number[1 if number.startswith('+') else 2:])
|
||||||
|
if operator == "=like":
|
||||||
|
term = f'%{term}'
|
||||||
|
self._cr.execute(
|
||||||
|
query, (pattern, '00' + term, pattern, '+' + term) * len(phone_fields)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
where_str = ' OR '.join(
|
||||||
|
f"(partner.{phone_field} IS NOT NULL AND REGEXP_REPLACE(partner.{phone_field}, %s, '', 'g') {sql_operator} %s)"
|
||||||
|
for phone_field in phone_fields
|
||||||
|
)
|
||||||
|
query = f"SELECT partner.id FROM {self._table} partner WHERE {where_str};"
|
||||||
|
term = re.sub(pattern, '', number)
|
||||||
|
if operator == "=like":
|
||||||
|
term = f'%{term}'
|
||||||
|
self._cr.execute(query, (pattern, term) * len(phone_fields))
|
||||||
|
res = self._cr.fetchall()
|
||||||
|
return self.browse([r[0] for r in res])
|
||||||
|
|
||||||
|
def action_open_partner_wa_channels(self):
|
||||||
|
return {
|
||||||
|
'name': _('WhatsApp Chats'),
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'domain': [('channel_type', '=', 'whatsapp'), ('channel_partner_ids', 'in', self.ids)],
|
||||||
|
'res_model': 'discuss.channel',
|
||||||
|
'views': [(self.env.ref('whatsapp.discuss_channel_view_list_whatsapp').id, 'list')],
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResUsersSettings(models.Model):
|
||||||
|
_inherit = 'res.users.settings'
|
||||||
|
|
||||||
|
is_discuss_sidebar_category_whatsapp_open = fields.Boolean(
|
||||||
|
string='WhatsApp Category Open', default=True,
|
||||||
|
help="If checked, the WhatsApp category is open in the discuss sidebar")
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError, ValidationError
|
||||||
|
from odoo.addons.whatsapp.tools.whatsapp_api import WhatsAppApi
|
||||||
|
from odoo.addons.whatsapp.tools.whatsapp_exception import WhatsAppError
|
||||||
|
from odoo.tools import plaintext2html
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppAccount(models.Model):
|
||||||
|
_name = 'whatsapp.account'
|
||||||
|
_inherit = ['mail.thread']
|
||||||
|
_description = 'WhatsApp Business Account'
|
||||||
|
|
||||||
|
name = fields.Char(string="Name", tracking=1)
|
||||||
|
active = fields.Boolean(default=True, tracking=6)
|
||||||
|
|
||||||
|
app_uid = fields.Char(string="App ID", required=True, tracking=2)
|
||||||
|
app_secret = fields.Char(string="App Secret", groups='whatsapp.group_whatsapp_admin', required=True)
|
||||||
|
account_uid = fields.Char(string="Account ID", required=True, tracking=3)
|
||||||
|
phone_uid = fields.Char(string="Phone Number ID", required=True, tracking=4)
|
||||||
|
token = fields.Char(string="Access Token", required=True, groups='whatsapp.group_whatsapp_admin')
|
||||||
|
webhook_verify_token = fields.Char(string="Webhook Verify Token", compute='_compute_verify_token',
|
||||||
|
groups='whatsapp.group_whatsapp_admin', store=True)
|
||||||
|
callback_url = fields.Char(string="Callback URL", compute='_compute_callback_url', readonly=True, copy=False)
|
||||||
|
|
||||||
|
allowed_company_ids = fields.Many2many(
|
||||||
|
comodel_name='res.company', string="Allowed Company",
|
||||||
|
default=lambda self: self.env.company)
|
||||||
|
notify_user_ids = fields.Many2many(
|
||||||
|
comodel_name='res.users', default=lambda self: self.env.user,
|
||||||
|
domain=[('share', '=', False)], required=True, tracking=5,
|
||||||
|
help="Users to notify when a message is received and there is no template send in last 15 days")
|
||||||
|
|
||||||
|
templates_count = fields.Integer(string="Message Count", compute='_compute_templates_count')
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('phone_uid_unique', 'unique(phone_uid)', "The same phone number ID already exists")]
|
||||||
|
|
||||||
|
@api.constrains('notify_user_ids')
|
||||||
|
def _check_notify_user_ids(self):
|
||||||
|
for phone in self:
|
||||||
|
if len(phone.notify_user_ids) < 1:
|
||||||
|
raise ValidationError(_("Users to notify is required"))
|
||||||
|
|
||||||
|
def _compute_callback_url(self):
|
||||||
|
for account in self:
|
||||||
|
account.callback_url = self.get_base_url() + '/whatsapp/webhook'
|
||||||
|
|
||||||
|
@api.depends('account_uid')
|
||||||
|
def _compute_verify_token(self):
|
||||||
|
""" webhook_verify_token only set when record is created. Not update after that."""
|
||||||
|
for rec in self:
|
||||||
|
if rec.id and not rec.webhook_verify_token:
|
||||||
|
rec.webhook_verify_token = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8))
|
||||||
|
|
||||||
|
def _compute_templates_count(self):
|
||||||
|
for tmpl in self:
|
||||||
|
tmpl.templates_count = self.env['whatsapp.template'].search_count([('wa_account_id', '=', tmpl.id)])
|
||||||
|
|
||||||
|
def button_sync_whatsapp_account_templates(self):
|
||||||
|
"""
|
||||||
|
This method will sync all the templates of the WhatsApp Business Account.
|
||||||
|
It will create new templates and update existing templates.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
try:
|
||||||
|
response = WhatsAppApi(self)._get_all_template(fetch_all=True)
|
||||||
|
except WhatsAppError as err:
|
||||||
|
raise ValidationError(str(err)) from err
|
||||||
|
|
||||||
|
WhatsappTemplate = self.env['whatsapp.template']
|
||||||
|
existing_tmpls = WhatsappTemplate.with_context(active_test=False).search([('wa_account_id', '=', self.id)])
|
||||||
|
existing_tmpl_by_id = {t.wa_template_uid: t for t in existing_tmpls}
|
||||||
|
template_update_count = 0
|
||||||
|
template_create_count = 0
|
||||||
|
if response.get('data'):
|
||||||
|
create_vals = []
|
||||||
|
for template in response['data']:
|
||||||
|
existing_tmpl = existing_tmpl_by_id.get(template['id'])
|
||||||
|
if existing_tmpl:
|
||||||
|
template_update_count += 1
|
||||||
|
existing_tmpl._update_template_from_response(template)
|
||||||
|
else:
|
||||||
|
template_create_count += 1
|
||||||
|
create_vals.append(WhatsappTemplate._create_template_from_response(template, self))
|
||||||
|
WhatsappTemplate.create(create_vals)
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'title': _("Templates synchronized!"),
|
||||||
|
'type': 'success',
|
||||||
|
'message': _("%(create_count)s were created, %(update_count)s were updated",
|
||||||
|
create_count=template_create_count, update_count=template_update_count),
|
||||||
|
'next': {'type': 'ir.actions.act_window_close'},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def button_test_connection(self):
|
||||||
|
""" Test connection of the WhatsApp Business Account. with the given credentials.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
wa_api = WhatsAppApi(self)
|
||||||
|
try:
|
||||||
|
wa_api._test_connection()
|
||||||
|
except WhatsAppError as e:
|
||||||
|
raise UserError(str(e))
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'type': 'success',
|
||||||
|
'message': _("Credentials look good!"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_open_templates(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'name': _("Templates Of %(account_name)s", account_name=self.name),
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'res_model': 'whatsapp.template',
|
||||||
|
'domain': [('wa_account_id', '=', self.id)],
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'context': {'default_wa_account_id': self.id},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _find_active_channel(self, sender_mobile_formatted, sender_name=False, create_if_not_found=False):
|
||||||
|
"""This method will find the active channel for the given sender mobile number."""
|
||||||
|
self.ensure_one()
|
||||||
|
whatsapp_message = self.env['whatsapp.message'].sudo().search(
|
||||||
|
[
|
||||||
|
('mobile_number_formatted', '=', sender_mobile_formatted),
|
||||||
|
('wa_account_id', '=', self.id),
|
||||||
|
('wa_template_id', '!=', False),
|
||||||
|
('state', 'not in', ['outgoing', 'error', 'cancel']),
|
||||||
|
], limit=1, order='id desc')
|
||||||
|
return self.env['discuss.channel'].sudo()._get_whatsapp_channel(
|
||||||
|
whatsapp_number=sender_mobile_formatted,
|
||||||
|
wa_account_id=self,
|
||||||
|
sender_name=sender_name,
|
||||||
|
create_if_not_found=create_if_not_found,
|
||||||
|
related_message=whatsapp_message.mail_message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _process_messages(self, value):
|
||||||
|
"""
|
||||||
|
This method is used for processing messages with the values received via webhook.
|
||||||
|
If any whatsapp message template has been sent from this account then it will find the active channel or
|
||||||
|
create new channel with last template message sent to that number and post message in that channel.
|
||||||
|
And if channel is not found then it will create new channel with notify user set in account and post message.
|
||||||
|
Supported Messages
|
||||||
|
=> Text Message
|
||||||
|
=> Attachment Message with caption
|
||||||
|
=> Location Message
|
||||||
|
=> Contact Message
|
||||||
|
=> Message Reactions
|
||||||
|
"""
|
||||||
|
if 'messages' not in value and value.get('whatsapp_business_api_data', {}).get('messages'):
|
||||||
|
value = value['whatsapp_business_api_data']
|
||||||
|
|
||||||
|
wa_api = WhatsAppApi(self)
|
||||||
|
|
||||||
|
for messages in value.get('messages', []):
|
||||||
|
parent_msg_id = False
|
||||||
|
parent_id = False
|
||||||
|
channel = False
|
||||||
|
sender_name = value.get('contacts', [{}])[0].get('profile', {}).get('name')
|
||||||
|
sender_mobile = messages['from']
|
||||||
|
message_type = messages['type']
|
||||||
|
if 'context' in messages and messages['context'].get('id'):
|
||||||
|
parent_whatsapp_message = self.env['whatsapp.message'].sudo().search([('msg_uid', '=', messages['context']['id'])])
|
||||||
|
if parent_whatsapp_message:
|
||||||
|
parent_msg_id = parent_whatsapp_message.id
|
||||||
|
parent_id = parent_whatsapp_message.mail_message_id
|
||||||
|
if parent_id:
|
||||||
|
channel = self.env['discuss.channel'].sudo().search([('message_ids', 'in', parent_id.id)], limit=1)
|
||||||
|
|
||||||
|
if not channel:
|
||||||
|
channel = self._find_active_channel(sender_mobile, sender_name=sender_name, create_if_not_found=True)
|
||||||
|
kwargs = {
|
||||||
|
'message_type': 'whatsapp_message',
|
||||||
|
'author_id': channel.whatsapp_partner_id.id,
|
||||||
|
'parent_msg_id': parent_msg_id,
|
||||||
|
'subtype_xmlid': 'mail.mt_comment',
|
||||||
|
'parent_id': parent_id.id if parent_id else None
|
||||||
|
}
|
||||||
|
if message_type == 'text':
|
||||||
|
kwargs['body'] = plaintext2html(messages['text']['body'])
|
||||||
|
elif message_type == 'button':
|
||||||
|
kwargs['body'] = messages['button']['text']
|
||||||
|
elif message_type in ('document', 'image', 'audio', 'video', 'sticker'):
|
||||||
|
filename = messages[message_type].get('filename')
|
||||||
|
is_voice = messages[message_type].get('voice')
|
||||||
|
mime_type = messages[message_type].get('mime_type')
|
||||||
|
caption = messages[message_type].get('caption')
|
||||||
|
datas = wa_api._get_whatsapp_document(messages[message_type]['id'])
|
||||||
|
if not filename:
|
||||||
|
extension = mimetypes.guess_extension(mime_type) or ''
|
||||||
|
filename = message_type + extension
|
||||||
|
kwargs['attachments'] = [(filename, datas, {'voice': is_voice})]
|
||||||
|
if caption:
|
||||||
|
kwargs['body'] = plaintext2html(caption)
|
||||||
|
elif message_type == 'location':
|
||||||
|
url = Markup("https://maps.google.com/maps?q={latitude},{longitude}").format(
|
||||||
|
latitude=messages['location']['latitude'], longitude=messages['location']['longitude'])
|
||||||
|
body = Markup('<a target="_blank" href="{url}"> <i class="fa fa-map-marker"/> {location_string} </a>').format(
|
||||||
|
url=url, location_string=_("Location"))
|
||||||
|
if messages['location'].get('name'):
|
||||||
|
body += Markup("<br/>{location_name}").format(location_name=messages['location']['name'])
|
||||||
|
if messages['location'].get('address'):
|
||||||
|
body += Markup("<br/>{location_address}").format(location_name=messages['location']['address'])
|
||||||
|
kwargs['body'] = body
|
||||||
|
elif message_type == 'contacts':
|
||||||
|
body = ""
|
||||||
|
for contact in messages['contacts']:
|
||||||
|
body += Markup("<i class='fa fa-address-book'/> {contact_name} <br/>").format(
|
||||||
|
contact_name=contact.get('name', {}).get('formatted_name', ''))
|
||||||
|
for phone in contact.get('phones'):
|
||||||
|
body += Markup("{phone_type}: {phone_number}<br/>").format(
|
||||||
|
phone_type=phone.get('type'), phone_number=phone.get('phone'))
|
||||||
|
kwargs['body'] = body
|
||||||
|
elif message_type == 'reaction':
|
||||||
|
msg_uid = messages['reaction'].get('message_id')
|
||||||
|
whatsapp_message = self.env['whatsapp.message'].sudo().search([('msg_uid', '=', msg_uid)])
|
||||||
|
if whatsapp_message:
|
||||||
|
partner_id = channel.whatsapp_partner_id
|
||||||
|
emoji = messages['reaction'].get('emoji')
|
||||||
|
whatsapp_message.mail_message_id._post_whatsapp_reaction(reaction_content=emoji, partner_id=partner_id)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
_logger.warning("Unsupported whatsapp message type: %s", messages)
|
||||||
|
continue
|
||||||
|
channel.message_post(whatsapp_inbound_msg_uid=messages['id'], **kwargs)
|
||||||
|
|
@ -0,0 +1,453 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
import markupsafe
|
||||||
|
from markupsafe import Markup, escape
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.addons.phone_validation.tools import phone_validation
|
||||||
|
from odoo.addons.whatsapp.tools import phone_validation as wa_phone_validation
|
||||||
|
from odoo.addons.whatsapp.tools.retryable_codes import WHATSAPP_RETRYABLE_ERROR_CODES
|
||||||
|
from odoo.addons.whatsapp.tools.bounced_codes import BOUNCED_ERROR_CODES
|
||||||
|
from odoo.addons.whatsapp.tools.whatsapp_api import WhatsAppApi
|
||||||
|
from odoo.addons.whatsapp.tools.whatsapp_exception import WhatsAppError
|
||||||
|
from odoo.exceptions import ValidationError, UserError
|
||||||
|
from odoo.tools import groupby, html2plaintext
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class WhatsAppMessage(models.Model):
|
||||||
|
_name = 'whatsapp.message'
|
||||||
|
_description = 'WhatsApp Messages'
|
||||||
|
_order = 'id desc'
|
||||||
|
_rec_name = 'mobile_number'
|
||||||
|
|
||||||
|
# Refer to https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media/#supported-media-types
|
||||||
|
# for more details about supported media types
|
||||||
|
_SUPPORTED_ATTACHMENT_TYPE = {
|
||||||
|
'audio': ('audio/aac', 'audio/mp4', 'audio/mpeg', 'audio/amr', 'audio/ogg'),
|
||||||
|
'document': (
|
||||||
|
'text/plain', 'application/pdf', 'application/vnd.ms-powerpoint', 'application/msword',
|
||||||
|
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
),
|
||||||
|
'image': ('image/jpeg', 'image/png'),
|
||||||
|
'video': ('video/mp4',),
|
||||||
|
}
|
||||||
|
# amount of days during which a message is considered active
|
||||||
|
# used for GC and for finding an active document channel using a recent whatsapp template message
|
||||||
|
_ACTIVE_THRESHOLD_DAYS = 15
|
||||||
|
|
||||||
|
mobile_number = fields.Char(string="Sent To")
|
||||||
|
mobile_number_formatted = fields.Char(
|
||||||
|
string="Mobile Number Formatted",
|
||||||
|
compute="_compute_mobile_number_formatted", readonly=False, store=True)
|
||||||
|
message_type = fields.Selection([
|
||||||
|
('outbound', 'Outbound'),
|
||||||
|
('inbound', 'Inbound')], string="Message Type", default='outbound')
|
||||||
|
state = fields.Selection(selection=[
|
||||||
|
('outgoing', 'In Queue'),
|
||||||
|
('sent', 'Sent'),
|
||||||
|
('delivered', 'Delivered'),
|
||||||
|
('read', 'Read'),
|
||||||
|
('replied', 'Replied'), # used internally
|
||||||
|
('received', 'Received'),
|
||||||
|
('error', 'Failed'),
|
||||||
|
('bounced', 'Bounced'), # failure linked to number usage, different from pure whatsapp error
|
||||||
|
('cancel', 'Cancelled')], string="State", default='outgoing')
|
||||||
|
failure_type = fields.Selection([
|
||||||
|
('account', 'Account Error'),
|
||||||
|
('blacklisted', 'Blacklisted Phone Number'),
|
||||||
|
('network', 'Network Error'),
|
||||||
|
('phone_invalid', 'Wrong Number Format'),
|
||||||
|
('template', 'Template Quality Rating Too Low'),
|
||||||
|
('unknown', 'Unknown Error'),
|
||||||
|
('whatsapp_recoverable', 'Identified Error'),
|
||||||
|
('whatsapp_unrecoverable', 'Other Technical Error')
|
||||||
|
])
|
||||||
|
failure_reason = fields.Char(string="Failure Reason", help="Usually an error message from Whatsapp")
|
||||||
|
free_text_json = fields.Json(string="Free Text Template Parameters")
|
||||||
|
wa_template_id = fields.Many2one(comodel_name='whatsapp.template')
|
||||||
|
msg_uid = fields.Char(string="WhatsApp Message ID")
|
||||||
|
wa_account_id = fields.Many2one(comodel_name='whatsapp.account', string="WhatsApp Business Account")
|
||||||
|
parent_id = fields.Many2one('whatsapp.message', 'Response To', ondelete="set null")
|
||||||
|
|
||||||
|
mail_message_id = fields.Many2one(comodel_name='mail.message', index=True)
|
||||||
|
body = fields.Html(related='mail_message_id.body', string="Body", related_sudo=False)
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('unique_msg_uid', 'unique(msg_uid)', "Each whatsapp message should correspond to a single message uuid.")
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.depends('mobile_number')
|
||||||
|
def _compute_mobile_number_formatted(self):
|
||||||
|
for message in self:
|
||||||
|
recipient_partner = message.mail_message_id.partner_ids[0] if message.mail_message_id.partner_ids else self.env['res.partner']
|
||||||
|
country = recipient_partner.country_id if recipient_partner.country_id else self.env.company.country_id
|
||||||
|
formatted = wa_phone_validation.wa_phone_format(
|
||||||
|
country, # could take mail.message record as context but seems overkill
|
||||||
|
number=message.mobile_number,
|
||||||
|
country=country,
|
||||||
|
force_format="WHATSAPP",
|
||||||
|
raise_exception=False,
|
||||||
|
)
|
||||||
|
message.mobile_number_formatted = formatted or ''
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# CRUD
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals):
|
||||||
|
"""Override to check blacklist number and also add to blacklist if user has send stop message."""
|
||||||
|
messages = super().create(vals)
|
||||||
|
for message in messages:
|
||||||
|
body = html2plaintext(message.body)
|
||||||
|
if message.message_type == 'inbound' and message.mobile_number_formatted:
|
||||||
|
body_message = re.findall('([a-zA-Z]+)', body)
|
||||||
|
message_string = "".join(i.lower() for i in body_message)
|
||||||
|
try:
|
||||||
|
if message_string in self._get_opt_out_message():
|
||||||
|
self.env['phone.blacklist'].sudo().add(
|
||||||
|
number=f'+{message.mobile_number_formatted}', # from WA to E164 format
|
||||||
|
message=_("User has been opt out of receiving WhatsApp messages"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.env['phone.blacklist'].sudo().remove(
|
||||||
|
number=f'+{message.mobile_number_formatted}', # from WA to E164 format
|
||||||
|
message=_("User has opted in to receiving WhatsApp messages"),
|
||||||
|
)
|
||||||
|
except UserError:
|
||||||
|
# there was something wrong with number formatting that cannot be
|
||||||
|
# accepted by the blacklist -> simply skip, better be defensive
|
||||||
|
_logger.warning(
|
||||||
|
'Whatsapp: impossible to change opt-in status of %s (formatted as %s) as it is not a valid number (whatsapp.message-%s)',
|
||||||
|
message.mobile_number, message.mobile_number_formatted, message.id
|
||||||
|
)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
@api.autovacuum
|
||||||
|
def _gc_whatsapp_messages(self):
|
||||||
|
""" To avoid bloating the database, we remove old whatsapp.messages that have been correctly
|
||||||
|
received / sent and are older than 15 days.'
|
||||||
|
|
||||||
|
We use these messages mainly to tie a customer answer to a certain document channel, but
|
||||||
|
only do so for the last 15 days (see '_find_active_channel').
|
||||||
|
|
||||||
|
After that period, they become non-relevant as the real content of conversations is kept
|
||||||
|
inside discuss.channel / mail.messages (as every other discussions).
|
||||||
|
|
||||||
|
Impact of GC when using the 'reply-to' function from the WhatsApp app as the customer:
|
||||||
|
- We could loose the context that a message is 'a reply to' another one, implying that
|
||||||
|
someone would reply to a message after 15 days, which is unlikely.
|
||||||
|
(To clarify: we will still receive the message, it will just not give the 'in-reply-to'
|
||||||
|
context anymore on the discuss channel).
|
||||||
|
- We could also loose the "right channel" in that case, and send the message to a another
|
||||||
|
(or a new) discuss channel, but it is again unlikely to answer more than 15 days later. """
|
||||||
|
|
||||||
|
domain = self._get_whatsapp_gc_domain()
|
||||||
|
self.env['whatsapp.message'].search(domain).unlink()
|
||||||
|
|
||||||
|
def _get_whatsapp_gc_domain(self):
|
||||||
|
date_threshold = fields.Datetime.now() - timedelta(
|
||||||
|
days=self.env['whatsapp.message']._ACTIVE_THRESHOLD_DAYS)
|
||||||
|
return [
|
||||||
|
('create_date', '<', date_threshold),
|
||||||
|
('state', 'not in', ['outgoing', 'error', 'cancel'])
|
||||||
|
]
|
||||||
|
|
||||||
|
def _get_formatted_number(self, sanitized_number, country_code):
|
||||||
|
""" Format a valid mobile number for whatsapp.
|
||||||
|
|
||||||
|
:examples:
|
||||||
|
'+919999912345' -> '919999912345'
|
||||||
|
:return: formatted mobile number
|
||||||
|
|
||||||
|
TDE FIXME: remove in master
|
||||||
|
"""
|
||||||
|
mobile_number_parse = phone_validation.phone_parse(sanitized_number, country_code)
|
||||||
|
return f'{mobile_number_parse.country_code}{mobile_number_parse.national_number}'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_opt_out_message(self):
|
||||||
|
return ['stop', 'unsubscribe', 'stop promotions']
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# ACTIONS
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
def button_resend(self):
|
||||||
|
""" Resend a failed message. """
|
||||||
|
if self.filtered(lambda rec: rec.state != 'error'):
|
||||||
|
raise UserError(_("You can not resend message which is not in failed state."))
|
||||||
|
self._resend_failed()
|
||||||
|
|
||||||
|
def button_cancel_send(self):
|
||||||
|
""" Cancel a draft or outgoing message. """
|
||||||
|
if self.filtered(lambda rec: rec.state != 'outgoing'):
|
||||||
|
raise UserError(_("You can not cancel message which is in queue."))
|
||||||
|
self.state = 'cancel'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# SEND
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _resend_failed(self):
|
||||||
|
""" Resend failed messages. """
|
||||||
|
retryable_messages = self.filtered(lambda msg: msg.state == 'error' and msg.failure_type != 'whatsapp_unrecoverable')
|
||||||
|
retryable_messages.write({'state': 'outgoing', 'failure_type': False, 'failure_reason': False})
|
||||||
|
self.env.ref('whatsapp.ir_cron_send_whatsapp_queue')._trigger()
|
||||||
|
|
||||||
|
def _send_cron(self):
|
||||||
|
""" Send all outgoing messages. """
|
||||||
|
records = self.search([
|
||||||
|
('state', '=', 'outgoing'), ('wa_template_id', '!=', False)
|
||||||
|
], limit=500)
|
||||||
|
records._send_message(with_commit=True)
|
||||||
|
if len(records) == 500: # assumes there are more whenever search hits limit
|
||||||
|
self.env.ref('whatsapp.ir_cron_send_whatsapp_queue')._trigger()
|
||||||
|
|
||||||
|
def _send(self, force_send_by_cron=False):
|
||||||
|
if len(self) <= 1 and not force_send_by_cron:
|
||||||
|
self._send_message()
|
||||||
|
else:
|
||||||
|
self.env.ref('whatsapp.ir_cron_send_whatsapp_queue')._trigger()
|
||||||
|
|
||||||
|
def _send_message(self, with_commit=False):
|
||||||
|
""" Prepare json data for sending messages, attachments and templates."""
|
||||||
|
# init api
|
||||||
|
message_to_api = {}
|
||||||
|
for account, messages in groupby(self, lambda msg: msg.wa_account_id):
|
||||||
|
if not account:
|
||||||
|
messages = self.env['whatsapp.message'].concat(*messages)
|
||||||
|
messages.write({
|
||||||
|
'failure_type': 'unknown',
|
||||||
|
'failure_reason': 'Missing whatsapp account for message.',
|
||||||
|
'state': 'error',
|
||||||
|
})
|
||||||
|
self -= messages
|
||||||
|
continue
|
||||||
|
wa_api = WhatsAppApi(account)
|
||||||
|
for message in messages:
|
||||||
|
message_to_api[message] = wa_api
|
||||||
|
|
||||||
|
for whatsapp_message in self:
|
||||||
|
wa_api = message_to_api[whatsapp_message]
|
||||||
|
# try to make changes with current user (notably due to ACLs), but limit
|
||||||
|
# to internal users to avoid crash - rewrite me in master please
|
||||||
|
if whatsapp_message.create_uid._is_internal():
|
||||||
|
whatsapp_message = whatsapp_message.with_user(whatsapp_message.create_uid)
|
||||||
|
if whatsapp_message.state != 'outgoing':
|
||||||
|
_logger.info("Message state in %s state so it will not sent.", whatsapp_message.state)
|
||||||
|
continue
|
||||||
|
msg_uid = False
|
||||||
|
try:
|
||||||
|
parent_message_id = False
|
||||||
|
body = whatsapp_message.body
|
||||||
|
if isinstance(body, markupsafe.Markup):
|
||||||
|
# If Body is in html format so we need to remove html tags before sending message.
|
||||||
|
body = body.striptags()
|
||||||
|
number = whatsapp_message.mobile_number_formatted
|
||||||
|
if not number:
|
||||||
|
raise WhatsAppError(failure_type='phone_invalid')
|
||||||
|
if self.env['phone.blacklist'].sudo().search_count([('number', 'ilike', number), ('active', '=', True)], limit=1):
|
||||||
|
raise WhatsAppError(failure_type='blacklisted')
|
||||||
|
|
||||||
|
# based on template
|
||||||
|
if whatsapp_message.wa_template_id:
|
||||||
|
message_type = 'template'
|
||||||
|
if whatsapp_message.wa_template_id.status != 'approved' or whatsapp_message.wa_template_id.quality == 'red':
|
||||||
|
raise WhatsAppError(failure_type='template')
|
||||||
|
whatsapp_message.message_type = 'outbound'
|
||||||
|
if whatsapp_message.mail_message_id.model != whatsapp_message.wa_template_id.model:
|
||||||
|
raise WhatsAppError(failure_type='template')
|
||||||
|
|
||||||
|
RecordModel = self.env[whatsapp_message.mail_message_id.model].with_user(whatsapp_message.create_uid)
|
||||||
|
from_record = RecordModel.browse(whatsapp_message.mail_message_id.res_id)
|
||||||
|
|
||||||
|
# if retrying message then we need to unlink previous attachment
|
||||||
|
# in case of header with report in order to generate it again
|
||||||
|
if whatsapp_message.wa_template_id.report_id and whatsapp_message.wa_template_id.header_type == 'document' and whatsapp_message.mail_message_id.attachment_ids:
|
||||||
|
whatsapp_message.mail_message_id.attachment_ids.unlink()
|
||||||
|
|
||||||
|
# generate sending values, components and attachments
|
||||||
|
send_vals, attachment = whatsapp_message.wa_template_id._get_send_template_vals(
|
||||||
|
record=from_record,
|
||||||
|
whatsapp_message=whatsapp_message,
|
||||||
|
)
|
||||||
|
if attachment and attachment not in whatsapp_message.mail_message_id.attachment_ids:
|
||||||
|
whatsapp_message.mail_message_id.attachment_ids = [(4, attachment.id)]
|
||||||
|
# no template
|
||||||
|
elif whatsapp_message.mail_message_id.attachment_ids:
|
||||||
|
attachment_vals = whatsapp_message._prepare_attachment_vals(whatsapp_message.mail_message_id.attachment_ids[0], wa_account_id=whatsapp_message.wa_account_id)
|
||||||
|
message_type = attachment_vals.get('type')
|
||||||
|
send_vals = attachment_vals.get(message_type)
|
||||||
|
if whatsapp_message.body:
|
||||||
|
send_vals['caption'] = body
|
||||||
|
else:
|
||||||
|
message_type = 'text'
|
||||||
|
send_vals = {
|
||||||
|
'preview_url': True,
|
||||||
|
'body': body,
|
||||||
|
}
|
||||||
|
# Tagging parent message id if parent message is available
|
||||||
|
if whatsapp_message.mail_message_id and whatsapp_message.mail_message_id.parent_id:
|
||||||
|
parent_id = whatsapp_message.mail_message_id.parent_id.wa_message_ids
|
||||||
|
if parent_id:
|
||||||
|
parent_message_id = parent_id[0].msg_uid
|
||||||
|
msg_uid = wa_api._send_whatsapp(number=number, message_type=message_type, send_vals=send_vals, parent_message_id=parent_message_id)
|
||||||
|
except WhatsAppError as we:
|
||||||
|
whatsapp_message._handle_error(whatsapp_error_code=we.error_code, error_message=we.error_message,
|
||||||
|
failure_type=we.failure_type)
|
||||||
|
except (UserError, ValidationError) as e:
|
||||||
|
whatsapp_message._handle_error(failure_type='unknown', error_message=str(e))
|
||||||
|
else:
|
||||||
|
if not msg_uid:
|
||||||
|
whatsapp_message._handle_error(failure_type='unknown')
|
||||||
|
else:
|
||||||
|
if message_type == 'template':
|
||||||
|
whatsapp_message._post_message_in_active_channel()
|
||||||
|
whatsapp_message.write({
|
||||||
|
'state': 'sent',
|
||||||
|
'msg_uid': msg_uid
|
||||||
|
})
|
||||||
|
if with_commit:
|
||||||
|
self._cr.commit()
|
||||||
|
|
||||||
|
def _handle_error(self, failure_type=False, whatsapp_error_code=False, error_message=False):
|
||||||
|
""" Format and write errors on the message. """
|
||||||
|
self.ensure_one()
|
||||||
|
state = 'error'
|
||||||
|
if whatsapp_error_code:
|
||||||
|
if whatsapp_error_code in WHATSAPP_RETRYABLE_ERROR_CODES:
|
||||||
|
failure_type = 'whatsapp_recoverable'
|
||||||
|
else:
|
||||||
|
failure_type = 'whatsapp_unrecoverable'
|
||||||
|
if not failure_type:
|
||||||
|
failure_type = 'unknown'
|
||||||
|
if whatsapp_error_code in BOUNCED_ERROR_CODES:
|
||||||
|
state = 'bounced'
|
||||||
|
self.write({
|
||||||
|
'failure_type': failure_type,
|
||||||
|
'failure_reason': error_message,
|
||||||
|
'state': state,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _post_message_in_active_channel(self):
|
||||||
|
""" Notify the active channel that someone has sent template message. """
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.wa_template_id:
|
||||||
|
return
|
||||||
|
channel = self.wa_account_id._find_active_channel(self.mobile_number_formatted)
|
||||||
|
if not channel:
|
||||||
|
return
|
||||||
|
|
||||||
|
# same user, different model: print full content of message into conversation
|
||||||
|
if channel.is_member:
|
||||||
|
body = self.body
|
||||||
|
message_type = "comment"
|
||||||
|
# diffrent user from any model: warn chat is about to be moved into a new conversation
|
||||||
|
else:
|
||||||
|
record_name = self.mail_message_id.record_name
|
||||||
|
if self.mail_message_id.model and self.mail_message_id.res_id:
|
||||||
|
if not record_name:
|
||||||
|
record_name = self.env[self.mail_message_id.model].browse(self.mail_message_id.res_id).display_name
|
||||||
|
url = f"{self.get_base_url()}/odoo/{self.mail_message_id.model}/{self.mail_message_id.res_id}"
|
||||||
|
record_link = f"<a target='_blank' href='{url}'>{escape(record_name)}</a>"
|
||||||
|
else:
|
||||||
|
record_link = record_name or _("another document")
|
||||||
|
body = Markup(
|
||||||
|
_("A new template was sent on %(record_link)s.<br>"
|
||||||
|
"Future replies will be transferred to a new chat.",
|
||||||
|
record_link=record_link
|
||||||
|
))
|
||||||
|
message_type = "notification"
|
||||||
|
channel.sudo().message_post(
|
||||||
|
body=body,
|
||||||
|
message_type=message_type,
|
||||||
|
subtype_xmlid='mail.mt_comment',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _prepare_attachment_vals(self, attachment, wa_account_id):
|
||||||
|
""" Upload the attachment to WhatsApp and return prepared values to attach to the message. """
|
||||||
|
whatsapp_media_type = next((
|
||||||
|
media_type
|
||||||
|
for media_type, mimetypes
|
||||||
|
in self._SUPPORTED_ATTACHMENT_TYPE.items()
|
||||||
|
if attachment.mimetype in mimetypes),
|
||||||
|
False
|
||||||
|
)
|
||||||
|
|
||||||
|
if not whatsapp_media_type:
|
||||||
|
raise WhatsAppError(_("Attachment mimetype is not supported by WhatsApp: %s.", attachment.mimetype))
|
||||||
|
wa_api = WhatsAppApi(wa_account_id)
|
||||||
|
whatsapp_media_uid = wa_api._upload_whatsapp_document(attachment)
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
'type': whatsapp_media_type,
|
||||||
|
whatsapp_media_type: {'id': whatsapp_media_uid}
|
||||||
|
}
|
||||||
|
|
||||||
|
if whatsapp_media_type == 'document':
|
||||||
|
vals[whatsapp_media_type]['filename'] = attachment.name
|
||||||
|
|
||||||
|
return vals
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# CALLBACK
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _process_statuses(self, value):
|
||||||
|
""" Process status of the message like 'send', 'delivered' and 'read'."""
|
||||||
|
mapping = {'failed': 'error', 'cancelled': 'cancel'}
|
||||||
|
processed_message_ids = set()
|
||||||
|
|
||||||
|
for statuses in value.get('statuses', []):
|
||||||
|
whatsapp_message = self.env['whatsapp.message'].sudo().search([('msg_uid', '=', statuses['id'])])
|
||||||
|
if whatsapp_message:
|
||||||
|
whatsapp_message.state = mapping.get(statuses['status'], statuses['status'])
|
||||||
|
processed_message_ids.add(whatsapp_message.id)
|
||||||
|
whatsapp_message._update_message_fetched_seen()
|
||||||
|
if statuses['status'] == 'failed':
|
||||||
|
error = statuses['errors'][0] if statuses.get('errors') else None
|
||||||
|
if error:
|
||||||
|
whatsapp_message._handle_error(whatsapp_error_code=error['code'],
|
||||||
|
error_message=f"{error['code']} : {error['title']}")
|
||||||
|
return self.env['whatsapp.message'].browse(sorted(processed_message_ids, reverse=True)).sudo()
|
||||||
|
|
||||||
|
def _update_message_fetched_seen(self):
|
||||||
|
""" Update message status for the whatsapp recipient. """
|
||||||
|
self.ensure_one()
|
||||||
|
if self.mail_message_id.model != 'discuss.channel':
|
||||||
|
return
|
||||||
|
channel = self.env['discuss.channel'].browse(self.mail_message_id.res_id)
|
||||||
|
channel_member = channel.channel_member_ids.filtered(lambda cm: cm.partner_id == channel.whatsapp_partner_id)
|
||||||
|
if not channel_member:
|
||||||
|
return
|
||||||
|
channel_member = channel_member[0]
|
||||||
|
notification_type = None
|
||||||
|
if self.state == 'read':
|
||||||
|
channel_member.write({
|
||||||
|
'fetched_message_id': max(channel_member.fetched_message_id.id, self.mail_message_id.id),
|
||||||
|
'seen_message_id': self.mail_message_id.id,
|
||||||
|
'last_seen_dt': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
notification_type = 'discuss.channel.member/seen'
|
||||||
|
elif self.state == 'delivered':
|
||||||
|
channel_member.write({'fetched_message_id': self.mail_message_id.id})
|
||||||
|
notification_type = 'discuss.channel.member/fetched'
|
||||||
|
if notification_type:
|
||||||
|
channel._bus_send(
|
||||||
|
notification_type,
|
||||||
|
{
|
||||||
|
"channel_id": channel.id,
|
||||||
|
"id": channel_member.id,
|
||||||
|
"last_message_id": self.mail_message_id.id,
|
||||||
|
"partner_id": channel.whatsapp_partner_id.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,934 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
from odoo import api, models, fields, _, Command
|
||||||
|
from odoo.addons.whatsapp.tools.lang_list import Languages
|
||||||
|
from odoo.addons.whatsapp.tools.whatsapp_api import WhatsAppApi
|
||||||
|
from odoo.addons.whatsapp.tools.whatsapp_exception import WhatsAppError
|
||||||
|
from odoo.exceptions import UserError, ValidationError, AccessError
|
||||||
|
from odoo.tools import plaintext2html
|
||||||
|
from odoo.tools.safe_eval import safe_eval
|
||||||
|
|
||||||
|
LATITUDE_LONGITUDE_REGEX = r'^[-+]?([1-8]?\d(\.\d+)?|90(\.0+)?),\s*[-+]?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$'
|
||||||
|
|
||||||
|
COMMON_WHATSAPP_PHONE_SAFE_FIELDS = {
|
||||||
|
'mobile',
|
||||||
|
'phone',
|
||||||
|
'phone_sanitized',
|
||||||
|
'partner_id.mobile',
|
||||||
|
'partner_id.phone',
|
||||||
|
'phone_sanitized.phone',
|
||||||
|
'x_studio_mobile',
|
||||||
|
'x_studio_phone',
|
||||||
|
'x_studio_partner_id.mobile',
|
||||||
|
'x_studio_partner_id.phone',
|
||||||
|
'x_studio_partner_id.phone_sanitized',
|
||||||
|
}
|
||||||
|
|
||||||
|
class WhatsAppTemplate(models.Model):
|
||||||
|
_name = 'whatsapp.template'
|
||||||
|
_inherit = ['mail.thread']
|
||||||
|
_description = 'WhatsApp Template'
|
||||||
|
_order = 'sequence asc, id'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_default_wa_account_id(self):
|
||||||
|
first_account = self.env['whatsapp.account'].search([
|
||||||
|
('allowed_company_ids', 'in', self.env.companies.ids)], limit=1)
|
||||||
|
return first_account.id if first_account else False
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_model_selection(self):
|
||||||
|
""" Available models are all models, as even transient models could have
|
||||||
|
templates associated (e.g. payment.link.wizard) """
|
||||||
|
return [
|
||||||
|
(model.model, model.name)
|
||||||
|
for model in self.env['ir.model'].sudo().search([])
|
||||||
|
]
|
||||||
|
|
||||||
|
name = fields.Char(string="Name", tracking=True)
|
||||||
|
template_name = fields.Char(
|
||||||
|
string="Template Name",
|
||||||
|
compute='_compute_template_name', readonly=False, store=True,
|
||||||
|
copy=False)
|
||||||
|
sequence = fields.Integer(required=True, default=0)
|
||||||
|
active = fields.Boolean(default=True)
|
||||||
|
|
||||||
|
wa_account_id = fields.Many2one(
|
||||||
|
comodel_name='whatsapp.account', string="Account", default=_get_default_wa_account_id,
|
||||||
|
ondelete="cascade")
|
||||||
|
wa_template_uid = fields.Char(string="WhatsApp Template ID", copy=False)
|
||||||
|
error_msg = fields.Char(string="Error Message")
|
||||||
|
|
||||||
|
model_id = fields.Many2one(
|
||||||
|
string='Applies to', comodel_name='ir.model',
|
||||||
|
default=lambda self: self.env['ir.model']._get_id('res.partner'),
|
||||||
|
ondelete='cascade', required=True, store=True,
|
||||||
|
tracking=1)
|
||||||
|
model = fields.Char(
|
||||||
|
string='Related Document Model',
|
||||||
|
related='model_id.model',
|
||||||
|
precompute=True, store=True, readonly=True)
|
||||||
|
phone_field = fields.Char(
|
||||||
|
string='Phone Field', compute='_compute_phone_field',
|
||||||
|
precompute=True, readonly=False, required=True, store=True)
|
||||||
|
lang_code = fields.Selection(string="Language", selection=Languages, default='en', required=True)
|
||||||
|
template_type = fields.Selection([
|
||||||
|
('authentication', 'Authentication'),
|
||||||
|
('marketing', 'Marketing'),
|
||||||
|
('utility', 'Utility')], string="Category", default='marketing', tracking=True, required=True,
|
||||||
|
help="Authentication - One-time passwords that your customers use to authenticate a transaction or login.\n"
|
||||||
|
"Marketing - Promotions or information about your business, products or services. Or any message that isn't utility or authentication.\n"
|
||||||
|
"Utility - Messages about a specific transaction, account, order or customer request.")
|
||||||
|
|
||||||
|
status = fields.Selection([
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('in_appeal', 'In Appeal'),
|
||||||
|
('approved', 'Approved'),
|
||||||
|
('paused', 'Paused'),
|
||||||
|
('disabled', 'Disabled'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
|
('pending_deletion', 'Pending Deletion'),
|
||||||
|
('deleted', 'Deleted'),
|
||||||
|
('limit_exceeded', 'Limit Exceeded')], string="Status", default='draft', copy=False, tracking=True)
|
||||||
|
quality = fields.Selection([
|
||||||
|
('none', 'None'),
|
||||||
|
('red', 'Red'),
|
||||||
|
('yellow', 'Yellow'),
|
||||||
|
('green', 'Green')], string="Quality", default='none', copy=False, tracking=True)
|
||||||
|
allowed_user_ids = fields.Many2many(
|
||||||
|
comodel_name='res.users', string="Users",
|
||||||
|
domain=[('share', '=', False)])
|
||||||
|
|
||||||
|
body = fields.Text(string="Template body", tracking=True)
|
||||||
|
header_type = fields.Selection([
|
||||||
|
('none', 'None'),
|
||||||
|
('text', 'Text'),
|
||||||
|
('image', 'Image'),
|
||||||
|
('video', 'Video'),
|
||||||
|
('document', 'Document'),
|
||||||
|
('location', 'Location')], string="Header Type", default='none')
|
||||||
|
header_text = fields.Char(string="Template Header Text", size=60)
|
||||||
|
header_attachment_ids = fields.Many2many(
|
||||||
|
'ir.attachment', string="Template Static Header",
|
||||||
|
copy=False) # keep False to avoid linking attachments; we have to copy them instead
|
||||||
|
footer_text = fields.Char(string="Footer Message")
|
||||||
|
report_id = fields.Many2one(
|
||||||
|
comodel_name='ir.actions.report', string="Report",
|
||||||
|
compute="_compute_report_id", readonly=False, store=True,
|
||||||
|
domain="[('model', '=', model)]", tracking=True)
|
||||||
|
variable_ids = fields.One2many(
|
||||||
|
'whatsapp.template.variable', 'wa_template_id', string="Template Variables",
|
||||||
|
store=True, compute='_compute_variable_ids', precompute=True, readonly=False,
|
||||||
|
copy=False) # done with custom code due to buttons variables
|
||||||
|
button_ids = fields.One2many(
|
||||||
|
'whatsapp.template.button', 'wa_template_id', string="Buttons",
|
||||||
|
copy=True) # will copy their variables
|
||||||
|
has_invalid_button_number = fields.Boolean(compute="_compute_has_invalid_button_number")
|
||||||
|
|
||||||
|
messages_count = fields.Integer(string="Messages Count", compute='_compute_messages_count')
|
||||||
|
has_action = fields.Boolean(string="Has Action", compute='_compute_has_action')
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('unique_name_account_template', 'unique(template_name, lang_code, wa_account_id)', "Duplicate template is not allowed for one Meta account.")
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.constrains('header_text')
|
||||||
|
def _check_header_text(self):
|
||||||
|
for tmpl in self.filtered(lambda l: l.header_type == 'text'):
|
||||||
|
header_variables = list(re.findall(r'{{[1-9][0-9]*}}', tmpl.header_text))
|
||||||
|
if len(header_variables) > 1 or (header_variables and header_variables[0] != '{{1}}'):
|
||||||
|
raise ValidationError(
|
||||||
|
_("The Header Text must either contain no variable or the first one {{1}}.")
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.constrains('phone_field', 'model')
|
||||||
|
def _check_phone_field(self):
|
||||||
|
is_system = self.env.user.has_group('base.group_system')
|
||||||
|
for tmpl in self.filtered('phone_field'):
|
||||||
|
model = self.env[tmpl.model]
|
||||||
|
if not is_system:
|
||||||
|
if not model.has_access('read'):
|
||||||
|
model_description = self.env['ir.model']._get(tmpl.model).display_name
|
||||||
|
raise AccessError(
|
||||||
|
_("You can not select field of %(model)s.", model=model_description)
|
||||||
|
)
|
||||||
|
safe_fields = set(COMMON_WHATSAPP_PHONE_SAFE_FIELDS)
|
||||||
|
if hasattr(model, '_wa_get_safe_phone_fields'):
|
||||||
|
safe_fields |= set(model._wa_get_safe_phone_fields())
|
||||||
|
if tmpl.phone_field not in safe_fields:
|
||||||
|
raise AccessError(
|
||||||
|
_("You are not allowed to use %(field)s in phone field, contact your administrator to configure it.",
|
||||||
|
field=tmpl.phone_field)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
model._find_value_from_field_path(tmpl.phone_field)
|
||||||
|
except UserError as err:
|
||||||
|
raise ValidationError(
|
||||||
|
_("'%(field)s' does not seem to be a valid field path on %(model)s",
|
||||||
|
field=tmpl.phone_field,
|
||||||
|
model=tmpl.model)
|
||||||
|
) from err
|
||||||
|
|
||||||
|
@api.constrains('header_attachment_ids', 'header_type', 'report_id')
|
||||||
|
def _check_header_attachment_ids(self):
|
||||||
|
templates_with_attachments = self.filtered('header_attachment_ids')
|
||||||
|
for tmpl in templates_with_attachments:
|
||||||
|
if len(tmpl.header_attachment_ids) > 1:
|
||||||
|
raise ValidationError(_('You may only use one header attachment for each template'))
|
||||||
|
if tmpl.header_type not in ['image', 'video', 'document']:
|
||||||
|
raise ValidationError(_("Only templates using media header types may have header documents"))
|
||||||
|
if not any(tmpl.header_attachment_ids.mimetype in mimetypes for mimetypes in self.env['whatsapp.message']._SUPPORTED_ATTACHMENT_TYPE[tmpl.header_type]):
|
||||||
|
raise ValidationError(_("File type %(file_type)s not supported for header type %(header_type)s",
|
||||||
|
file_type=tmpl.header_attachment_ids.mimetype, header_type=tmpl.header_type))
|
||||||
|
for tmpl in self - templates_with_attachments:
|
||||||
|
if tmpl.header_type == 'document' and not tmpl.report_id:
|
||||||
|
raise ValidationError(_("Header document or report is required"))
|
||||||
|
if tmpl.header_type in ['image', 'video']:
|
||||||
|
raise ValidationError(_("Header document is required"))
|
||||||
|
|
||||||
|
@api.constrains('button_ids', 'variable_ids')
|
||||||
|
def _check_buttons(self):
|
||||||
|
for tmpl in self:
|
||||||
|
if len(tmpl.button_ids) > 10:
|
||||||
|
raise ValidationError(_('Maximum 10 buttons allowed.'))
|
||||||
|
if len(tmpl.button_ids.filtered(lambda button: button.button_type == 'url')) > 2:
|
||||||
|
raise ValidationError(_('Maximum 2 URL buttons allowed.'))
|
||||||
|
if len(tmpl.button_ids.filtered(lambda button: button.button_type == 'phone_number')) > 1:
|
||||||
|
raise ValidationError(_('Maximum 1 Call Number button allowed.'))
|
||||||
|
|
||||||
|
@api.constrains('variable_ids')
|
||||||
|
def _check_body_variables(self):
|
||||||
|
for template in self:
|
||||||
|
variables = template.variable_ids.filtered(lambda variable: variable.line_type == 'body')
|
||||||
|
free_text_variables = variables.filtered(lambda variable: variable.field_type == 'free_text')
|
||||||
|
if len(free_text_variables) > 10:
|
||||||
|
raise ValidationError(_('Only 10 free text is allowed in body of template'))
|
||||||
|
|
||||||
|
variable_indices = sorted(var._extract_variable_index() for var in variables)
|
||||||
|
if len(variable_indices) > 0 and (variable_indices[0] != 1 or variable_indices[-1] != len(variables)):
|
||||||
|
missing = next(
|
||||||
|
(index for index in range(1, len(variables)) if variable_indices[index - 1] + 1 != variable_indices[index]),
|
||||||
|
0) + 1
|
||||||
|
raise ValidationError(_('Body variables should start at 1 and not skip any number, missing %d', missing))
|
||||||
|
|
||||||
|
@api.constrains('header_type', 'variable_ids')
|
||||||
|
def _check_header_variables(self):
|
||||||
|
for template in self:
|
||||||
|
location_vars = template.variable_ids.filtered(lambda var: var.line_type == 'location')
|
||||||
|
text_vars = template.variable_ids.filtered(lambda var: var.line_type == 'header')
|
||||||
|
if template.header_type == 'location' and len(location_vars) != 4:
|
||||||
|
raise ValidationError(_('When using a "location" header, there should 4 location variables not %(count)d.',
|
||||||
|
count=len(location_vars)))
|
||||||
|
elif template.header_type != 'location' and location_vars:
|
||||||
|
raise ValidationError(_('Location variables should only exist when a "location" header is selected.'))
|
||||||
|
if len(text_vars) > 1:
|
||||||
|
raise ValidationError(_('There should be at most 1 variable in the header of the template.'))
|
||||||
|
if text_vars and text_vars._extract_variable_index() != 1:
|
||||||
|
raise ValidationError(_('Free text variable in the header should be {{1}}'))
|
||||||
|
|
||||||
|
@api.constrains('model')
|
||||||
|
def _check_model(self):
|
||||||
|
for template in self:
|
||||||
|
if self.env['ir.actions.server'].sudo().search_count([('wa_template_id', '=', template.id), ('model_name', '!=', template.model)]):
|
||||||
|
raise UserError(_('You cannot modify a template model when it is linked to server actions.'))
|
||||||
|
|
||||||
|
#=====================================================
|
||||||
|
# Compute Methods
|
||||||
|
#=====================================================
|
||||||
|
|
||||||
|
|
||||||
|
@api.depends('model')
|
||||||
|
def _compute_phone_field(self):
|
||||||
|
to_reset = self.filtered(lambda template: not template.model)
|
||||||
|
if to_reset:
|
||||||
|
to_reset.phone_field = False
|
||||||
|
for template in self.filtered('model'):
|
||||||
|
if template.phone_field and template.phone_field in self.env[template.model]._fields:
|
||||||
|
continue
|
||||||
|
if 'mobile' in self.env[template.model]._fields:
|
||||||
|
template.phone_field = 'mobile'
|
||||||
|
elif 'phone' in self.env[template.model]._fields:
|
||||||
|
template.phone_field = 'phone'
|
||||||
|
|
||||||
|
@api.depends('name', 'status', 'wa_template_uid')
|
||||||
|
def _compute_template_name(self):
|
||||||
|
for template in self:
|
||||||
|
if not template.template_name or (template.status == 'draft' and not template.wa_template_uid):
|
||||||
|
slugify = self.env['ir.http']._slugify
|
||||||
|
template.template_name = re.sub(r'\W+', '_', slugify(template.name or ''))
|
||||||
|
|
||||||
|
@api.depends('model')
|
||||||
|
def _compute_model_id(self):
|
||||||
|
self.filtered(lambda tpl: not tpl.model).model_id = False
|
||||||
|
for template in self.filtered('model'):
|
||||||
|
template.model_id = self.env['ir.model']._get_id(template.model)
|
||||||
|
|
||||||
|
@api.depends('model')
|
||||||
|
def _compute_report_id(self):
|
||||||
|
""" Reset if model changes to avoid ill defined reports """
|
||||||
|
to_reset = self.filtered(lambda tpl: tpl.report_id.model != tpl.model)
|
||||||
|
if to_reset:
|
||||||
|
to_reset.report_id = False
|
||||||
|
|
||||||
|
@api.depends('header_type', 'header_text', 'body')
|
||||||
|
def _compute_variable_ids(self):
|
||||||
|
"""compute template variable according to header text, body and buttons"""
|
||||||
|
for tmpl in self:
|
||||||
|
to_delete = self.env["whatsapp.template.variable"]
|
||||||
|
to_keep = self.env["whatsapp.template.variable"]
|
||||||
|
to_create_values = []
|
||||||
|
|
||||||
|
header_variables = list(re.findall(r'{{[1-9][0-9]*}}', tmpl.header_text or ''))
|
||||||
|
body_variables = set(re.findall(r'{{[1-9][0-9]*}}', tmpl.body or ''))
|
||||||
|
|
||||||
|
# if there is header text
|
||||||
|
existing_header_text_variable = tmpl.variable_ids.filtered(lambda line: line.line_type == 'header')
|
||||||
|
if header_variables and not existing_header_text_variable:
|
||||||
|
to_create_values.append({'name': header_variables[0], 'line_type': 'header', 'wa_template_id': tmpl.id})
|
||||||
|
elif not header_variables and existing_header_text_variable:
|
||||||
|
to_delete += existing_header_text_variable
|
||||||
|
elif existing_header_text_variable:
|
||||||
|
to_keep += existing_header_text_variable
|
||||||
|
|
||||||
|
# if the header is a location
|
||||||
|
existing_header_location_variables = tmpl.variable_ids.filtered(lambda line: line.line_type == 'location')
|
||||||
|
if tmpl.header_type == 'location' and not existing_header_location_variables:
|
||||||
|
to_create_values += [
|
||||||
|
{'name': 'name', 'line_type': 'location', 'wa_template_id': tmpl.id},
|
||||||
|
{'name': 'address', 'line_type': 'location', 'wa_template_id': tmpl.id},
|
||||||
|
{'name': 'latitude', 'line_type': 'location', 'wa_template_id': tmpl.id},
|
||||||
|
{'name': 'longitude', 'line_type': 'location', 'wa_template_id': tmpl.id}
|
||||||
|
]
|
||||||
|
elif tmpl.header_type != 'location' and existing_header_location_variables:
|
||||||
|
to_delete += existing_header_location_variables
|
||||||
|
elif existing_header_location_variables:
|
||||||
|
to_keep += existing_header_location_variables
|
||||||
|
|
||||||
|
# body
|
||||||
|
existing_body_variables = tmpl.variable_ids.filtered(lambda line: line.line_type == 'body')
|
||||||
|
new_body_variable_names = [var_name for var_name in body_variables if var_name not in existing_body_variables.mapped('name')]
|
||||||
|
deleted_body_variables = existing_body_variables.filtered(lambda var: var.name not in body_variables)
|
||||||
|
|
||||||
|
to_create_values += [{'name': var_name, 'line_type': 'body', 'wa_template_id': tmpl.id} for var_name in set(new_body_variable_names)]
|
||||||
|
to_delete += deleted_body_variables
|
||||||
|
to_keep += existing_body_variables - deleted_body_variables
|
||||||
|
|
||||||
|
# if to_delete:
|
||||||
|
# to_delete.unlink()
|
||||||
|
tmpl.variable_ids = [(3, to_remove.id) for to_remove in to_delete] + [(0, 0, vals) for vals in to_create_values]
|
||||||
|
|
||||||
|
@api.depends('button_ids')
|
||||||
|
def _compute_has_invalid_button_number(self):
|
||||||
|
for template in self:
|
||||||
|
template.has_invalid_button_number = any(template.button_ids.mapped('has_invalid_number'))
|
||||||
|
|
||||||
|
@api.depends('model_id')
|
||||||
|
def _compute_has_action(self):
|
||||||
|
for tmpl in self:
|
||||||
|
action = self.env['ir.actions.act_window'].sudo().search([('res_model', '=', 'whatsapp.composer'), ('binding_model_id', '=', tmpl.model_id.id)])
|
||||||
|
if action:
|
||||||
|
tmpl.has_action = True
|
||||||
|
else:
|
||||||
|
tmpl.has_action = False
|
||||||
|
|
||||||
|
def _compute_messages_count(self):
|
||||||
|
messages_by_template = dict(self.env['whatsapp.message']._read_group(
|
||||||
|
[('wa_template_id', 'in', self.ids)],
|
||||||
|
groupby=['wa_template_id'],
|
||||||
|
aggregates=['__count'],
|
||||||
|
))
|
||||||
|
for tmpl in self:
|
||||||
|
tmpl.messages_count = messages_by_template.get(tmpl, 0)
|
||||||
|
|
||||||
|
@api.depends('name', 'wa_account_id')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
for template in self:
|
||||||
|
template.display_name = _('%(template_name)s [%(account_name)s]',
|
||||||
|
template_name=template.name,
|
||||||
|
account_name=template.wa_account_id.name
|
||||||
|
) if template.wa_account_id.name else template.name
|
||||||
|
|
||||||
|
@api.onchange('header_type')
|
||||||
|
def _onchange_header_type(self):
|
||||||
|
toreset_attachments = self.filtered(lambda t: t.header_type not in {"image", "video", "document"})
|
||||||
|
if toreset_attachments:
|
||||||
|
toreset_attachments.header_attachment_ids = [(5, 0)]
|
||||||
|
toreset_attachments.report_id = False
|
||||||
|
toreset_text = self.filtered(lambda t: t.header_type != "text")
|
||||||
|
if toreset_text:
|
||||||
|
toreset_text.header_text = False
|
||||||
|
|
||||||
|
@api.onchange('header_attachment_ids')
|
||||||
|
def _onchange_header_attachment_ids(self):
|
||||||
|
for template in self:
|
||||||
|
template.header_attachment_ids.res_id = template.id
|
||||||
|
template.header_attachment_ids.res_model = template._name
|
||||||
|
|
||||||
|
@api.onchange('wa_account_id')
|
||||||
|
def _onchange_wa_account_id(self):
|
||||||
|
"""Avoid carrying remote sync data when changing account."""
|
||||||
|
self.status = 'draft'
|
||||||
|
self.quality = 'none'
|
||||||
|
self.wa_template_uid = False
|
||||||
|
|
||||||
|
#===================================================================
|
||||||
|
# CRUD
|
||||||
|
#===================================================================
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
for vals in vals_list:
|
||||||
|
# stable backward compatible change for model fields
|
||||||
|
if vals.get('model_id'):
|
||||||
|
vals['model'] = self.env['ir.model'].sudo().browse(vals[('model_id')]).model
|
||||||
|
records = super().create(vals_list)
|
||||||
|
# the model of the variable might have been changed with x2many commands
|
||||||
|
records.variable_ids._check_field_name()
|
||||||
|
# update the attachment res_id for new records
|
||||||
|
records._onchange_header_attachment_ids()
|
||||||
|
return records
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
if vals.get("model_id"):
|
||||||
|
vals["model"] = self.env['ir.model'].sudo().browse(vals["model_id"]).model
|
||||||
|
res = super().write(vals)
|
||||||
|
# Model change: explicitly check for field access. Other changes at variable
|
||||||
|
# level are checked by '_check_field_name' constraint.
|
||||||
|
if 'model_id' in vals:
|
||||||
|
self.variable_ids._check_field_name()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def copy_data(self, default=None):
|
||||||
|
default = {} if default is None else default
|
||||||
|
values_list = super().copy_data(default=default)
|
||||||
|
|
||||||
|
for values, template in zip(values_list, self):
|
||||||
|
if not values.get("name"):
|
||||||
|
values["name"] = _('%(original_name)s (copy)', original_name=template.name)
|
||||||
|
if not values.get("template_name"):
|
||||||
|
values["template_name"] = f'{template.template_name}_copy'
|
||||||
|
if not values.get('header_attachment_ids') and template.header_attachment_ids:
|
||||||
|
values['header_attachment_ids'] = [
|
||||||
|
(0, 0, att.copy_data(default={'res_id': False})[0])
|
||||||
|
for att in template.header_attachment_ids
|
||||||
|
]
|
||||||
|
if template.variable_ids:
|
||||||
|
variable_commands = values.get('variable_ids', []) + [
|
||||||
|
(0, 0, {
|
||||||
|
'button_id': False,
|
||||||
|
'demo_value': variable.demo_value,
|
||||||
|
'field_name': variable.field_name,
|
||||||
|
'field_type': variable.field_type,
|
||||||
|
'line_type': variable.line_type,
|
||||||
|
'name': variable.name,
|
||||||
|
})
|
||||||
|
for variable in template.variable_ids if not variable.button_id
|
||||||
|
]
|
||||||
|
if variable_commands:
|
||||||
|
values['variable_ids'] = variable_commands
|
||||||
|
return values_list
|
||||||
|
|
||||||
|
#===================================================================
|
||||||
|
# Register template to whatsapp
|
||||||
|
#===================================================================
|
||||||
|
|
||||||
|
def _get_template_head_component(self, file_handle):
|
||||||
|
"""Return header component according to header type for template registration to whatsapp"""
|
||||||
|
if self.header_type == 'none':
|
||||||
|
return None
|
||||||
|
head_component = {'type': 'HEADER', 'format': self.header_type.upper()}
|
||||||
|
if self.header_type == 'text' and self.header_text:
|
||||||
|
head_component['text'] = self.header_text
|
||||||
|
header_params = self.variable_ids.filtered(lambda line: line.line_type == 'header')
|
||||||
|
if header_params:
|
||||||
|
head_component['example'] = {'header_text': header_params.mapped('demo_value')}
|
||||||
|
elif self.header_type in ['image', 'video', 'document']:
|
||||||
|
head_component['example'] = {
|
||||||
|
'header_handle': [file_handle]
|
||||||
|
}
|
||||||
|
return head_component
|
||||||
|
|
||||||
|
def _get_template_body_component(self):
|
||||||
|
"""Return body component for template registration to whatsapp"""
|
||||||
|
if not self.body:
|
||||||
|
return None
|
||||||
|
body_component = {'type': 'BODY', 'text': self.body}
|
||||||
|
body_params = self.variable_ids.filtered(lambda line: line.line_type == 'body')
|
||||||
|
if body_params:
|
||||||
|
body_component['example'] = {'body_text': [body_params.mapped('demo_value')]}
|
||||||
|
return body_component
|
||||||
|
|
||||||
|
def _get_template_button_component(self):
|
||||||
|
"""Return button component for template registration to whatsapp"""
|
||||||
|
if not self.button_ids:
|
||||||
|
return None
|
||||||
|
buttons = []
|
||||||
|
for button in self.button_ids:
|
||||||
|
button_data = {
|
||||||
|
'type': button.button_type.upper(),
|
||||||
|
'text': button.name
|
||||||
|
}
|
||||||
|
if button.button_type == 'url':
|
||||||
|
button_data.update(self._get_url_button_data(button))
|
||||||
|
elif button.button_type == 'phone_number':
|
||||||
|
button_data['phone_number'] = button.call_number
|
||||||
|
buttons.append(button_data)
|
||||||
|
return {'type': 'BUTTONS', 'buttons': buttons}
|
||||||
|
|
||||||
|
def _get_url_button_data(self, button):
|
||||||
|
button_data = {'url': button.website_url}
|
||||||
|
if button.url_type == 'dynamic':
|
||||||
|
button_data['url'] += '{{1}}'
|
||||||
|
button_data['example'] = button.variable_ids[0].demo_value
|
||||||
|
return button_data
|
||||||
|
|
||||||
|
def _get_template_footer_component(self):
|
||||||
|
if not self.footer_text:
|
||||||
|
return None
|
||||||
|
return {'type': 'FOOTER', 'text': self.footer_text}
|
||||||
|
|
||||||
|
def _get_sample_record(self):
|
||||||
|
return self.env[self.model].search([], limit=1)
|
||||||
|
|
||||||
|
def button_submit_template(self):
|
||||||
|
"""Register template to WhatsApp Business Account """
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.template_type:
|
||||||
|
raise ValidationError(_("Template category is missing"))
|
||||||
|
wa_api = WhatsAppApi(self.wa_account_id)
|
||||||
|
attachment = False
|
||||||
|
if self.header_type in ('image', 'video', 'document'):
|
||||||
|
if self.header_type == 'document' and self.report_id:
|
||||||
|
record = self._get_sample_record()
|
||||||
|
if not record:
|
||||||
|
raise ValidationError(_("There is no record for preparing demo pdf in model %(model)s", model=self.model_id.name))
|
||||||
|
attachment = self._generate_attachment_from_report(record)
|
||||||
|
else:
|
||||||
|
attachment = self.header_attachment_ids
|
||||||
|
if not attachment:
|
||||||
|
raise ValidationError("Header Document is missing")
|
||||||
|
file_handle = False
|
||||||
|
if attachment:
|
||||||
|
try:
|
||||||
|
file_handle = wa_api._upload_demo_document(attachment)
|
||||||
|
except WhatsAppError as e:
|
||||||
|
raise UserError(str(e))
|
||||||
|
|
||||||
|
components = [self._get_template_body_component()]
|
||||||
|
components += [comp for comp in (
|
||||||
|
self._get_template_head_component(file_handle),
|
||||||
|
self._get_template_button_component(),
|
||||||
|
self._get_template_footer_component()) if comp]
|
||||||
|
json_data = json.dumps({
|
||||||
|
'name': self.template_name,
|
||||||
|
'language': self.lang_code,
|
||||||
|
'category': self.template_type.upper(),
|
||||||
|
'components': components,
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
if self.wa_template_uid:
|
||||||
|
wa_api._submit_template_update(json_data, self.wa_template_uid)
|
||||||
|
self.status = 'pending'
|
||||||
|
else:
|
||||||
|
response = wa_api._submit_template_new(json_data)
|
||||||
|
self.write({
|
||||||
|
'wa_template_uid': response['id'],
|
||||||
|
'status': response['status'].lower()
|
||||||
|
})
|
||||||
|
except WhatsAppError as we:
|
||||||
|
raise UserError(str(we))
|
||||||
|
|
||||||
|
#===================================================================
|
||||||
|
# Sync template from whatsapp
|
||||||
|
#===================================================================
|
||||||
|
|
||||||
|
def button_sync_template(self):
|
||||||
|
"""Sync template from WhatsApp Business Account """
|
||||||
|
self.ensure_one()
|
||||||
|
wa_api = WhatsAppApi(self.wa_account_id)
|
||||||
|
try:
|
||||||
|
response = wa_api._get_template_data(wa_template_uid=self.wa_template_uid)
|
||||||
|
except WhatsAppError as e:
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
if response.get('id'):
|
||||||
|
self._update_template_from_response(response)
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'reload',
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _create_template_from_response(self, remote_template_vals, wa_account):
|
||||||
|
template_vals = self._get_template_vals_from_response(remote_template_vals, wa_account)
|
||||||
|
template_vals['variable_ids'] = [Command.create(var) for var in template_vals['variable_ids']]
|
||||||
|
for button in template_vals['button_ids']:
|
||||||
|
button['variable_ids'] = [Command.create(var) for var in button['variable_ids']]
|
||||||
|
template_vals['button_ids'] = [Command.create(button) for button in template_vals['button_ids']]
|
||||||
|
template_vals['header_attachment_ids'] = [Command.create(attachment) for attachment in template_vals['header_attachment_ids']]
|
||||||
|
return template_vals
|
||||||
|
|
||||||
|
def _get_additional_button_values(self, button):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _update_template_from_response(self, remote_template_vals):
|
||||||
|
self.ensure_one()
|
||||||
|
update_fields = ('body', 'header_type', 'header_text', 'footer_text', 'lang_code', 'template_type', 'status', 'quality')
|
||||||
|
template_vals = self._get_template_vals_from_response(remote_template_vals, self.wa_account_id)
|
||||||
|
update_vals = {field: template_vals[field] for field in update_fields}
|
||||||
|
|
||||||
|
# variables should be preserved instead of overwritten to keep odoo-specific data like fields
|
||||||
|
variable_ids = []
|
||||||
|
existing_template_variables = {(variable_id.name, variable_id.line_type): variable_id.id for variable_id in self.variable_ids}
|
||||||
|
for variable_vals in template_vals['variable_ids']:
|
||||||
|
if not existing_template_variables.pop((variable_vals['name'], variable_vals['line_type']), False):
|
||||||
|
variable_ids.append(Command.create(variable_vals))
|
||||||
|
variable_ids.extend([Command.delete(to_remove) for to_remove in existing_template_variables.values()])
|
||||||
|
update_vals['variable_ids'] = variable_ids
|
||||||
|
|
||||||
|
for button in template_vals['button_ids']:
|
||||||
|
button['variable_ids'] = [Command.create(var) for var in button['variable_ids']]
|
||||||
|
additional_button_vals = self._get_additional_button_values(button)
|
||||||
|
button.update(additional_button_vals)
|
||||||
|
|
||||||
|
update_vals['button_ids'] = [Command.clear()] + [Command.create(button) for button in template_vals['button_ids']]
|
||||||
|
if not self.header_attachment_ids or self.header_type != template_vals['header_type']:
|
||||||
|
new_attachment_commands = [Command.create(attachment) for attachment in template_vals['header_attachment_ids']]
|
||||||
|
update_vals['header_attachment_ids'] = [Command.clear()] + new_attachment_commands
|
||||||
|
|
||||||
|
self.write(update_vals)
|
||||||
|
|
||||||
|
def _get_template_vals_from_response(self, remote_template_vals, wa_account):
|
||||||
|
"""Get dictionary of field: values from whatsapp template response json.
|
||||||
|
|
||||||
|
Relational fields will use arrays instead of commands.
|
||||||
|
"""
|
||||||
|
quality_score = remote_template_vals['quality_score']['score'].lower()
|
||||||
|
template_vals = {
|
||||||
|
'body': False,
|
||||||
|
'button_ids': [],
|
||||||
|
'footer_text': False,
|
||||||
|
'header_text': False,
|
||||||
|
'header_attachment_ids': [],
|
||||||
|
'header_type': 'none',
|
||||||
|
'lang_code': remote_template_vals['language'],
|
||||||
|
'name': remote_template_vals['name'].replace("_", " ").title(),
|
||||||
|
'quality': 'none' if quality_score == 'unknown' else quality_score,
|
||||||
|
'status': remote_template_vals['status'].lower(),
|
||||||
|
'template_name': remote_template_vals['name'],
|
||||||
|
'template_type': remote_template_vals['category'].lower(),
|
||||||
|
'variable_ids': [],
|
||||||
|
'wa_account_id': wa_account.id,
|
||||||
|
'wa_template_uid': int(remote_template_vals['id']),
|
||||||
|
}
|
||||||
|
for component in remote_template_vals['components']:
|
||||||
|
component_type = component['type']
|
||||||
|
if component_type == 'HEADER':
|
||||||
|
template_vals['header_type'] = component['format'].lower()
|
||||||
|
if component['format'] == 'TEXT':
|
||||||
|
template_vals['header_text'] = component['text']
|
||||||
|
if 'example' in component:
|
||||||
|
for index, example_value in enumerate(component['example'].get('header_text', [])):
|
||||||
|
template_vals['variable_ids'].append({
|
||||||
|
'name': '{{%s}}' % (index + 1),
|
||||||
|
'demo_value': example_value,
|
||||||
|
'line_type': 'header',
|
||||||
|
})
|
||||||
|
elif component['format'] == 'LOCATION':
|
||||||
|
for location_val in ['name', 'address', 'latitude', 'longitude']:
|
||||||
|
template_vals['variable_ids'].append({
|
||||||
|
'name': location_val,
|
||||||
|
'line_type': 'location',
|
||||||
|
})
|
||||||
|
elif component['format'] in ('IMAGE', 'VIDEO', 'DOCUMENT'):
|
||||||
|
document_url = component.get('example', {}).get('header_handle', [False])[0]
|
||||||
|
if document_url:
|
||||||
|
wa_api = WhatsAppApi(wa_account)
|
||||||
|
data, mimetype = wa_api._get_header_data_from_handle(document_url)
|
||||||
|
extension = mimetypes.guess_extension(mimetype)
|
||||||
|
else:
|
||||||
|
data = b'AAAA'
|
||||||
|
extension, mimetype = {
|
||||||
|
'IMAGE': ('jpg', 'image/jpeg'),
|
||||||
|
'VIDEO': ('mp4', 'video/mp4'),
|
||||||
|
'DOCUMENT': ('pdf', 'application/pdf')
|
||||||
|
}[component['format']]
|
||||||
|
template_vals['header_attachment_ids'] = [{
|
||||||
|
'name': f'{template_vals["template_name"]}{extension}',
|
||||||
|
'res_model': self._name,
|
||||||
|
'res_id': self.ids[0] if self else False,
|
||||||
|
'raw': data,
|
||||||
|
'mimetype': mimetype,
|
||||||
|
}]
|
||||||
|
elif component_type == 'BODY':
|
||||||
|
template_vals['body'] = component['text']
|
||||||
|
if 'example' in component:
|
||||||
|
for index, example_value in enumerate(component['example'].get('body_text', [[]])[0]):
|
||||||
|
template_vals['variable_ids'].append({
|
||||||
|
'name': '{{%s}}' % (index + 1),
|
||||||
|
'demo_value': example_value,
|
||||||
|
'line_type': 'body',
|
||||||
|
})
|
||||||
|
elif component_type == 'FOOTER':
|
||||||
|
template_vals['footer_text'] = component['text']
|
||||||
|
elif component_type == 'BUTTONS':
|
||||||
|
for index, button in enumerate(component['buttons']):
|
||||||
|
if button['type'] in ('URL', 'PHONE_NUMBER', 'QUICK_REPLY'):
|
||||||
|
button_vals = {
|
||||||
|
'sequence': index,
|
||||||
|
'name': button['text'],
|
||||||
|
'button_type': button['type'].lower(),
|
||||||
|
'call_number': button.get('phone_number'),
|
||||||
|
'website_url': button.get('url').replace('{{1}}', '') if button.get('url') else None,
|
||||||
|
'url_type': button.get('example', []) and 'dynamic' or 'static',
|
||||||
|
'variable_ids': []
|
||||||
|
}
|
||||||
|
for example_index, example_value in enumerate(button.get('example', [])):
|
||||||
|
button_vals['variable_ids'].append({
|
||||||
|
'name': '{{%s}}' % (example_index + 1),
|
||||||
|
'demo_value': example_value,
|
||||||
|
'line_type': 'button',
|
||||||
|
})
|
||||||
|
template_vals['button_ids'].append(button_vals)
|
||||||
|
return template_vals
|
||||||
|
|
||||||
|
#========================================================================
|
||||||
|
# Send WhatsApp message using template
|
||||||
|
#========================================================================
|
||||||
|
|
||||||
|
def _get_header_component(self, free_text_json, template_variables_value, attachment):
|
||||||
|
""" Prepare header component for sending WhatsApp template message"""
|
||||||
|
header = []
|
||||||
|
header_type = self.header_type
|
||||||
|
if header_type == 'text' and template_variables_value.get('header-{{1}}'):
|
||||||
|
value = (free_text_json or {}).get('header_text') or template_variables_value.get('header-{{1}}') or ' '
|
||||||
|
header = {
|
||||||
|
'type': 'header',
|
||||||
|
'parameters': [{'type': 'text', 'text': value}]
|
||||||
|
}
|
||||||
|
elif header_type in ['image', 'video', 'document']:
|
||||||
|
header = {
|
||||||
|
'type': 'header',
|
||||||
|
'parameters': [self.env['whatsapp.message']._prepare_attachment_vals(attachment, wa_account_id=self.wa_account_id)]
|
||||||
|
}
|
||||||
|
elif header_type == 'location':
|
||||||
|
header = {
|
||||||
|
'type': 'header',
|
||||||
|
'parameters': [self._prepare_location_vals(template_variables_value)]
|
||||||
|
}
|
||||||
|
return header
|
||||||
|
|
||||||
|
def _prepare_location_vals(self, template_variables_value):
|
||||||
|
""" Prepare location values for sending WhatsApp template message having header type location"""
|
||||||
|
self._check_location_latitude_longitude(template_variables_value.get('location-latitude'), template_variables_value.get('location-longitude'))
|
||||||
|
return {
|
||||||
|
'type': 'location',
|
||||||
|
'location': {
|
||||||
|
'name': template_variables_value.get('location-name'),
|
||||||
|
'address': template_variables_value.get('location-address'),
|
||||||
|
'latitude': template_variables_value.get('location-latitude'),
|
||||||
|
'longitude': template_variables_value.get('location-longitude'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_body_component(self, free_text_json, template_variables_value):
|
||||||
|
""" Prepare body component for sending WhatsApp template message"""
|
||||||
|
if not self.variable_ids:
|
||||||
|
return None
|
||||||
|
parameters = []
|
||||||
|
free_text_count = 1
|
||||||
|
for body_val in self.variable_ids.filtered(lambda line: line.line_type == 'body'):
|
||||||
|
free_text_value = body_val.field_type == 'free_text' and free_text_json.get(f'free_text_{free_text_count}') or False
|
||||||
|
parameters.append({
|
||||||
|
'type': 'text',
|
||||||
|
'text': free_text_value or template_variables_value.get(f'{body_val.line_type}-{body_val.name}') or ' '
|
||||||
|
})
|
||||||
|
if body_val.field_type == 'free_text':
|
||||||
|
free_text_count += 1
|
||||||
|
return {'type': 'body', 'parameters': parameters}
|
||||||
|
|
||||||
|
def _get_button_components(self, free_text_json, template_variables_value):
|
||||||
|
""" Prepare button component for sending WhatsApp template message"""
|
||||||
|
components = []
|
||||||
|
if not self.variable_ids:
|
||||||
|
return components
|
||||||
|
dynamic_buttons = self.button_ids._filter_dynamic_buttons()
|
||||||
|
dynamic_buttons = dynamic_buttons.sorted(lambda btn: btn.sequence)
|
||||||
|
dynamic_index = {button: i for i, button in enumerate(self.button_ids)}
|
||||||
|
free_text_index = 1
|
||||||
|
for button in dynamic_buttons:
|
||||||
|
button_var = button.variable_ids[0]
|
||||||
|
dynamic_url = button.website_url
|
||||||
|
if button_var.field_type == 'free_text':
|
||||||
|
value = free_text_json.get(f'button_dynamic_url_{free_text_index}') or ' '
|
||||||
|
free_text_index += 1
|
||||||
|
else:
|
||||||
|
value = template_variables_value.get(f'button-{button.name}') or ' '
|
||||||
|
value = value.replace(dynamic_url, '').lstrip('/') # / is implicit
|
||||||
|
components.append({
|
||||||
|
'type': 'button',
|
||||||
|
'sub_type': 'url',
|
||||||
|
'index': dynamic_index.get(button),
|
||||||
|
'parameters': [{'type': 'text', 'text': value}]
|
||||||
|
})
|
||||||
|
return components
|
||||||
|
|
||||||
|
def _get_send_template_vals(self, record, whatsapp_message):
|
||||||
|
"""Prepare JSON dictionary for sending WhatsApp template message"""
|
||||||
|
self.ensure_one()
|
||||||
|
free_text_json = whatsapp_message.free_text_json
|
||||||
|
attachment = whatsapp_message.mail_message_id.attachment_ids
|
||||||
|
|
||||||
|
components = []
|
||||||
|
template_variables_value = self.variable_ids._get_variables_value(record)
|
||||||
|
|
||||||
|
# generate attachment
|
||||||
|
if not attachment and self.report_id:
|
||||||
|
attachment = self._generate_attachment_from_report(record)
|
||||||
|
if not attachment and self.header_attachment_ids:
|
||||||
|
attachment = self.header_attachment_ids[0]
|
||||||
|
|
||||||
|
# generate content
|
||||||
|
header = self._get_header_component(free_text_json=free_text_json, attachment=attachment, template_variables_value=template_variables_value)
|
||||||
|
body = self._get_body_component(free_text_json=free_text_json, template_variables_value=template_variables_value)
|
||||||
|
buttons = self._get_button_components(free_text_json=free_text_json, template_variables_value=template_variables_value)
|
||||||
|
if header:
|
||||||
|
components.append(header)
|
||||||
|
if body:
|
||||||
|
components.append(body)
|
||||||
|
components.extend(buttons)
|
||||||
|
template_vals = {
|
||||||
|
'name': self.template_name,
|
||||||
|
'language': {'code': self.lang_code},
|
||||||
|
}
|
||||||
|
if components:
|
||||||
|
template_vals['components'] = components
|
||||||
|
return template_vals, attachment
|
||||||
|
|
||||||
|
def button_reset_to_draft(self):
|
||||||
|
for tmpl in self:
|
||||||
|
tmpl.write({'status': 'draft'})
|
||||||
|
|
||||||
|
def action_open_messages(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'name': _("Message Statistics Of %(template_name)s", template_name=self.name),
|
||||||
|
'view_mode': 'list,form,graph',
|
||||||
|
'res_model': 'whatsapp.message',
|
||||||
|
'domain': [('wa_template_id', '=', self.id)],
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
}
|
||||||
|
|
||||||
|
def button_create_action(self):
|
||||||
|
""" Create action for sending WhatsApp template message in model defined in template. It will be used in bulk sending"""
|
||||||
|
self.check_access('write')
|
||||||
|
actions = self.env['ir.actions.act_window'].sudo().search([
|
||||||
|
('res_model', '=', 'whatsapp.composer'),
|
||||||
|
('binding_model_id', 'in', self.model_id.ids)
|
||||||
|
])
|
||||||
|
actions = self.env['ir.actions.act_window'].sudo().create([
|
||||||
|
{
|
||||||
|
'binding_model_id': model.id,
|
||||||
|
'name': _('WhatsApp Message'),
|
||||||
|
'res_model': 'whatsapp.composer',
|
||||||
|
'target': 'new',
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'view_mode': 'form',
|
||||||
|
}
|
||||||
|
for model in (self.model_id - actions.binding_model_id)
|
||||||
|
])
|
||||||
|
return actions
|
||||||
|
|
||||||
|
def button_delete_action(self):
|
||||||
|
self.check_access('write')
|
||||||
|
self.env['ir.actions.act_window'].sudo().search([
|
||||||
|
('res_model', '=', 'whatsapp.composer'),
|
||||||
|
('binding_model_id', 'in', self.model_id.ids)
|
||||||
|
]).unlink()
|
||||||
|
|
||||||
|
def _generate_attachment_from_report(self, record=False):
|
||||||
|
"""Create attachment from report if relevant"""
|
||||||
|
if record and self.header_type == 'document' and self.report_id:
|
||||||
|
report_content, report_format = self.report_id._render_qweb_pdf(self.report_id, record.id)
|
||||||
|
if self.report_id.print_report_name:
|
||||||
|
report_name = safe_eval(self.report_id.print_report_name, {'object': record}) + '.' + report_format
|
||||||
|
else:
|
||||||
|
report_name = self.display_name + '.' + report_format
|
||||||
|
return self.env['ir.attachment'].create({
|
||||||
|
'name': report_name,
|
||||||
|
'raw': report_content,
|
||||||
|
'mimetype': 'application/pdf',
|
||||||
|
})
|
||||||
|
return self.env['ir.attachment']
|
||||||
|
|
||||||
|
def _check_location_latitude_longitude(self, latitude, longitude):
|
||||||
|
if not re.match(LATITUDE_LONGITUDE_REGEX, f"{latitude}, {longitude}"):
|
||||||
|
raise ValidationError(
|
||||||
|
_("Location Latitude and Longitude %(latitude)s / %(longitude)s is not in proper format.",
|
||||||
|
latitude=latitude, longitude=longitude)
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _format_markup_to_html(self, body_html):
|
||||||
|
"""
|
||||||
|
Convert WhatsApp format text to HTML format text
|
||||||
|
*bold* -> <b>bold</b>
|
||||||
|
_italic_ -> <i>italic</i>
|
||||||
|
~strikethrough~ -> <s>strikethrough</s>
|
||||||
|
```monospace``` -> <code>monospace</code>
|
||||||
|
"""
|
||||||
|
formatted_body = str(plaintext2html(body_html)) # stringify for regex
|
||||||
|
formatted_body = re.sub(r'\*(.*?)\*', r'<b>\1</b>', formatted_body)
|
||||||
|
formatted_body = re.sub(r'_(.*?)_', r'<i>\1</i>', formatted_body)
|
||||||
|
formatted_body = re.sub(r'~(.*?)~', r'<s>\1</s>', formatted_body)
|
||||||
|
formatted_body = re.sub(r'```(.*?)```', r'<code>\1</code>', formatted_body)
|
||||||
|
return Markup(formatted_body)
|
||||||
|
|
||||||
|
def _get_formatted_body(self, demo_fallback=False, variable_values=None):
|
||||||
|
"""Get formatted body and header with specified values.
|
||||||
|
|
||||||
|
:param bool demo_fallback: if true, fallback on demo values instead of blanks
|
||||||
|
:param dict variable_values: values to use instead of demo values {'header-{{1}}': 'Hello'}
|
||||||
|
:return Markup:
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
variable_values = variable_values or {}
|
||||||
|
header = ''
|
||||||
|
if self.header_type == 'text' and self.header_text:
|
||||||
|
header = self.header_text
|
||||||
|
header_variables = self.variable_ids.filtered(lambda line: line.line_type == 'header')
|
||||||
|
if header_variables:
|
||||||
|
fallback_value = header_variables[0].demo_value if demo_fallback else ' '
|
||||||
|
header = header.replace('{{1}}', variable_values.get('header-{{1}}', fallback_value))
|
||||||
|
body = self.body
|
||||||
|
for var in self.variable_ids.filtered(lambda var: var.line_type == 'body'):
|
||||||
|
fallback_value = var.demo_value if demo_fallback else ' '
|
||||||
|
body = body.replace(var.name, variable_values.get(f'{var.line_type}-{var.name}', fallback_value))
|
||||||
|
return self._format_markup_to_html(f'*{header}*\n\n{body}' if header else body)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# TOOLS
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _can_use_whatsapp(self, model_name):
|
||||||
|
if not self.has_access('read'):
|
||||||
|
return False
|
||||||
|
return self.env.user.has_group('whatsapp.group_whatsapp_admin') or \
|
||||||
|
len(self._find_default_for_model(model_name)) > 0
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _find_default_for_model(self, model_name):
|
||||||
|
return self.search([
|
||||||
|
('model', '=', model_name),
|
||||||
|
('status', '=', 'approved'),
|
||||||
|
'|',
|
||||||
|
('allowed_user_ids', '=', False),
|
||||||
|
('allowed_user_ids', 'in', self.env.user.ids)
|
||||||
|
], limit=1)
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.addons.phone_validation.tools import phone_validation
|
||||||
|
from odoo.exceptions import UserError, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppTemplateButton(models.Model):
|
||||||
|
_name = 'whatsapp.template.button'
|
||||||
|
_description = 'WhatsApp Template Button'
|
||||||
|
_order = 'sequence,id'
|
||||||
|
|
||||||
|
sequence = fields.Integer()
|
||||||
|
name = fields.Char(string="Button Text", size=25)
|
||||||
|
wa_template_id = fields.Many2one(comodel_name='whatsapp.template', required=True, ondelete='cascade')
|
||||||
|
|
||||||
|
button_type = fields.Selection([
|
||||||
|
('url', 'Visit Website'),
|
||||||
|
('phone_number', 'Call Number'),
|
||||||
|
('quick_reply', 'Quick Reply')], string="Type", required=True, default='quick_reply')
|
||||||
|
url_type = fields.Selection([
|
||||||
|
('static', 'Static'),
|
||||||
|
('dynamic', 'Dynamic')], string="Url Type", default='static')
|
||||||
|
website_url = fields.Char(string="Website URL")
|
||||||
|
call_number = fields.Char(string="Call Number")
|
||||||
|
has_invalid_number = fields.Boolean(compute="_compute_has_invalid_number")
|
||||||
|
variable_ids = fields.One2many(
|
||||||
|
'whatsapp.template.variable', 'button_id',
|
||||||
|
compute='_compute_variable_ids', precompute=True, store=True,
|
||||||
|
copy=True)
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
(
|
||||||
|
'unique_name_per_template',
|
||||||
|
'UNIQUE(name, wa_template_id)',
|
||||||
|
"Button names must be unique in a given template"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.depends('button_type', 'call_number')
|
||||||
|
def _compute_has_invalid_number(self):
|
||||||
|
for button in self:
|
||||||
|
if button.button_type == 'phone_number' and button.call_number:
|
||||||
|
try:
|
||||||
|
phone_validation.phone_format(
|
||||||
|
button.call_number,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
except UserError:
|
||||||
|
if country := self.env.user.country_id or self.env.company.country_id:
|
||||||
|
try:
|
||||||
|
phone_validation.phone_format(
|
||||||
|
button.call_number,
|
||||||
|
country.code,
|
||||||
|
country.phone_code,
|
||||||
|
)
|
||||||
|
except UserError:
|
||||||
|
button.has_invalid_number = True
|
||||||
|
continue
|
||||||
|
button.has_invalid_number = False
|
||||||
|
|
||||||
|
def _get_button_variable_vals(self, button):
|
||||||
|
return {
|
||||||
|
"demo_value": button.website_url + "???",
|
||||||
|
"line_type": "button",
|
||||||
|
"name": button.name,
|
||||||
|
"wa_template_id": button.wa_template_id.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _filter_dynamic_buttons(self):
|
||||||
|
"""
|
||||||
|
Retrieve buttons filtered by 'dynamic' URL type.
|
||||||
|
"""
|
||||||
|
dynamic_urls = self.filtered(lambda button: button.button_type == 'url' and button.url_type == 'dynamic')
|
||||||
|
return dynamic_urls
|
||||||
|
|
||||||
|
@api.depends('button_type', 'url_type', 'website_url', 'name')
|
||||||
|
def _compute_variable_ids(self):
|
||||||
|
button_urls = self._filter_dynamic_buttons()
|
||||||
|
to_clear = self - button_urls
|
||||||
|
for button in button_urls:
|
||||||
|
if button.variable_ids:
|
||||||
|
button.variable_ids = [
|
||||||
|
(1, button.variable_ids[0].id, self._get_button_variable_vals(button)),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
button.variable_ids = [
|
||||||
|
(0, 0, self._get_button_variable_vals(button)),
|
||||||
|
]
|
||||||
|
if to_clear:
|
||||||
|
to_clear.variable_ids = [(5, 0)]
|
||||||
|
|
||||||
|
def check_variable_ids(self):
|
||||||
|
for button in self:
|
||||||
|
if len(button.variable_ids) > 1:
|
||||||
|
raise ValidationError(_('Buttons may only contain one placeholder.'))
|
||||||
|
if button.variable_ids and button.url_type != 'dynamic':
|
||||||
|
raise ValidationError(_('Only dynamic urls may have a placeholder.'))
|
||||||
|
elif button.url_type == 'dynamic' and not button.variable_ids:
|
||||||
|
raise ValidationError(_('All dynamic urls must have a placeholder.'))
|
||||||
|
if button.variable_ids.name != "{{1}}":
|
||||||
|
raise ValidationError(_('The placeholder for a button can only be {{1}}.'))
|
||||||
|
|
||||||
|
@api.onchange('website_url')
|
||||||
|
def _onchange_website_url(self):
|
||||||
|
if self.website_url:
|
||||||
|
parsed_url = urlparse(self.website_url)
|
||||||
|
if not (parsed_url.scheme in {'http', 'https'} and parsed_url.netloc):
|
||||||
|
self.website_url = f"https://{self.website_url}"
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from werkzeug.urls import url_join
|
||||||
|
|
||||||
|
from odoo import api, models, fields, _
|
||||||
|
from odoo.exceptions import UserError, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppTemplateVariable(models.Model):
|
||||||
|
_name = 'whatsapp.template.variable'
|
||||||
|
_description = 'WhatsApp Template Variable'
|
||||||
|
_order = 'line_type desc, name, id'
|
||||||
|
|
||||||
|
name = fields.Char(string="Placeholder", required=True)
|
||||||
|
button_id = fields.Many2one('whatsapp.template.button', ondelete='cascade')
|
||||||
|
wa_template_id = fields.Many2one(comodel_name='whatsapp.template', required=True, ondelete='cascade')
|
||||||
|
model = fields.Char(string="Model Name", related='wa_template_id.model')
|
||||||
|
|
||||||
|
line_type = fields.Selection([
|
||||||
|
('button', 'Button'),
|
||||||
|
('header', 'Header'),
|
||||||
|
('location', 'Location'),
|
||||||
|
('body', 'Body')], string="Variable location", required=True)
|
||||||
|
field_type = fields.Selection([
|
||||||
|
('user_name', 'User Name'),
|
||||||
|
('user_mobile', 'User Mobile'),
|
||||||
|
('free_text', 'Free Text'),
|
||||||
|
('portal_url', 'Portal Link'),
|
||||||
|
('field', 'Field of Model')], string="Type", default='free_text', required=True)
|
||||||
|
field_name = fields.Char(string="Field")
|
||||||
|
demo_value = fields.Char(string="Sample Value", default="Sample Value", required=True)
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
(
|
||||||
|
'name_type_template_unique',
|
||||||
|
'UNIQUE(name, line_type, wa_template_id, button_id)',
|
||||||
|
'Variable names must be unique for a given template'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.constrains("field_type", "demo_value", "button_id")
|
||||||
|
def _check_demo_values(self):
|
||||||
|
if self.filtered(lambda var: var.field_type == 'free_text' and not var.demo_value):
|
||||||
|
raise ValidationError(_('Free Text template variables must have a demo value.'))
|
||||||
|
|
||||||
|
@api.constrains("field_type", "field_name")
|
||||||
|
def _check_field_name(self):
|
||||||
|
is_system = self.env.user.has_group('base.group_system')
|
||||||
|
failing = self.browse()
|
||||||
|
to_check = self.filtered(lambda v: v.field_type == "field")
|
||||||
|
missing = to_check.filtered(lambda v: not v.field_name)
|
||||||
|
if missing:
|
||||||
|
raise ValidationError(
|
||||||
|
_("Field template variables %(var_names)s must be associated with a field.",
|
||||||
|
var_names=", ".join(missing.mapped("name")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for variable in to_check:
|
||||||
|
model = self.env[variable.model]
|
||||||
|
if not is_system:
|
||||||
|
if not model.has_access('read'):
|
||||||
|
model_description = self.env['ir.model']._get(variable.model).display_name
|
||||||
|
raise ValidationError(
|
||||||
|
_("You can not select field of %(model)s.", model=model_description)
|
||||||
|
)
|
||||||
|
safe_fields = model._get_whatsapp_safe_fields() if hasattr(model, '_get_whatsapp_safe_fields') else []
|
||||||
|
if variable.field_name not in safe_fields:
|
||||||
|
raise ValidationError(
|
||||||
|
_("You are not allowed to use field %(field)s, contact your administrator.",
|
||||||
|
field=variable.field_name)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
model._find_value_from_field_path(variable.field_name)
|
||||||
|
except UserError:
|
||||||
|
failing += variable
|
||||||
|
if failing:
|
||||||
|
model_description = self.env['ir.model']._get(failing.mapped('model')[0]).display_name
|
||||||
|
raise ValidationError(
|
||||||
|
_("Variables %(field_names)s do not seem to be valid field path for model %(model_name)s.",
|
||||||
|
field_names=", ".join(failing.mapped("field_name")),
|
||||||
|
model_name=model_description,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.constrains('name')
|
||||||
|
def _check_name(self):
|
||||||
|
for variable in self:
|
||||||
|
if variable.line_type == 'location' and variable.name not in {'name', 'address', 'latitude', 'longitude'}:
|
||||||
|
raise ValidationError(
|
||||||
|
_("Location variable should be 'name', 'address', 'latitude' or 'longitude'. Cannot parse '%(placeholder)s'",
|
||||||
|
placeholder=variable.name))
|
||||||
|
elif variable.line_type == 'button' and variable.name != variable.button_id.name:
|
||||||
|
raise ValidationError(_("Dynamic button variable name must be the same as its respective button's name"))
|
||||||
|
elif variable.line_type in ('header', 'body') and not variable._extract_variable_index():
|
||||||
|
raise ValidationError(
|
||||||
|
_('Template variable should be in format {{number}}. Cannot parse "%(placeholder)s"',
|
||||||
|
placeholder=variable.name))
|
||||||
|
|
||||||
|
@api.constrains('button_id', 'line_type')
|
||||||
|
def _check_button_id(self):
|
||||||
|
for variable in self:
|
||||||
|
if variable.line_type == 'button' and not variable.button_id:
|
||||||
|
raise ValidationError(_('Button variables must be linked to a button.'))
|
||||||
|
|
||||||
|
@api.depends('line_type', 'name')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
type_names = dict(self._fields["line_type"]._description_selection(self.env))
|
||||||
|
for variable in self:
|
||||||
|
type_name = type_names[variable.line_type or 'body']
|
||||||
|
variable.display_name = type_name if variable.line_type == 'header' else f'{type_name} - {variable.name}'
|
||||||
|
|
||||||
|
@api.onchange('model')
|
||||||
|
def _onchange_model_id(self):
|
||||||
|
self.field_name = False
|
||||||
|
|
||||||
|
@api.onchange('field_type')
|
||||||
|
def _onchange_field_type(self):
|
||||||
|
if self.field_type != 'field':
|
||||||
|
self.field_name = False
|
||||||
|
|
||||||
|
def _get_variables_value(self, record):
|
||||||
|
value_by_name = {}
|
||||||
|
user = self.env.user
|
||||||
|
for variable in self:
|
||||||
|
if variable.field_type == 'user_name':
|
||||||
|
value = user.name
|
||||||
|
elif variable.field_type == 'user_mobile':
|
||||||
|
value = user.mobile
|
||||||
|
elif variable.field_type == 'field':
|
||||||
|
value = variable._find_value_from_field_chain(record)
|
||||||
|
elif variable.field_type == 'portal_url':
|
||||||
|
portal_url = record._whatsapp_get_portal_url()
|
||||||
|
value = url_join(variable.get_base_url(), (portal_url or ''))
|
||||||
|
else:
|
||||||
|
value = variable.demo_value
|
||||||
|
|
||||||
|
value_str = value and str(value) or ''
|
||||||
|
if variable.button_id:
|
||||||
|
value_by_name[f"button-{variable.button_id.name}"] = value_str
|
||||||
|
else:
|
||||||
|
value_by_name[f"{variable.line_type}-{variable.name}"] = value_str
|
||||||
|
|
||||||
|
return value_by_name
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# TOOLS
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _find_value_from_field_chain(self, record):
|
||||||
|
"""Get the value of field, returning display_name(s) if the field is a model."""
|
||||||
|
self.ensure_one()
|
||||||
|
return record.sudo(False)._find_value_from_field_path(self.field_name)
|
||||||
|
|
||||||
|
def _extract_variable_index(self):
|
||||||
|
""" Extract variable index, located between '{{}}' markers. """
|
||||||
|
self.ensure_one()
|
||||||
|
try:
|
||||||
|
return int(self.name.lstrip('{{').rstrip('}}'))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import whatsapp_account
|
||||||
|
from . import whatsapp_template
|
||||||
|
from . import whatsapp_message
|
||||||
|
from . import discuss_channel
|
||||||
|
from . import mail_message
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscussChannel(models.Model):
|
||||||
|
_inherit = "discuss.channel"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _populate_dependencies(self):
|
||||||
|
return super()._populate_dependencies + ["whatsapp.account"]
|
||||||
|
|
||||||
|
def _populate(self, size):
|
||||||
|
res = super()._populate(size)
|
||||||
|
accounts = self.env["whatsapp.account"].browse(self.env.registry.populated_models["whatsapp.account"])
|
||||||
|
group = self.env.ref("base.group_system")
|
||||||
|
partners = self.env["res.partner"].browse(self.env.registry.populated_models["res.partner"])
|
||||||
|
partners = partners.filtered(lambda partner: not partner.is_company)
|
||||||
|
|
||||||
|
def generate_random_phone(**kwargs):
|
||||||
|
return '+91' + ''.join(random.choices(string.digits[1:], k=10))
|
||||||
|
|
||||||
|
channels = []
|
||||||
|
for _ in range(0, {"small": 30, "medium": 200, "large": 1000}[size]):
|
||||||
|
whatsapp_number = generate_random_phone()
|
||||||
|
partner = random.choice(partners)
|
||||||
|
channels.append(
|
||||||
|
{
|
||||||
|
"channel_partner_ids": [(4, partner.id)],
|
||||||
|
"channel_type": "whatsapp",
|
||||||
|
"group_ids": group,
|
||||||
|
"name": f"{whatsapp_number} {partner.name}",
|
||||||
|
'wa_account_id': random.choice(accounts.ids),
|
||||||
|
'whatsapp_number': whatsapp_number,
|
||||||
|
"whatsapp_partner_id": partner.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# install_mode to prevent from automatically adding system as member
|
||||||
|
res += self.env["discuss.channel"].with_context(install_mode=True).create(channels)
|
||||||
|
return res
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import models
|
||||||
|
from odoo.tools import populate
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Message(models.Model):
|
||||||
|
_inherit = "mail.message"
|
||||||
|
|
||||||
|
def _populate(self, size):
|
||||||
|
res = super()._populate(size)
|
||||||
|
random = populate.Random("mail.message in WhatsApp")
|
||||||
|
channels = self.env["discuss.channel"].browse(self.env.registry.populated_models["discuss.channel"])
|
||||||
|
messages = []
|
||||||
|
big_done = 0
|
||||||
|
for channel in channels.filtered(lambda channel: channel.channel_type == "whatsapp"):
|
||||||
|
big = {"small": 80, "medium": 150, "large": 300}[size]
|
||||||
|
small_big_ratio = {"small": 10, "medium": 150, "large": 1000}[size]
|
||||||
|
max_messages = big if random.randint(1, small_big_ratio) == 1 else 60
|
||||||
|
number_messages = 200 if big_done < 2 else random.randrange(max_messages)
|
||||||
|
if number_messages >= 200:
|
||||||
|
big_done += 1
|
||||||
|
for counter in range(number_messages):
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"author_id": random.choice(channel.channel_member_ids.partner_id).id,
|
||||||
|
"body": f"whatsapp_message_body_{counter}",
|
||||||
|
"message_type": "whatsapp_message",
|
||||||
|
"model": "discuss.channel",
|
||||||
|
"res_id": channel.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
batches = [messages[i : i + 1000] for i in range(0, len(messages), 1000)]
|
||||||
|
count = 0
|
||||||
|
for batch in batches:
|
||||||
|
count += len(batch)
|
||||||
|
_logger.info("Batch of mail.message for discuss.channel(whatsapp): %s/%s", count, len(messages))
|
||||||
|
res += self.env["mail.message"].create(batch)
|
||||||
|
return res
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import string
|
||||||
|
|
||||||
|
from odoo import models
|
||||||
|
from odoo.tools import populate
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsappAccount(models.Model):
|
||||||
|
_inherit = "whatsapp.account"
|
||||||
|
|
||||||
|
_populate_dependencies = ["res.users"]
|
||||||
|
_populate_sizes = {'small': 2, 'medium': 5, 'large': 10}
|
||||||
|
|
||||||
|
def _populate_factories(self):
|
||||||
|
random = populate.Random("whatsapp.account")
|
||||||
|
users = self.env.registry.populated_models["res.users"]
|
||||||
|
|
||||||
|
def generate_random_app_id_secret(**kwargs):
|
||||||
|
return ''.join(random.choices(string.ascii_uppercase, k=15))
|
||||||
|
|
||||||
|
def generate_random_account_uid(**kwargs):
|
||||||
|
return ''.join(random.choices(string.ascii_uppercase, k=10))
|
||||||
|
|
||||||
|
def generate_random_phone_uid(**kwargs):
|
||||||
|
return ''.join(random.choices(string.digits[1:], k=10))
|
||||||
|
|
||||||
|
def generate_random_access_token(**kwargs):
|
||||||
|
return ''.join(random.choices(string.ascii_uppercase, k=20))
|
||||||
|
|
||||||
|
def get_notify_user_ids(**kwargs):
|
||||||
|
return [
|
||||||
|
(6, 0, [
|
||||||
|
random.choice(users) for i in range(random.randint(1, len(users)))
|
||||||
|
])
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
('name', populate.constant("WA-ac-{counter}")),
|
||||||
|
('app_uid', populate.compute(generate_random_app_id_secret)),
|
||||||
|
('app_secret', populate.compute(generate_random_app_id_secret)),
|
||||||
|
('account_uid', populate.compute(generate_random_account_uid)),
|
||||||
|
('phone_uid', populate.compute(generate_random_phone_uid)),
|
||||||
|
('token', populate.compute(generate_random_access_token)),
|
||||||
|
('notify_user_ids', populate.compute(get_notify_user_ids)),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import string
|
||||||
|
|
||||||
|
from odoo import models
|
||||||
|
from odoo.tools import populate
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsappMessage(models.Model):
|
||||||
|
_inherit = "whatsapp.message"
|
||||||
|
|
||||||
|
_populate_dependencies = ["whatsapp.template", "mail.message"]
|
||||||
|
_populate_sizes = {'small': 100, 'medium': 1_500, 'large': 25_000}
|
||||||
|
|
||||||
|
def _populate_factories(self):
|
||||||
|
random = populate.Random("whatsapp.message")
|
||||||
|
templates = self.env["whatsapp.template"].browse(self.env.registry.populated_models["whatsapp.template"])
|
||||||
|
templates = templates.filtered(lambda template: template.status == 'approved')
|
||||||
|
accounts = self.env.registry.populated_models["whatsapp.account"]
|
||||||
|
messages = self.env.registry.populated_models["mail.message"]
|
||||||
|
message_type = ['outbound', 'inbound']
|
||||||
|
state = ['outgoing', 'sent', 'delivered', 'read', 'received', 'error', 'cancel']
|
||||||
|
|
||||||
|
def compute_mobile_number(**kwargs):
|
||||||
|
return ''.join(random.choices(string.digits[1:], k=10))
|
||||||
|
|
||||||
|
def generate_random_message_uid(**kwargs):
|
||||||
|
return ''.join(random.choices(string.ascii_uppercase, k=10))
|
||||||
|
|
||||||
|
def compute_wa_template_id(**kwargs):
|
||||||
|
return random.choice(templates.ids)
|
||||||
|
|
||||||
|
return [
|
||||||
|
('mobile_number', populate.compute(compute_mobile_number)),
|
||||||
|
('message_type', populate.randomize(message_type)),
|
||||||
|
('state', populate.randomize(state)),
|
||||||
|
('wa_template_id', populate.compute(compute_wa_template_id)),
|
||||||
|
('msg_uid', populate.compute(generate_random_message_uid)),
|
||||||
|
('wa_account_id', populate.randomize(accounts)),
|
||||||
|
('mail_message_id', populate.randomize(messages)),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import models
|
||||||
|
from odoo.tools import populate
|
||||||
|
from odoo.addons.whatsapp.tools.lang_list import Languages
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsappTemplate(models.Model):
|
||||||
|
_inherit = "whatsapp.template"
|
||||||
|
|
||||||
|
_populate_dependencies = ["whatsapp.account"]
|
||||||
|
_populate_sizes = {'small': 10, 'medium': 100, 'large': 1000}
|
||||||
|
|
||||||
|
def _populate_factories(self):
|
||||||
|
random = populate.Random("whatsapp.template")
|
||||||
|
accounts = self.env["whatsapp.account"].browse(self.env.registry.populated_models["whatsapp.account"])
|
||||||
|
template_type = ['authentication', 'marketing', 'utility']
|
||||||
|
header_type = ['none', 'text']
|
||||||
|
status = [
|
||||||
|
'approved', 'draft', 'pending', 'in_appeal', 'paused',
|
||||||
|
'disabled', 'rejected', 'pending_deletion', 'deleted', 'limit_exceeded'
|
||||||
|
]
|
||||||
|
body = [
|
||||||
|
"Welcome to Odoo!\nWe're excited to have you.\nLet's achieve great things together.",
|
||||||
|
"Greetings from Odoo!\nYour journey with us starts now.\nLet's make it memorable.",
|
||||||
|
"Hello and welcome to Odoo!\nWe're here to support you.\nTogether, we'll reach new heights.",
|
||||||
|
"Odoo welcomes you!\nJoin our community of innovators.\nLet's create something amazing.",
|
||||||
|
"Glad to see you at Odoo!\nYour success is our priority.\nLet's work together for greatness.",
|
||||||
|
"Welcome aboard Odoo!\nWe're thrilled to have you here.\nLet's embark on this journey together.",
|
||||||
|
"Odoo is happy to have you!\nWe believe in your potential.\nLet's unlock it together.",
|
||||||
|
"A warm welcome to Odoo!\nYou are now part of our family.\nLet's grow and succeed together.",
|
||||||
|
"You are welcome at Odoo!\nWe value your presence.\nLet's achieve excellence together.",
|
||||||
|
"Odoo greets you warmly!\nWe look forward to working with you.\nLet's make great things happen."
|
||||||
|
]
|
||||||
|
|
||||||
|
def compute_lang_code(**kwargs):
|
||||||
|
return Languages[random.randint(0, len(Languages) - 1)][0]
|
||||||
|
|
||||||
|
def compute_wa_account_id(**kwargs):
|
||||||
|
return random.choice(accounts.ids)
|
||||||
|
|
||||||
|
return [
|
||||||
|
('name', populate.constant("WA-T-{counter}")),
|
||||||
|
('template_name', populate.constant("Template-{counter}")),
|
||||||
|
('lang_code', populate.compute(compute_lang_code)),
|
||||||
|
('template_type', populate.randomize(template_type)),
|
||||||
|
('wa_account_id', populate.compute(compute_wa_account_id)),
|
||||||
|
('header_type', populate.randomize(header_type)),
|
||||||
|
('footer_text', populate.constant("Write 'stop' to stop receiving messages")),
|
||||||
|
('header_text', populate.constant("Welcome to Odoo!")),
|
||||||
|
('status', populate.randomize(status, [55, 5, 5, 5, 5, 5, 5, 5, 5, 5])),
|
||||||
|
('body', populate.iterate(body)),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_ir_model_wa_admin,access.ir.model.wa.admin,base.model_ir_model,group_whatsapp_admin,1,0,0,0
|
||||||
|
access_whatsapp_account_user,access.whatsapp.account.user,model_whatsapp_account,base.group_user,1,0,0,0
|
||||||
|
access_whatsapp_account_system_admin,access.whatsapp.account.system.admin,model_whatsapp_account,base.group_system,1,1,1,1
|
||||||
|
access_whatsapp_account_administrator,access.whatsapp.account.admin,model_whatsapp_account,group_whatsapp_admin,1,1,1,0
|
||||||
|
access_whatsapp_composer_user,access.whatsapp.composer,model_whatsapp_composer,base.group_user,1,1,1,1
|
||||||
|
access_whatsapp_message_administrator,access.whatsapp.message,model_whatsapp_message,group_whatsapp_admin,1,1,1,1
|
||||||
|
access_whatsapp_message_user,access.whatsapp.message,model_whatsapp_message,base.group_user,1,1,1,0
|
||||||
|
access_whatsapp_preview_user,access.whatsapp.preview,model_whatsapp_preview,base.group_user,1,1,1,1
|
||||||
|
access_whatsapp_template_administrator,access.whatsapp.template,model_whatsapp_template,group_whatsapp_admin,1,1,1,1
|
||||||
|
access_whatsapp_template_user,access.whatsapp.template,model_whatsapp_template,base.group_user,1,0,0,0
|
||||||
|
access_whatsapp_template_button_administrator,access.whatsapp.template.button,model_whatsapp_template_button,group_whatsapp_admin,1,1,1,1
|
||||||
|
access_whatsapp_template_button_user,access.whatsapp.template.button,model_whatsapp_template_button,base.group_user,1,0,0,0
|
||||||
|
access_whatsapp_template_variable_administrator,access.whatsapp.template.variable,model_whatsapp_template_variable,group_whatsapp_admin,1,1,1,1
|
||||||
|
access_whatsapp_template_variable_user,access.whatsapp.template.variable,model_whatsapp_template_variable,base.group_user,1,0,0,0
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<record id="security_rule_whatsapp_account" model="ir.rule">
|
||||||
|
<field name="name">WA Account: Restrict to Allowed Companies</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_account"/>
|
||||||
|
<field name="domain_force">[('allowed_company_ids', 'in', company_ids)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="security_rule_whatsapp_composer" model="ir.rule">
|
||||||
|
<field name="name">WA Composer: Restrict to Own</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_composer"/>
|
||||||
|
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="security_rule_whatsapp_message_user" model="ir.rule">
|
||||||
|
<field name="name">WA Message: Restrict to Own</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_message"/>
|
||||||
|
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
</record>
|
||||||
|
<record id="security_rule_whatsapp_message_admin" model="ir.rule">
|
||||||
|
<field name="name">WA Message: Un-restrict for WA Admins</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_message"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('whatsapp.group_whatsapp_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="security_rule_whatsapp_preview" model="ir.rule">
|
||||||
|
<field name="name">WA Preview: Restrict to Own</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_preview"/>
|
||||||
|
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="security_rule_whatsapp_template" model="ir.rule">
|
||||||
|
<field name="name">WA Template: Restrict to Allowed Companies</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template"/>
|
||||||
|
<field name="domain_force">['|', ('wa_account_id', '=', False), ('wa_account_id.allowed_company_ids', 'in', company_ids)]</field>
|
||||||
|
</record>
|
||||||
|
<record id="security_rule_whatsapp_template_user" model="ir.rule">
|
||||||
|
<field name="name">WA Template: Restrict to Allowed Users</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template"/>
|
||||||
|
<field name="domain_force">['|', ('allowed_user_ids', '=', False), ('allowed_user_ids', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
</record>
|
||||||
|
<record id="security_rule_whatsapp_template_admin" model="ir.rule">
|
||||||
|
<field name="name">WA Template: Un-restrict for WA Admins</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('whatsapp.group_whatsapp_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="security_rule_whatsapp_template_button" model="ir.rule">
|
||||||
|
<field name="name">WA Template Button: Restrict to Allowed Companies</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template_button"/>
|
||||||
|
<field name="domain_force">['|', ('wa_template_id.wa_account_id', '=', False), ('wa_template_id.wa_account_id.allowed_company_ids', 'in', company_ids)]</field>
|
||||||
|
</record>
|
||||||
|
<record id="security_rule_whatsapp_template_button_user" model="ir.rule">
|
||||||
|
<field name="name">WA Template Button: Restrict to Allowed Users</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template_button"/>
|
||||||
|
<field name="domain_force">['|', ('wa_template_id.allowed_user_ids', '=', False), ('wa_template_id.allowed_user_ids', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
</record>
|
||||||
|
<record id="security_rule_whatsapp_template_button_admin" model="ir.rule">
|
||||||
|
<field name="name">WA Template Button: Un-restrict for WA Admins</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template_button"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('whatsapp.group_whatsapp_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="security_rule_whatsapp_template_variable" model="ir.rule">
|
||||||
|
<field name="name">WA Template Variable: Restrict to Allowed Companies</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template_variable"/>
|
||||||
|
<field name="domain_force">['|', ('wa_template_id.wa_account_id', '=', False), ('wa_template_id.wa_account_id.allowed_company_ids', 'in', company_ids)]</field>
|
||||||
|
</record>
|
||||||
|
<record id="security_rule_whatsapp_template_variable_user" model="ir.rule">
|
||||||
|
<field name="name">WA Template Variable: Restrict to Allowed Users</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template_variable"/>
|
||||||
|
<field name="domain_force">['|', ('wa_template_id.allowed_user_ids', '=', False), ('wa_template_id.allowed_user_ids', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
</record>
|
||||||
|
<record id="security_rule_whatsapp_template_variable_admin" model="ir.rule">
|
||||||
|
<field name="name">WA Template Variable: Un-restrict for WA Admins</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template_variable"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('whatsapp.group_whatsapp_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<odoo>
|
||||||
|
<record id="group_whatsapp_admin" model="res.groups">
|
||||||
|
<field name="name">Administrator</field>
|
||||||
|
<field name="category_id" ref="base.module_category_marketing_whatsapp"/>
|
||||||
|
<field name="users" eval="[Command.link(ref('base.user_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
After Width: | Height: | Size: 4.4 KiB |
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 50 50" style="enable-background:new 0 0 50 50;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#B3B3B3;}
|
||||||
|
.st1{fill:#FFFFFF;}
|
||||||
|
.st2{fill:none;}
|
||||||
|
.st3{fill:url(#SVGID_1_);}
|
||||||
|
.st4{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
|
||||||
|
</style>
|
||||||
|
<path class="st0" d="M14.5,40.4l0.7,0.4c2.9,1.7,6.2,2.6,9.6,2.6h0c10.4,0,18.9-8.5,18.9-18.9c0-5.1-2-9.8-5.5-13.4
|
||||||
|
c-3.5-3.6-8.4-5.6-13.4-5.6c-10.4,0-18.9,8.5-18.9,18.9c0,3.6,1,7.1,2.9,10.1l0.5,0.7l-1.9,7L14.5,40.4z M1.9,47.7l3.2-11.8
|
||||||
|
c-2-3.5-3-7.4-3-11.4C2.1,12,12.3,1.8,24.8,1.8c6.1,0,11.8,2.4,16.1,6.7s6.7,10,6.7,16.1c0,12.6-10.2,22.8-22.8,22.8h0
|
||||||
|
c-3.8,0-7.6-1-10.9-2.8L1.9,47.7z"/>
|
||||||
|
<path class="st1" d="M1.6,47.5l3.2-11.8c-2-3.5-3-7.4-3-11.4C1.8,11.7,12,1.5,24.6,1.5c6.1,0,11.8,2.4,16.1,6.7s6.7,10,6.7,16.1
|
||||||
|
c0,12.6-10.2,22.8-22.8,22.8h0c-3.8,0-7.6-1-10.9-2.8L1.6,47.5z"/>
|
||||||
|
<path class="st2" d="M24.6,5.4c-10.4,0-18.9,8.5-18.9,18.9c0,3.6,1,7.1,2.9,10.1L9,35.1l-1.9,7l7.2-1.9l0.7,0.4
|
||||||
|
c2.9,1.7,6.2,2.6,9.6,2.6h0c10.4,0,18.9-8.5,18.9-18.9c0-5-2-9.8-5.5-13.4C34.5,7.4,29.6,5.4,24.6,5.4L24.6,5.4z"/>
|
||||||
|
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="24.5016" y1="44.8036" x2="24.6936" y2="12.4423" gradientTransform="matrix(1 0 0 -1 0 52.448)">
|
||||||
|
<stop offset="0" style="stop-color:#57D163"/>
|
||||||
|
<stop offset="1" style="stop-color:#23B33A"/>
|
||||||
|
</linearGradient>
|
||||||
|
<path class="st3" d="M24.6,5.4c-10.4,0-18.9,8.5-18.9,18.9c0,3.6,1,7.1,2.9,10.1L9,35.1l-1.9,7l7.2-1.9l0.7,0.4
|
||||||
|
c2.9,1.7,6.2,2.6,9.6,2.6h0c10.4,0,18.9-8.5,18.9-18.9c0-5-2-9.8-5.5-13.4C34.5,7.4,29.6,5.4,24.6,5.4z"/>
|
||||||
|
<path class="st4" d="M18.9,14.8c-0.4-0.9-0.9-1-1.3-1l-1.1,0c-0.4,0-1,0.1-1.5,0.7c-0.5,0.6-2,1.9-2,4.7s2,5.5,2.3,5.9
|
||||||
|
c0.3,0.4,3.9,6.3,9.7,8.6c4.8,1.9,5.8,1.5,6.8,1.4c1-0.1,3.4-1.4,3.8-2.7c0.5-1.3,0.5-2.5,0.3-2.7c-0.1-0.2-0.5-0.4-1.1-0.7
|
||||||
|
c-0.6-0.3-3.4-1.7-3.9-1.9c-0.5-0.2-0.9-0.3-1.3,0.3c-0.4,0.6-1.5,1.9-1.8,2.2c-0.3,0.4-0.7,0.4-1.2,0.1c-0.6-0.3-2.4-0.9-4.6-2.8
|
||||||
|
c-1.7-1.5-2.8-3.4-3.2-3.9s0-0.9,0.3-1.2c0.3-0.3,0.6-0.7,0.9-1c0.3-0.3,0.4-0.6,0.6-0.9c0.2-0.4,0.1-0.7,0-1
|
||||||
|
C20.5,18.7,19.4,15.9,18.9,14.8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
|
@ -0,0 +1,39 @@
|
||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { Chatter } from "@mail/chatter/web_portal/chatter";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
|
patch(Chatter.prototype, {
|
||||||
|
sendWhatsapp() {
|
||||||
|
const send = async (thread) => {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
this.env.services.action.doAction(
|
||||||
|
{
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
name: _t("Send WhatsApp Message"),
|
||||||
|
res_model: "whatsapp.composer",
|
||||||
|
view_mode: "form",
|
||||||
|
views: [[false, "form"]],
|
||||||
|
target: "new",
|
||||||
|
context: {
|
||||||
|
active_model: thread.model,
|
||||||
|
active_id: thread.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ onClose: resolve }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this.store.Thread.insert({
|
||||||
|
model: this.props.threadModel,
|
||||||
|
id: this.props.threadId,
|
||||||
|
}).fetchNewMessages();
|
||||||
|
};
|
||||||
|
if (this.state.thread.id) {
|
||||||
|
send(this.state.thread);
|
||||||
|
} else {
|
||||||
|
this.onThreadCreated = send;
|
||||||
|
this.props.saveRecord?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="whatsapp.Chatter" t-inherit="mail.Chatter" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//*[contains(@class, 'o-mail-Chatter-activity')]" position="before">
|
||||||
|
<button t-if="state.thread.canSendWhatsapp"
|
||||||
|
class="btn btn-secondary text-nowrap me-1"
|
||||||
|
t-att-class="{'my-2': !props.compactHeight }"
|
||||||
|
data-hotkey="shift+w"
|
||||||
|
t-on-click="sendWhatsapp"
|
||||||
|
>
|
||||||
|
<span>WhatsApp</span>
|
||||||
|
</button>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
import { PhoneField, phoneField, formPhoneField } from "@web/views/fields/phone/phone_field";
|
||||||
|
import { SendWhatsAppButton } from "../whatsapp_button/whatsapp_button.js";
|
||||||
|
|
||||||
|
patch(PhoneField, {
|
||||||
|
components: {
|
||||||
|
...PhoneField.components,
|
||||||
|
SendWhatsAppButton,
|
||||||
|
},
|
||||||
|
defaultProps: {
|
||||||
|
...PhoneField.defaultProps,
|
||||||
|
enableWhatsAppButton: true,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
...PhoneField.props,
|
||||||
|
enableWhatsAppButton: { type: Boolean, optional: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchDescr = {
|
||||||
|
extractProps({ options }) {
|
||||||
|
const props = super.extractProps(...arguments);
|
||||||
|
props.enableWhatsAppButton = options.enable_whatsapp;
|
||||||
|
return props;
|
||||||
|
},
|
||||||
|
supportedOptions: [
|
||||||
|
...(phoneField.supportedOptions ? phoneField.supportedOptions : []),
|
||||||
|
{
|
||||||
|
label: _t("Enable WhatsApp"),
|
||||||
|
name: "enable_whatsapp",
|
||||||
|
type: "boolean",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
patch(phoneField, patchDescr);
|
||||||
|
patch(formPhoneField, patchDescr);
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="whatsapp.PhoneField" t-inherit="web.PhoneField" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//div[contains(@class, 'o_phone_content')]//a" position="after">
|
||||||
|
<t t-if="props.enableWhatsAppButton and props.record.data[props.name].length > 0">
|
||||||
|
<SendWhatsAppButton t-props="props" />
|
||||||
|
</t>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-name="whatsapp.FormPhoneField" t-inherit="web.FormPhoneField" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//div[contains(@class, 'o_phone_content')]" position="inside">
|
||||||
|
<t t-if="props.enableWhatsAppButton and props.record.data[props.name].length > 0">
|
||||||
|
<SendWhatsAppButton t-props="props" />
|
||||||
|
</t>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { user } from "@web/core/user";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
|
||||||
|
export class SendWhatsAppButton extends Component {
|
||||||
|
static template = "whatsapp.SendWhatsAppButton";
|
||||||
|
static props = ["*"];
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.action = useService("action");
|
||||||
|
this.title = _t("Send WhatsApp Message");
|
||||||
|
}
|
||||||
|
|
||||||
|
async onClick() {
|
||||||
|
await this.props.record.save();
|
||||||
|
this.action.doAction(
|
||||||
|
{
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
target: "new",
|
||||||
|
name: this.title,
|
||||||
|
res_model: "whatsapp.composer",
|
||||||
|
views: [[false, "form"]],
|
||||||
|
context: {
|
||||||
|
...user.context,
|
||||||
|
active_model: this.props.record.resModel,
|
||||||
|
active_id: this.props.record.resId,
|
||||||
|
default_phone: this.props.record.data[this.props.name],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onClose: () => {
|
||||||
|
this.props.record.load();
|
||||||
|
this.props.record.model.notify();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="whatsapp.SendWhatsAppButton">
|
||||||
|
<a
|
||||||
|
t-att-title="title"
|
||||||
|
t-att-href="'whatsapp:' + props.record.data[props.name]"
|
||||||
|
t-on-click.prevent.stop="onClick"
|
||||||
|
class="ms-3 d-inline-flex align-items-center o_field_phone_whatsapp"
|
||||||
|
>
|
||||||
|
<i class="fa fa-whatsapp"/>
|
||||||
|
<small class="fw-bold ms-1">WhatsApp</small>
|
||||||
|
</a>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
/* @odoo-module */
|
||||||
|
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { browser } from "@web/core/browser/browser";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { TextField, textField } from "@web/views/fields/text/text_field";
|
||||||
|
import { Tooltip } from "@web/core/tooltip/tooltip";
|
||||||
|
import { usePopover } from "@web/core/popover/popover_hook";
|
||||||
|
|
||||||
|
import { useRef } from "@odoo/owl";
|
||||||
|
|
||||||
|
|
||||||
|
export class WhatsappVariablesTextField extends TextField {
|
||||||
|
static template = "whatsapp.WhatsappVariablesTextField";
|
||||||
|
static components = { ...TextField.components };
|
||||||
|
setup() {
|
||||||
|
super.setup();
|
||||||
|
this.textareaRef = useRef('textarea');
|
||||||
|
this.variablesButton = useRef('variablesButton');
|
||||||
|
this.popover = usePopover(Tooltip, { animation: false, position: "left" });
|
||||||
|
}
|
||||||
|
|
||||||
|
_onClickAddVariables() {
|
||||||
|
const originalContent = this.textareaRef.el.value;
|
||||||
|
const start = this.textareaRef.el.selectionStart;
|
||||||
|
const end = this.textareaRef.el.selectionEnd;
|
||||||
|
|
||||||
|
const matches = Array.from(originalContent.matchAll(/{{(\d+)}}/g));
|
||||||
|
const integerList = matches.map(match => parseInt(match[1]));
|
||||||
|
const nextVariable = Math.max(...integerList, 0) + 1;
|
||||||
|
|
||||||
|
if (nextVariable > 10){
|
||||||
|
// Show tooltip
|
||||||
|
this.popover.open(this.variablesButton.el, { tooltip: _t("You can set a maximum of 10 variables.") });
|
||||||
|
browser.setTimeout(this.popover.close, 2600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = originalContent.slice(0, start) ? ' ' : '';
|
||||||
|
this.textareaRef.el.value = originalContent.slice(0, start) + separator + '{{' + nextVariable + '}}' + originalContent.slice(end, originalContent.length);
|
||||||
|
// Trigger onInput from input_field hook to set field as dirty
|
||||||
|
this.textareaRef.el.dispatchEvent(new InputEvent("input"));
|
||||||
|
// Keydown on "enter" serves to both commit the changes in input_field and trigger onchange for some fields
|
||||||
|
this.textareaRef.el.dispatchEvent(new KeyboardEvent("keydown", {key: 'Enter'}));
|
||||||
|
this.textareaRef.el.focus();
|
||||||
|
const newCursorPos = start + separator.length + nextVariable.toString().length + 4; // 4 is the number of brackets {{ }}
|
||||||
|
this.textareaRef.el.setSelectionRange(newCursorPos, newCursorPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const whatsappVariablesTextField = {
|
||||||
|
...textField,
|
||||||
|
component: WhatsappVariablesTextField,
|
||||||
|
additionalClasses: [...(textField.additionalClasses || []), "o_field_text"],
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("fields").add("whatsapp_text_variables", whatsappVariablesTextField);
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="whatsapp.WhatsappVariablesTextField" t-inherit="web.TextField" t-inherit-mode="primary">
|
||||||
|
<xpath expr="//textarea" position="after">
|
||||||
|
<div class="position-relative d-inline">
|
||||||
|
<button class="btn position-absolute end-0 text-primary py-0" t-ref="variablesButton" t-on-click="_onClickAddVariables">
|
||||||
|
<div class="d-flex align-items-center gap-1 lh-1">
|
||||||
|
<i class="fa fa-plus" role="img" title="Insert variable"/>
|
||||||
|
<span>variable</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
declare module "models" {
|
||||||
|
export interface Message {
|
||||||
|
whatsappStatus: string,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="whatsapp.ChatWindow.headerContent" t-inherit="mail.ChatWindow.headerContent" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//ThreadIcon" position="after">
|
||||||
|
<ThreadIcon t-elif="thread and thread.channel_type === 'whatsapp' and thread.correspondent" thread="thread"/>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { Composer } from "@mail/core/common/composer_model";
|
||||||
|
|
||||||
|
Object.assign(Composer.prototype, "whatsapp_composer_model", {
|
||||||
|
threadExpired: false,
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { Composer } from "@mail/core/common/composer";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
|
import { onWillDestroy, useEffect } from "@odoo/owl";
|
||||||
|
|
||||||
|
patch(Composer.prototype, {
|
||||||
|
setup() {
|
||||||
|
super.setup();
|
||||||
|
this.composerDisableCheckTimeout = null;
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
clearTimeout(this.composerDisableCheckTimeout);
|
||||||
|
this.checkComposerDisabled();
|
||||||
|
},
|
||||||
|
() => [this.thread?.whatsapp_channel_valid_until]
|
||||||
|
);
|
||||||
|
onWillDestroy(() => clearTimeout(this.composerDisableCheckTimeout));
|
||||||
|
},
|
||||||
|
|
||||||
|
get placeholder() {
|
||||||
|
if (
|
||||||
|
this.thread &&
|
||||||
|
this.thread.channel_type === "whatsapp" &&
|
||||||
|
!this.state.active &&
|
||||||
|
this.props.composer.threadExpired
|
||||||
|
) {
|
||||||
|
return _t(
|
||||||
|
"Can't send message as it has been 24 hours since the last message of the User."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return super.placeholder;
|
||||||
|
},
|
||||||
|
|
||||||
|
checkComposerDisabled() {
|
||||||
|
if (this.thread && this.thread.channel_type === "whatsapp") {
|
||||||
|
const datetime = this.thread.whatsappChannelValidUntilDatetime;
|
||||||
|
if (!datetime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delta = datetime.ts - Date.now();
|
||||||
|
if (delta <= 0) {
|
||||||
|
this.state.active = false;
|
||||||
|
this.props.composer.threadExpired = true;
|
||||||
|
} else {
|
||||||
|
this.state.active = true;
|
||||||
|
this.props.composer.threadExpired = false;
|
||||||
|
this.composerDisableCheckTimeout = setTimeout(() => {
|
||||||
|
this.checkComposerDisabled();
|
||||||
|
}, delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get hasSendButtonNonEditing() {
|
||||||
|
if (this.thread?.channel_type === "whatsapp" && !this.state.active) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return super.hasSendButtonNonEditing;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @override */
|
||||||
|
get isSendButtonDisabled() {
|
||||||
|
const whatsappInactive =
|
||||||
|
this.thread && this.thread.channel_type === "whatsapp" && !this.state.active;
|
||||||
|
return super.isSendButtonDisabled || whatsappInactive;
|
||||||
|
},
|
||||||
|
|
||||||
|
onDropFile(ev) {
|
||||||
|
this.processFileUploading(ev, super.onDropFile.bind(this));
|
||||||
|
},
|
||||||
|
|
||||||
|
onPaste(ev) {
|
||||||
|
if (ev.clipboardData.files.length === 0) {
|
||||||
|
return super.onPaste(ev);
|
||||||
|
}
|
||||||
|
this.processFileUploading(ev, super.onPaste.bind(this));
|
||||||
|
},
|
||||||
|
|
||||||
|
processFileUploading(ev, superCb) {
|
||||||
|
if (
|
||||||
|
this.thread?.channel_type === "whatsapp" &&
|
||||||
|
this.props.composer.attachments.length > 0
|
||||||
|
) {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.env.services.notification.add(
|
||||||
|
_t("Only one attachment is allowed for each message"),
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
superCb(ev);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="whatsapp.Composer.actions" t-inherit="mail.Composer.actions" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//*[@name='root']" position="replace">
|
||||||
|
<t t-if="thread?.channel_type === 'whatsapp' and !state.active"></t>
|
||||||
|
<t t-else="">$0</t>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
<t t-name="whatsapp.Composer.quickActions" t-inherit="mail.Composer.quickActions" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//*[@name='root']" position="replace">
|
||||||
|
<t t-if="thread?.channel_type === 'whatsapp' and !state.active"></t>
|
||||||
|
<t t-else="">$0</t>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
<t t-name="whatsapp.Composer.extraActions" t-inherit="mail.Composer.extraActions" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//*[@name='root']" position="replace">
|
||||||
|
<t t-if="thread?.channel_type === 'whatsapp' and !state.active"></t>
|
||||||
|
<t t-else="">$0</t>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
<t t-name="whatsapp.Composer.attachFiles" t-inherit="mail.Composer.attachFiles" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//FileUploader" position="attributes">
|
||||||
|
<attribute name="multiUpload">thread and thread.channel_type === 'whatsapp' ? false : true</attribute>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//FileUploader/t/button" position="attributes">
|
||||||
|
<attribute name="t-att-disabled" add="or (thread and thread.channel_type === 'whatsapp' and props.composer.attachments.length > 0)" separator=" " />
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="whatsapp.ImStatus" t-inherit="mail.ImStatus" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//i[@title='Bot']" position="after">
|
||||||
|
<i t-elif="props.member?.eq(props.member.thread.whatsappMember)"
|
||||||
|
class="fa fa-whatsapp text-success" title="WhatsApp User"
|
||||||
|
role="img" aria-label="WhatsApp User" />
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { Message } from "@mail/core/common/message_model";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
|
patch(Message.prototype, {
|
||||||
|
get editable() {
|
||||||
|
if (this.thread?.channel_type === "whatsapp") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return super.editable;
|
||||||
|
},
|
||||||
|
/** @override */
|
||||||
|
canReplyTo(thread) {
|
||||||
|
return super.canReplyTo(thread) && !this.thread?.composer?.threadExpired;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Message } from "@mail/core/common/message";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
|
patch(Message.prototype, {
|
||||||
|
get showSeenIndicator() {
|
||||||
|
return super.showSeenIndicator && this.message.whatsappStatus !== "error";
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @param {MouseEvent} ev
|
||||||
|
*/
|
||||||
|
async onClick(ev) {
|
||||||
|
const id = Number(ev.target.dataset.oeId);
|
||||||
|
if (ev.target.closest(".o_whatsapp_channel_redirect")) {
|
||||||
|
ev.preventDefault();
|
||||||
|
let thread = await this.store.Thread.getOrFetch({ model: "discuss.channel", id });
|
||||||
|
if (!thread?.hasSelfAsMember) {
|
||||||
|
await this.env.services.orm.call("discuss.channel", "add_members", [[id]], {
|
||||||
|
partner_ids: [this.store.self.id],
|
||||||
|
});
|
||||||
|
thread = await this.store.Thread.getOrFetch({ model: "discuss.channel", id });
|
||||||
|
}
|
||||||
|
thread.open();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super.onClick(ev);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="whatsapp.Message" t-inherit="mail.Message" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//div[contains(@class, 'o-mail-Message-header')]" position="inside">
|
||||||
|
<t t-if="message.whatsappStatus === 'error' or message.message_type === 'whatsapp_message'">
|
||||||
|
<span class="fa fa-whatsapp ms-1" t-att-class="message.whatsappStatus === 'error' ? 'text-danger' : 'text-success'"
|
||||||
|
t-att-title="message.whatsappStatus === 'error' ? 'Failed to send': 'Sent On WhatsApp'" />
|
||||||
|
</t>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Store } from "@mail/core/common/store_service";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
|
patch(Store.prototype, {
|
||||||
|
async getMessagePostParams({ thread }) {
|
||||||
|
const params = await super.getMessagePostParams(...arguments);
|
||||||
|
|
||||||
|
if (thread.channel_type === "whatsapp") {
|
||||||
|
params.post_data.message_type = "whatsapp_message";
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
|
||||||
|
async openWhatsAppChannel(id, name) {
|
||||||
|
const thread = this.Thread.insert({
|
||||||
|
channel_type: "whatsapp",
|
||||||
|
id,
|
||||||
|
model: "discuss.channel",
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
if (!thread.avatarCacheKey) {
|
||||||
|
thread.avatarCacheKey = "hello";
|
||||||
|
}
|
||||||
|
if (!thread.hasSelfAsMember) {
|
||||||
|
const data = await this.env.services.orm.call(
|
||||||
|
"discuss.channel",
|
||||||
|
"whatsapp_channel_join_and_pin",
|
||||||
|
[[id]]
|
||||||
|
);
|
||||||
|
this.insert(data);
|
||||||
|
} else if (!thread.is_pinned) {
|
||||||
|
thread.pin();
|
||||||
|
}
|
||||||
|
thread.open();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="whatsapp.ThreadIcon" t-inherit="mail.ThreadIcon" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//*[contains(@class, 'o-mail-ThreadIcon')]" position="inside">
|
||||||
|
<t t-if="props.thread.channel_type === 'whatsapp'">
|
||||||
|
<t name="whatsapp">
|
||||||
|
<t name="whatsapp_static">
|
||||||
|
<div class="fa fa-whatsapp text-success" title="WhatsApp" />
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/* @odoo-module */
|
||||||
|
|
||||||
|
import { Thread } from "@mail/core/common/thread_model";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
import { deserializeDateTime } from "@web/core/l10n/dates";
|
||||||
|
|
||||||
|
import { toRaw } from "@odoo/owl";
|
||||||
|
|
||||||
|
patch(Thread.prototype, {
|
||||||
|
get importantCounter() {
|
||||||
|
if (this.channel_type === "whatsapp") {
|
||||||
|
return this.selfMember?.message_unread_counter || this.message_needaction_counter;
|
||||||
|
}
|
||||||
|
return super.importantCounter;
|
||||||
|
},
|
||||||
|
get canLeave() {
|
||||||
|
return this.channel_type !== "whatsapp" && super.canLeave;
|
||||||
|
},
|
||||||
|
get canUnpin() {
|
||||||
|
if (this.channel_type === "whatsapp") {
|
||||||
|
return this.importantCounter === 0;
|
||||||
|
}
|
||||||
|
return super.canUnpin;
|
||||||
|
},
|
||||||
|
|
||||||
|
get avatarUrl() {
|
||||||
|
if (this.channel_type === "whatsapp" && this.correspondent) {
|
||||||
|
return this.correspondent.persona.avatarUrl;
|
||||||
|
}
|
||||||
|
return super.avatarUrl;
|
||||||
|
},
|
||||||
|
|
||||||
|
get isChatChannel() {
|
||||||
|
return this.channel_type === "whatsapp" || super.isChatChannel;
|
||||||
|
},
|
||||||
|
|
||||||
|
get whatsappChannelValidUntilDatetime() {
|
||||||
|
if (!this.whatsapp_channel_valid_until) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return toRaw(deserializeDateTime(this.whatsapp_channel_valid_until));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { Thread } from "@mail/core/common/thread";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
|
patch(Thread.prototype, {
|
||||||
|
isSquashed(msg, prevMsg) {
|
||||||
|
if (msg.whatsappStatus === "error") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return super.isSquashed(msg, prevMsg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
declare module "models" {
|
||||||
|
export interface DiscussApp {
|
||||||
|
whatsapp: DiscussAppCategory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/* @odoo-module */
|
||||||
|
|
||||||
|
import { DiscussApp } from "@mail/core/public_web/discuss_app_model";
|
||||||
|
import { Record } from "@mail/core/common/record";
|
||||||
|
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
|
patch(DiscussApp, {
|
||||||
|
new(data) {
|
||||||
|
const res = super.new(data);
|
||||||
|
res.whatsapp = {
|
||||||
|
extraClass: "o-mail-DiscussSidebarCategory-whatsapp",
|
||||||
|
icon: "fa fa-whatsapp",
|
||||||
|
id: "whatsapp",
|
||||||
|
name: _t("WhatsApp"),
|
||||||
|
hideWhenEmpty: true,
|
||||||
|
canView: false,
|
||||||
|
canAdd: true,
|
||||||
|
addTitle: _t("Search WhatsApp Channel"),
|
||||||
|
serverStateKey: "is_discuss_sidebar_category_whatsapp_open",
|
||||||
|
sequence: 20,
|
||||||
|
};
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
patch(DiscussApp.prototype, {
|
||||||
|
setup(env) {
|
||||||
|
super.setup(env);
|
||||||
|
this.whatsapp = Record.one("DiscussAppCategory");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { MessagingMenu } from "@mail/core/public_web/messaging_menu";
|
||||||
|
import { ThreadIcon } from "@mail/core/common/thread_icon";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
|
||||||
|
patch(MessagingMenu, {
|
||||||
|
components: { ...MessagingMenu.components, ThreadIcon },
|
||||||
|
});
|
||||||
|
|
||||||
|
patch(MessagingMenu.prototype, {
|
||||||
|
get tabs() {
|
||||||
|
const items = super.tabs;
|
||||||
|
const hasWhatsApp = Object.values(this.store.Thread.records).some(
|
||||||
|
({ channel_type }) => channel_type === "whatsapp"
|
||||||
|
);
|
||||||
|
if (hasWhatsApp) {
|
||||||
|
items.push({
|
||||||
|
icon: "fa fa-whatsapp",
|
||||||
|
id: "whatsapp",
|
||||||
|
label: _t("WhatsApp"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
|
||||||
|
get displayStartConversation() {
|
||||||
|
return super.displayStartConversation && this.store.discuss.activeTab !== "whatsapp";
|
||||||
|
},
|
||||||
|
});
|
||||||