upload watsapp module
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import main
|
||||||
|
from . import websocket
|
||||||
|
|
@ -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'}]}]}
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="ir_actions_server_resend_whatsapp_queue" model="ir.actions.server">
|
||||||
|
<field name="name">WhatsApp : Resend failed Messages</field>
|
||||||
|
<field name="model_id" ref="whatsapp.model_whatsapp_message"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="binding_view_types">list</field>
|
||||||
|
<field name="code">action = records._resend_failed()</field>
|
||||||
|
<field name="binding_model_id" eval="ref('whatsapp.model_whatsapp_message')"/>
|
||||||
|
<field name="binding_type">action</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
UPDATE whatsapp_account
|
||||||
|
SET token = 'dummy_token';
|
||||||
|
|
@ -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 < 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>
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)), ))
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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))]
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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])
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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}"
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"])
|
||||||
|
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
])
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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])
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,))
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_ir_model_wa_admin,access.ir.model.wa.admin,base.model_ir_model,group_whatsapp_admin,1,0,0,0
|
||||||
|
access_whatsapp_account_user,access.whatsapp.account.user,model_whatsapp_account,base.group_user,1,0,0,0
|
||||||
|
access_whatsapp_account_system_admin,access.whatsapp.account.system.admin,model_whatsapp_account,base.group_system,1,1,1,1
|
||||||
|
access_whatsapp_account_administrator,access.whatsapp.account.admin,model_whatsapp_account,group_whatsapp_admin,1,1,1,0
|
||||||
|
access_whatsapp_composer_user,access.whatsapp.composer,model_whatsapp_composer,base.group_user,1,1,1,1
|
||||||
|
access_whatsapp_message_administrator,access.whatsapp.message,model_whatsapp_message,group_whatsapp_admin,1,1,1,1
|
||||||
|
access_whatsapp_message_user,access.whatsapp.message,model_whatsapp_message,base.group_user,1,1,1,0
|
||||||
|
access_whatsapp_preview_user,access.whatsapp.preview,model_whatsapp_preview,base.group_user,1,1,1,1
|
||||||
|
access_whatsapp_template_administrator,access.whatsapp.template,model_whatsapp_template,group_whatsapp_admin,1,1,1,1
|
||||||
|
access_whatsapp_template_user,access.whatsapp.template,model_whatsapp_template,base.group_user,1,0,0,0
|
||||||
|
access_whatsapp_template_button_administrator,access.whatsapp.template.button,model_whatsapp_template_button,group_whatsapp_admin,1,1,1,1
|
||||||
|
access_whatsapp_template_button_user,access.whatsapp.template.button,model_whatsapp_template_button,base.group_user,1,0,0,0
|
||||||
|
access_whatsapp_template_variable_administrator,access.whatsapp.template.variable,model_whatsapp_template_variable,group_whatsapp_admin,1,1,1,1
|
||||||
|
access_whatsapp_template_variable_user,access.whatsapp.template.variable,model_whatsapp_template_variable,base.group_user,1,0,0,0
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<record id="security_rule_whatsapp_account" model="ir.rule">
|
||||||
|
<field name="name">WA Account: Restrict to Allowed Companies</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_account"/>
|
||||||
|
<field name="domain_force">[('allowed_company_ids', 'in', company_ids)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="security_rule_whatsapp_composer" model="ir.rule">
|
||||||
|
<field name="name">WA Composer: Restrict to Own</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_composer"/>
|
||||||
|
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="security_rule_whatsapp_message_user" model="ir.rule">
|
||||||
|
<field name="name">WA Message: Restrict to Own</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_message"/>
|
||||||
|
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
</record>
|
||||||
|
<record id="security_rule_whatsapp_message_admin" model="ir.rule">
|
||||||
|
<field name="name">WA Message: Un-restrict for WA Admins</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_message"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('whatsapp.group_whatsapp_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="security_rule_whatsapp_preview" model="ir.rule">
|
||||||
|
<field name="name">WA Preview: Restrict to Own</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_preview"/>
|
||||||
|
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="security_rule_whatsapp_template" model="ir.rule">
|
||||||
|
<field name="name">WA Template: Restrict to Allowed Companies</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template"/>
|
||||||
|
<field name="domain_force">['|', ('wa_account_id', '=', False), ('wa_account_id.allowed_company_ids', 'in', company_ids)]</field>
|
||||||
|
</record>
|
||||||
|
<record id="security_rule_whatsapp_template_user" model="ir.rule">
|
||||||
|
<field name="name">WA Template: Restrict to Allowed Users</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template"/>
|
||||||
|
<field name="domain_force">['|', ('allowed_user_ids', '=', False), ('allowed_user_ids', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
</record>
|
||||||
|
<record id="security_rule_whatsapp_template_admin" model="ir.rule">
|
||||||
|
<field name="name">WA Template: Un-restrict for WA Admins</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('whatsapp.group_whatsapp_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="security_rule_whatsapp_template_button" model="ir.rule">
|
||||||
|
<field name="name">WA Template Button: Restrict to Allowed Companies</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template_button"/>
|
||||||
|
<field name="domain_force">['|', ('wa_template_id.wa_account_id', '=', False), ('wa_template_id.wa_account_id.allowed_company_ids', 'in', company_ids)]</field>
|
||||||
|
</record>
|
||||||
|
<record id="security_rule_whatsapp_template_button_user" model="ir.rule">
|
||||||
|
<field name="name">WA Template Button: Restrict to Allowed Users</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template_button"/>
|
||||||
|
<field name="domain_force">['|', ('wa_template_id.allowed_user_ids', '=', False), ('wa_template_id.allowed_user_ids', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
</record>
|
||||||
|
<record id="security_rule_whatsapp_template_button_admin" model="ir.rule">
|
||||||
|
<field name="name">WA Template Button: Un-restrict for WA Admins</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template_button"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('whatsapp.group_whatsapp_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="security_rule_whatsapp_template_variable" model="ir.rule">
|
||||||
|
<field name="name">WA Template Variable: Restrict to Allowed Companies</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template_variable"/>
|
||||||
|
<field name="domain_force">['|', ('wa_template_id.wa_account_id', '=', False), ('wa_template_id.wa_account_id.allowed_company_ids', 'in', company_ids)]</field>
|
||||||
|
</record>
|
||||||
|
<record id="security_rule_whatsapp_template_variable_user" model="ir.rule">
|
||||||
|
<field name="name">WA Template Variable: Restrict to Allowed Users</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template_variable"/>
|
||||||
|
<field name="domain_force">['|', ('wa_template_id.allowed_user_ids', '=', False), ('wa_template_id.allowed_user_ids', '=', user.id)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
</record>
|
||||||
|
<record id="security_rule_whatsapp_template_variable_admin" model="ir.rule">
|
||||||
|
<field name="name">WA Template Variable: Un-restrict for WA Admins</field>
|
||||||
|
<field name="model_id" ref="model_whatsapp_template_variable"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('whatsapp.group_whatsapp_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,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>
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 4.4 KiB |
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 50 50" style="enable-background:new 0 0 50 50;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#B3B3B3;}
|
||||||
|
.st1{fill:#FFFFFF;}
|
||||||
|
.st2{fill:none;}
|
||||||
|
.st3{fill:url(#SVGID_1_);}
|
||||||
|
.st4{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
|
||||||
|
</style>
|
||||||
|
<path class="st0" d="M14.5,40.4l0.7,0.4c2.9,1.7,6.2,2.6,9.6,2.6h0c10.4,0,18.9-8.5,18.9-18.9c0-5.1-2-9.8-5.5-13.4
|
||||||
|
c-3.5-3.6-8.4-5.6-13.4-5.6c-10.4,0-18.9,8.5-18.9,18.9c0,3.6,1,7.1,2.9,10.1l0.5,0.7l-1.9,7L14.5,40.4z M1.9,47.7l3.2-11.8
|
||||||
|
c-2-3.5-3-7.4-3-11.4C2.1,12,12.3,1.8,24.8,1.8c6.1,0,11.8,2.4,16.1,6.7s6.7,10,6.7,16.1c0,12.6-10.2,22.8-22.8,22.8h0
|
||||||
|
c-3.8,0-7.6-1-10.9-2.8L1.9,47.7z"/>
|
||||||
|
<path class="st1" d="M1.6,47.5l3.2-11.8c-2-3.5-3-7.4-3-11.4C1.8,11.7,12,1.5,24.6,1.5c6.1,0,11.8,2.4,16.1,6.7s6.7,10,6.7,16.1
|
||||||
|
c0,12.6-10.2,22.8-22.8,22.8h0c-3.8,0-7.6-1-10.9-2.8L1.6,47.5z"/>
|
||||||
|
<path class="st2" d="M24.6,5.4c-10.4,0-18.9,8.5-18.9,18.9c0,3.6,1,7.1,2.9,10.1L9,35.1l-1.9,7l7.2-1.9l0.7,0.4
|
||||||
|
c2.9,1.7,6.2,2.6,9.6,2.6h0c10.4,0,18.9-8.5,18.9-18.9c0-5-2-9.8-5.5-13.4C34.5,7.4,29.6,5.4,24.6,5.4L24.6,5.4z"/>
|
||||||
|
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="24.5016" y1="44.8036" x2="24.6936" y2="12.4423" gradientTransform="matrix(1 0 0 -1 0 52.448)">
|
||||||
|
<stop offset="0" style="stop-color:#57D163"/>
|
||||||
|
<stop offset="1" style="stop-color:#23B33A"/>
|
||||||
|
</linearGradient>
|
||||||
|
<path class="st3" d="M24.6,5.4c-10.4,0-18.9,8.5-18.9,18.9c0,3.6,1,7.1,2.9,10.1L9,35.1l-1.9,7l7.2-1.9l0.7,0.4
|
||||||
|
c2.9,1.7,6.2,2.6,9.6,2.6h0c10.4,0,18.9-8.5,18.9-18.9c0-5-2-9.8-5.5-13.4C34.5,7.4,29.6,5.4,24.6,5.4z"/>
|
||||||
|
<path class="st4" d="M18.9,14.8c-0.4-0.9-0.9-1-1.3-1l-1.1,0c-0.4,0-1,0.1-1.5,0.7c-0.5,0.6-2,1.9-2,4.7s2,5.5,2.3,5.9
|
||||||
|
c0.3,0.4,3.9,6.3,9.7,8.6c4.8,1.9,5.8,1.5,6.8,1.4c1-0.1,3.4-1.4,3.8-2.7c0.5-1.3,0.5-2.5,0.3-2.7c-0.1-0.2-0.5-0.4-1.1-0.7
|
||||||
|
c-0.6-0.3-3.4-1.7-3.9-1.9c-0.5-0.2-0.9-0.3-1.3,0.3c-0.4,0.6-1.5,1.9-1.8,2.2c-0.3,0.4-0.7,0.4-1.2,0.1c-0.6-0.3-2.4-0.9-4.6-2.8
|
||||||
|
c-1.7-1.5-2.8-3.4-3.2-3.9s0-0.9,0.3-1.2c0.3-0.3,0.6-0.7,0.9-1c0.3-0.3,0.4-0.6,0.6-0.9c0.2-0.4,0.1-0.7,0-1
|
||||||
|
C20.5,18.7,19.4,15.9,18.9,14.8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
|
@ -0,0 +1,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);
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="whatsapp.PhoneField" t-inherit="web.PhoneField" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//div[contains(@class, 'o_phone_content')]//a" position="after">
|
||||||
|
<t t-if="props.enableWhatsAppButton and props.record.data[props.name].length > 0">
|
||||||
|
<SendWhatsAppButton t-props="props" />
|
||||||
|
</t>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-name="whatsapp.FormPhoneField" t-inherit="web.FormPhoneField" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//div[contains(@class, 'o_phone_content')]" position="inside">
|
||||||
|
<t t-if="props.enableWhatsAppButton and props.record.data[props.name].length > 0">
|
||||||
|
<SendWhatsAppButton t-props="props" />
|
||||||
|
</t>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { 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();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="whatsapp.SendWhatsAppButton">
|
||||||
|
<a
|
||||||
|
t-att-title="title"
|
||||||
|
t-att-href="'whatsapp:' + props.record.data[props.name]"
|
||||||
|
t-on-click.prevent.stop="onClick"
|
||||||
|
class="ms-3 d-inline-flex align-items-center o_field_phone_whatsapp"
|
||||||
|
>
|
||||||
|
<i class="fa fa-whatsapp"/>
|
||||||
|
<small class="fw-bold ms-1">WhatsApp</small>
|
||||||
|
</a>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
declare module "models" {
|
||||||
|
export interface DiscussApp {
|
||||||
|
whatsapp: DiscussAppCategory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { Composer } from "@mail/core/common/composer_model";
|
||||||
|
|
||||||
|
Object.assign(Composer.prototype, "whatsapp_composer_model", {
|
||||||
|
threadExpired: false,
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||