upload watsapp module

This commit is contained in:
eman 2024-08-08 12:17:37 +03:00
parent f6c258aa39
commit 4900610a43
171 changed files with 105447 additions and 0 deletions

View File

@ -0,0 +1,6 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controller
from . import models
from . import tools
from . import wizard

View File

@ -0,0 +1,53 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Odoo WhatsApp Integration',
'category': 'WhatsApp',
'summary': 'Integrates Odoo with WhatsApp to use WhatsApp messaging service',
'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/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/resources.xml',
],
'external_dependencies': {
'python': ['phonenumbers'],
},
# 'assets': {
# 'web.assets_backend': [
# 'whatsapp/static/src/**/*',
# # 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.tests_assets': [
# 'whatsapp/static/tests/helpers/**/*.js',
# ],
# 'web.qunit_suite_tests': [
# 'whatsapp/static/tests/**/*',
# ('remove', 'whatsapp/static/tests/helpers/**/*.js'),
# ],
# },
'license': 'OEEL-1',
'application': True,
'installable': True,
}

View File

@ -0,0 +1,757 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import configparser as ConfigParser
import errno
import logging
import optparse
import glob
import os
import sys
import tempfile
import warnings
import odoo
from os.path import expandvars, expanduser, abspath, realpath, normcase
from odoo import release, conf, loglevels
from odoo.tools import appdirs
# todo from odoo.tools
from passlib.context import CryptContext
crypt_context = CryptContext(schemes=['pbkdf2_sha512', 'plaintext'],
deprecated=['plaintext'],
pbkdf2_sha512__rounds=600_000)
class MyOption (optparse.Option, object):
""" optparse Option with two additional attributes.
The list of command line options (getopt.Option) is used to create the
list of the configuration file options. When reading the file, and then
reading the command line arguments, we don't want optparse.parse results
to override the configuration file values. But if we provide default
values to optparse, optparse will return them and we can't know if they
were really provided by the user or not. A solution is to not use
optparse's default attribute, but use a custom one (that will be copied
to create the default values of the configuration file).
"""
def __init__(self, *opts, **attrs):
self.my_default = attrs.pop('my_default', None)
super(MyOption, self).__init__(*opts, **attrs)
DEFAULT_LOG_HANDLER = ':INFO'
def _get_default_datadir():
home = os.path.expanduser('~')
if os.path.isdir(home):
func = appdirs.user_data_dir
else:
if sys.platform in ['win32', 'darwin']:
func = appdirs.site_data_dir
else:
func = lambda **kwarg: "/var/lib/%s" % kwarg['appname'].lower()
# No "version" kwarg as session and filestore paths are shared against series
return func(appname=release.product_name, appauthor=release.author)
def _deduplicate_loggers(loggers):
""" Avoid saving multiple logging levels for the same loggers to a save
file, that just takes space and the list can potentially grow unbounded
if for some odd reason people use :option`--save`` all the time.
"""
# dict(iterable) -> the last item of iterable for any given key wins,
# which is what we want and expect. Output order should not matter as
# there are no duplicates within the output sequence
return (
'{}:{}'.format(logger, level)
for logger, level in dict(it.split(':') for it in loggers).items()
)
class configmanager(object):
def __init__(self, fname=None):
"""Constructor.
:param fname: a shortcut allowing to instantiate :class:`configmanager`
from Python code without resorting to environment
variable
"""
# Options not exposed on the command line. Command line options will be added
# from optparse's parser.
self.options = {
'admin_passwd': 'admin',
'csv_internal_sep': ',',
'publisher_warranty_url': 'http://services.openerp.com/publisher-warranty/',
'reportgz': False,
'root_path': None,
'websocket_keep_alive_timeout': 3600,
'websocket_rate_limit_burst': 10,
'websocket_rate_limit_delay': 0.2,
}
# Not exposed in the configuration file.
self.blacklist_for_save = set([
'publisher_warranty_url', 'load_language', 'root_path',
'init', 'save', 'config', 'update', 'stop_after_init', 'dev_mode', 'shell_interface',
'longpolling_port',
])
# dictionary mapping option destination (keys in self.options) to MyOptions.
self.casts = {}
self.misc = {}
self.config_file = fname
self._LOGLEVELS = dict([
(getattr(loglevels, 'LOG_%s' % x), getattr(logging, x))
for x in ('CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'NOTSET')
])
version = "%s %s" % (release.description, release.version)
self.parser = parser = optparse.OptionParser(version=version, option_class=MyOption)
# Server startup config
group = optparse.OptionGroup(parser, "Common options")
group.add_option("-c", "--config", dest="config", help="specify alternate config file")
group.add_option("-s", "--save", action="store_true", dest="save", default=False,
help="save configuration to ~/.odoorc (or to ~/.openerp_serverrc if it exists)")
group.add_option("-i", "--init", dest="init", help="install one or more modules (comma-separated list, use \"all\" for all modules), requires -d")
group.add_option("-u", "--update", dest="update",
help="update one or more modules (comma-separated list, use \"all\" for all modules). Requires -d.")
group.add_option("--without-demo", dest="without_demo",
help="disable loading demo data for modules to be installed (comma-separated, use \"all\" for all modules). Requires -d and -i. Default is %default",
my_default=False)
group.add_option("-P", "--import-partial", dest="import_partial", my_default='',
help="Use this for big data importation, if it crashes you will be able to continue at the current state. Provide a filename to store intermediate importation states.")
group.add_option("--pidfile", dest="pidfile", help="file where the server pid will be stored")
group.add_option("--addons-path", dest="addons_path",
help="specify additional addons paths (separated by commas).",
action="callback", callback=self._check_addons_path, nargs=1, type="string")
group.add_option("--upgrade-path", dest="upgrade_path",
help="specify an additional upgrade path.",
action="callback", callback=self._check_upgrade_path, nargs=1, type="string")
group.add_option("--load", dest="server_wide_modules", help="Comma-separated list of server-wide modules.", my_default='base,web')
group.add_option("-D", "--data-dir", dest="data_dir", my_default=_get_default_datadir(),
help="Directory where to store Odoo data")
parser.add_option_group(group)
# HTTP
group = optparse.OptionGroup(parser, "HTTP Service Configuration")
group.add_option("--http-interface", dest="http_interface", my_default='',
help="Listen interface address for HTTP services. "
"Keep empty to listen on all interfaces (0.0.0.0)")
group.add_option("-p", "--http-port", dest="http_port", my_default=8069,
help="Listen port for the main HTTP service", type="int", metavar="PORT")
group.add_option("--longpolling-port", dest="longpolling_port", my_default=0,
help="Deprecated alias to the gevent-port option", type="int", metavar="PORT")
group.add_option("--gevent-port", dest="gevent_port", my_default=8072,
help="Listen port for the gevent worker", type="int", metavar="PORT")
group.add_option("--no-http", dest="http_enable", action="store_false", my_default=True,
help="Disable the HTTP and Longpolling services entirely")
group.add_option("--proxy-mode", dest="proxy_mode", action="store_true", my_default=False,
help="Activate reverse proxy WSGI wrappers (headers rewriting) "
"Only enable this when running behind a trusted web proxy!")
group.add_option("--x-sendfile", dest="x_sendfile", action="store_true", my_default=False,
help="Activate X-Sendfile (apache) and X-Accel-Redirect (nginx) "
"HTTP response header to delegate the delivery of large "
"files (assets/attachments) to the web server.")
# HTTP: hidden backwards-compatibility for "*xmlrpc*" options
hidden = optparse.SUPPRESS_HELP
group.add_option("--xmlrpc-interface", dest="http_interface", help=hidden)
group.add_option("--xmlrpc-port", dest="http_port", type="int", help=hidden)
group.add_option("--no-xmlrpc", dest="http_enable", action="store_false", help=hidden)
parser.add_option_group(group)
# WEB
group = optparse.OptionGroup(parser, "Web interface Configuration")
group.add_option("--db-filter", dest="dbfilter", my_default='', metavar="REGEXP",
help="Regular expressions for filtering available databases for Web UI. "
"The expression can use %d (domain) and %h (host) placeholders.")
parser.add_option_group(group)
# Testing Group
group = optparse.OptionGroup(parser, "Testing Configuration")
group.add_option("--test-file", dest="test_file", my_default=False,
help="Launch a python test file.")
group.add_option("--test-enable", action="callback", callback=self._test_enable_callback,
dest='test_enable',
help="Enable unit tests.")
group.add_option("--test-tags", dest="test_tags",
help="Comma-separated list of specs to filter which tests to execute. Enable unit tests if set. "
"A filter spec has the format: [-][tag][/module][:class][.method] "
"The '-' specifies if we want to include or exclude tests matching this spec. "
"The tag will match tags added on a class with a @tagged decorator "
"(all Test classes have 'standard' and 'at_install' tags "
"until explicitly removed, see the decorator documentation). "
"'*' will match all tags. "
"If tag is omitted on include mode, its value is 'standard'. "
"If tag is omitted on exclude mode, its value is '*'. "
"The module, class, and method will respectively match the module name, test class name and test method name. "
"Example: --test-tags :TestClass.test_func,/test_module,external "
"Filtering and executing the tests happens twice: right "
"after each module installation/update and at the end "
"of the modules loading. At each stage tests are filtered "
"by --test-tags specs and additionally by dynamic specs "
"'at_install' and 'post_install' correspondingly.")
group.add_option("--screencasts", dest="screencasts", action="store", my_default=None,
metavar='DIR',
help="Screencasts will go in DIR/{db_name}/screencasts.")
temp_tests_dir = os.path.join(tempfile.gettempdir(), 'odoo_tests')
group.add_option("--screenshots", dest="screenshots", action="store", my_default=temp_tests_dir,
metavar='DIR',
help="Screenshots will go in DIR/{db_name}/screenshots. Defaults to %s." % temp_tests_dir)
parser.add_option_group(group)
# Logging Group
group = optparse.OptionGroup(parser, "Logging Configuration")
group.add_option("--logfile", dest="logfile", help="file where the server log will be stored")
group.add_option("--syslog", action="store_true", dest="syslog", my_default=False, help="Send the log to the syslog server")
group.add_option('--log-handler', action="append", default=[], my_default=DEFAULT_LOG_HANDLER, metavar="PREFIX:LEVEL", help='setup a handler at LEVEL for a given PREFIX. An empty PREFIX indicates the root logger. This option can be repeated. Example: "odoo.orm:DEBUG" or "werkzeug:CRITICAL" (default: ":INFO")')
group.add_option('--log-web', action="append_const", dest="log_handler", const="odoo.http:DEBUG", help='shortcut for --log-handler=odoo.http:DEBUG')
group.add_option('--log-sql', action="append_const", dest="log_handler", const="odoo.sql_db:DEBUG", help='shortcut for --log-handler=odoo.sql_db:DEBUG')
group.add_option('--log-db', dest='log_db', help="Logging database", my_default=False)
group.add_option('--log-db-level', dest='log_db_level', my_default='warning', help="Logging database level")
# For backward-compatibility, map the old log levels to something
# quite close.
levels = [
'info', 'debug_rpc', 'warn', 'test', 'critical', 'runbot',
'debug_sql', 'error', 'debug', 'debug_rpc_answer', 'notset'
]
group.add_option('--log-level', dest='log_level', type='choice',
choices=levels, my_default='info',
help='specify the level of the logging. Accepted values: %s.' % (levels,))
parser.add_option_group(group)
# SMTP Group
group = optparse.OptionGroup(parser, "SMTP Configuration")
group.add_option('--email-from', dest='email_from', my_default=False,
help='specify the SMTP email address for sending email')
group.add_option('--from-filter', dest='from_filter', my_default=False,
help='specify for which email address the SMTP configuration can be used')
group.add_option('--smtp', dest='smtp_server', my_default='localhost',
help='specify the SMTP server for sending email')
group.add_option('--smtp-port', dest='smtp_port', my_default=25,
help='specify the SMTP port', type="int")
group.add_option('--smtp-ssl', dest='smtp_ssl', action='store_true', my_default=False,
help='if passed, SMTP connections will be encrypted with SSL (STARTTLS)')
group.add_option('--smtp-user', dest='smtp_user', my_default=False,
help='specify the SMTP username for sending email')
group.add_option('--smtp-password', dest='smtp_password', my_default=False,
help='specify the SMTP password for sending email')
group.add_option('--smtp-ssl-certificate-filename', dest='smtp_ssl_certificate_filename', my_default=False,
help='specify the SSL certificate used for authentication')
group.add_option('--smtp-ssl-private-key-filename', dest='smtp_ssl_private_key_filename', my_default=False,
help='specify the SSL private key used for authentication')
parser.add_option_group(group)
group = optparse.OptionGroup(parser, "Database related options")
group.add_option("-d", "--database", dest="db_name", my_default=False,
help="specify the database name")
group.add_option("-r", "--db_user", dest="db_user", my_default=False,
help="specify the database user name")
group.add_option("-w", "--db_password", dest="db_password", my_default=False,
help="specify the database password")
group.add_option("--pg_path", dest="pg_path", help="specify the pg executable path")
group.add_option("--db_host", dest="db_host", my_default=False,
help="specify the database host")
group.add_option("--db_port", dest="db_port", my_default=False,
help="specify the database port", type="int")
group.add_option("--db_sslmode", dest="db_sslmode", type="choice", my_default='prefer',
choices=['disable', 'allow', 'prefer', 'require', 'verify-ca', 'verify-full'],
help="specify the database ssl connection mode (see PostgreSQL documentation)")
group.add_option("--db_maxconn", dest="db_maxconn", type='int', my_default=64,
help="specify the maximum number of physical connections to PostgreSQL")
group.add_option("--db_maxconn_gevent", dest="db_maxconn_gevent", type='int', my_default=False,
help="specify the maximum number of physical connections to PostgreSQL specifically for the gevent worker")
group.add_option("--db-template", dest="db_template", my_default="template0",
help="specify a custom database template to create a new database")
parser.add_option_group(group)
group = optparse.OptionGroup(parser, "Internationalisation options",
"Use these options to translate Odoo to another language. "
"See i18n section of the user manual. Option '-d' is mandatory. "
"Option '-l' is mandatory in case of importation"
)
group.add_option('--load-language', dest="load_language",
help="specifies the languages for the translations you want to be loaded")
group.add_option('-l', "--language", dest="language",
help="specify the language of the translation file. Use it with --i18n-export or --i18n-import")
group.add_option("--i18n-export", dest="translate_out",
help="export all sentences to be translated to a CSV file, a PO file or a TGZ archive and exit")
group.add_option("--i18n-import", dest="translate_in",
help="import a CSV or a PO file with translations and exit. The '-l' option is required.")
group.add_option("--i18n-overwrite", dest="overwrite_existing_translations", action="store_true", my_default=False,
help="overwrites existing translation terms on updating a module or importing a CSV or a PO file.")
group.add_option("--modules", dest="translate_modules",
help="specify modules to export. Use in combination with --i18n-export")
parser.add_option_group(group)
security = optparse.OptionGroup(parser, 'Security-related options')
security.add_option('--no-database-list', action="store_false", dest='list_db', my_default=True,
help="Disable the ability to obtain or view the list of databases. "
"Also disable access to the database manager and selector, "
"so be sure to set a proper --database parameter first")
parser.add_option_group(security)
# Advanced options
group = optparse.OptionGroup(parser, "Advanced options")
group.add_option('--dev', dest='dev_mode', type="string",
help="Enable developer mode. Param: List of options separated by comma. "
"Options : all, reload, qweb, xml")
group.add_option('--shell-interface', dest='shell_interface', type="string",
help="Specify a preferred REPL to use in shell mode. Supported REPLs are: "
"[ipython|ptpython|bpython|python]")
group.add_option("--stop-after-init", action="store_true", dest="stop_after_init", my_default=False,
help="stop the server after its initialization")
group.add_option("--osv-memory-count-limit", dest="osv_memory_count_limit", my_default=0,
help="Force a limit on the maximum number of records kept in the virtual "
"osv_memory tables. By default there is no limit.",
type="int")
group.add_option("--transient-age-limit", dest="transient_age_limit", my_default=1.0,
help="Time limit (decimal value in hours) records created with a "
"TransientModel (mostly wizard) are kept in the database. Default to 1 hour.",
type="float")
group.add_option("--max-cron-threads", dest="max_cron_threads", my_default=2,
help="Maximum number of threads processing concurrently cron jobs (default 2).",
type="int")
group.add_option("--unaccent", dest="unaccent", my_default=False, action="store_true",
help="Try to enable the unaccent extension when creating new databases.")
group.add_option("--geoip-city-db", "--geoip-db", dest="geoip_city_db", my_default='/usr/share/GeoIP/GeoLite2-City.mmdb',
help="Absolute path to the GeoIP City database file.")
group.add_option("--geoip-country-db", dest="geoip_country_db", my_default='/usr/share/GeoIP/GeoLite2-Country.mmdb',
help="Absolute path to the GeoIP Country database file.")
parser.add_option_group(group)
if os.name == 'posix':
group = optparse.OptionGroup(parser, "Multiprocessing options")
# TODO sensible default for the three following limits.
group.add_option("--workers", dest="workers", my_default=0,
help="Specify the number of workers, 0 disable prefork mode.",
type="int")
group.add_option("--limit-memory-soft", dest="limit_memory_soft", my_default=2048 * 1024 * 1024,
help="Maximum allowed virtual memory per worker (in bytes), when reached the worker be "
"reset after the current request (default 2048MiB).",
type="int")
group.add_option("--limit-memory-hard", dest="limit_memory_hard", my_default=2560 * 1024 * 1024,
help="Maximum allowed virtual memory per worker (in bytes), when reached, any memory "
"allocation will fail (default 2560MiB).",
type="int")
group.add_option("--limit-time-cpu", dest="limit_time_cpu", my_default=60,
help="Maximum allowed CPU time per request (default 60).",
type="int")
group.add_option("--limit-time-real", dest="limit_time_real", my_default=120,
help="Maximum allowed Real time per request (default 120).",
type="int")
group.add_option("--limit-time-real-cron", dest="limit_time_real_cron", my_default=-1,
help="Maximum allowed Real time per cron job. (default: --limit-time-real). "
"Set to 0 for no limit. ",
type="int")
group.add_option("--limit-request", dest="limit_request", my_default=2**16,
help="Maximum number of request to be processed per worker (default 65536).",
type="int")
parser.add_option_group(group)
# Copy all optparse options (i.e. MyOption) into self.options.
for group in parser.option_groups:
for option in group.option_list:
if option.dest not in self.options:
self.options[option.dest] = option.my_default
self.casts[option.dest] = option
# generate default config
self._parse_config()
def parse_config(self, args=None):
""" Parse the configuration file (if any) and the command-line
arguments.
This method initializes odoo.tools.config and openerp.conf (the
former should be removed in the future) with library-wide
configuration values.
This method must be called before proper usage of this library can be
made.
Typical usage of this method:
odoo.tools.config.parse_config(sys.argv[1:])
"""
opt = self._parse_config(args)
odoo.netsvc.init_logger()
self._warn_deprecated_options()
odoo.modules.module.initialize_sys_path()
return opt
def _parse_config(self, args=None):
if args is None:
args = []
opt, args = self.parser.parse_args(args)
def die(cond, msg):
if cond:
self.parser.error(msg)
# Ensures no illegitimate argument is silently discarded (avoids insidious "hyphen to dash" problem)
die(args, "unrecognized parameters: '%s'" % " ".join(args))
die(bool(opt.syslog) and bool(opt.logfile),
"the syslog and logfile options are exclusive")
die(opt.translate_in and (not opt.language or not opt.db_name),
"the i18n-import option cannot be used without the language (-l) and the database (-d) options")
die(opt.overwrite_existing_translations and not (opt.translate_in or opt.update),
"the i18n-overwrite option cannot be used without the i18n-import option or without the update option")
die(opt.translate_out and (not opt.db_name),
"the i18n-export option cannot be used without the database (-d) option")
# Check if the config file exists (-c used, but not -s)
die(not opt.save and opt.config and not os.access(opt.config, os.R_OK),
"The config file '%s' selected with -c/--config doesn't exist or is not readable, "\
"use -s/--save if you want to generate it"% opt.config)
# place/search the config file on Win32 near the server installation
# (../etc from the server)
# if the server is run by an unprivileged user, he has to specify location of a config file where he has the rights to write,
# else he won't be able to save the configurations, or even to start the server...
# TODO use appdirs
if os.name == 'nt':
rcfilepath = os.path.join(os.path.abspath(os.path.dirname(sys.argv[0])), 'odoo.conf')
else:
rcfilepath = os.path.expanduser('~/.odoorc')
old_rcfilepath = os.path.expanduser('~/.openerp_serverrc')
die(os.path.isfile(rcfilepath) and os.path.isfile(old_rcfilepath),
"Found '.odoorc' and '.openerp_serverrc' in your path. Please keep only one of "\
"them, preferably '.odoorc'.")
if not os.path.isfile(rcfilepath) and os.path.isfile(old_rcfilepath):
rcfilepath = old_rcfilepath
self.rcfile = os.path.abspath(
self.config_file or opt.config or os.environ.get('ODOO_RC') or os.environ.get('OPENERP_SERVER') or rcfilepath)
self.load()
# Verify that we want to log or not, if not the output will go to stdout
if self.options['logfile'] in ('None', 'False'):
self.options['logfile'] = False
# the same for the pidfile
if self.options['pidfile'] in ('None', 'False'):
self.options['pidfile'] = False
# the same for the test_tags
if self.options['test_tags'] == 'None':
self.options['test_tags'] = None
# and the server_wide_modules
if self.options['server_wide_modules'] in ('', 'None', 'False'):
self.options['server_wide_modules'] = 'base,web'
# if defined do not take the configfile value even if the defined value is None
keys = ['gevent_port', 'http_interface', 'http_port', 'longpolling_port', 'http_enable', 'x_sendfile',
'db_name', 'db_user', 'db_password', 'db_host', 'db_sslmode',
'db_port', 'db_template', 'logfile', 'pidfile', 'smtp_port',
'email_from', 'smtp_server', 'smtp_user', 'smtp_password', 'from_filter',
'smtp_ssl_certificate_filename', 'smtp_ssl_private_key_filename',
'db_maxconn', 'db_maxconn_gevent', 'import_partial', 'addons_path', 'upgrade_path',
'syslog', 'without_demo', 'screencasts', 'screenshots',
'dbfilter', 'log_level', 'log_db',
'log_db_level', 'geoip_city_db', 'geoip_country_db', 'dev_mode',
'shell_interface',
]
for arg in keys:
# Copy the command-line argument (except the special case for log_handler, due to
# action=append requiring a real default, so we cannot use the my_default workaround)
if getattr(opt, arg, None) is not None:
self.options[arg] = getattr(opt, arg)
# ... or keep, but cast, the config file value.
elif isinstance(self.options[arg], str) and self.casts[arg].type in optparse.Option.TYPE_CHECKER:
self.options[arg] = optparse.Option.TYPE_CHECKER[self.casts[arg].type](self.casts[arg], arg, self.options[arg])
if isinstance(self.options['log_handler'], str):
self.options['log_handler'] = self.options['log_handler'].split(',')
self.options['log_handler'].extend(opt.log_handler)
# if defined but None take the configfile value
keys = [
'language', 'translate_out', 'translate_in', 'overwrite_existing_translations',
'dev_mode', 'shell_interface', 'smtp_ssl', 'load_language',
'stop_after_init', 'without_demo', 'http_enable', 'syslog',
'list_db', 'proxy_mode',
'test_file', 'test_tags',
'osv_memory_count_limit', 'transient_age_limit', 'max_cron_threads', 'unaccent',
'data_dir',
'server_wide_modules',
]
posix_keys = [
'workers',
'limit_memory_hard', 'limit_memory_soft',
'limit_time_cpu', 'limit_time_real', 'limit_request', 'limit_time_real_cron'
]
if os.name == 'posix':
keys += posix_keys
else:
self.options.update(dict.fromkeys(posix_keys, None))
# Copy the command-line arguments...
for arg in keys:
if getattr(opt, arg) is not None:
self.options[arg] = getattr(opt, arg)
# ... or keep, but cast, the config file value.
elif isinstance(self.options[arg], str) and self.casts[arg].type in optparse.Option.TYPE_CHECKER:
self.options[arg] = optparse.Option.TYPE_CHECKER[self.casts[arg].type](self.casts[arg], arg, self.options[arg])
ismultidb = ',' in (self.options.get('db_name') or '')
die(ismultidb and (opt.init or opt.update), "Cannot use -i/--init or -u/--update with multiple databases in the -d/--database/db_name")
self.options['root_path'] = self._normalize(os.path.join(os.path.dirname(__file__), '..'))
if not self.options['addons_path'] or self.options['addons_path']=='None':
default_addons = []
base_addons = os.path.join(self.options['root_path'], 'addons')
if os.path.exists(base_addons):
default_addons.append(base_addons)
main_addons = os.path.abspath(os.path.join(self.options['root_path'], '../addons'))
if os.path.exists(main_addons):
default_addons.append(main_addons)
self.options['addons_path'] = ','.join(default_addons)
else:
self.options['addons_path'] = ",".join(
self._normalize(x)
for x in self.options['addons_path'].split(','))
self.options["upgrade_path"] = (
",".join(self._normalize(x)
for x in self.options['upgrade_path'].split(','))
if self.options['upgrade_path']
else ""
)
self.options['init'] = opt.init and dict.fromkeys(opt.init.split(','), 1) or {}
self.options['demo'] = (dict(self.options['init'])
if not self.options['without_demo'] else {})
self.options['update'] = opt.update and dict.fromkeys(opt.update.split(','), 1) or {}
self.options['translate_modules'] = opt.translate_modules and [m.strip() for m in opt.translate_modules.split(',')] or ['all']
self.options['translate_modules'].sort()
dev_split = [s.strip() for s in opt.dev_mode.split(',')] if opt.dev_mode else []
self.options['dev_mode'] = dev_split + (['reload', 'qweb', 'xml'] if 'all' in dev_split else [])
if opt.pg_path:
self.options['pg_path'] = opt.pg_path
self.options['test_enable'] = bool(self.options['test_tags'])
if opt.save:
self.save()
# normalize path options
for key in ['data_dir', 'logfile', 'pidfile', 'test_file', 'screencasts', 'screenshots', 'pg_path', 'translate_out', 'translate_in', 'geoip_city_db', 'geoip_country_db']:
self.options[key] = self._normalize(self.options[key])
conf.addons_paths = self.options['addons_path'].split(',')
conf.server_wide_modules = [
m.strip() for m in self.options['server_wide_modules'].split(',') if m.strip()
]
return opt
def _warn_deprecated_options(self):
if self.options['longpolling_port']:
warnings.warn(
"The longpolling-port is a deprecated alias to "
"the gevent-port option, please use the latter.",
DeprecationWarning)
self.options['gevent_port'] = self.options.pop('longpolling_port')
def _is_addons_path(self, path):
from odoo.modules.module import MANIFEST_NAMES
for f in os.listdir(path):
modpath = os.path.join(path, f)
if os.path.isdir(modpath):
def hasfile(filename):
return os.path.isfile(os.path.join(modpath, filename))
if hasfile('__init__.py') and any(hasfile(mname) for mname in MANIFEST_NAMES):
return True
return False
def _check_addons_path(self, option, opt, value, parser):
ad_paths = []
for path in value.split(','):
path = path.strip()
res = os.path.abspath(os.path.expanduser(path))
if not os.path.isdir(res):
raise optparse.OptionValueError("option %s: no such directory: %r" % (opt, res))
if not self._is_addons_path(res):
raise optparse.OptionValueError("option %s: the path %r is not a valid addons directory" % (opt, path))
ad_paths.append(res)
setattr(parser.values, option.dest, ",".join(ad_paths))
def _check_upgrade_path(self, option, opt, value, parser):
upgrade_path = []
for path in value.split(','):
path = path.strip()
res = self._normalize(path)
if not os.path.isdir(res):
raise optparse.OptionValueError("option %s: no such directory: %r" % (opt, path))
if not self._is_upgrades_path(res):
raise optparse.OptionValueError("option %s: the path %r is not a valid upgrade directory" % (opt, path))
if res not in upgrade_path:
upgrade_path.append(res)
setattr(parser.values, option.dest, ",".join(upgrade_path))
def _is_upgrades_path(self, res):
return any(
glob.glob(os.path.join(res, f"*/*/{prefix}-*.py"))
for prefix in ["pre", "post", "end"]
)
def _test_enable_callback(self, option, opt, value, parser):
if not parser.values.test_tags:
parser.values.test_tags = "+standard"
def load(self):
outdated_options_map = {
'xmlrpc_port': 'http_port',
'xmlrpc_interface': 'http_interface',
'xmlrpc': 'http_enable',
}
p = ConfigParser.RawConfigParser()
try:
p.read([self.rcfile])
for (name,value) in p.items('options'):
name = outdated_options_map.get(name, name)
if value=='True' or value=='true':
value = True
if value=='False' or value=='false':
value = False
self.options[name] = value
#parse the other sections, as well
for sec in p.sections():
if sec == 'options':
continue
self.misc.setdefault(sec, {})
for (name, value) in p.items(sec):
if value=='True' or value=='true':
value = True
if value=='False' or value=='false':
value = False
self.misc[sec][name] = value
except IOError:
pass
except ConfigParser.NoSectionError:
pass
def save(self, keys=None):
p = ConfigParser.RawConfigParser()
loglevelnames = dict(zip(self._LOGLEVELS.values(), self._LOGLEVELS))
rc_exists = os.path.exists(self.rcfile)
if rc_exists and keys:
p.read([self.rcfile])
if not p.has_section('options'):
p.add_section('options')
for opt in sorted(self.options):
if keys is not None and opt not in keys:
continue
if opt in ('version', 'language', 'translate_out', 'translate_in', 'overwrite_existing_translations', 'init', 'update'):
continue
if opt in self.blacklist_for_save:
continue
if opt in ('log_level',):
p.set('options', opt, loglevelnames.get(self.options[opt], self.options[opt]))
elif opt == 'log_handler':
p.set('options', opt, ','.join(_deduplicate_loggers(self.options[opt])))
else:
p.set('options', opt, self.options[opt])
for sec in sorted(self.misc):
p.add_section(sec)
for opt in sorted(self.misc[sec]):
p.set(sec,opt,self.misc[sec][opt])
# try to create the directories and write the file
try:
if not rc_exists and not os.path.exists(os.path.dirname(self.rcfile)):
os.makedirs(os.path.dirname(self.rcfile))
try:
p.write(open(self.rcfile, 'w'))
if not rc_exists:
os.chmod(self.rcfile, 0o600)
except IOError:
sys.stderr.write("ERROR: couldn't write the config file\n")
except OSError:
# what to do if impossible?
sys.stderr.write("ERROR: couldn't create the config directory\n")
def get(self, key, default=None):
return self.options.get(key, default)
def pop(self, key, default=None):
return self.options.pop(key, default)
def get_misc(self, sect, key, default=None):
return self.misc.get(sect,{}).get(key, default)
def __setitem__(self, key, value):
self.options[key] = value
if key in self.options and isinstance(self.options[key], str) and \
key in self.casts and self.casts[key].type in optparse.Option.TYPE_CHECKER:
self.options[key] = optparse.Option.TYPE_CHECKER[self.casts[key].type](self.casts[key], key, self.options[key])
def __getitem__(self, key):
return self.options[key]
@property
def addons_data_dir(self):
add_dir = os.path.join(self['data_dir'], 'addons')
d = os.path.join(add_dir, release.series)
if not os.path.exists(d):
try:
# bootstrap parent dir +rwx
if not os.path.exists(add_dir):
os.makedirs(add_dir, 0o700)
# try to make +rx placeholder dir, will need manual +w to activate it
os.makedirs(d, 0o500)
except OSError:
logging.getLogger(__name__).debug('Failed to create addons data dir %s', d)
return d
@property
def session_dir(self):
d = os.path.join(self['data_dir'], 'sessions')
try:
os.makedirs(d, 0o700)
except OSError as e:
if e.errno != errno.EEXIST:
raise
assert os.access(d, os.W_OK), \
"%s: directory is not writable" % d
return d
def filestore(self, dbname):
return os.path.join(self['data_dir'], 'filestore', dbname)
def set_admin_password(self, new_password):
hash_password = crypt_context.hash if hasattr(crypt_context, 'hash') else crypt_context.encrypt
self.options['admin_passwd'] = hash_password(new_password)
def verify_admin_password(self, password):
"""Verifies the super-admin password, possibly updating the stored hash if needed"""
stored_hash = self.options['admin_passwd']
if not stored_hash:
# empty password/hash => authentication forbidden
return False
result, updated_hash = crypt_context.verify_and_update(password, stored_hash)
if result:
if updated_hash:
self.options['admin_passwd'] = updated_hash
return True
def _normalize(self, path):
if not path:
return ''
return normcase(realpath(abspath(expanduser(expandvars(path.strip())))))
config = configmanager()

View File

@ -0,0 +1,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
from . import websocket

View File

@ -0,0 +1,138 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import hashlib
import hmac
import json
import logging
from http import HTTPStatus
from markupsafe import Markup
from odoo import http, _
from odoo.http import request
from odoo.tools import consteq
from werkzeug.exceptions import Forbidden
_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)
print('ddddddddddddd', 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':
template.write({'quality': value['new_quality_score'].lower()})
continue
if changes['field'] == 'template_category_update':
template.write({'template_type': value['new_category'].lower()})
continue
if changes['field'] == 'messages':
print('messagesmessages')
template_id = self.env['whatsapp.template'].browse(4)
partner_id = self.env['res.partner'].browse(7577)
self.wa_create_composer(template_id, partner_id)
_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.error('Invalid signature header %r', signature)
return False
if not business_account.app_secret:
_logger.error('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)
def wa_create_composer(self, template_id, records):
values = {
'res_model': records._name,
'res_ids': records.ids,
'wa_template_id': template_id.id,
}
composer_id = self.env['whatsapp.composer'].create(values)
print('composer_idcomposer_id', composer_id)
composer_id.action_send_whatsapp_template()
ddddddddddddd = {'object': 'whatsapp_business_account', 'entry': [{'id': '407473312443001', 'changes': [{'value': {
'messaging_product': 'whatsapp',
'metadata': {'display_phone_number': '15556257124', 'phone_number_id': '329683520237319'},
'contacts': [{'profile': {'name': 'Ahmed Hannachi'}, 'wa_id': '21697527420'}], 'messages': [
{'context': {'from': '15556257124', 'id': 'wamid.HBgLMjE2OTc1Mjc0MjAVAgARGBJDNUQ0MEQyRDQ2N0M5RTNENEQA'},
'from': '21697527420',
'id': 'wamid.HBgLMjE2OTc1Mjc0MjAVAgASGCA4QTJERkI4REY2OTcwM0RCQUVCNjI0QjgyRTlENTg0MgA=',
'timestamp': '1723043846', 'type': 'button',
'button': {'payload': 'Rep 1 pour ce message', 'text': 'Rep 1 pour ce message'}}]},
'field': 'messages'}]}]}

View File

@ -0,0 +1,64 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from odoo.http import Controller, request, route, SessionExpiredException
from odoo.addons.base.models.assetsbundle import AssetsBundle
from ..models.discuss.bus import channel_with_db
from ..tools.websocket import WebsocketConnectionHandler
# todo from odoo/addons/bus/Controller/websocket.py
class WebsocketController(Controller):
@route('/websocket', type="http", auth="public", cors='*', websocket=True)
def websocket(self):
"""
Handle the websocket handshake, upgrade the connection if
successfull.
"""
return WebsocketConnectionHandler.open_connection(request)
@route('/websocket/health', type='http', auth='none', save_session=False)
def health(self):
data = json.dumps({
'status': 'pass',
})
headers = [('Content-Type', 'application/json'),
('Cache-Control', 'no-store')]
return request.make_response(data, headers)
@route('/websocket/peek_notifications', type='json', auth='public', cors='*')
def peek_notifications(self, channels, last, is_first_poll=False):
if not all(isinstance(c, str) for c in channels):
raise ValueError("bus.Bus only string channels are allowed.")
if is_first_poll:
# Used to detect when the current session is expired.
request.session['is_websocket_session'] = True
elif 'is_websocket_session' not in request.session:
raise SessionExpiredException()
channels = list(set(
channel_with_db(request.db, c)
for c in request.env['ir.websocket']._build_bus_channel_list(channels)
))
last_known_notification_id = request.env['bus.bus'].sudo().search([], limit=1, order='id desc').id or 0
if last > last_known_notification_id:
last = 0
notifications = request.env['bus.bus']._poll(channels, last)
return {'channels': channels, 'notifications': notifications}
@route('/websocket/update_bus_presence', type='json', auth='public', cors='*')
def update_bus_presence(self, inactivity_period, im_status_ids_by_model):
if 'is_websocket_session' not in request.session:
raise SessionExpiredException()
request.env['ir.websocket']._update_bus_presence(int(inactivity_period), im_status_ids_by_model)
return {}
@route('/bus/websocket_worker_bundle', type='http', auth='public', cors='*')
def get_websocket_worker_bundle(self, v=None): # pylint: disable=unused-argument
"""
:param str v: Version of the worker, frontend only argument used to
prevent new worker versions to be loaded from the browser cache.
"""
bundle_name = 'bus.websocket_worker_assets'
bundle = request.env["ir.qweb"]._get_asset_bundle(bundle_name, debug_assets="assets" in request.session.debug)
stream = request.env['ir.binary']._get_stream_from(bundle.js())
return stream.get_response()

File diff suppressed because it is too large Load Diff

View File

@ -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>

View File

@ -0,0 +1,14 @@
<?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>
<field name="numbercall">-1</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record model="ir.module.category" id="module_whatsapp">
<field name="name">WhatsApp</field>
<field name="description">User access levels for WhatsApp module</field>
<field name="sequence">10</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,2 @@
UPDATE whatsapp_account
SET token = 'dummy_token';

View File

@ -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-foreach="buttons" t-as="button">
<t t-if="button.sequence &lt; 2">
<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="button.sequence == 2">
<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>
</t>
</div>
</div>
</div>
</template>
</odoo>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1902
odex25_base/whatsapp/misc.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import discuss
from . import discuss_channel
from . import discuss_channel_member
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

View File

@ -0,0 +1,20 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# mail
from . import mail_message
# discuss
from . import discuss_channel_member
from . import discuss_channel_rtc_session
from . import discuss_channel
from . import discuss_gif_favorite
from . import discuss_voice_metadata
from . import mail_guest
# odoo models
from . import ir_attachment
from . import ir_websocket
from . import res_groups
from . import res_partner
from . import res_users
from . import bus

View File

@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
import contextlib
import datetime
import json
import logging
import os
import random
import selectors
import threading
import time
from psycopg2 import InterfaceError, sql
import odoo
from odoo import api, fields, models
from odoo.service.server import CommonServer
from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
from odoo.tools import date_utils
_logger = logging.getLogger(__name__)
# longpolling timeout connection
TIMEOUT = 50
# custom function to call instead of default PostgreSQL's `pg_notify`
ODOO_NOTIFY_FUNCTION = os.getenv('ODOO_NOTIFY_FUNCTION', 'pg_notify')
#----------------------------------------------------------
# Bus
#----------------------------------------------------------
# def json_dump(v):
# return json.dumps(v, separators=(',', ':'), default=date_utils.json_default)
#
# def hashable(key):
# if isinstance(key, list):
# key = tuple(key)
# return key
def channel_with_db(dbname, channel):
if isinstance(channel, models.Model):
return (dbname, channel._name, channel.id)
if isinstance(channel, str):
return (dbname, channel)
return channel
def json_dump(v):
return json.dumps(v, separators=(',', ':'), default=date_utils.json_default)
class ImBus(models.Model):
_inherit = 'bus.bus'
@api.model
def _sendmany(self, notifications):
channels = set()
values = []
for target, notification_type, message in notifications:
channel = channel_with_db(self.env.cr.dbname, target)
channels.add(channel)
values.append({
'channel': json_dump(channel),
'message': json_dump({
'type': notification_type,
'payload': message,
})
})
self.sudo().create(values)
if channels:
# We have to wait until the notifications are commited in database.
# When calling `NOTIFY imbus`, notifications will be fetched in the
# bus table. If the transaction is not commited yet, there will be
# nothing to fetch, and the websocket will return no notification.
@self.env.cr.postcommit.add
def notify():
with odoo.sql_db.db_connect('postgres').cursor() as cr:
query = sql.SQL("SELECT {}('imbus', %s)").format(sql.Identifier(ODOO_NOTIFY_FUNCTION))
cr.execute(query, (json_dump(list(channels)), ))

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,359 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import requests
import odoo
from odoo import api, fields, models, _
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.osv import expression
from ...tools import jwt, discuss
_logger = logging.getLogger(__name__)
SFU_MODE_THRESHOLD = 3
class ChannelMember(models.Model):
_name = "discuss.channel.member"
_description = "Channel Member"
_rec_names_search = ["channel_id", "partner_id", "guest_id"]
_bypass_create_check = {}
# identity
partner_id = fields.Many2one("res.partner", "Partner", ondelete="cascade", index=True)
guest_id = fields.Many2one("mail.guest", "Guest", ondelete="cascade", index=True)
is_self = fields.Boolean(compute="_compute_is_self", search="_search_is_self")
# channel
channel_id = fields.Many2one("discuss.channel", "Channel", ondelete="cascade", required=True, auto_join=True)
# state
custom_channel_name = fields.Char('Custom channel name')
fetched_message_id = fields.Many2one('mail.message', string='Last Fetched', index="btree_not_null")
seen_message_id = fields.Many2one('mail.message', string='Last Seen', index="btree_not_null")
message_unread_counter = fields.Integer('Unread Messages Counter', compute='_compute_message_unread', compute_sudo=True)
fold_state = fields.Selection([('open', 'Open'), ('folded', 'Folded'), ('closed', 'Closed')], string='Conversation Fold State', default='open')
is_minimized = fields.Boolean("Conversation is minimized")
custom_notifications = fields.Selection(
[("mentions", "Mentions Only"), ("no_notif", "Nothing")],
"Customized Notifications",
help="All Messages if not specified",
)
mute_until_dt = fields.Datetime("Mute notifications until", help="If set, the member will not receive notifications from the channel until this date.")
is_pinned = fields.Boolean("Is pinned on the interface", default=True)
last_interest_dt = fields.Datetime("Last Interest", default=fields.Datetime.now, help="Contains the date and time of the last interesting event that happened in this channel for this partner. This includes: creating, joining, pinning, and new message posted.")
last_seen_dt = fields.Datetime("Last seen date")
# RTC
rtc_session_ids = fields.One2many(string="RTC Sessions", comodel_name='discuss.channel.rtc.session', inverse_name='channel_member_id')
rtc_inviting_session_id = fields.Many2one('discuss.channel.rtc.session', string='Ringing session')
@api.constrains('partner_id')
def _contrains_no_public_member(self):
for member in self:
if any(user._is_public() for user in member.partner_id.user_ids):
raise ValidationError(_("Channel members cannot include public users."))
@api.depends_context("uid", "guest")
def _compute_is_self(self):
if not self:
return
current_partner, current_guest = self.env["res.partner"]._get_current_persona()
self.is_self = False
for member in self:
if current_partner and member.partner_id == current_partner:
member.is_self = True
if current_guest and member.guest_id == current_guest:
member.is_self = True
def _search_is_self(self, operator, operand):
is_in = (operator == "=" and operand) or (operator == "!=" and not operand)
current_partner, current_guest = self.env["res.partner"]._get_current_persona()
if is_in:
return [
'|',
("partner_id", "=", current_partner.id) if current_partner else expression.FALSE_LEAF,
("guest_id", "=", current_guest.id) if current_guest else expression.FALSE_LEAF,
]
else:
return [
("partner_id", "!=", current_partner.id) if current_partner else expression.TRUE_LEAF,
("guest_id", "!=", current_guest.id) if current_guest else expression.TRUE_LEAF,
]
@api.depends("channel_id.message_ids", "seen_message_id")
def _compute_message_unread(self):
if self.ids:
self.env['mail.message'].flush_model()
self.flush_recordset(['channel_id', 'seen_message_id'])
self.env.cr.execute("""
SELECT count(mail_message.id) AS count,
discuss_channel_member.id
FROM mail_message
INNER JOIN discuss_channel_member
ON discuss_channel_member.channel_id = mail_message.res_id
WHERE mail_message.model = 'discuss.channel'
AND mail_message.message_type NOT IN ('notification', 'user_notification')
AND (
mail_message.id > discuss_channel_member.seen_message_id
OR discuss_channel_member.seen_message_id IS NULL
)
AND discuss_channel_member.id IN %(ids)s
GROUP BY discuss_channel_member.id
""", {'ids': tuple(self.ids)})
unread_counter_by_member = {res['id']: res['count'] for res in self.env.cr.dictfetchall()}
for member in self:
member.message_unread_counter = unread_counter_by_member.get(member.id)
else:
self.message_unread_counter = 0
@api.depends("partner_id.name", "guest_id.name", "channel_id.display_name")
def _compute_display_name(self):
for member in self:
member.display_name = _(
"%(member_name)s” in “%(channel_name)s",
member_name=member.partner_id.name or member.guest_id.name,
channel_name=member.channel_id.display_name,
)
def init(self):
self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS discuss_channel_member_partner_unique ON %s (channel_id, partner_id) WHERE partner_id IS NOT NULL" % self._table)
self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS discuss_channel_member_guest_unique ON %s (channel_id, guest_id) WHERE guest_id IS NOT NULL" % self._table)
_sql_constraints = [
("partner_or_guest_exists", "CHECK((partner_id IS NOT NULL AND guest_id IS NULL) OR (partner_id IS NULL AND guest_id IS NOT NULL))", "A channel member must be a partner or a guest."),
]
@api.model_create_multi
def create(self, vals_list):
if self.env.context.get("mail_create_bypass_create_check") is self._bypass_create_check:
self = self.sudo()
for vals in vals_list:
if "channel_id" not in vals:
raise UserError(
_(
"It appears you're trying to create a channel member, but it seems like you forgot to specify the related channel. "
"To move forward, please make sure to provide the necessary channel information."
)
)
channel = self.env["discuss.channel"].browse(vals["channel_id"])
if channel.channel_type == "chat" and len(channel.channel_member_ids) > 0:
raise UserError(
_("Adding more members to this chat isn't possible; it's designed for just two people.")
)
res = super().create(vals_list)
# help the ORM to detect changes
# res.partner_id.invalidate_recordset(["channel_ids"])
# res.guest_id.invalidate_recordset(["channel_ids"])
return res
def write(self, vals):
for channel_member in self:
for field_name in ['channel_id', 'partner_id', 'guest_id']:
if field_name in vals and vals[field_name] != channel_member[field_name].id:
raise AccessError(_('You can not write on %(field_name)s.', field_name=field_name))
return super().write(vals)
def unlink(self):
# sudo: discuss.channel.rtc.session - cascade unlink of sessions for self member
self.sudo().rtc_session_ids.unlink() # ensure unlink overrides are applied
return super().unlink()
def _notify_typing(self, is_typing):
""" Broadcast the typing notification to channel members
:param is_typing: (boolean) tells whether the members are typing or not
"""
notifications = []
for member in self:
formatted_member = member._discuss_channel_member_format().get(member)
formatted_member['isTyping'] = is_typing
notifications.append([member.channel_id, 'discuss.channel.member/typing_status', formatted_member])
notifications.append([member.channel_id.uuid, 'discuss.channel.member/typing_status', formatted_member]) # notify livechat users
self.env['bus.bus']._sendmany(notifications)
@api.model
def _unmute(self):
# Unmute notifications for the all the channel members whose mute date is passed.
members = self.search([("mute_until_dt", "<=", fields.Datetime.now())])
members.write({"mute_until_dt": False})
notifications = []
for member in members:
channel_data = {
"id": member.channel_id.id,
"model": "discuss.channel",
"mute_until_dt": False,
}
notifications.append((member.partner_id, "mail.record/insert", {"Thread": channel_data}))
self.env["bus.bus"]._sendmany(notifications)
def _discuss_channel_member_format(self, fields=None):
if not fields:
fields = {'id': True, 'channel': {}, 'persona': {}, 'create_date': True}
members_formatted_data = {}
for member in self:
data = {}
if 'id' in fields:
data['id'] = member.id
if 'channel' in fields:
data['thread'] = member.channel_id._channel_format(fields=fields.get('channel')).get(member.channel_id)
if 'persona' in fields:
if member.partner_id:
# sudo: res.partner - reading _get_partner_data related to a member is considered acceptable
persona = member.sudo()._get_partner_data(fields=fields.get('persona', {}).get('partner'))
print('personapersona', persona)
persona['type'] = "partner"
if member.guest_id:
# sudo: mail.guest - reading _guest_format related to a member is considered acceptable
persona = member.guest_id.sudo()._guest_format(fields=fields.get('persona', {}).get('guest')).get(member.guest_id)
data['persona'] = persona
if 'custom_notifications' in fields:
data['custom_notifications'] = member.custom_notifications
if 'mute_until_dt' in fields:
data['mute_until_dt'] = member.mute_until_dt
if 'create_date' in fields:
data['create_date'] = odoo.fields.Datetime.to_string(member.create_date)
members_formatted_data[member] = data
return members_formatted_data
def _get_partner_data(self, fields=None):
self.ensure_one()
return self.partner_id.mail_partner_format().get(self.partner_id)
# --------------------------------------------------------------------------
# RTC (voice/video)
# --------------------------------------------------------------------------
def _rtc_join_call(self, check_rtc_session_ids=None):
self.ensure_one()
check_rtc_session_ids = (check_rtc_session_ids or []) + self.rtc_session_ids.ids
self.channel_id._rtc_cancel_invitations(member_ids=self.ids)
self.rtc_session_ids.unlink()
rtc_session = self.env['discuss.channel.rtc.session'].create({'channel_member_id': self.id})
current_rtc_sessions, outdated_rtc_sessions = self._rtc_sync_sessions(check_rtc_session_ids=check_rtc_session_ids)
ice_servers = self.env["mail.ice.server"]._get_ice_servers()
self._join_sfu(ice_servers)
res = {
'iceServers': ice_servers or False,
'rtcSessions': [
('ADD', [rtc_session_sudo._mail_rtc_session_format() for rtc_session_sudo in current_rtc_sessions]),
('DELETE', [{'id': missing_rtc_session_sudo.id} for missing_rtc_session_sudo in outdated_rtc_sessions]),
],
'sessionId': rtc_session.id,
'serverInfo': self._get_rtc_server_info(rtc_session, ice_servers),
}
if len(self.channel_id.rtc_session_ids) == 1 and self.channel_id.channel_type in {'chat', 'group'}:
self.channel_id.message_post(body=_("%s started a live conference", self.partner_id.name or self.guest_id.name), message_type='notification')
invited_members = self._rtc_invite_members()
if invited_members:
res['invitedMembers'] = [('ADD', list(invited_members._discuss_channel_member_format(fields={'id': True, 'channel': {}, 'persona': {'partner': {'id', 'name', 'im_status'}, 'guest': {'id', 'name', 'im_status'}}}).values()))]
return res
def _join_sfu(self, ice_servers=None):
if len(self.channel_id.rtc_session_ids) < SFU_MODE_THRESHOLD:
if self.channel_id.sfu_channel_uuid:
self.channel_id.sfu_channel_uuid = None
self.channel_id.sfu_server_url = None
return
elif self.channel_id.sfu_channel_uuid and self.channel_id.sfu_server_url:
return
sfu_server_url = discuss.get_sfu_url(self.env)
if not sfu_server_url:
return
sfu_server_key = discuss.get_sfu_key(self.env)
json_web_token = jwt.sign(
{"iss": f"{self.get_base_url()}:channel:{self.channel_id.id}"},
key=sfu_server_key,
ttl=30,
algorithm=jwt.Algorithm.HS256,
)
try:
response = requests.get(
sfu_server_url + "/v1/channel",
headers={"Authorization": "jwt " + json_web_token},
timeout=3,
)
response.raise_for_status()
except requests.exceptions.RequestException as error:
_logger.warning("Failed to obtain a channel from the SFU server, user will stay in p2p: %s", error)
return
response_dict = response.json()
self.channel_id.sfu_channel_uuid = response_dict["uuid"]
self.channel_id.sfu_server_url = response_dict["url"]
notifications = [
[
session.guest_id or session.partner_id,
"discuss.channel.rtc.session/sfu_hot_swap",
{"serverInfo": self._get_rtc_server_info(session, ice_servers, key=sfu_server_key)},
]
for session in self.channel_id.rtc_session_ids
]
self.env["bus.bus"]._sendmany(notifications)
def _get_rtc_server_info(self, rtc_session, ice_servers=None, key=None):
sfu_channel_uuid = self.channel_id.sfu_channel_uuid
sfu_server_url = self.channel_id.sfu_server_url
if not sfu_channel_uuid or not sfu_server_url:
return None
if not key:
key = discuss.get_sfu_key(self.env)
claims = {
"sfu_channel_uuid": sfu_channel_uuid,
"session_id": rtc_session.id,
"ice_servers": ice_servers,
}
json_web_token = jwt.sign(claims, key=key, ttl=60 * 60 * 8, algorithm=jwt.Algorithm.HS256) # 8 hours
return {"url": sfu_server_url, "jsonWebToken": json_web_token}
def _rtc_leave_call(self):
self.ensure_one()
if self.rtc_session_ids:
self.rtc_session_ids.unlink()
else:
return self.channel_id._rtc_cancel_invitations(member_ids=self.ids)
def _rtc_sync_sessions(self, check_rtc_session_ids=None):
"""Synchronize the RTC sessions for self channel member.
- Inactive sessions of the channel are deleted.
- Current sessions are returned.
- Sessions given in check_rtc_session_ids that no longer exists
are returned as non-existing.
:param list check_rtc_session_ids: list of the ids of the sessions to check
:returns tuple: (current_rtc_sessions, outdated_rtc_sessions)
"""
self.ensure_one()
self.channel_id.rtc_session_ids._delete_inactive_rtc_sessions()
check_rtc_sessions = self.env['discuss.channel.rtc.session'].browse([int(check_rtc_session_id) for check_rtc_session_id in (check_rtc_session_ids or [])])
return self.channel_id.rtc_session_ids, check_rtc_sessions - self.channel_id.rtc_session_ids
def _rtc_invite_members(self, member_ids=None):
""" Sends invitations to join the RTC call to all connected members of the thread who are not already invited,
if member_ids is set, only the specified ids will be invited.
:param list member_ids: list of the partner ids to invite
"""
self.ensure_one()
channel_member_domain = [
('channel_id', '=', self.channel_id.id),
('rtc_inviting_session_id', '=', False),
('rtc_session_ids', '=', False),
]
if member_ids:
channel_member_domain = expression.AND([channel_member_domain, [('id', 'in', member_ids)]])
invitation_notifications = []
members = self.env['discuss.channel.member'].search(channel_member_domain)
for member in members:
member.rtc_inviting_session_id = self.rtc_session_ids.id
if member.partner_id:
target = member.partner_id
else:
target = member.guest_id
invitation_notifications.append((target, 'mail.record/insert', {
'Thread': {
'id': self.channel_id.id,
'model': 'discuss.channel',
'rtcInvitingSession': self.rtc_session_ids._mail_rtc_session_format(),
}
}))
self.env['bus.bus']._sendmany(invitation_notifications)
if members:
channel_data = {'id': self.channel_id.id, 'model': 'discuss.channel'}
channel_data['invitedMembers'] = [('ADD', list(members._discuss_channel_member_format(fields={'id': True, 'channel': {}, 'persona': {'partner': {'id', 'name', 'im_status'}, 'guest': {'id', 'name', 'im_status'}}}).values()))]
self.env['bus.bus']._sendone(self.channel_id, 'mail.record/insert', {'Thread': channel_data})
return members

View File

@ -0,0 +1,160 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import requests
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
from ...tools import discuss, jwt
_logger = logging.getLogger(__name__)
class MailRtcSession(models.Model):
_name = 'discuss.channel.rtc.session'
_description = 'Mail RTC session'
_rec_name = 'channel_member_id'
channel_member_id = fields.Many2one('discuss.channel.member', required=True, ondelete='cascade')
channel_id = fields.Many2one('discuss.channel', related='channel_member_id.channel_id', store=True, readonly=True)
partner_id = fields.Many2one('res.partner', related='channel_member_id.partner_id', string="Partner")
guest_id = fields.Many2one('mail.guest', related='channel_member_id.guest_id')
write_date = fields.Datetime("Last Updated On", index=True)
is_screen_sharing_on = fields.Boolean(string="Is sharing the screen")
is_camera_on = fields.Boolean(string="Is sending user video")
is_muted = fields.Boolean(string="Is microphone muted")
is_deaf = fields.Boolean(string="Has disabled incoming sound")
_sql_constraints = [
('channel_member_unique', 'UNIQUE(channel_member_id)',
'There can only be one rtc session per channel member')
]
@api.model_create_multi
def create(self, vals_list):
rtc_sessions = super().create(vals_list)
self.env['bus.bus']._sendmany([(channel, 'discuss.channel/rtc_sessions_update', {
'id': channel.id,
'rtcSessions': [('ADD', sessions_data)],
}) for channel, sessions_data in rtc_sessions._mail_rtc_session_format_by_channel().items()])
return rtc_sessions
def unlink(self):
channels = self.channel_id
for channel in channels:
if channel.rtc_session_ids and len(channel.rtc_session_ids - self) == 0:
# If there is no member left in the RTC call, all invitations are cancelled.
# Note: invitation depends on field `rtc_inviting_session_id` so the cancel must be
# done before the delete to be able to know who was invited.
channel._rtc_cancel_invitations()
# If there is no member left in the RTC call, we remove the SFU channel uuid as the SFU
# server will timeout the channel. It is better to obtain a new channel from the SFU server
# than to attempt recycling a possibly stale channel uuid.
channel.sfu_channel_uuid = False
channel.sfu_server_url = False
notifications = [(channel, 'discuss.channel/rtc_sessions_update', {
'id': channel.id,
'rtcSessions': [('DELETE', [{'id': session_data['id']} for session_data in sessions_data])],
}) for channel, sessions_data in self._mail_rtc_session_format_by_channel().items()]
for rtc_session in self:
target = rtc_session.guest_id or rtc_session.partner_id
notifications.append((target, 'discuss.channel.rtc.session/ended', {'sessionId': rtc_session.id}))
self.env['bus.bus']._sendmany(notifications)
return super().unlink()
def _update_and_broadcast(self, values):
""" Updates the session and notifies all members of the channel
of the change.
"""
valid_values = {'is_screen_sharing_on', 'is_camera_on', 'is_muted', 'is_deaf'}
self.write({key: values[key] for key in valid_values if key in values})
session_data = self._mail_rtc_session_format(extra=True)
self.env["bus.bus"]._sendone(
self.channel_id,
"discuss.channel.rtc.session/update_and_broadcast",
{"data": session_data, "channelId": self.channel_id.id},
)
@api.autovacuum
def _gc_inactive_sessions(self):
""" Garbage collect sessions that aren't active anymore,
this can happen when the server or the user's browser crash
or when the user's odoo session ends.
"""
self.search(self._inactive_rtc_session_domain()).unlink()
def action_disconnect(self):
session_ids_by_channel_by_url = defaultdict(lambda: defaultdict(list))
for rtc_session in self:
sfu_channel_uuid = rtc_session.channel_id.sfu_channel_uuid
url = rtc_session.channel_id.sfu_server_url
if sfu_channel_uuid and url:
session_ids_by_channel_by_url[url][sfu_channel_uuid].append(rtc_session.id)
key = discuss.get_sfu_key(self.env)
if key:
with requests.Session() as requests_session:
for url, session_ids_by_channel in session_ids_by_channel_by_url.items():
try:
requests_session.post(
url + '/v1/disconnect',
data=jwt.sign({'sessionIdsByChannel': session_ids_by_channel}, key=key, ttl=20, algorithm=jwt.Algorithm.HS256),
timeout=3
).raise_for_status()
except requests.exceptions.RequestException as error:
_logger.warning("Could not disconnect sessions at sfu server %s: %s", url, error)
self.unlink()
def _delete_inactive_rtc_sessions(self):
"""Deletes the inactive sessions from self."""
self.filtered_domain(self._inactive_rtc_session_domain()).unlink()
def _notify_peers(self, notifications):
""" Used for peer-to-peer communication,
guarantees that the sender is the current guest or partner.
:param notifications: list of tuple with the following elements:
- target_session_ids: a list of discuss.channel.rtc.session ids
- content: a string with the content to be sent to the targets
"""
self.ensure_one()
payload_by_target = defaultdict(lambda: {'sender': self.id, 'notifications': []})
for target_session_ids, content in notifications:
for target_session in self.env['discuss.channel.rtc.session'].browse(target_session_ids).exists():
target = target_session.guest_id or target_session.partner_id
payload_by_target[target]['notifications'].append(content)
return self.env['bus.bus']._sendmany([(target, 'discuss.channel.rtc.session/peer_notification', payload) for target, payload in payload_by_target.items()])
def _mail_rtc_session_format(self, extra=False):
self.ensure_one()
vals = {
"id": self.id,
"channelMember": self.channel_member_id._discuss_channel_member_format(
fields={
"id": True,
"channel": {},
"persona": {"partner": {"id", "name", "im_status"}, "guest": {"id", "name", "im_status"}},
}
).get(self.channel_member_id),
}
if extra:
vals.update({
"isCameraOn": self.is_camera_on,
"isDeaf": self.is_deaf,
"isSelfMuted": self.is_muted,
"isScreenSharingOn": self.is_screen_sharing_on,
})
return vals
def _mail_rtc_session_format_by_channel(self, extra=False):
data = {}
for rtc_session in self:
data.setdefault(rtc_session.channel_id, []).append(rtc_session._mail_rtc_session_format(extra=extra))
return data
@api.model
def _inactive_rtc_session_domain(self):
return [('write_date', '<', fields.Datetime.now() - relativedelta(minutes=1))]

View File

@ -0,0 +1,18 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class GifFavorite(models.Model):
_name = "discuss.gif.favorite"
_description = "Save favorite GIF from Tenor API"
tenor_gif_id = fields.Char("GIF id from Tenor", required=True)
_sql_constraints = [
(
"user_gif_favorite",
"unique(create_uid,tenor_gif_id)",
"User should not have duplicated favorite GIF",
),
]

View File

@ -0,0 +1,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class DiscussVoiceMetadata(models.Model):
_name = "discuss.voice.metadata"
_description = "Metadata for voice attachments"
attachment_id = fields.Many2one(
"ir.attachment", ondelete="cascade", auto_join=True, copy=False, index=True
)

View File

@ -0,0 +1,30 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class IrAttachment(models.Model):
_inherit = "ir.attachment"
voice_ids = fields.One2many("discuss.voice.metadata", "attachment_id")
def _bus_notification_target(self):
self.ensure_one()
if self.res_model == "discuss.channel" and self.res_id:
return self.env["discuss.channel"].browse(self.res_id)
guest = self.env["mail.guest"]._get_guest_from_context()
if self.env.user._is_public() and guest:
return guest
return super()._bus_notification_target()
def _attachment_format(self, commands=False):
attachment_format = super()._attachment_format(commands=commands)
for a in attachment_format:
# sudo: discuss.voice.metadata - checking the existence of voice metadata for accessible attachments is fine
a["voice"] = bool(self.browse(a["id"]).with_prefetch(self._prefetch_ids).sudo().voice_ids)
return attachment_format
def _post_add_create(self, **kwargs):
super()._post_add_create()
if kwargs.get('voice'):
self.env["discuss.voice.metadata"].create([{"attachment_id": attachment.id} for attachment in self])

View File

@ -0,0 +1,109 @@
from odoo import models
from odoo.http import request, SessionExpiredException
from odoo.service import security
from odoo.addons.bus.models.bus import dispatch
from ...tools.websocket import wsrequest
import re
# todo from odoo/addons/bus/models/ir_websocket.py
class IrWebsocket(models.AbstractModel):
_name = 'ir.websocket'
_description = 'websocket message handling'
def _get_im_status(self, im_status_ids_by_model):
im_status = {}
if 'res.partner' in im_status_ids_by_model:
im_status['Persona'] = [{**p, 'type': "partner"} for p in self.env['res.partner'].with_context(active_test=False).search_read(
[('id', 'in', im_status_ids_by_model['res.partner'])],
['im_status']
)]
# whatsapp custom code -----------------------------------------------
if "mail.guest" in im_status_ids_by_model:
# sudo: mail.guest - necessary to read im_status from other guests, information is not considered sensitive
im_status["Persona"] += [{**g, 'type': "guest"} for g in (
self.env["mail.guest"]
.sudo()
.with_context(active_test=False)
.search_read([("id", "in", im_status_ids_by_model["mail.guest"])], ["im_status"])
)]
# whatsapp custom code -----------------------------------------------
return im_status
def _build_bus_channel_list(self, channels):
"""
Return the list of channels to subscribe to. Override this
method to add channels in addition to the ones the client
sent.
:param channels: The channel list sent by the client.
"""
# whatsapp custom code -----------------------------------------------
channels = list(channels) # do not alter original list
discuss_channel_ids = list()
for channel in list(channels):
if isinstance(channel, str) and channel.startswith("mail.guest_"):
channels.remove(channel)
guest = self.env["mail.guest"]._get_guest_from_token(channel.split("_")[1])
if guest:
self = self.with_context(guest=guest)
if isinstance(channel, str):
match = re.findall(r'discuss\.channel_(\d+)', channel)
if match:
channels.remove(channel)
discuss_channel_ids.append(int(match[0]))
guest = self.env["mail.guest"]._get_guest_from_context()
if guest:
channels.append(guest)
domain = ["|", ("is_member", "=", True), ("id", "in", discuss_channel_ids)]
channels.extend(self.env["discuss.channel"].search(domain))
# whatsapp custom code -----------------------------------------------
req = request or wsrequest
channels.append('broadcast')
if req.session.uid:
channels.append(self.env.user.partner_id)
return channels
def _subscribe(self, data):
if not all(isinstance(c, str) for c in data['channels']):
raise ValueError("bus.Bus only string channels are allowed.")
last_known_notification_id = self.env['bus.bus'].sudo().search([], limit=1, order='id desc').id or 0
if data['last'] > last_known_notification_id:
data['last'] = 0
channels = set(self._build_bus_channel_list(data['channels']))
dispatch.subscribe(channels, data['last'], self.env.registry.db_name, wsrequest.ws)
def _update_bus_presence(self, inactivity_period, im_status_ids_by_model):
if self.env.user and not self.env.user._is_public():
self.env['bus.presence'].update_presence(
inactivity_period,
identity_field='user_id',
identity_value=self.env.uid
)
im_status_notification = self._get_im_status(im_status_ids_by_model)
if im_status_notification:
self.env['bus.bus']._sendone(self.env.user.partner_id, 'mail.record/insert', im_status_notification)
# whatsapp custom code -----------------------------------------------
if not self.env.user or self.env.user._is_public():
guest = self.env["mail.guest"]._get_guest_from_context()
if not guest:
return
# sudo: bus.presence - guests currently need sudo to write their own presence
self.env["bus.presence"].sudo().update_presence(
inactivity_period,
identity_field="guest_id",
identity_value=guest.id,
)
# whatsapp custom code -----------------------------------------------
@classmethod
def _authenticate(cls):
if wsrequest.session.uid is not None:
if not security.check_session(wsrequest.session, wsrequest.env):
wsrequest.session.logout(keep_db=True)
raise SessionExpiredException()
else:
public_user = wsrequest.env.ref('base.public_user')
wsrequest.update_env(user=public_user.id)

View File

@ -0,0 +1,204 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import pytz
import uuid
from datetime import datetime, timedelta
from functools import wraps
from inspect import Parameter, signature
import odoo
from odoo.tools import consteq, get_lang
from odoo import _, api, fields, models
from odoo.http import request
from odoo.addons.base.models.res_partner import _tz_get
from odoo.exceptions import UserError
from odoo.addons.bus.models.bus_presence import AWAY_TIMER, DISCONNECTION_TIMER
from ...tools.websocket import wsrequest
def add_guest_to_context(func):
""" Decorate a function to extract the guest from the request.
The guest is then available on the context of the current
request.
"""
@wraps(func)
def wrapper(self, *args, **kwargs):
req = request or wsrequest
token = (
req.httprequest.cookies.get(req.env["mail.guest"]._cookie_name, "")
)
guest = req.env["mail.guest"]._get_guest_from_token(token)
if guest and not guest.timezone:
timezone = req.env["mail.guest"]._get_timezone_from_request(req)
if timezone:
guest._update_timezone(timezone)
if guest:
req.update_context(guest=guest)
if hasattr(self, "env"):
self.env.context = {**self.env.context, "guest": guest}
return func(self, *args, **kwargs)
return wrapper
class MailGuest(models.Model):
_name = 'mail.guest'
_description = "Guest"
_inherit = ['avatar.mixin']
_avatar_name_field = "name"
_cookie_name = 'dgid'
_cookie_separator = '|'
@api.model
def _lang_get(self):
return self.env['res.lang'].get_installed()
name = fields.Char(string="Name", required=True)
access_token = fields.Char(string="Access Token", default=lambda self: str(uuid.uuid4()), groups='base.group_system', required=True, readonly=True, copy=False)
country_id = fields.Many2one(string="Country", comodel_name='res.country')
lang = fields.Selection(string="Language", selection=_lang_get)
timezone = fields.Selection(string="Timezone", selection=_tz_get)
channel_ids = fields.Many2many(string="Channels", comodel_name='discuss.channel', relation='discuss_channel_member', column1='guest_id', column2='channel_id', copy=False)
im_status = fields.Char('IM Status', compute='_compute_im_status')
def _compute_im_status(self):
self.env.cr.execute("""
SELECT
guest_id as id,
CASE WHEN age(now() AT TIME ZONE 'UTC', last_poll) > interval %s THEN 'offline'
WHEN age(now() AT TIME ZONE 'UTC', last_presence) > interval %s THEN 'away'
ELSE 'online'
END as status
FROM bus_presence
WHERE guest_id IN %s
""", ("%s seconds" % DISCONNECTION_TIMER, "%s seconds" % AWAY_TIMER, tuple(self.ids)))
res = dict(((status['id'], status['status']) for status in self.env.cr.dictfetchall()))
for guest in self:
guest.im_status = res.get(guest.id, 'offline')
def _get_guest_from_token(self, token=""):
"""Returns the guest record for the given token, if applicable."""
guest = self.env["mail.guest"]
parts = token.split(self._cookie_separator)
if len(parts) == 2:
guest_id, guest_access_token = parts
# sudo: mail.guest: guests need sudo to read their access_token
guest = self.browse(int(guest_id)).sudo().exists()
if not guest or not guest.access_token or not consteq(guest.access_token, guest_access_token):
guest = self.env["mail.guest"]
return guest.sudo(False)
def _get_guest_from_context(self):
"""Returns the current guest record from the context, if applicable."""
guest = self.env.context.get('guest')
if isinstance(guest, self.pool['mail.guest']):
return guest.sudo(False).with_context(guest=guest)
return self.env['mail.guest']
def _get_timezone_from_request(self, request):
timezone = request.httprequest.cookies.get('tz')
return timezone if timezone in pytz.all_timezones else False
def _update_name(self, name):
self.ensure_one()
name = name.strip()
if len(name) < 1:
raise UserError(_("Guest's name cannot be empty."))
if len(name) > 512:
raise UserError(_("Guest's name is too long."))
self.name = name
guest_data = {
'id': self.id,
'name': self.name,
'type': "guest"
}
bus_notifs = [(channel, 'mail.record/insert', {'Persona': guest_data}) for channel in self.channel_ids]
bus_notifs.append((self, 'mail.record/insert', {'Persona': guest_data}))
self.env['bus.bus']._sendmany(bus_notifs)
def _update_timezone(self, timezone):
query = """
UPDATE mail_guest
SET timezone = %s
WHERE id IN (
SELECT id FROM mail_guest WHERE id = %s
FOR NO KEY UPDATE SKIP LOCKED
)
"""
self.env.cr.execute(query, (timezone, self.id))
def _init_messaging(self):
self.ensure_one()
# sudo: res.partner - exposing OdooBot name and id
odoobot = self.env.ref('base.partner_root').sudo()
# sudo: mail.guest - guest reading their own id/name/channels
guest_sudo = self.sudo()
return {
'channels': guest_sudo.channel_ids.sudo(False)._channel_info(),
'companyName': self.env.company.name,
'currentGuest': {
'id': guest_sudo.id,
'name': guest_sudo.name,
'type': "guest",
},
'current_partner': False,
'current_user_id': False,
'current_user_settings': False,
# sudo: ir.config_parameter: safe to check for existence of tenor api key
'hasGifPickerFeature': bool(self.env["ir.config_parameter"].sudo().get_param("discuss.tenor_api_key")),
'hasLinkPreviewFeature': self.env['mail.link.preview']._is_link_preview_enabled(),
'hasMessageTranslationFeature': False,
# sudo: bus.bus: reading non-sensitive last id
'initBusId': self.env['bus.bus'].sudo()._bus_last_id(),
'menu_id': False,
'needaction_inbox_counter': False,
'odoobot': {
'id': odoobot.id,
'name': odoobot.name,
'type': "partner",
},
'shortcodes': [],
'starred_counter': False,
}
def _guest_format(self, fields=None):
if not fields:
fields = {'id': True, 'name': True, 'im_status': True, "write_date": True}
guests_formatted_data = {}
for guest in self:
data = {}
if 'id' in fields:
data['id'] = guest.id
if 'name' in fields:
data['name'] = guest.name
if 'im_status' in fields:
data['im_status'] = guest.im_status
if "write_date" in fields:
data["write_date"] = odoo.fields.Datetime.to_string(guest.write_date)
data['type'] = "guest"
guests_formatted_data[guest] = data
return guests_formatted_data
def _set_auth_cookie(self):
"""Add a cookie to the response to identify the guest. Every route
that expects a guest will make use of it to authenticate the guest
through `add_guest_to_context`.
"""
self.ensure_one()
expiration_date = datetime.now() + timedelta(days=365)
request.future_response.set_cookie(
self._cookie_name,
self._format_auth_cookie(),
httponly=True,
expires=expiration_date,
)
request.update_context(guest=self.sudo(False))
def _format_auth_cookie(self):
"""Format the cookie value for the given guest.
:param guest: guest to format the cookie value for
:return str: formatted cookie value
"""
self.ensure_one()
return f"{self.id}{self._cookie_separator}{self.access_token}"

View File

@ -0,0 +1,33 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class MailMessage(models.Model):
_inherit = "mail.message"
def _validate_access_for_current_persona(self, operation):
if not self:
return False
self.ensure_one()
if self.env.user._is_public():
guest = self.env["mail.guest"]._get_guest_from_context()
# sudo: mail.guest - current guest can read channels they are member of
return guest and self.model == "discuss.channel" and self.res_id in guest.sudo().channel_ids.ids
return super()._validate_access_for_current_persona(operation)
def _message_format_extras(self, format_reply):
self.ensure_one()
vals = super()._message_format_extras(format_reply)
if format_reply and self.model == "discuss.channel" and self.parent_id:
vals["parentMessage"] = self.parent_id.message_format(format_reply=False)[0]
return vals
def _bus_notification_target(self):
self.ensure_one()
if self.model == "discuss.channel" and self.res_id:
return self.env["discuss.channel"].browse(self.res_id)
guest = self.env["mail.guest"]._get_guest_from_context()
if self.env.user._is_public() and guest:
return guest
return super()._bus_notification_target()

View File

@ -0,0 +1,16 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ResGroups(models.Model):
_inherit = "res.groups"
def write(self, vals):
res = super().write(vals)
if vals.get("users"):
# form: {'group_ids': [(3, 10), (3, 3), (4, 10), (4, 3)]} or {'group_ids': [(6, 0, [ids]}
user_ids = [command[1] for command in vals["users"] if command[0] == 4]
user_ids += [id for command in vals["users"] if command[0] == 6 for id in command[2]]
self.env["discuss.channel"].search([("group_ids", "in", self._ids)])._subscribe_users_automatically()
return res

View File

@ -0,0 +1,101 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.osv import expression
class ResPartner(models.Model):
_inherit = "res.partner"
channel_ids = fields.Many2many(
"discuss.channel",
"discuss_channel_member",
"partner_id",
"channel_id",
string="Channels",
copy=False,
)
@api.model
def search_for_channel_invite(self, search_term, channel_id=None, limit=30):
"""Returns partners matching search_term that can be invited to a channel.
If the channel_id is specified, only partners that can actually be invited to the channel
are returned (not already members, and in accordance to the channel configuration).
"""
domain = expression.AND(
[
expression.OR(
[
[("name", "ilike", search_term)],
[("email", "ilike", search_term)],
]
),
[("active", "=", True)],
[("user_ids", "!=", False)],
[("user_ids.active", "=", True)],
[("user_ids.share", "=", False)],
]
)
if channel_id:
channel = self.env["discuss.channel"].search([("id", "=", int(channel_id))])
domain = expression.AND([domain, [("channel_ids", "not in", channel.id)]])
if channel.group_public_id:
domain = expression.AND([domain, [("user_ids.groups_id", "in", channel.group_public_id.id)]])
query = self.env["res.partner"]._search(domain, order="name, id")
query.order = 'LOWER("res_partner"."name"), "res_partner"."id"' # bypass lack of support for case insensitive order in search()
query.limit = int(limit)
return {
"count": self.env["res.partner"].search_count(domain),
"partners": list(self.env["res.partner"].browse(query).mail_partner_format().values()),
}
@api.model
def get_mention_suggestions_from_channel(self, channel_id, search, limit=8):
"""Return 'limit'-first partners' such that the name or email matches a 'search' string.
Prioritize partners that are also (internal) users, and then extend the research to all partners.
Only members of the given channel are returned.
The return format is a list of partner data (as per returned by `mail_partner_format()`).
"""
channel = self.env["discuss.channel"].search([("id", "=", channel_id)])
if not channel:
return []
domain = expression.AND(
[
self._get_mention_suggestions_domain(search),
[("channel_ids", "in", channel.id)],
]
)
partners = self._search_mention_suggestions(domain, limit)
member_by_partner = {
member.partner_id: member
for member in self.env["discuss.channel.member"].search(
[
("channel_id", "=", channel.id),
("partner_id", "in", partners.ids),
]
)
}
partners_format = partners.mail_partner_format()
for partner in partners:
partners_format.get(partner)["channelMembers"] = [
(
"ADD",
member_by_partner.get(partner)
._discuss_channel_member_format(
fields={
"id": True,
"channel": {"id"},
"persona": {"partner": {"id"}},
}
)
.get(member_by_partner.get(partner)),
)
]
return list(partners_format.values())
@api.model
def _get_current_persona(self):
if not self.env.user or self.env.user._is_public():
return (self.env["res.partner"], self.env["mail.guest"]._get_guest_from_context())
return (self.env.user.partner_id, self.env["mail.guest"])

View File

@ -0,0 +1,63 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from odoo.addons.base.models.res_users import is_selection_groups
class ResUsers(models.Model):
_inherit = "res.users"
@api.model_create_multi
def create(self, vals_list):
users = super().create(vals_list)
self.env["discuss.channel"].search([("group_ids", "in", users.groups_id.ids)])._subscribe_users_automatically()
return users
def write(self, vals):
res = super().write(vals)
if "active" in vals and not vals["active"]:
self._unsubscribe_from_non_public_channels()
sel_groups = [vals[k] for k in vals if is_selection_groups(k) and vals[k]]
if vals.get("groups_id"):
# form: {'group_ids': [(3, 10), (3, 3), (4, 10), (4, 3)]} or {'group_ids': [(6, 0, [ids]}
user_group_ids = [command[1] for command in vals["groups_id"] if command[0] == 4]
user_group_ids += [id for command in vals["groups_id"] if command[0] == 6 for id in command[2]]
self.env["discuss.channel"].search([("group_ids", "in", user_group_ids)])._subscribe_users_automatically()
elif sel_groups:
self.env["discuss.channel"].search([("group_ids", "in", sel_groups)])._subscribe_users_automatically()
return res
def unlink(self):
self._unsubscribe_from_non_public_channels()
return super().unlink()
def _unsubscribe_from_non_public_channels(self):
"""This method un-subscribes users from group restricted channels. Main purpose
of this method is to prevent sending internal communication to archived / deleted users.
"""
domain = [("partner_id", "in", self.partner_id.ids)]
# sudo: discuss.channel.member - removing member of other users based on channel restrictions
current_cm = self.env["discuss.channel.member"].sudo().search(domain)
current_cm.filtered(
lambda cm: (cm.channel_id.channel_type == "channel" and cm.channel_id.group_public_id)
).unlink()
def _init_messaging(self):
self.ensure_one()
# 2 different queries because the 2 sub-queries together with OR are less efficient
channels = self.env["discuss.channel"].with_user(self)
channels += channels.search([("channel_type", "in", ("channel", "group")), ("is_member", "=", True)])
channels += channels.search(
[
("channel_type", "not in", ("channel", "group")),
("channel_member_ids", "any", [("is_self", "=", True), ("is_pinned", "=", True)]),
]
)
return {
"channels": channels._channel_info(),
# sudo: ir.config_parameter - reading hard-coded key to check its existence, safe to return if the feature is enabled
"hasGifPickerFeature": bool(self.env["ir.config_parameter"].sudo().get_param("discuss.tenor_api_key")),
# sudo: ir.config_parameter - reading hard-coded key to check its existence, safe to return if the feature is enabled
'hasMessageTranslationFeature': bool(self.env["ir.config_parameter"].sudo().get_param("mail.google_translate_api_key")),
**super()._init_messaging(),
}

View File

@ -0,0 +1,306 @@
# 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, fields, models, tools, _
from odoo.addons.whatsapp.tools import phone_validation as wa_phone_validation
from odoo.exceptions import ValidationError
from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
_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 Partner Last Message Datetime", compute="_compute_whatsapp_channel_valid_until")
whatsapp_partner_id = fields.Many2one(comodel_name='res.partner', string="WhatsApp Partner")
whatsapp_mail_message_id = fields.Many2one(comodel_name='mail.message', string="Related message", index="btree_not_null")
wa_account_id = fields.Many2one(comodel_name='whatsapp.account', string="WhatsApp Business Account")
_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('message_ids')
def _compute_whatsapp_channel_valid_until(self):
self.whatsapp_channel_valid_until = False
wa_channels = self.filtered(lambda c: c.channel_type == 'whatsapp')
if wa_channels:
channel_last_msg_ids = {
r['id']: r['message_id']
for r in wa_channels._channel_last_whatsapp_partner_id_message_ids()
}
MailMessage = self.env['mail.message'].with_prefetch(list(channel_last_msg_ids.values()))
for channel in wa_channels:
last_msg_id = channel_last_msg_ids.get(channel.id, False)
if not last_msg_id:
continue
channel.whatsapp_channel_valid_until = MailMessage.browse(last_msg_id).create_date + timedelta(hours=24)
def _channel_last_whatsapp_partner_id_message_ids(self):
""" Return the last message of the whatsapp_partner_id given whatsapp channels."""
if not self:
return []
self.env['mail.message'].flush_model()
self.env.cr.execute("""
SELECT res_id AS id, MAX(mm.id) AS message_id
FROM mail_message AS mm
JOIN discuss_channel AS dc ON mm.res_id = dc.id
WHERE mm.model = 'discuss.channel'
AND mm.res_id IN %s
AND mm.author_id = dc.whatsapp_partner_id
GROUP BY mm.res_id
""", [tuple(self.ids)])
return self.env.cr.dictfetchall()
# INHERITED COMPUTES
def _compute_is_chat(self):
super()._compute_is_chat()
self.filtered(lambda channel: channel.channel_type == 'whatsapp').is_chat = True
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):
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'],
'state': 'received',
'wa_account_id': self.wa_account_id.id,
})
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.env['bus.bus']._sendone(self, 'discuss.channel/whatsapp_channel_valid_until_changed', {
'id': self.id,
'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
if 'message_ids' in related_record:
record_messages = related_record.message_ids
else:
record_messages = self.env['mail.message'].search([
('model', '=', related_record._name),
('res_id', '=', related_record.id),
('message_type', '!=', 'user_notification'),
])
channel_domain += [
('whatsapp_mail_message_id', 'in', record_messages.ids),
]
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,
'whatsapp_mail_message_id': related_message.id if related_message else None,
})
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)
print('selfffffff', self)
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
url = Markup('{base_url}/web#model={model}&id={res_id}').format(
base_url=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")
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
url = Markup('{base_url}/web#model=discuss.channel&id={channel_id}').format(
base_url=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 = [(5,0,0)] + [(0,0, {'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_rights('write')
self.check_access_rule('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({'is_pinned': True})
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.env['bus.bus']._sendone(self, 'mail.record/insert', {
'Thread': {
'channelMembers': [('ADD', list(new_member._discuss_channel_member_format().values()))],
'id': self.id,
'memberCount': self.member_count,
'model': "discuss.channel",
}
})
return self._channel_info()[0]
# ------------------------------------------------------------
# OVERRIDE
# ------------------------------------------------------------
def _action_unfollow(self, partner):
if self.channel_type == 'whatsapp' \
and ((self.whatsapp_mail_message_id \
and self.whatsapp_mail_message_id.author_id == partner) \
or len(self.channel_member_ids) <= 2):
msg = _("You can't leave this channel. As you are the owner of this WhatsApp channel, you can only delete it.")
self._send_transient_message(partner, msg)
return
super()._action_unfollow(partner)
def _channel_info(self):
channel_infos = super()._channel_info()
channel_infos_dict = {c['id']: c for c in channel_infos}
for channel in self:
if channel.channel_type == 'whatsapp':
channel_infos_dict[channel.id]['whatsapp_channel_valid_until'] = \
channel.whatsapp_channel_valid_until.strftime(DEFAULT_SERVER_DATETIME_FORMAT) \
if channel.whatsapp_channel_valid_until else False
return list(channel_infos_dict.values())
# ------------------------------------------------------------
# COMMANDS
# ------------------------------------------------------------
def execute_command_leave(self, **kwargs):
if self.channel_type == 'whatsapp':
self.action_unfollow()
else:
super().execute_command_leave(**kwargs)

View File

@ -0,0 +1,25 @@
# 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({'is_pinned': False})
self.env['bus.bus']._sendmany([
(member.partner_id, 'discuss.channel/unpin', {'id': member.channel_id.id})
for member in members_to_be_unpinned
])

View File

@ -0,0 +1,68 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
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')
pinned_at = fields.Datetime('Pinned', help='Datetime at which the message has been pinned')
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)
reactionGroups = []
if reaction_to_delete:
content = reaction_to_delete.content
reaction_to_delete.unlink()
reactionGroups.append(self._get_whatsapp_reaction_format(content, partner_id, unlink_reaction=True))
if reaction_content and self.id:
self.env['mail.message.reaction'].create({
'message_id': self.id,
'content': reaction_content,
'partner_id': partner_id.id,
})
reactionGroups.append(self._get_whatsapp_reaction_format(reaction_content, partner_id))
payload = {'Message': {'id': self.id, 'reactions': reactionGroups}}
self.env['bus.bus']._sendone(self._bus_notification_target(), 'mail.record/insert', payload)
def _get_whatsapp_reaction_format(self, content, partner_id, unlink_reaction=False):
self.ensure_one()
group_domain = [('message_id', '=', self.id), ('content', '=', content)]
count = self.env['mail.message.reaction'].search_count(group_domain)
# remove old reaction and add new one if count > 0 from same partner
group_command = 'ADD' if count > 0 else 'DELETE'
return (group_command, {
'content': content,
'count': count,
'guests': [],
'message': {'id': self.id},
'partners': [('DELETE' if unlink_reaction else 'ADD', {'id': partner_id.id})],
})
def message_format(self, *args, **kwargs):
vals_list = super().message_format(*args, **kwargs)
whatsapp_mail_message = self.filtered(lambda m: m.message_type == 'whatsapp_message')
if whatsapp_mail_message:
whatsapp_message_by_message_id = {
whatsapp_message.mail_message_id.id: whatsapp_message
for whatsapp_message in self.env['whatsapp.message'].sudo().search([('mail_message_id', 'in', whatsapp_mail_message.ids)])
}
for vals in vals_list:
if whatsapp_message_by_message_id.get(vals['id']):
vals['whatsappStatus'] = whatsapp_message_by_message_id[vals['id']].state
return vals_list
def _message_format(self, fnames):
"""Reads values from messages and formats them for the web client."""
vals_list = self._read_format(fnames)
for vals in vals_list:
message_sudo = self.browse(vals['id']).sudo().with_prefetch(self.ids)
vals['pinned_at'] = message_sudo.pinned_at
return vals_list

View File

@ -0,0 +1,17 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class MailThread(models.AbstractModel):
_inherit = 'mail.thread'
def _get_mail_thread_data(self, request_list):
res = super()._get_mail_thread_data(request_list)
res['canSendWhatsapp'] = self.env['whatsapp.template']._can_use_whatsapp(self._name)
return res
class PhoneMixin(models.AbstractModel):
_inherit = 'mail.thread.phone'
_phone_search_min_length = 3

View File

@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import exceptions, models, _
class BaseModel(models.AbstractModel):
_inherit = 'base'
def _find_value_from_field_path(self, field_path):
""" Get the value of field, returning display_name(s) if the field is a
model. Can be called on a void recordset, in which case it mainly serves
as a field path validation. """
if self:
self.ensure_one()
# as we use mapped(False) returns record, better return a void string
if not field_path:
return ''
try:
field_value = self.mapped(field_path)
except KeyError as err:
raise exceptions.UserError(
_("'%(field)s' does not seem to be a valid field path", field=field_path)
) from err
except Exception as err: # noqa: BLE001
raise exceptions.UserError(
_("We were not able to fetch value of field '%(field)s'", field=field_path)
) from err
if isinstance(field_value, models.Model):
return ' '.join((value.display_name or '') for value in field_value)
return ' '.join(str(value if value is not False and value is not None else '') for value in field_value)
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

View File

@ -0,0 +1,107 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import re
from odoo import exceptions, models, _
from odoo.addons.phone_validation.tools import phone_validation
from ..tools import phone_validation as phone_validation_wa
_logger = logging.getLogger(__name__)
class ResPartner(models.Model):
_inherit = 'res.partner'
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_wa.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])

View File

@ -0,0 +1,23 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo import SUPERUSER_ID
class ResUsers(models.Model):
_inherit = 'res.users'
def _is_internal(self):
self.ensure_one()
return not self.sudo().share
def _is_superuser(self):
self.ensure_one()
return self.id == SUPERUSER_ID
# todo fix with js downgrade
# 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")

View File

@ -0,0 +1,246 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import mimetypes
import secrets
import string
from datetime import timedelta
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from ..tools.whatsapp_api import WhatsAppApi
from ..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.Text(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()
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': _("Template Synced"),
'type': 'success',
'message': _("""Template synchronization Completed.
Template Created count %d
Template Updated count %d
""", template_create_count, template_update_count),
}
}
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:
print('eeeeeee', e)
raise UserError(str(e))
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _("Testing Credentials"),
'type': 'success',
'message': _("Credentials are valid."),
}
}
def action_open_templates(self):
self.ensure_one()
return {
'name': _("Templates Of %(account_name)s", account_name=self.name),
'view_mode': 'tree,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()
allowed_old_msg_date = fields.Datetime.now() - timedelta(
days=self.env['whatsapp.message']._ACTIVE_THRESHOLD_DAYS)
whatsapp_message = self.env['whatsapp.message'].sudo().search(
[
('mobile_number_formatted', '=', sender_mobile_formatted),
('create_date', '>', allowed_old_msg_date),
('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_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:
parent_whatsapp_message = self.env['whatsapp.message'].sudo().search([('msg_uid', '=', messages['context'].get('id'))])
if parent_whatsapp_message:
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,
'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')
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)]
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)

View File

@ -0,0 +1,442 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
import logging
import markupsafe
from markupsafe import Markup
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.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
import json
_logger = logging.getLogger(__name__)
class WhatsAppMessage(models.Model):
_name = 'whatsapp.message'
_description = 'WhatsApp Messages'
_order = 'id desc'
_rec_name = 'mobile_number'
_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', 'video/3gp'),
}
# 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'),
('received', 'Received'),
('error', 'Failed'),
('cancel', 'Cancelled')], string="State", default='outgoing')
failure_type = fields.Selection([
('account', 'Misconfigured or shared account'),
('blacklisted', 'Phone is blacklisted'),
('network', 'Invalid query or unreachable endpoint'),
('phone_invalid', 'Phone number in the wrong format'),
('template', 'Template cannot be used'),
('unknown', 'Unidentified error'),
('whatsapp_recoverable', 'Fixable Whatsapp error'),
('whatsapp_unrecoverable', 'Unfixable Whatsapp error')
])
failure_reason = fields.Char(string="Failure Reason", help="Usually an error message from Whatsapp")
free_text_json = fields.Text(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")
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.")
]
def get_json_field(self):
try:
return json.loads(self.free_text_json or '{}')
except json.JSONDecodeError:
return {}
@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. """
date_threshold = fields.Datetime.now() - timedelta(
days=self.env['whatsapp.message']._ACTIVE_THRESHOLD_DAYS)
self.env['whatsapp.message'].search([
('create_date', '<', date_threshold),
('state', 'not in', ['outgoing', 'error', 'cancel'])
]).unlink()
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})
if len(self) <= 1:
self._send_message()
else:
self.env.ref('whatsapp.ir_cron_send_whatsapp_queue')._trigger()
if retryable_messages != self:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'sticky': True,
'type': 'warning',
'title': _("Some messages are not retryable."),
'message': _(
"Sent messages or messages with unfixable failures cannot be resent."
),
}
}
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]
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([('number', 'ilike', number)]):
raise WhatsAppError(failure_type='blacklisted')
if whatsapp_message.wa_template_id:
message_type = 'template'
if whatsapp_message.wa_template_id.status != 'approved' or whatsapp_message.wa_template_id.quality in ('red', 'yellow'):
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)
send_vals, attachment = whatsapp_message.wa_template_id._get_send_template_vals(
record=from_record, free_text_json=whatsapp_message.get_json_field(),
attachment=whatsapp_message.mail_message_id.attachment_ids)
if attachment:
# If retrying message then we need to remove previous attachment and add new attachment.
if whatsapp_message.mail_message_id.attachment_ids and whatsapp_message.wa_template_id.header_type == 'document' and whatsapp_message.wa_template_id.report_id:
whatsapp_message.mail_message_id.attachment_ids.unlink()
if attachment not in whatsapp_message.mail_message_id.attachment_ids:
whatsapp_message.mail_message_id.attachment_ids = [(4, attachment.id)]
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()
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'
self.write({
'failure_type': failure_type,
'failure_reason': error_message,
'state': 'error',
})
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
model_name = False
if self.mail_message_id.model:
model_name = self.env['ir.model']._get(self.mail_message_id.model).display_name
if model_name:
info = _("Template %(template_name)s was sent from %(model_name)s",
template_name=self.wa_template_id.name, model_name=model_name)
else:
info = _("Template %(template_name)s was sent from another model",
template_name=self.wa_template_id.name)
record_name = self.mail_message_id.record_name
if not record_name and self.mail_message_id.res_id:
record_name = self.env[self.mail_message_id.model].browse(self.mail_message_id.res_id).display_name
url = f"{self.get_base_url()}/web#model={self.mail_message_id.model}&id={self.mail_message_id.res_id}"
channel.sudo().message_post(
message_type='notification',
body=Markup('<p>{info} <a target="_blank" href="{url}">{record_name}</a></p>').format(
info=info,
url=url,
record_name=record_name,
),
)
@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'}
for statuses in value.get('statuses', []):
whatsapp_message_id = self.env['whatsapp.message'].sudo().search([('msg_uid', '=', statuses['id'])])
if whatsapp_message_id:
whatsapp_message_id.state = mapping.get(statuses['status'], statuses['status'])
whatsapp_message_id._update_message_fetched_seen()
if statuses['status'] == 'failed':
error = statuses['errors'][0] if statuses.get('errors') else None
if error:
whatsapp_message_id._handle_error(whatsapp_error_code=error['code'],
error_message=f"{error['code']} : {error['title']}")
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)[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:
self.env['bus.bus']._sendone(channel, 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,
})

View File

@ -0,0 +1,833 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import re
from markupsafe import Markup
from odoo import api, models, fields, _
from odoo.addons.http_routing.models.ir_http import slugify
from ..tools.lang_list import Languages
from ..tools.whatsapp_api import WhatsAppApi
from ..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)
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,
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)
footer_text = fields.Char(string="Footer Message")
report_id = fields.Many2one(comodel_name='ir.actions.report', string="Report", domain="[('model_id', '=', model_id)]", tracking=True)
variable_ids = fields.One2many('whatsapp.template.variable', 'wa_template_id', copy=True,
string="Template Variables", store=True, compute='_compute_variable_ids', precompute=True, readonly=False)
button_ids = fields.One2many('whatsapp.template.button', 'wa_template_id', string="Buttons")
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(_("Header text can only contain a single {{variable}}."))
@api.constrains('phone_field', 'model')
def _check_phone_field(self):
is_system = self.user_has_groups('base.group_system')
for tmpl in self.filtered('phone_field'):
model = self.env[tmpl.model]
if not is_system:
if not model.check_access_rights('read', raise_exception=False):
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')
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}}'))
#=====================================================
# 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')
def _compute_template_name(self):
for template in self:
if template.status == 'draft' and not template.wa_template_uid:
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('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 = []
to_create = []
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.append({'name': header_variables[0], 'line_type': 'header', 'wa_template_id': tmpl.id})
elif not header_variables and existing_header_text_variable:
to_delete.append(existing_header_text_variable.id)
# 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':
if not existing_header_location_variables:
to_create += [
{'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}
]
else:
to_delete += existing_header_location_variables.ids
# body
existing_body_variables = tmpl.variable_ids.filtered(lambda line: line.line_type == 'body')
existing_body_variables = {var.name: var for var in existing_body_variables}
new_body_variable_names = [var_name for var_name in body_variables if var_name not in existing_body_variables]
deleted_body_variables = [var.id for name, var in existing_body_variables.items() if name not in body_variables]
to_create += [{'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
update_commands = [(2, to_delete_id) for to_delete_id in to_delete] + [(0, 0, vals) for vals in to_create]
if update_commands:
tmpl.variable_ids = update_commands
@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):
grouped_messages = self.env['whatsapp.message'].read_group(
domain=[('wa_template_id', 'in', self.ids)],
fields=[],
groupby=['wa_template_id'],
)
messages_by_template = {}
for item in grouped_messages:
key = item['wa_template_id'][0]
value = item['wa_template_id_count']
messages_by_template[key] = value
for tmpl in self:
tmpl.messages_count = messages_by_template.get(tmpl.id, 0)
@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(self, default=None):
self.ensure_one()
default = default or {}
if not default.get('name'):
default['name'] = _('%(original_name)s (copy)', original_name=self.name)
default['template_name'] = f'{self.template_name}_copy'
return super().copy(default)
@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
#===================================================================
# 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['url'] = button.website_url
if button.url_type == 'dynamic':
button_data['url'] += '{{1}}'
button_data['example'] = button.variable_ids[0].demo_value
elif button.button_type == 'phone_number':
button_data['phone_number'] = button.call_number
buttons.append(button_data)
return {'type': 'BUTTONS', 'buttons': buttons}
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()
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'] = [(0, 0, var) for var in template_vals['variable_ids']]
for button in template_vals['button_ids']:
button['variable_ids'] = [(0, 0, var) for var in button['variable_ids']]
template_vals['button_ids'] = [(0, 0, button) for button in template_vals['button_ids']]
template_vals['header_attachment_ids'] = [(0, 0, attachment) for attachment in template_vals['header_attachment_ids']]
return template_vals
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')
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((0, 0, variable_vals))
variable_ids.extend([(2, 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'] = [(0, 0, var) for var in button['variable_ids']]
update_vals['button_ids'] = [(5, 0, 0)] + [(0, 0, 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 = [(0, 0, attachment) for attachment in template_vals['header_attachment_ids']]
update_vals['header_attachment_ids'] = [(5, 0, 0)] + 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.
"""
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(),
'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'):
# TODO RETH fetch remote example if set
extension, mimetype = {
'IMAGE': ('jpg', 'image/jpeg'),
'VIDEO': ('mp4', 'video/mp4'),
'DOCUMENT': ('pdf', 'application/pdf')
}[component['format']]
template_vals['header_attachment_ids'] = [{
'name': f'Missing.{extension}', 'res_model': self._name, 'res_id': self.ids[0] if self else False,
'datas': "AAAA", '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.filtered(lambda line: line.url_type == 'dynamic')
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, free_text_json, attachment=False):
"""Prepare JSON dictionary for sending WhatsApp template message"""
self.ensure_one()
components = []
template_variables_value = self.variable_ids._get_variables_value(record)
attachment = attachment or self.header_attachment_ids or self._generate_attachment_from_report(record)
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': 'tree,form',
'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_rule('write')
actions = self.env['ir.actions.act_window'].sudo().search([
('res_model', '=', 'whatsapp.composer'),
('binding_model_id', 'in', self.model_id.ids)
])
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)
])
def button_delete_action(self):
self.check_access_rule('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_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 = self.header_text.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{body}' if header else body)
# ------------------------------------------------------------
# TOOLS
# ------------------------------------------------------------
@api.model
def _can_use_whatsapp(self, model_name):
return self.env.user.has_group('whatsapp.group_whatsapp_admin') or \
bool(self._find_default_for_model(model_name))
@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)

View File

@ -0,0 +1,81 @@
# 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.exceptions import ValidationError
from odoo.addons.phone_validation.tools import phone_validation
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")
variable_ids = fields.One2many('whatsapp.template.variable', 'button_id',
compute='_compute_variable_ids', precompute=True, store=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', 'url_type', 'website_url')
def _compute_variable_ids(self):
dynamic_urls = self.filtered(lambda button: button.button_type == 'url' and button.url_type == 'dynamic')
to_clear = self - dynamic_urls
for button in dynamic_urls:
url_vars = {'{{1}}'} # for now the var is mandatory and automatically added at the end of the url
if not url_vars and button.variable_ids:
to_clear += button
continue
existing_vars = {var.name: var for var in button.variable_ids}
unlink_commands = [(3, var.id) for name, var in existing_vars.items() if name not in url_vars]
create_commands = [(0, 0, {
'demo_value': button.website_url + '???', 'line_type': 'button',
'name': name, 'wa_template_id': button.wa_template_id.id})
for name in url_vars - existing_vars.keys()]
if unlink_commands or create_commands:
button.write({'variable_ids': unlink_commands + create_commands})
if to_clear:
to_clear.write({'variable_ids': [(5, 0, 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.constrains('button_type', 'url_type', 'variable_ids', 'website_url')
def _validate_website_url(self):
for button in self.filtered(lambda button: button.button_type == 'url'):
parsed_url = urlparse(button.website_url)
if not (parsed_url.scheme in {'http', 'https'} and parsed_url.netloc):
raise ValidationError(_("Please enter a valid URL in the format 'https://www.example.com'."))
@api.constrains('call_number')
def _validate_call_number(self):
for button in self:
if button.button_type == 'phone_number':
phone_validation.phone_format(button.call_number, False, False)

View File

@ -0,0 +1,152 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from functools import reduce
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')
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.'))
if self.filtered(lambda var: var.field_type == 'field' and not var.field_name):
raise ValidationError(_("Field template variables must be associated with a field."))
for var in self.filtered('button_id'):
if not var.demo_value.startswith(var.button_id.website_url):
raise ValidationError(_('Demo value of a dynamic url must start with the non-dynamic part'
'of the url such as "https://www.example.com/menu?id=20"'))
@api.constrains('field_name')
def _check_field_name(self):
is_system = self.user_has_groups('base.group_system')
for variable in self.filtered('field_name'):
model = self.env[variable.model]
if not is_system:
if not model.check_access_rights('read', raise_exception=False):
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 as err:
raise ValidationError(
_("'%(field)s' does not seem to be a valid field path", field=variable.field_name)
) from err
@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))
if variable.line_type != 'location' 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):
for variable in self:
if variable.line_type in ('body', 'location'):
variable.display_name = f'{variable.line_type} - {variable.name}'
elif variable.line_type == 'button':
variable.display_name = f'{variable.line_type} "{variable.button_id.name}" - {variable.name}'
else:
variable.display_name = variable.line_type
@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

View File

@ -0,0 +1,495 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import collections.abc
import copy
import functools
import importlib
import logging
import os
import pkg_resources
import re
import sys
import warnings
from os.path import join as opj, normpath
import odoo
import odoo.tools as tools
import odoo.release as release
from odoo.tools import pycompat
from .misc import file_path
# todo from odoo.modules.module
MANIFEST_NAMES = ('__manifest__.py', '__openerp__.py')
README = ['README.rst', 'README.md', 'README.txt']
_DEFAULT_MANIFEST = {
#addons_path: f'/path/to/the/addons/path/of/{module}', # automatic
'application': False,
'bootstrap': False, # web
'assets': {},
'author': 'Odoo S.A.',
'auto_install': False,
'category': 'Uncategorized',
'configurator_snippets': {}, # website themes
'countries': [],
'data': [],
'demo': [],
'demo_xml': [],
'depends': [],
'description': '',
'external_dependencies': {},
#icon: f'/{module}/static/description/icon.png', # automatic
'init_xml': [],
'installable': True,
'images': [], # website
'images_preview_theme': {}, # website themes
#license, mandatory
'live_test_url': '', # website themes
'new_page_templates': {}, # website themes
#name, mandatory
'post_init_hook': '',
'post_load': '',
'pre_init_hook': '',
'sequence': 100,
'summary': '',
'test': [],
'update_xml': [],
'uninstall_hook': '',
'version': '1.0',
'web': False,
'website': '',
}
_logger = logging.getLogger(__name__)
class UpgradeHook(object):
"""Makes the legacy `migrations` package being `odoo.upgrade`"""
def find_spec(self, fullname, path=None, target=None):
if re.match(r"^odoo\.addons\.base\.maintenance\.migrations\b", fullname):
# We can't trigger a DeprecationWarning in this case.
# In order to be cross-versions, the multi-versions upgrade scripts (0.0.0 scripts),
# the tests, and the common files (utility functions) still needs to import from the
# legacy name.
return importlib.util.spec_from_loader(fullname, self)
def load_module(self, name):
assert name not in sys.modules
canonical_upgrade = name.replace("odoo.addons.base.maintenance.migrations", "odoo.upgrade")
if canonical_upgrade in sys.modules:
mod = sys.modules[canonical_upgrade]
else:
mod = importlib.import_module(canonical_upgrade)
sys.modules[name] = mod
return sys.modules[name]
def initialize_sys_path():
"""
Setup the addons path ``odoo.addons.__path__`` with various defaults
and explicit directories.
"""
# hook odoo.addons on data dir
dd = os.path.normcase(tools.config.addons_data_dir)
if os.access(dd, os.R_OK) and dd not in odoo.addons.__path__:
odoo.addons.__path__.append(dd)
# hook odoo.addons on addons paths
for ad in tools.config['addons_path'].split(','):
ad = os.path.normcase(os.path.abspath(tools.ustr(ad.strip())))
if ad not in odoo.addons.__path__:
odoo.addons.__path__.append(ad)
# hook odoo.addons on base module path
base_path = os.path.normcase(os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'addons')))
if base_path not in odoo.addons.__path__ and os.path.isdir(base_path):
odoo.addons.__path__.append(base_path)
# hook odoo.upgrade on upgrade-path
from odoo import upgrade
legacy_upgrade_path = os.path.join(base_path, 'base', 'maintenance', 'migrations')
for up in (tools.config['upgrade_path'] or legacy_upgrade_path).split(','):
up = os.path.normcase(os.path.abspath(tools.ustr(up.strip())))
if os.path.isdir(up) and up not in upgrade.__path__:
upgrade.__path__.append(up)
# create decrecated module alias from odoo.addons.base.maintenance.migrations to odoo.upgrade
spec = importlib.machinery.ModuleSpec("odoo.addons.base.maintenance", None, is_package=True)
maintenance_pkg = importlib.util.module_from_spec(spec)
maintenance_pkg.migrations = upgrade
sys.modules["odoo.addons.base.maintenance"] = maintenance_pkg
sys.modules["odoo.addons.base.maintenance.migrations"] = upgrade
# hook deprecated module alias from openerp to odoo and "crm"-like to odoo.addons
if not getattr(initialize_sys_path, 'called', False): # only initialize once
sys.meta_path.insert(0, UpgradeHook())
initialize_sys_path.called = True
def get_module_path(module, downloaded=False, display_warning=True):
"""Return the path of the given module.
Search the addons paths and return the first path where the given
module is found. If downloaded is True, return the default addons
path if nothing else is found.
"""
if re.search(r"[\/\\]", module):
return False
for adp in odoo.addons.__path__:
files = [opj(adp, module, manifest) for manifest in MANIFEST_NAMES] +\
[opj(adp, module + '.zip')]
if any(os.path.exists(f) for f in files):
return opj(adp, module)
if downloaded:
return opj(tools.config.addons_data_dir, module)
if display_warning:
_logger.warning('module %s: module not found', module)
return False
def get_module_filetree(module, dir='.'):
warnings.warn(
"Since 16.0: use os.walk or a recursive glob or something",
DeprecationWarning,
stacklevel=2
)
path = get_module_path(module)
if not path:
return False
dir = os.path.normpath(dir)
if dir == '.':
dir = ''
if dir.startswith('..') or (dir and dir[0] == '/'):
raise Exception('Cannot access file outside the module')
files = odoo.tools.osutil.listdir(path, True)
tree = {}
for f in files:
if not f.startswith(dir):
continue
if dir:
f = f[len(dir)+int(not dir.endswith('/')):]
lst = f.split(os.sep)
current = tree
while len(lst) != 1:
current = current.setdefault(lst.pop(0), {})
current[lst.pop(0)] = None
return tree
def get_resource_path(module, *args):
"""Return the full path of a resource of the given module.
:param module: module name
:param list(str) args: resource path components within module
:rtype: str
:return: absolute path to the resource
"""
warnings.warn(
f"Since 17.0: use tools.misc.file_path instead of get_resource_path({module}, {args})",
DeprecationWarning,
)
resource_path = opj(module, *args)
try:
return file_path(resource_path)
except (FileNotFoundError, ValueError):
return False
# backwards compatibility
get_module_resource = get_resource_path
check_resource_path = get_resource_path
def get_resource_from_path(path):
"""Tries to extract the module name and the resource's relative path
out of an absolute resource path.
If operation is successful, returns a tuple containing the module name, the relative path
to the resource using '/' as filesystem seperator[1] and the same relative path using
os.path.sep seperators.
[1] same convention as the resource path declaration in manifests
:param path: absolute resource path
:rtype: tuple
:return: tuple(module_name, relative_path, os_relative_path) if possible, else None
"""
resource = False
sorted_paths = sorted(odoo.addons.__path__, key=len, reverse=True)
for adpath in sorted_paths:
# force trailing separator
adpath = os.path.join(adpath, "")
if os.path.commonprefix([adpath, path]) == adpath:
resource = path.replace(adpath, "", 1)
break
if resource:
relative = resource.split(os.path.sep)
if not relative[0]:
relative.pop(0)
module = relative.pop(0)
return (module, '/'.join(relative), os.path.sep.join(relative))
return None
def get_module_icon(module):
fpath = f"{module}/static/description/icon.png"
try:
file_path(fpath)
return "/" + fpath
except FileNotFoundError:
return "/base/static/description/icon.png"
def get_module_icon_path(module):
try:
return file_path(f"{module}/static/description/icon.png")
except FileNotFoundError:
return file_path("base/static/description/icon.png")
def module_manifest(path):
"""Returns path to module manifest if one can be found under `path`, else `None`."""
if not path:
return None
for manifest_name in MANIFEST_NAMES:
candidate = opj(path, manifest_name)
if os.path.isfile(candidate):
if manifest_name == '__openerp__.py':
warnings.warn(
"__openerp__.py manifests are deprecated since 17.0, "
f"rename {candidate!r} to __manifest__.py "
"(valid since 10.0)",
category=DeprecationWarning
)
return candidate
def get_module_root(path):
"""
Get closest module's root beginning from path
# Given:
# /foo/bar/module_dir/static/src/...
get_module_root('/foo/bar/module_dir/static/')
# returns '/foo/bar/module_dir'
get_module_root('/foo/bar/module_dir/')
# returns '/foo/bar/module_dir'
get_module_root('/foo/bar')
# returns None
@param path: Path from which the lookup should start
@return: Module root path or None if not found
"""
while not module_manifest(path):
new_path = os.path.abspath(opj(path, os.pardir))
if path == new_path:
return None
path = new_path
return path
def load_manifest(module, mod_path=None):
""" Load the module manifest from the file system. """
if not mod_path:
mod_path = get_module_path(module, downloaded=True)
manifest_file = module_manifest(mod_path)
if not manifest_file:
_logger.debug('module %s: no manifest file found %s', module, MANIFEST_NAMES)
return {}
manifest = copy.deepcopy(_DEFAULT_MANIFEST)
manifest['icon'] = get_module_icon(module)
with tools.file_open(manifest_file, mode='r') as f:
manifest.update(ast.literal_eval(f.read()))
if not manifest['description']:
readme_path = [opj(mod_path, x) for x in README
if os.path.isfile(opj(mod_path, x))]
if readme_path:
with tools.file_open(readme_path[0]) as fd:
manifest['description'] = fd.read()
if not manifest.get('license'):
manifest['license'] = 'LGPL-3'
_logger.warning("Missing `license` key in manifest for %r, defaulting to LGPL-3", module)
# auto_install is either `False` (by default) in which case the module
# is opt-in, either a list of dependencies in which case the module is
# automatically installed if all dependencies are (special case: [] to
# always install the module), either `True` to auto-install the module
# in case all dependencies declared in `depends` are installed.
if isinstance(manifest['auto_install'], collections.abc.Iterable):
manifest['auto_install'] = set(manifest['auto_install'])
non_dependencies = manifest['auto_install'].difference(manifest['depends'])
assert not non_dependencies,\
"auto_install triggers must be dependencies, found " \
"non-dependencies [%s] for module %s" % (
', '.join(non_dependencies), module
)
elif manifest['auto_install']:
manifest['auto_install'] = set(manifest['depends'])
try:
manifest['version'] = adapt_version(manifest['version'])
except ValueError as e:
if manifest.get("installable", True):
raise ValueError(f"Module {module}: invalid manifest") from e
manifest['addons_path'] = normpath(opj(mod_path, os.pardir))
return manifest
def get_manifest(module, mod_path=None):
"""
Get the module manifest.
:param str module: The name of the module (sale, purchase, ...).
:param Optional[str] mod_path: The optional path to the module on
the file-system. If not set, it is determined by scanning the
addons-paths.
:returns: The module manifest as a dict or an empty dict
when the manifest was not found.
:rtype: dict
"""
return copy.deepcopy(_get_manifest_cached(module, mod_path))
@functools.lru_cache(maxsize=None)
def _get_manifest_cached(module, mod_path=None):
return load_manifest(module, mod_path)
def load_information_from_description_file(module, mod_path=None):
warnings.warn(
'load_information_from_description_file() is a deprecated '
'alias to get_manifest()', DeprecationWarning, stacklevel=2)
return get_manifest(module, mod_path)
def load_openerp_module(module_name):
""" Load an OpenERP module, if not already loaded.
This loads the module and register all of its models, thanks to either
the MetaModel metaclass, or the explicit instantiation of the model.
This is also used to load server-wide module (i.e. it is also used
when there is no model to register).
"""
qualname = f'odoo.addons.{module_name}'
if qualname in sys.modules:
return
try:
__import__(qualname)
# Call the module's post-load hook. This can done before any model or
# data has been initialized. This is ok as the post-load hook is for
# server-wide (instead of registry-specific) functionalities.
info = get_manifest(module_name)
if info['post_load']:
getattr(sys.modules[qualname], info['post_load'])()
except Exception:
_logger.critical("Couldn't load module %s", module_name)
raise
def get_modules():
"""Returns the list of module names
"""
def listdir(dir):
def clean(name):
name = os.path.basename(name)
if name[-4:] == '.zip':
name = name[:-4]
return name
def is_really_module(name):
for mname in MANIFEST_NAMES:
if os.path.isfile(opj(dir, name, mname)):
return True
return [
clean(it)
for it in os.listdir(dir)
if is_really_module(it)
]
plist = []
for ad in odoo.addons.__path__:
if not os.path.exists(ad):
_logger.warning("addons path does not exist: %s", ad)
continue
plist.extend(listdir(ad))
return sorted(set(plist))
def get_modules_with_version():
modules = get_modules()
res = dict.fromkeys(modules, adapt_version('1.0'))
for module in modules:
try:
info = get_manifest(module)
res[module] = info['version']
except Exception:
continue
return res
def adapt_version(version):
serie = release.major_version
if version == serie or not version.startswith(serie + '.'):
base_version = version
version = '%s.%s' % (serie, version)
else:
base_version = version[len(serie) + 1:]
if not re.match(r"^[0-9]+\.[0-9]+(?:\.[0-9]+)?$", base_version):
raise ValueError(f"Invalid version {base_version!r}. Modules should have a version in format `x.y`, `x.y.z`,"
f" `{serie}.x.y` or `{serie}.x.y.z`.")
return version
current_test = None
def check_python_external_dependency(pydep):
try:
pkg_resources.get_distribution(pydep)
except pkg_resources.DistributionNotFound as e:
try:
importlib.import_module(pydep)
_logger.info("python external dependency on '%s' does not appear to be a valid PyPI package. Using a PyPI package name is recommended.", pydep)
except ImportError:
# backward compatibility attempt failed
_logger.warning("DistributionNotFound: %s", e)
raise Exception('Python library not installed: %s' % (pydep,))
except pkg_resources.VersionConflict as e:
_logger.warning("VersionConflict: %s", e)
raise Exception('Python library version conflict: %s' % (pydep,))
except Exception as e:
_logger.warning("get_distribution(%s) failed: %s", pydep, e)
raise Exception('Error finding python library %s' % (pydep,))
def check_manifest_dependencies(manifest):
depends = manifest.get('external_dependencies')
if not depends:
return
for pydep in depends.get('python', []):
check_python_external_dependency(pydep)
for binary in depends.get('bin', []):
try:
tools.find_in_path(binary)
except IOError:
raise Exception('Unable to find %r in path' % (binary,))

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ir_model_wa_admin access.ir.model.wa.admin base.model_ir_model group_whatsapp_admin 1 0 0 0
3 access_whatsapp_account_user access.whatsapp.account.user model_whatsapp_account base.group_user 1 0 0 0
4 access_whatsapp_account_system_admin access.whatsapp.account.system.admin model_whatsapp_account base.group_system 1 1 1 1
5 access_whatsapp_account_administrator access.whatsapp.account.admin model_whatsapp_account group_whatsapp_admin 1 1 1 0
6 access_whatsapp_composer_user access.whatsapp.composer model_whatsapp_composer base.group_user 1 1 1 1
7 access_whatsapp_message_administrator access.whatsapp.message model_whatsapp_message group_whatsapp_admin 1 1 1 1
8 access_whatsapp_message_user access.whatsapp.message model_whatsapp_message base.group_user 1 1 1 0
9 access_whatsapp_preview_user access.whatsapp.preview model_whatsapp_preview base.group_user 1 1 1 1
10 access_whatsapp_template_administrator access.whatsapp.template model_whatsapp_template group_whatsapp_admin 1 1 1 1
11 access_whatsapp_template_user access.whatsapp.template model_whatsapp_template base.group_user 1 0 0 0
12 access_whatsapp_template_button_administrator access.whatsapp.template.button model_whatsapp_template_button group_whatsapp_admin 1 1 1 1
13 access_whatsapp_template_button_user access.whatsapp.template.button model_whatsapp_template_button base.group_user 1 0 0 0
14 access_whatsapp_template_variable_administrator access.whatsapp.template.variable model_whatsapp_template_variable group_whatsapp_admin 1 1 1 1
15 access_whatsapp_template_variable_user access.whatsapp.template.variable model_whatsapp_template_variable base.group_user 1 0 0 0

View File

@ -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>

View File

@ -0,0 +1,9 @@
<?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="module_whatsapp"/>
<field name="users" eval="[(4, ref('base.user_admin'))]"/>
</record>
</odoo>

View File

@ -0,0 +1,20 @@
import logging
from odoo.service.server import CommonServer
_logger = logging.getLogger(__name__)
class ExtendedCommonServer(CommonServer):
_on_stop_funcs = []
@classmethod
def on_stop(cls, func):
""" Register a cleanup function to be executed when the server stops """
cls._on_stop_funcs.append(func)
def stop(self):
for func in self._on_stop_funcs:
try:
_logger.debug("on_close call %s", func)
func()
except Exception:
_logger.warning("Exception in %s", func.__name__, exc_info=True)

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

View File

@ -0,0 +1,41 @@
/** @odoo-module **/
import { _lt } 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: _lt("Enable WhatsApp"),
name: "enable_whatsapp",
type: "boolean",
default: true,
},
],
};
patch(phoneField, patchDescr);
patch(formPhoneField, patchDescr);

View File

@ -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>

View File

@ -0,0 +1,42 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
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.user = useService("user");
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: {
...this.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();
},
}
);
}
}

View File

@ -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>

View File

@ -0,0 +1,5 @@
declare module "models" {
export interface DiscussApp {
whatsapp: DiscussAppCategory,
}
}

View File

@ -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.type === 'whatsapp' and thread.correspondent" thread="thread"/>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,7 @@
/** @odoo-module */
import { Composer } from "@mail/core/common/composer_model";
Object.assign(Composer.prototype, "whatsapp_composer_model", {
threadExpired: false,
});

View File

@ -0,0 +1,85 @@
/** @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.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.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);
}
}
},
/** @override */
get isSendButtonDisabled() {
const whatsappInactive = (this.thread && this.thread.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?.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);
},
});

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="whatsapp.Composer" t-inherit="mail.Composer" t-inherit-mode="extension">
<xpath expr="//div[contains(@class, 'o-mail-Composer-actions')]" position="attributes">
<attribute name="t-if">!thread || thread.type != 'whatsapp' || state.active</attribute>
</xpath>
<xpath expr="//FileUploader" position="attributes">
<attribute name="multiUpload">thread and thread.type == 'whatsapp' ? false : true</attribute>
</xpath>
<xpath expr="//FileUploader/t/button" position="attributes">
<attribute name="t-att-disabled" add="or (thread and thread.type === 'whatsapp' and props.composer.attachments.length > 0)" separator=" " />
</xpath>
<xpath expr="//*[@t-if='!extended' and @t-call='mail.Composer.sendButton']" position="attributes">
<attribute name="t-if" add="and (!thread or thread.type != 'whatsapp' or state.active)" separator=" " />
</xpath>
</t>
</templates>

Some files were not shown because too many files have changed in this diff Show More