Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View file

@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# core models (required for mixins)
from . import mail_alias
from . import models
# mixin
from . import mail_activity_mixin
from . import mail_alias_mixin
from . import mail_render_mixin
from . import mail_composer_mixin
from . import mail_thread
from . import mail_thread_blacklist
from . import mail_thread_cc
from . import template_reset_mixin
# mail models
from . import fetchmail
from . import mail_notification # keep before as decorated m2m
from . import mail_activity_type
from . import mail_activity
from . import mail_blacklist
from . import mail_followers
from . import mail_gateway_allowed
from . import mail_link_preview
from . import mail_message_reaction
from . import mail_message_schedule
from . import mail_message_subtype
from . import mail_message
from . import mail_mail
from . import mail_tracking_value
from . import mail_template
# discuss
from . import mail_channel_member
from . import mail_channel_rtc_session
from . import mail_channel
from . import mail_guest
from . import mail_ice_server
from . import mail_shortcode
from . import res_users_settings
from . import res_users_settings_volumes
# odoo models
from . import bus_presence
from . import ir_action_act_window
from . import ir_actions_server
from . import ir_attachment
from . import ir_config_parameter
from . import ir_http
from . import ir_mail_server
from . import ir_model
from . import ir_model_fields
from . import ir_ui_view
from . import ir_qweb
from . import ir_websocket
from . import res_company
from . import res_config_settings
from . import res_partner
from . import res_users
from . import res_groups
from . import update

View file

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class BusPresence(models.Model):
_inherit = ['bus.presence']
guest_id = fields.Many2one('mail.guest', 'Guest', ondelete='cascade')
def init(self):
self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS bus_presence_guest_unique ON %s (guest_id) WHERE guest_id IS NOT NULL" % self._table)
_sql_constraints = [
("partner_or_guest_exists", "CHECK((user_id IS NOT NULL AND guest_id IS NULL) OR (user_id IS NULL AND guest_id IS NOT NULL))", "A bus presence must have a user or a guest."),
]

View file

@ -0,0 +1,308 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import imaplib
import logging
import poplib
import socket
from imaplib import IMAP4, IMAP4_SSL
from poplib import POP3, POP3_SSL
from socket import gaierror, timeout
from ssl import SSLError
from odoo import api, fields, models, tools, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
MAX_POP_MESSAGES = 50
MAIL_TIMEOUT = 60
# Workaround for Python 2.7.8 bug https://bugs.python.org/issue23906
poplib._MAXLINE = 65536
# Add timeout to IMAP connections
# HACK https://bugs.python.org/issue38615
# TODO: clean in Python 3.9
IMAP4._create_socket = lambda self, timeout=MAIL_TIMEOUT: socket.create_connection((self.host or None, self.port), timeout)
def make_wrap_property(name):
return property(
lambda self: getattr(self.__obj__, name),
lambda self, value: setattr(self.__obj__, name, value),
)
class IMAP4Connection:
"""Wrapper around IMAP4 and IMAP4_SSL"""
def __init__(self, server, port, is_ssl):
self.__obj__ = IMAP4_SSL(server, port) if is_ssl else IMAP4(server, port)
class POP3Connection:
"""Wrapper around POP3 and POP3_SSL"""
def __init__(self, server, port, is_ssl, timeout=MAIL_TIMEOUT):
self.__obj__ = POP3_SSL(server, port, timeout=timeout) if is_ssl else POP3(server, port, timeout=timeout)
IMAP_COMMANDS = [cmd.lower() for cmd in imaplib.Commands]
IMAP_ATTRIBUTES = ['examine', 'login_cram_md5', 'move', 'recent', 'response', 'shutdown', 'unselect'] + IMAP_COMMANDS
POP3_ATTRIBUTES = [
'apop', 'capa', 'close', 'dele', 'list', 'noop', 'pass_', 'quit', 'retr', 'rpop', 'rset', 'set_debuglevel', 'stat',
'stls', 'top', 'uidl', 'user', 'utf8'
]
for name in IMAP_ATTRIBUTES:
setattr(IMAP4Connection, name, make_wrap_property(name))
for name in POP3_ATTRIBUTES:
setattr(POP3Connection, name, make_wrap_property(name))
class FetchmailServer(models.Model):
"""Incoming POP/IMAP mail server account"""
_name = 'fetchmail.server'
_description = 'Incoming Mail Server'
_order = 'priority'
name = fields.Char('Name', required=True)
active = fields.Boolean('Active', default=True)
state = fields.Selection([
('draft', 'Not Confirmed'),
('done', 'Confirmed'),
], string='Status', index=True, readonly=True, copy=False, default='draft')
server = fields.Char(string='Server Name', readonly=True, help="Hostname or IP of the mail server", states={'draft': [('readonly', False)]})
port = fields.Integer(readonly=True, states={'draft': [('readonly', False)]})
server_type = fields.Selection([
('imap', 'IMAP Server'),
('pop', 'POP Server'),
('local', 'Local Server'),
], string='Server Type', index=True, required=True, default='imap')
server_type_info = fields.Text('Server Type Info', compute='_compute_server_type_info')
is_ssl = fields.Boolean('SSL/TLS', help="Connections are encrypted with SSL/TLS through a dedicated port (default: IMAPS=993, POP3S=995)")
attach = fields.Boolean('Keep Attachments', help="Whether attachments should be downloaded. "
"If not enabled, incoming emails will be stripped of any attachments before being processed", default=True)
original = fields.Boolean('Keep Original', help="Whether a full original copy of each email should be kept for reference "
"and attached to each processed message. This will usually double the size of your message database.")
date = fields.Datetime(string='Last Fetch Date', readonly=True)
user = fields.Char(string='Username', readonly=True, states={'draft': [('readonly', False)]})
password = fields.Char(readonly=True, states={'draft': [('readonly', False)]})
object_id = fields.Many2one('ir.model', string="Create a New Record", help="Process each incoming mail as part of a conversation "
"corresponding to this document type. This will create "
"new documents for new conversations, or attach follow-up "
"emails to the existing conversations (documents).")
priority = fields.Integer(string='Server Priority', readonly=True, states={'draft': [('readonly', False)]}, help="Defines the order of processing, lower values mean higher priority", default=5)
message_ids = fields.One2many('mail.mail', 'fetchmail_server_id', string='Messages', readonly=True)
configuration = fields.Text('Configuration', readonly=True)
script = fields.Char(readonly=True, default='/mail/static/scripts/odoo-mailgate.py')
@api.depends('server_type')
def _compute_server_type_info(self):
for server in self:
if server.server_type == 'local':
server.server_type_info = _('Use a local script to fetch your emails and create new records.')
else:
server.server_type_info = False
@api.onchange('server_type', 'is_ssl', 'object_id')
def onchange_server_type(self):
self.port = 0
if self.server_type == 'pop':
self.port = self.is_ssl and 995 or 110
elif self.server_type == 'imap':
self.port = self.is_ssl and 993 or 143
conf = {
'dbname': self.env.cr.dbname,
'uid': self.env.uid,
'model': self.object_id.model if self.object_id else 'MODELNAME'
}
self.configuration = """Use the below script with the following command line options with your Mail Transport Agent (MTA)
odoo-mailgate.py --host=HOSTNAME --port=PORT -u %(uid)d -p PASSWORD -d %(dbname)s
Example configuration for the postfix mta running locally:
/etc/postfix/virtual_aliases: @youdomain odoo_mailgate@localhost
/etc/aliases:
odoo_mailgate: "|/path/to/odoo-mailgate.py --host=localhost -u %(uid)d -p PASSWORD -d %(dbname)s"
""" % conf
@api.model_create_multi
def create(self, vals_list):
res = super(FetchmailServer, self).create(vals_list)
self._update_cron()
return res
def write(self, values):
res = super(FetchmailServer, self).write(values)
self._update_cron()
return res
def unlink(self):
res = super(FetchmailServer, self).unlink()
self._update_cron()
return res
def set_draft(self):
self.write({'state': 'draft'})
return True
def connect(self, allow_archived=False):
"""
:param bool allow_archived: by default (False), an exception is raised when calling this method on an
archived record. It can be set to True for testing so that the exception is no longer raised.
"""
self.ensure_one()
if not allow_archived and not self.active:
raise UserError(_('The server "%s" cannot be used because it is archived.', self.display_name))
connection_type = self._get_connection_type()
if connection_type == 'imap':
connection = IMAP4Connection(self.server, int(self.port), self.is_ssl)
self._imap_login(connection)
elif connection_type == 'pop':
connection = POP3Connection(self.server, int(self.port), self.is_ssl)
#TODO: use this to remove only unread messages
#connection.user("recent:"+server.user)
connection.user(self.user)
connection.pass_(self.password)
return connection
def _imap_login(self, connection):
"""Authenticate the IMAP connection.
Can be overridden in other module for different authentication methods.
:param connection: The IMAP connection to authenticate
"""
self.ensure_one()
connection.login(self.user, self.password)
def button_confirm_login(self):
for server in self:
connection = False
try:
connection = server.connect(allow_archived=True)
server.write({'state': 'done'})
except UnicodeError as e:
raise UserError(_("Invalid server name !\n %s", tools.ustr(e)))
except (gaierror, timeout, IMAP4.abort) as e:
raise UserError(_("No response received. Check server information.\n %s", tools.ustr(e)))
except (IMAP4.error, poplib.error_proto) as err:
raise UserError(_("Server replied with following exception:\n %s", tools.ustr(err)))
except SSLError as e:
raise UserError(_("An SSL exception occurred. Check SSL/TLS configuration on server port.\n %s", tools.ustr(e)))
except (OSError, Exception) as err:
_logger.info("Failed to connect to %s server %s.", server.server_type, server.name, exc_info=True)
raise UserError(_("Connection test failed: %s", tools.ustr(err)))
finally:
try:
if connection:
connection_type = server._get_connection_type()
if connection_type == 'imap':
connection.close()
elif connection_type == 'pop':
connection.quit()
except Exception:
# ignored, just a consequence of the previous exception
pass
return True
@api.model
def _fetch_mails(self):
""" Method called by cron to fetch mails from servers """
return self.search([('state', '=', 'done'), ('server_type', '!=', 'local')]).fetch_mail()
def fetch_mail(self):
""" WARNING: meant for cron usage only - will commit() after each email! """
additionnal_context = {
'fetchmail_cron_running': True
}
MailThread = self.env['mail.thread']
for server in self:
_logger.info('start checking for new emails on %s server %s', server.server_type, server.name)
additionnal_context['default_fetchmail_server_id'] = server.id
count, failed = 0, 0
imap_server = None
pop_server = None
connection_type = server._get_connection_type()
if connection_type == 'imap':
try:
imap_server = server.connect()
imap_server.select()
result, data = imap_server.search(None, '(UNSEEN)')
for num in data[0].split():
res_id = None
result, data = imap_server.fetch(num, '(RFC822)')
imap_server.store(num, '-FLAGS', '\\Seen')
try:
res_id = MailThread.with_context(**additionnal_context).message_process(server.object_id.model, data[0][1], save_original=server.original, strip_attachments=(not server.attach))
except Exception:
_logger.info('Failed to process mail from %s server %s.', server.server_type, server.name, exc_info=True)
failed += 1
imap_server.store(num, '+FLAGS', '\\Seen')
self._cr.commit()
count += 1
_logger.info("Fetched %d email(s) on %s server %s; %d succeeded, %d failed.", count, server.server_type, server.name, (count - failed), failed)
except Exception:
_logger.info("General failure when trying to fetch mail from %s server %s.", server.server_type, server.name, exc_info=True)
finally:
if imap_server:
try:
imap_server.close()
imap_server.logout()
except OSError:
_logger.warning('Failed to properly finish imap connection: %s.', server.name, exc_info=True)
elif connection_type == 'pop':
try:
while True:
failed_in_loop = 0
num = 0
pop_server = server.connect()
(num_messages, total_size) = pop_server.stat()
pop_server.list()
for num in range(1, min(MAX_POP_MESSAGES, num_messages) + 1):
(header, messages, octets) = pop_server.retr(num)
message = (b'\n').join(messages)
res_id = None
try:
res_id = MailThread.with_context(**additionnal_context).message_process(server.object_id.model, message, save_original=server.original, strip_attachments=(not server.attach))
pop_server.dele(num)
except Exception:
_logger.info('Failed to process mail from %s server %s.', server.server_type, server.name, exc_info=True)
failed += 1
failed_in_loop += 1
self.env.cr.commit()
_logger.info("Fetched %d email(s) on %s server %s; %d succeeded, %d failed.", num, server.server_type, server.name, (num - failed_in_loop), failed_in_loop)
# Stop if (1) no more message left or (2) all messages have failed
if num_messages < MAX_POP_MESSAGES or failed_in_loop == num:
break
pop_server.quit()
except Exception:
_logger.info("General failure when trying to fetch mail from %s server %s.", server.server_type, server.name, exc_info=True)
finally:
if pop_server:
try:
pop_server.quit()
except OSError:
_logger.warning('Failed to properly finish pop connection: %s.', server.name, exc_info=True)
server.write({'date': fields.Datetime.now()})
return True
def _get_connection_type(self):
"""Return which connection must be used for this mail server (IMAP or POP).
Can be overridden in sub-module to define which connection to use for a specific
"server_type" (e.g. Gmail server).
"""
self.ensure_one()
return self.server_type
@api.model
def _update_cron(self):
if self.env.context.get('fetchmail_cron_running'):
return
try:
# Enabled/Disable cron based on the number of 'done' server of type pop or imap
cron = self.env.ref('mail.ir_cron_mail_gateway_action')
cron.toggle(model=self._name, domain=[('state', '=', 'done'), ('server_type', '!=', 'local')])
except ValueError:
pass

View file

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class ActWindowView(models.Model):
_inherit = 'ir.actions.act_window.view'
view_mode = fields.Selection(selection_add=[
('activity', 'Activity')
], ondelete={'activity': 'cascade'})

View file

@ -0,0 +1,262 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class ServerActions(models.Model):
""" Add mail.thread related options in server actions. """
_name = 'ir.actions.server'
_description = 'Server Action'
_inherit = ['ir.actions.server']
state = fields.Selection(
selection_add=[('mail_post', 'Send Email'),
('followers', 'Add Followers'),
('next_activity', 'Create Next Activity'),
],
ondelete={'mail_post': 'cascade',
'followers': 'cascade',
'next_activity': 'cascade',
}
)
# Followers
partner_ids = fields.Many2many(
'res.partner', string='Add Followers',
compute='_compute_partner_ids', readonly=False, store=True)
# Message Post / Email
template_id = fields.Many2one(
'mail.template', 'Email Template',
domain="[('model_id', '=', model_id)]",
compute='_compute_template_id',
ondelete='set null', readonly=False, store=True,
)
# Message post
mail_post_autofollow = fields.Boolean(
'Subscribe Recipients', compute='_compute_mail_post_autofollow',
readonly=False, store=True)
mail_post_method = fields.Selection(
selection=[('email', 'Email'), ('comment', 'Post as Message'), ('note', 'Post as Note')],
string='Send as',
compute='_compute_mail_post_method',
readonly=False, store=True,
help='Choose method for email sending:\nEMail: send directly emails\nPost as Message: post on document and notify followers\nPost as Note: log a note on document')
# Next Activity
activity_type_id = fields.Many2one(
'mail.activity.type', string='Activity',
domain="['|', ('res_model', '=', False), ('res_model', '=', model_name)]",
compute='_compute_activity_type_id', readonly=False, store=True,
ondelete='restrict')
activity_summary = fields.Char(
'Summary',
compute='_compute_activity_info', readonly=False, store=True)
activity_note = fields.Html(
'Note',
compute='_compute_activity_info', readonly=False, store=True)
activity_date_deadline_range = fields.Integer(
string='Due Date In',
compute='_compute_activity_info', readonly=False, store=True)
activity_date_deadline_range_type = fields.Selection(
[('days', 'Days'),
('weeks', 'Weeks'),
('months', 'Months')],
string='Due type', default='days',
compute='_compute_activity_info', readonly=False, store=True)
activity_user_type = fields.Selection(
[('specific', 'Specific User'),
('generic', 'Generic User From Record')],
compute='_compute_activity_info', readonly=False, store=True,
help="Use 'Specific User' to always assign the same user on the next activity. Use 'Generic User From Record' to specify the field name of the user to choose on the record.")
activity_user_id = fields.Many2one(
'res.users', string='Responsible',
compute='_compute_activity_info', readonly=False, store=True)
activity_user_field_name = fields.Char(
'User field name',
compute='_compute_activity_info', readonly=False, store=True)
@api.depends('model_id', 'state')
def _compute_template_id(self):
to_reset = self.filtered(
lambda act: act.state != 'mail_post' or \
(act.model_id != act.template_id.model_id)
)
if to_reset:
to_reset.template_id = False
@api.depends('state', 'mail_post_method')
def _compute_mail_post_autofollow(self):
to_reset = self.filtered(lambda act: act.state != 'mail_post' or act.mail_post_method == 'email')
if to_reset:
to_reset.mail_post_autofollow = False
other = self - to_reset
if other:
other.mail_post_autofollow = True
@api.depends('state')
def _compute_mail_post_method(self):
to_reset = self.filtered(lambda act: act.state != 'mail_post')
if to_reset:
to_reset.mail_post_method = False
other = self - to_reset
if other:
other.mail_post_method = 'email'
@api.depends('state')
def _compute_partner_ids(self):
to_reset = self.filtered(lambda act: act.state != 'followers')
if to_reset:
to_reset.partner_ids = False
@api.depends('model_id', 'state')
def _compute_activity_type_id(self):
to_reset = self.filtered(
lambda act: act.state != 'next_activity' or \
(act.model_id.model != act.activity_type_id.res_model)
)
if to_reset:
to_reset.activity_type_id = False
@api.depends('state')
def _compute_activity_info(self):
to_reset = self.filtered(lambda act: act.state != 'next_activity')
if to_reset:
to_reset.activity_summary = False
to_reset.activity_note = False
to_reset.activity_date_deadline_range = False
to_reset.activity_date_deadline_range_type = False
to_reset.activity_user_type = False
to_reset.activity_user_id = False
to_reset.activity_user_field_name = False
to_default = self.filtered(lambda act: act.state == 'next_activity')
for activity in to_default:
if not activity.activity_date_deadline_range_type:
activity.activity_date_deadline_range_type = 'days'
if not activity.activity_user_type:
activity.activity_user_type = 'specific'
if not activity.activity_user_field_name:
activity.activity_user_field_name = 'user_id'
@api.constrains('activity_date_deadline_range')
def _check_activity_date_deadline_range(self):
if any(action.activity_date_deadline_range < 0 for action in self):
raise ValidationError(_("The 'Due Date In' value can't be negative."))
@api.constrains('state', 'model_id')
def _check_model_coherency(self):
for action in self:
if action.state in ('followers', 'next_activity') and action.model_id.transient:
raise ValidationError(_("This action cannot be done on transient models."))
if action.state == 'followers' and not action.model_id.is_mail_thread:
raise ValidationError(_("Add Followers can only be done on a mail thread models"))
if action.state == 'next_activity' and not action.model_id.is_mail_activity:
raise ValidationError(_("A next activity can only be planned on models that use activities."))
def _run_action_followers_multi(self, eval_context=None):
Model = self.env[self.model_name]
if self.partner_ids and hasattr(Model, 'message_subscribe'):
records = Model.browse(self._context.get('active_ids', self._context.get('active_id')))
records.message_subscribe(partner_ids=self.partner_ids.ids)
return False
def _is_recompute(self):
"""When an activity is set on update of a record,
update might be triggered many times by recomputes.
When need to know it to skip these steps.
Except if the computed field is supposed to trigger the action
"""
records = self.env[self.model_name].browse(
self._context.get('active_ids', self._context.get('active_id')))
old_values = self._context.get('old_values')
if old_values:
domain_post = self._context.get('domain_post')
tracked_fields = []
if domain_post:
for leaf in domain_post:
if isinstance(leaf, (tuple, list)):
tracked_fields.append(leaf[0])
fields_to_check = [field for record, field_names in old_values.items() for field in field_names if field not in tracked_fields]
if fields_to_check:
field = records._fields[fields_to_check[0]]
# Pick an arbitrary field; if it is marked to be recomputed,
# it means we are in an extraneous write triggered by the recompute.
# In this case, we should not create a new activity.
if records & self.env.records_to_compute(field):
return True
return False
def _run_action_mail_post_multi(self, eval_context=None):
# TDE CLEANME: when going to new api with server action, remove action
if not self.template_id or (not self._context.get('active_ids') and not self._context.get('active_id')) or self._is_recompute():
return False
res_ids = self._context.get('active_ids', [self._context.get('active_id')])
# Clean context from default_type to avoid making attachment
# with wrong values in subsequent operations
cleaned_ctx = dict(self.env.context)
cleaned_ctx.pop('default_type', None)
cleaned_ctx.pop('default_parent_id', None)
cleaned_ctx['mail_create_nosubscribe'] = True # do not subscribe random people to records
cleaned_ctx['mail_post_autofollow'] = self.mail_post_autofollow
if self.mail_post_method in ('comment', 'note'):
records = self.env[self.model_name].with_context(cleaned_ctx).browse(res_ids)
if self.mail_post_method == 'comment':
subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment')
else:
subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note')
for record in records:
record.message_post_with_template(
self.template_id.id,
composition_mode='comment',
subtype_id=subtype_id,
)
else:
template = self.template_id.with_context(cleaned_ctx)
for res_id in res_ids:
template.send_mail(
res_id,
force_send=False,
raise_exception=False
)
return False
def _run_action_next_activity(self, eval_context=None):
if not self.activity_type_id or not self._context.get('active_id') or self._is_recompute():
return False
records = self.env[self.model_name].browse(self._context.get('active_ids', self._context.get('active_id')))
vals = {
'summary': self.activity_summary or '',
'note': self.activity_note or '',
'activity_type_id': self.activity_type_id.id,
}
if self.activity_date_deadline_range > 0:
vals['date_deadline'] = fields.Date.context_today(self) + relativedelta(**{
self.activity_date_deadline_range_type or 'days': self.activity_date_deadline_range})
for record in records:
user = False
if self.activity_user_type == 'specific':
user = self.activity_user_id
elif self.activity_user_type == 'generic' and self.activity_user_field_name in record:
user = record[self.activity_user_field_name]
if user:
vals['user_id'] = user.id
record.activity_schedule(**vals)
return False
@api.model
def _get_eval_context(self, action=None):
""" Override the method giving the evaluation context but also the
context used in all subsequent calls. Add the mail_notify_force_send
key set to False in the context. This way all notification emails linked
to the currently executed action will be set in the queue instead of
sent directly. This will avoid possible break in transactions. """
eval_context = super(ServerActions, self)._get_eval_context(action=action)
ctx = dict(eval_context['env'].context)
ctx['mail_notify_force_send'] = False
eval_context['env'].context = ctx
return eval_context

View file

@ -0,0 +1,94 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, models, SUPERUSER_ID
from odoo.exceptions import AccessError, MissingError, UserError
from odoo.tools import consteq
class IrAttachment(models.Model):
_inherit = 'ir.attachment'
def _check_attachments_access(self, attachment_tokens):
"""This method relies on access rules/rights and therefore it should not be called from a sudo env."""
self = self.sudo(False)
attachment_tokens = attachment_tokens or ([None] * len(self))
if len(attachment_tokens) != len(self):
raise UserError(_("An access token must be provided for each attachment."))
for attachment, access_token in zip(self, attachment_tokens):
try:
attachment_sudo = attachment.with_user(SUPERUSER_ID).exists()
if not attachment_sudo:
raise MissingError(_("The attachment %s does not exist.", attachment.id))
try:
attachment.check('write')
except AccessError:
if not access_token or not attachment_sudo.access_token or not consteq(attachment_sudo.access_token, access_token):
message_sudo = self.env['mail.message'].sudo().search([('attachment_ids', 'in', attachment_sudo.ids)], limit=1)
if not message_sudo or not message_sudo.is_current_user_or_guest_author:
raise
except (AccessError, MissingError):
raise UserError(_("The attachment %s does not exist or you do not have the rights to access it.", attachment.id))
def _post_add_create(self):
""" Overrides behaviour when the attachment is created through the controller
"""
super(IrAttachment, self)._post_add_create()
for record in self:
record.register_as_main_attachment(force=False)
def register_as_main_attachment(self, force=True):
""" Registers this attachment as the main one of the model it is
attached to.
"""
self.ensure_one()
if not self.res_model:
return
related_record = self.env[self.res_model].browse(self.res_id)
if not related_record.check_access_rights('write', raise_exception=False):
return
# message_main_attachment_id field can be empty, that's why we compare to False;
# we are just checking that it exists on the model before writing it
if related_record and hasattr(related_record, 'message_main_attachment_id'):
if force or not related_record.message_main_attachment_id:
#Ignore AccessError, if you don't have access to modify the document
#Just don't set the value
try:
related_record.message_main_attachment_id = self
except AccessError:
pass
def _delete_and_notify(self):
for attachment in self:
if attachment.res_model == 'mail.channel' and attachment.res_id:
target = self.env['mail.channel'].browse(attachment.res_id)
else:
target = self.env.user.partner_id
self.env['bus.bus']._sendone(target, 'ir.attachment/delete', {
'id': attachment.id,
})
self.unlink()
def _attachment_format(self, legacy=False):
res_list = []
for attachment in self:
res = {
'checksum': attachment.checksum,
'id': attachment.id,
'filename': attachment.name,
'name': attachment.name,
'mimetype': attachment.mimetype,
'type': attachment.type,
'url': attachment.url,
}
if not legacy:
res['originThread'] = [('insert', {
'id': attachment.res_id,
'model': attachment.res_model,
})]
else:
res.update({
'res_id': attachment.res_id,
'res_model': attachment.res_model,
})
res_list.append(res)
return res_list

View file

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class IrConfigParameter(models.Model):
_inherit = 'ir.config_parameter'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('key') in ['mail.bounce.alias', 'mail.catchall.alias']:
vals['value'] = self.env['mail.alias']._clean_and_check_unique([vals.get('value')])[0]
return super().create(vals_list)
def write(self, vals):
for parameter in self:
if 'value' in vals and parameter.key in ['mail.bounce.alias', 'mail.catchall.alias'] and vals['value'] != parameter.value:
vals['value'] = self.env['mail.alias']._clean_and_check_unique([vals.get('value')])[0]
return super().write(vals)
@api.model
def set_param(self, key, value):
if key == 'mail.restrict.template.rendering':
group_user = self.env.ref('base.group_user')
group_mail_template_editor = self.env.ref('mail.group_mail_template_editor')
if not value and group_mail_template_editor not in group_user.implied_ids:
group_user.implied_ids |= group_mail_template_editor
elif value and group_mail_template_editor in group_user.implied_ids:
# remove existing users, including inactive template user
# admin will regain the right via implied_ids on group_system
group_user._remove_group(group_mail_template_editor)
# sanitize and normalize allowed catchall domains
elif key == 'mail.catchall.domain.allowed' and value:
value = self.env['mail.alias']._clean_and_check_mail_catchall_allowed_domains(value)
return super(IrConfigParameter, self).set_param(key, value)

View file

@ -0,0 +1,27 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo
from odoo import models
from odoo.http import request
class IrHttp(models.AbstractModel):
_inherit = 'ir.http'
def session_info(self):
user = self.env.user
result = super(IrHttp, self).session_info()
if self.env.user._is_internal():
result['notification_type'] = user.notification_type
guest = self.env['mail.guest']._get_guest_from_context()
if not request.session.uid and guest:
user_context = {'lang': guest.lang}
mods = odoo.conf.server_wide_modules or []
lang = user_context.get("lang")
translation_hash = self.env['ir.http'].sudo().get_web_translations_hash(mods, lang)
result['cache_hashes']['translations'] = translation_hash
result.update({
'name': guest.name,
'user_context': user_context,
})
return result

View file

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, fields, models
class IrMailServer(models.Model):
_name = 'ir.mail_server'
_inherit = ['ir.mail_server']
mail_template_ids = fields.One2many(
comodel_name='mail.template',
inverse_name='mail_server_id',
string='Mail template using this mail server',
readonly=True)
def _active_usages_compute(self):
usages_super = super()._active_usages_compute()
for record in self.filtered('mail_template_ids'):
usages_super.setdefault(record.id, []).extend(
map(lambda t: _('%s (Email Template)', t.display_name), record.mail_template_ids)
)
return usages_super

View file

@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class IrModel(models.Model):
_inherit = 'ir.model'
_order = 'is_mail_thread DESC, name ASC'
is_mail_thread = fields.Boolean(
string="Has Mail Thread", default=False,
)
is_mail_activity = fields.Boolean(
string="Has Mail Activity", default=False,
)
is_mail_blacklist = fields.Boolean(
string="Has Mail Blacklist", default=False,
)
def unlink(self):
""" Delete mail data (followers, messages, activities) associated with
the models being deleted.
"""
mail_models = self.search([
('model', 'in', ('mail.activity', 'mail.activity.type', 'mail.followers', 'mail.message'))
], order='id')
if not (self & mail_models):
models = tuple(self.mapped('model'))
model_ids = tuple(self.ids)
query = "DELETE FROM mail_activity WHERE res_model_id IN %s"
self.env.cr.execute(query, [model_ids])
query = "DELETE FROM mail_activity_type WHERE res_model IN %s"
self.env.cr.execute(query, [models])
query = "DELETE FROM mail_followers WHERE res_model IN %s"
self.env.cr.execute(query, [models])
query = "DELETE FROM mail_message WHERE model in %s"
self.env.cr.execute(query, [models])
# Get files attached solely to the models being deleted (and none other)
models = tuple(self.mapped('model'))
query = """
SELECT DISTINCT store_fname
FROM ir_attachment
WHERE res_model IN %s
EXCEPT
SELECT store_fname
FROM ir_attachment
WHERE res_model not IN %s;
"""
self.env.cr.execute(query, [models, models])
fnames = self.env.cr.fetchall()
query = """DELETE FROM ir_attachment WHERE res_model in %s"""
self.env.cr.execute(query, [models])
for (fname,) in fnames:
self.env['ir.attachment']._file_delete(fname)
return super(IrModel, self).unlink()
def write(self, vals):
if self and ('is_mail_thread' in vals or 'is_mail_activity' in vals or 'is_mail_blacklist' in vals):
if any(rec.state != 'manual' for rec in self):
raise UserError(_('Only custom models can be modified.'))
if 'is_mail_thread' in vals and any(rec.is_mail_thread > vals['is_mail_thread'] for rec in self):
raise UserError(_('Field "Mail Thread" cannot be changed to "False".'))
if 'is_mail_activity' in vals and any(rec.is_mail_activity > vals['is_mail_activity'] for rec in self):
raise UserError(_('Field "Mail Activity" cannot be changed to "False".'))
if 'is_mail_blacklist' in vals and any(rec.is_mail_blacklist > vals['is_mail_blacklist'] for rec in self):
raise UserError(_('Field "Mail Blacklist" cannot be changed to "False".'))
res = super(IrModel, self).write(vals)
self.env.flush_all()
# setup models; this reloads custom models in registry
self.pool.setup_models(self._cr)
# update database schema of models
models = self.pool.descendants(self.mapped('model'), '_inherits')
self.pool.init_models(self._cr, models, dict(self._context, update_custom_fields=True))
else:
res = super(IrModel, self).write(vals)
return res
def _reflect_model_params(self, model):
vals = super(IrModel, self)._reflect_model_params(model)
vals['is_mail_thread'] = isinstance(model, self.pool['mail.thread'])
vals['is_mail_activity'] = isinstance(model, self.pool['mail.activity.mixin'])
vals['is_mail_blacklist'] = isinstance(model, self.pool['mail.thread.blacklist'])
return vals
@api.model
def _instanciate(self, model_data):
model_class = super(IrModel, self)._instanciate(model_data)
if model_data.get('is_mail_blacklist') and model_class._name != 'mail.thread.blacklist':
parents = model_class._inherit or []
parents = [parents] if isinstance(parents, str) else parents
model_class._inherit = parents + ['mail.thread.blacklist']
if model_class._custom:
model_class._primary_email = 'x_email'
elif model_data.get('is_mail_thread') and model_class._name != 'mail.thread':
parents = model_class._inherit or []
parents = [parents] if isinstance(parents, str) else parents
model_class._inherit = parents + ['mail.thread']
if model_data.get('is_mail_activity') and model_class._name != 'mail.activity.mixin':
parents = model_class._inherit or []
parents = [parents] if isinstance(parents, str) else parents
model_class._inherit = parents + ['mail.activity.mixin']
return model_class
def _get_model_definitions(self, model_names_to_fetch):
fields_by_model_names = super()._get_model_definitions(model_names_to_fetch)
for model_name, field_by_fname in fields_by_model_names.items():
model = self.env[model_name]
tracked_field_names = model._track_get_fields() if 'mail.thread' in model._inherit else []
for fname, field in field_by_fname.items():
if fname in tracked_field_names:
field['tracking'] = True
return fields_by_model_names

View file

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class IrModelField(models.Model):
_inherit = 'ir.model.fields'
tracking = fields.Integer(
string="Enable Ordered Tracking",
help="If set every modification done to this field is tracked in the chatter. Value is used to order tracking values.",
)
def _reflect_field_params(self, field, model_id):
""" Tracking value can be either a boolean enabling tracking mechanism
on field, either an integer giving the sequence. Default sequence is
set to 100. """
vals = super(IrModelField, self)._reflect_field_params(field, model_id)
tracking = getattr(field, 'tracking', None)
if tracking is True:
tracking = 100
elif tracking is False:
tracking = None
vals['tracking'] = tracking
return vals
def _instanciate_attrs(self, field_data):
attrs = super(IrModelField, self)._instanciate_attrs(field_data)
if attrs and field_data.get('tracking'):
attrs['tracking'] = field_data['tracking']
return attrs

View file

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from odoo import models
class IrQweb(models.AbstractModel):
""" Add ``raise_on_code`` option for qweb. When this option is activated
then all directives are prohibited.
"""
_inherit = 'ir.qweb'
def _get_template_cache_keys(self):
return super()._get_template_cache_keys() + ['raise_on_code']
def _compile_directives(self, el, compile_context, indent):
if compile_context.get('raise_on_code'):
raise PermissionError("This rendering mode prohibits the use of directives.")
return super()._compile_directives(el, compile_context, indent)

View file

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class View(models.Model):
_inherit = 'ir.ui.view'
type = fields.Selection(selection_add=[('activity', 'Activity')])
def _postprocess_tag_field(self, node, name_manager, node_info):
if node.xpath("ancestor::div[hasclass('oe_chatter')]"):
# Pass the postprocessing of the mail thread fields
# The web client makes it completely custom, and this is therefore pointless.
name_manager.has_field(node, node.get('name'), {})
return
return super()._postprocess_tag_field(node, name_manager, node_info)

View file

@ -0,0 +1,49 @@
from odoo import models
from odoo.http import request
from odoo.addons.bus.websocket import wsrequest
class IrWebsocket(models.AbstractModel):
_inherit = 'ir.websocket'
def _get_im_status(self, data):
im_status = super()._get_im_status(data)
if 'mail.guest' in data:
im_status['guests'] = self.env['mail.guest'].sudo().with_context(active_test=False).search_read(
[('id', 'in', data['mail.guest'])],
['im_status']
)
return im_status
def _build_bus_channel_list(self, channels):
# This method can either be called due to an http or a
# websocket request. The request itself is necessary to
# retrieve the current guest. Let's retrieve the proper
# request.
req = request or wsrequest
channels = list(channels) # do not alter original list
guest_sudo = self.env['mail.guest']._get_guest_from_request(req).sudo()
mail_channels = self.env['mail.channel']
if req.session.uid:
partner = self.env.user.partner_id
mail_channels = partner.channel_ids
channels.append(partner)
elif guest_sudo:
mail_channels = guest_sudo.channel_ids
channels.append(guest_sudo)
for mail_channel in mail_channels:
channels.append(mail_channel)
return super()._build_bus_channel_list(channels)
def _update_bus_presence(self, inactivity_period, im_status_ids_by_model):
super()._update_bus_presence(inactivity_period, im_status_ids_by_model)
if not self.env.user or self.env.user._is_public():
# This method can either be called due to an http or a
# websocket request. The request itself is necessary to
# retrieve the current guest. Let's retrieve the proper
# request.
req = request or wsrequest
guest_sudo = self.env['mail.guest']._get_guest_from_request(req).sudo()
if not guest_sudo:
return
guest_sudo.env['bus.presence'].update(inactivity_period, identity_field='guest_id', identity_value=guest_sudo.id)

View file

@ -0,0 +1,719 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import pytz
from collections import defaultdict
from datetime import date, datetime
from dateutil.relativedelta import relativedelta
from odoo import api, exceptions, fields, models, _, Command
from odoo.osv import expression
from odoo.tools import is_html_empty
from odoo.tools.misc import clean_context, get_lang
class MailActivity(models.Model):
""" An actual activity to perform. Activities are linked to
documents using res_id and res_model_id fields. Activities have a deadline
that can be used in kanban view to display a status. Once done activities
are unlinked and a message is posted. This message has a new activity_type_id
field that indicates the activity linked to the message. """
_name = 'mail.activity'
_description = 'Activity'
_order = 'date_deadline ASC, id ASC'
_rec_name = 'summary'
@api.model
def default_get(self, fields):
res = super(MailActivity, self).default_get(fields)
if not fields or 'res_model_id' in fields and res.get('res_model'):
res['res_model_id'] = self.env['ir.model']._get(res['res_model']).id
return res
@api.model
def _default_activity_type(self):
default_vals = self.default_get(['res_model_id', 'res_model'])
if not default_vals.get('res_model_id'):
return False
current_model = self.env["ir.model"].sudo().browse(default_vals['res_model_id']).model
return self._default_activity_type_for_model(current_model)
@api.model
def _default_activity_type_for_model(self, model):
todo_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mail_activity_data_todo', raise_if_not_found=False)
activity_type_todo = self.env['mail.activity.type'].browse(todo_id) if todo_id else self.env['mail.activity.type']
if activity_type_todo and activity_type_todo.active and \
(activity_type_todo.res_model == model or not activity_type_todo.res_model):
return activity_type_todo
activity_type_model = self.env['mail.activity.type'].search([('res_model', '=', model)], limit=1)
if activity_type_model:
return activity_type_model
activity_type_generic = self.env['mail.activity.type'].search([('res_model', '=', False)], limit=1)
return activity_type_generic
# owner
res_model_id = fields.Many2one(
'ir.model', 'Document Model',
index=True, ondelete='cascade', required=True)
res_model = fields.Char(
'Related Document Model',
index=True, related='res_model_id.model', compute_sudo=True, store=True, readonly=True)
res_id = fields.Many2oneReference(string='Related Document ID', index=True, model_field='res_model')
res_name = fields.Char(
'Document Name', compute='_compute_res_name', compute_sudo=True, store=True,
readonly=True)
# activity
activity_type_id = fields.Many2one(
'mail.activity.type', string='Activity Type',
domain="['|', ('res_model', '=', False), ('res_model', '=', res_model)]", ondelete='restrict',
default=_default_activity_type)
activity_category = fields.Selection(related='activity_type_id.category', readonly=True)
activity_decoration = fields.Selection(related='activity_type_id.decoration_type', readonly=True)
icon = fields.Char('Icon', related='activity_type_id.icon', readonly=True)
summary = fields.Char('Summary')
note = fields.Html('Note', sanitize_style=True)
date_deadline = fields.Date('Due Date', index=True, required=True, default=fields.Date.context_today)
automated = fields.Boolean(
'Automated activity', readonly=True,
help='Indicates this activity has been created automatically and not by any user.')
# description
user_id = fields.Many2one(
'res.users', 'Assigned to',
default=lambda self: self.env.user,
index=True, required=True)
request_partner_id = fields.Many2one('res.partner', string='Requesting Partner')
state = fields.Selection([
('overdue', 'Overdue'),
('today', 'Today'),
('planned', 'Planned')], 'State',
compute='_compute_state')
recommended_activity_type_id = fields.Many2one('mail.activity.type', string="Recommended Activity Type")
previous_activity_type_id = fields.Many2one('mail.activity.type', string='Previous Activity Type', readonly=True)
has_recommended_activities = fields.Boolean(
'Next activities available',
compute='_compute_has_recommended_activities') # technical field for UX purpose
mail_template_ids = fields.Many2many(related='activity_type_id.mail_template_ids', readonly=True)
chaining_type = fields.Selection(related='activity_type_id.chaining_type', readonly=True)
# access
can_write = fields.Boolean(compute='_compute_can_write') # used to hide buttons if the current user has no access
_sql_constraints = [
# Required on a Many2one reference field is not sufficient as actually
# writing 0 is considered as a valid value, because this is an integer field.
# We therefore need a specific constraint check.
('check_res_id_is_set',
'CHECK(res_id IS NOT NULL AND res_id !=0 )',
'Activities have to be linked to records with a not null res_id.')
]
@api.onchange('previous_activity_type_id')
def _compute_has_recommended_activities(self):
for record in self:
record.has_recommended_activities = bool(record.previous_activity_type_id.suggested_next_type_ids)
@api.onchange('previous_activity_type_id')
def _onchange_previous_activity_type_id(self):
for record in self:
if record.previous_activity_type_id.triggered_next_type_id:
record.activity_type_id = record.previous_activity_type_id.triggered_next_type_id
@api.depends('res_model', 'res_id')
def _compute_res_name(self):
for activity in self:
activity.res_name = activity.res_model and \
self.env[activity.res_model].browse(activity.res_id).display_name
@api.depends('date_deadline')
def _compute_state(self):
for record in self.filtered(lambda activity: activity.date_deadline):
tz = record.user_id.sudo().tz
date_deadline = record.date_deadline
record.state = self._compute_state_from_date(date_deadline, tz)
@api.model
def _compute_state_from_date(self, date_deadline, tz=False):
date_deadline = fields.Date.from_string(date_deadline)
today_default = date.today()
today = today_default
if tz:
today_utc = pytz.utc.localize(datetime.utcnow())
today_tz = today_utc.astimezone(pytz.timezone(tz))
today = date(year=today_tz.year, month=today_tz.month, day=today_tz.day)
diff = (date_deadline - today)
if diff.days == 0:
return 'today'
elif diff.days < 0:
return 'overdue'
else:
return 'planned'
@api.depends('res_model', 'res_id', 'user_id')
def _compute_can_write(self):
valid_records = self._filter_access_rules('write')
for record in self:
record.can_write = record in valid_records
@api.onchange('activity_type_id')
def _onchange_activity_type_id(self):
if self.activity_type_id:
if self.activity_type_id.summary:
self.summary = self.activity_type_id.summary
self.date_deadline = self._calculate_date_deadline(self.activity_type_id)
self.user_id = self.activity_type_id.default_user_id or self.env.user
if self.activity_type_id.default_note:
self.note = self.activity_type_id.default_note
def _calculate_date_deadline(self, activity_type):
# Date.context_today is correct because date_deadline is a Date and is meant to be
# expressed in user TZ
base = fields.Date.context_today(self)
if activity_type.delay_from == 'previous_activity' and 'activity_previous_deadline' in self.env.context:
base = fields.Date.from_string(self.env.context.get('activity_previous_deadline'))
return base + relativedelta(**{activity_type.delay_unit: activity_type.delay_count})
@api.onchange('recommended_activity_type_id')
def _onchange_recommended_activity_type_id(self):
if self.recommended_activity_type_id:
self.activity_type_id = self.recommended_activity_type_id
def _filter_access_rules(self, operation):
# write / unlink: valid for creator / assigned
if operation in ('write', 'unlink'):
valid = super(MailActivity, self)._filter_access_rules(operation)
if valid and valid == self:
return self
else:
valid = self.env[self._name]
return self._filter_access_rules_remaining(valid, operation, '_filter_access_rules')
def _filter_access_rules_python(self, operation):
# write / unlink: valid for creator / assigned
if operation in ('write', 'unlink'):
valid = super(MailActivity, self)._filter_access_rules_python(operation)
if valid and valid == self:
return self
else:
valid = self.env[self._name]
return self._filter_access_rules_remaining(valid, operation, '_filter_access_rules_python')
def _filter_access_rules_remaining(self, valid, operation, filter_access_rules_method):
""" Return the subset of ``self`` for which ``operation`` is allowed.
A custom implementation is done on activities as this document has some
access rules and is based on related document for activities that are
not covered by those rules.
Access on activities are the following :
* create: (``mail_post_access`` or write) right on related documents;
* read: read rights on related documents;
* write: access rule OR
(``mail_post_access`` or write) rights on related documents);
* unlink: access rule OR
(``mail_post_access`` or write) rights on related documents);
"""
# compute remaining for hand-tailored rules
remaining = self - valid
remaining_sudo = remaining.sudo()
# fall back on related document access right checks. Use the same as defined for mail.thread
# if available; otherwise fall back on read for read, write for other operations.
activity_to_documents = dict()
for activity in remaining_sudo:
# write / unlink: if not updating self or assigned, limit to automated activities to avoid
# updating other people's activities. As unlinking a document bypasses access rights checks
# on related activities this will not prevent people from deleting documents with activities
# create / read: just check rights on related document
activity_to_documents.setdefault(activity.res_model, list()).append(activity.res_id)
for doc_model, doc_ids in activity_to_documents.items():
if hasattr(self.env[doc_model], '_mail_post_access'):
doc_operation = self.env[doc_model]._mail_post_access
elif operation == 'read':
doc_operation = 'read'
else:
doc_operation = 'write'
right = self.env[doc_model].check_access_rights(doc_operation, raise_exception=False)
if right:
valid_doc_ids = getattr(self.env[doc_model].browse(doc_ids), filter_access_rules_method)(doc_operation)
valid += remaining.filtered(lambda activity: activity.res_model == doc_model and activity.res_id in valid_doc_ids.ids)
return valid
def _check_access_assignation(self):
""" Check assigned user (user_id field) has access to the document. Purpose
is to allow assigned user to handle their activities. For that purpose
assigned user should be able to at least read the document. We therefore
raise an UserError if the assigned user has no access to the document. """
for model, activity_data in self._classify_by_model().items():
# group activities / user, in order to batch the check of ACLs
per_user = dict()
for activity in activity_data['activities'].filtered(lambda act: act.user_id):
if activity.user_id not in per_user:
per_user[activity.user_id] = activity
else:
per_user[activity.user_id] += activity
for user, activities in per_user.items():
RecordModel = self.env[model].with_user(user).with_context(
allowed_company_ids=user.company_ids.ids
)
try:
RecordModel.check_access_rights('read')
except exceptions.AccessError:
raise exceptions.UserError(
_('Assigned user %s has no access to the document and is not able to handle this activity.',
user.display_name))
else:
try:
target_records = self.env[model].browse(activities.mapped('res_id'))
target_records.check_access_rule('read')
except exceptions.AccessError:
raise exceptions.UserError(
_('Assigned user %s has no access to the document and is not able to handle this activity.',
user.display_name))
# ------------------------------------------------------
# ORM overrides
# ------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
activities = super(MailActivity, self).create(vals_list)
# find partners related to responsible users, separate readable from unreadable
if any(user != self.env.user for user in activities.user_id):
user_partners = activities.user_id.partner_id
readable_user_partners = user_partners._filter_access_rules_python('read')
else:
readable_user_partners = self.env.user.partner_id
# when creating activities for other: send a notification to assigned user;
# in case of manually done activity also check target has rights on document
# otherwise we prevent its creation. Automated activities are checked since
# they are integrated into business flows that should not crash.
if self.env.context.get('mail_activity_quick_update'):
activities_to_notify = self.env['mail.activity']
else:
activities_to_notify = activities.filtered(lambda act: act.user_id != self.env.user)
activities_to_notify.filtered(lambda act: not act.automated)._check_access_assignation()
if activities_to_notify:
to_sudo = activities_to_notify.filtered(lambda act: act.user_id.partner_id not in readable_user_partners)
other = activities_to_notify - to_sudo
to_sudo.sudo().action_notify()
other.action_notify()
# subscribe (batch by model and user to speedup)
for model, activity_data in activities._classify_by_model().items():
per_user = dict()
for activity in activity_data['activities'].filtered(lambda act: act.user_id):
if activity.user_id not in per_user:
per_user[activity.user_id] = [activity.res_id]
else:
per_user[activity.user_id].append(activity.res_id)
for user, res_ids in per_user.items():
pids = user.partner_id.ids if user.partner_id in readable_user_partners else user.sudo().partner_id.ids
self.env[model].browse(res_ids).message_subscribe(partner_ids=pids)
# send notifications about activity creation
todo_activities = activities.filtered(lambda act: act.date_deadline <= fields.Date.today())
if todo_activities:
self.env['bus.bus']._sendmany([
(activity.user_id.partner_id, 'mail.activity/updated', {'activity_created': True})
for activity in todo_activities
])
return activities
def read(self, fields=None, load='_classic_read'):
""" When reading specific fields, read calls _read that manually applies ir rules
(_apply_ir_rules), instead of calling check_access_rule.
Meaning that our custom rules enforcing from '_filter_access_rules' and
'_filter_access_rules_python' are bypassed in that case.
To make sure we apply our custom security rules, we force a call to 'check_access_rule'. """
self.check_access_rule('read')
return super(MailActivity, self).read(fields=fields, load=load)
def write(self, values):
if values.get('user_id'):
user_changes = self.filtered(lambda activity: activity.user_id.id != values.get('user_id'))
pre_responsibles = user_changes.mapped('user_id.partner_id')
res = super(MailActivity, self).write(values)
if values.get('user_id'):
if values['user_id'] != self.env.uid:
to_check = user_changes.filtered(lambda act: not act.automated)
to_check._check_access_assignation()
if not self.env.context.get('mail_activity_quick_update', False):
user_changes.action_notify()
for activity in user_changes:
self.env[activity.res_model].browse(activity.res_id).message_subscribe(partner_ids=[activity.user_id.partner_id.id])
# send bus notifications
todo_activities = user_changes.filtered(lambda act: act.date_deadline <= fields.Date.today())
if todo_activities:
self.env['bus.bus']._sendmany([
[partner, 'mail.activity/updated', {'activity_created': True}]
for partner in todo_activities.user_id.partner_id
])
self.env['bus.bus']._sendmany([
[partner, 'mail.activity/updated', {'activity_deleted': True}]
for partner in pre_responsibles
])
return res
def unlink(self):
todo_activities = self.filtered(lambda act: act.date_deadline <= fields.Date.today())
if todo_activities:
self.env['bus.bus']._sendmany([
[partner, 'mail.activity/updated', {'activity_deleted': True}]
for partner in todo_activities.user_id.partner_id
])
return super(MailActivity, self).unlink()
@api.model
def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
""" Override that adds specific access rights of mail.activity, to remove
ids uid could not see according to our custom rules. Please refer to
_filter_access_rules_remaining for more details about those rules.
The method is inspired by what has been done on mail.message. """
# Rules do not apply to administrator
if self.env.is_superuser():
return super(MailActivity, self)._search(
args, offset=offset, limit=limit, order=order,
count=count, access_rights_uid=access_rights_uid)
# Perform a super with count as False, to have the ids, not a counter
ids = super(MailActivity, self)._search(
args, offset=offset, limit=limit, order=order,
count=False, access_rights_uid=access_rights_uid)
if not ids and count:
return 0
elif not ids:
return ids
# check read access rights before checking the actual rules on the given ids
super(MailActivity, self.with_user(access_rights_uid or self._uid)).check_access_rights('read')
self.flush_model(['res_model', 'res_id'])
activities_to_check = []
for sub_ids in self._cr.split_for_in_conditions(ids):
self._cr.execute("""
SELECT DISTINCT activity.id, activity.res_model, activity.res_id
FROM "%s" activity
WHERE activity.id = ANY (%%(ids)s) AND activity.res_id != 0""" % self._table, dict(ids=list(sub_ids)))
activities_to_check += self._cr.dictfetchall()
activity_to_documents = {}
for activity in activities_to_check:
activity_to_documents.setdefault(activity['res_model'], set()).add(activity['res_id'])
allowed_ids = set()
for doc_model, doc_ids in activity_to_documents.items():
# fall back on related document access right checks. Use the same as defined for mail.thread
# if available; otherwise fall back on read
if hasattr(self.env[doc_model], '_mail_post_access'):
doc_operation = self.env[doc_model]._mail_post_access
else:
doc_operation = 'read'
DocumentModel = self.env[doc_model].with_user(access_rights_uid or self._uid)
right = DocumentModel.check_access_rights(doc_operation, raise_exception=False)
if right:
valid_docs = DocumentModel.browse(doc_ids)._filter_access_rules(doc_operation)
valid_doc_ids = set(valid_docs.ids)
allowed_ids.update(
activity['id'] for activity in activities_to_check
if activity['res_model'] == doc_model and activity['res_id'] in valid_doc_ids)
if count:
return len(allowed_ids)
else:
# re-construct a list based on ids, because 'allowed_ids' does not keep the original order
id_list = [id for id in ids if id in allowed_ids]
return id_list
@api.model
def _read_group_raw(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
""" The base _read_group_raw method implementation computes a where based on a given domain
(_where_calc) and manually applies ir rules (_apply_ir_rules).
Meaning that our custom rules enforcing from '_filter_access_rules' and
'_filter_access_rules_python' are bypassed in that case.
This overrides re-uses the _search implementation to force the read group domain to allowed
ids only, that are computed based on our custom rules (see _filter_access_rules_remaining
for more details). """
# Rules do not apply to administrator
if not self.env.is_superuser():
allowed_ids = self._search(domain, count=False)
if allowed_ids:
domain = expression.AND([domain, [('id', 'in', allowed_ids)]])
else:
# force void result if no allowed ids found
domain = expression.AND([domain, [(0, '=', 1)]])
return super(MailActivity, self)._read_group_raw(
domain=domain, fields=fields, groupby=groupby, offset=offset,
limit=limit, orderby=orderby, lazy=lazy,
)
def name_get(self):
res = []
for record in self:
name = record.summary or record.activity_type_id.display_name
res.append((record.id, name))
return res
# ------------------------------------------------------
# Business Methods
# ------------------------------------------------------
def action_notify(self):
if not self:
return
for activity in self:
if activity.user_id.lang:
# Send the notification in the assigned user's language
activity = activity.with_context(lang=activity.user_id.lang)
model_description = activity.env['ir.model']._get(activity.res_model).display_name
body = activity.env['ir.qweb']._render(
'mail.message_activity_assigned',
{
'activity': activity,
'model_description': model_description,
'is_html_empty': is_html_empty,
},
minimal_qcontext=True
)
record = activity.env[activity.res_model].browse(activity.res_id)
if activity.user_id:
record.message_notify(
partner_ids=activity.user_id.partner_id.ids,
body=body,
record_name=activity.res_name,
model_description=model_description,
email_layout_xmlid='mail.mail_notification_layout',
subject=_('"%(activity_name)s: %(summary)s" assigned to you',
activity_name=activity.res_name,
summary=activity.summary or activity.activity_type_id.name),
subtitles=[_('Activity: %s', activity.activity_type_id.name),
_('Deadline: %s', activity.date_deadline.strftime(get_lang(activity.env).date_format))]
)
def action_done(self):
""" Wrapper without feedback because web button add context as
parameter, therefore setting context to feedback """
return self.action_feedback()
def action_feedback(self, feedback=False, attachment_ids=None):
messages, _next_activities = self.with_context(
clean_context(self.env.context)
)._action_done(feedback=feedback, attachment_ids=attachment_ids)
return messages[0].id if messages else False
def action_done_schedule_next(self):
""" Wrapper without feedback because web button add context as
parameter, therefore setting context to feedback """
return self.action_feedback_schedule_next()
def action_feedback_schedule_next(self, feedback=False, attachment_ids=None):
ctx = dict(
clean_context(self.env.context),
default_previous_activity_type_id=self.activity_type_id.id,
activity_previous_deadline=self.date_deadline,
default_res_id=self.res_id,
default_res_model=self.res_model,
)
_messages, next_activities = self._action_done(feedback=feedback, attachment_ids=attachment_ids) # will unlink activity, dont access self after that
if next_activities:
return False
return {
'name': _('Schedule an Activity'),
'context': ctx,
'view_mode': 'form',
'res_model': 'mail.activity',
'views': [(False, 'form')],
'type': 'ir.actions.act_window',
'target': 'new',
}
def _action_done(self, feedback=False, attachment_ids=None):
""" Private implementation of marking activity as done: posting a message, deleting activity
(since done), and eventually create the automatical next activity (depending on config).
:param feedback: optional feedback from user when marking activity as done
:param attachment_ids: list of ir.attachment ids to attach to the posted mail.message
:returns (messages, activities) where
- messages is a recordset of posted mail.message
- activities is a recordset of mail.activity of forced automically created activities
"""
# marking as 'done'
messages = self.env['mail.message']
next_activities_values = []
# Search for all attachments linked to the activities we are about to unlink. This way, we
# can link them to the message posted and prevent their deletion.
attachments = self.env['ir.attachment'].search_read([
('res_model', '=', self._name),
('res_id', 'in', self.ids),
], ['id', 'res_id'])
activity_attachments = defaultdict(list)
for attachment in attachments:
activity_id = attachment['res_id']
activity_attachments[activity_id].append(attachment['id'])
for model, activity_data in self._classify_by_model().items():
records = self.env[model].browse(activity_data['record_ids'])
for record, activity in zip(records, activity_data['activities']):
# extract value to generate next activities
if activity.chaining_type == 'trigger':
vals = activity.with_context(activity_previous_deadline=activity.date_deadline)._prepare_next_activity_values()
next_activities_values.append(vals)
# post message on activity, before deleting it
activity_message = record.message_post_with_view(
'mail.message_activity_done',
values={
'activity': activity,
'feedback': feedback,
'display_assignee': activity.user_id != self.env.user
},
subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_activities'),
mail_activity_type_id=activity.activity_type_id.id,
attachment_ids=[Command.link(attachment_id) for attachment_id in attachment_ids] if attachment_ids else [],
)
# Moving the attachments in the message
# TODO: Fix void res_id on attachment when you create an activity with an image
# directly, see route /web_editor/attachment/add
if activity_attachments[activity.id]:
message_attachments = self.env['ir.attachment'].browse(activity_attachments[activity.id])
if message_attachments:
message_attachments.write({
'res_id': activity_message.id,
'res_model': activity_message._name,
})
activity_message.attachment_ids = message_attachments
messages += activity_message
next_activities = self.env['mail.activity']
if next_activities_values:
next_activities = self.env['mail.activity'].create(next_activities_values)
self.unlink() # will unlink activity, dont access `self` after that
return messages, next_activities
def action_close_dialog(self):
return {'type': 'ir.actions.act_window_close'}
def action_open_document(self):
""" Opens the related record based on the model and ID """
self.ensure_one()
return {
'res_id': self.res_id,
'res_model': self.res_model,
'target': 'current',
'type': 'ir.actions.act_window',
'view_mode': 'form',
}
def activity_format(self):
activities = self.read()
mail_template_ids = set([template_id for activity in activities for template_id in activity["mail_template_ids"]])
mail_template_info = self.env["mail.template"].browse(mail_template_ids).read(['id', 'name'])
mail_template_dict = dict([(mail_template['id'], mail_template) for mail_template in mail_template_info])
for activity in activities:
activity['mail_template_ids'] = [mail_template_dict[mail_template_id] for mail_template_id in activity['mail_template_ids']]
return activities
@api.model
def get_activity_data(self, res_model, domain):
activity_domain = [('res_model', '=', res_model)]
if domain:
res = self.env[res_model].search(domain)
activity_domain.append(('res_id', 'in', res.ids))
grouped_activities = self.env['mail.activity'].read_group(
activity_domain,
['res_id', 'activity_type_id', 'ids:array_agg(id)', 'date_deadline:min(date_deadline)'],
['res_id', 'activity_type_id'],
lazy=False)
# filter out unreadable records
if not domain:
res_ids = tuple(a['res_id'] for a in grouped_activities)
res = self.env[res_model].search([('id', 'in', res_ids)])
grouped_activities = [a for a in grouped_activities if a['res_id'] in res.ids]
res_id_to_deadline = {}
activity_data = defaultdict(dict)
for group in grouped_activities:
res_id = group['res_id']
activity_type_id = (group.get('activity_type_id') or (False, False))[0]
res_id_to_deadline[res_id] = group['date_deadline'] if (res_id not in res_id_to_deadline or group['date_deadline'] < res_id_to_deadline[res_id]) else res_id_to_deadline[res_id]
state = self._compute_state_from_date(group['date_deadline'], self.user_id.sudo().tz)
activity_data[res_id][activity_type_id] = {
'count': group['__count'],
'ids': group['ids'],
'state': state,
'o_closest_deadline': group['date_deadline'],
}
activity_type_infos = []
activity_type_ids = self.env['mail.activity.type'].search(
['|', ('res_model', '=', res_model), ('res_model', '=', False)])
for elem in sorted(activity_type_ids, key=lambda item: item.sequence):
mail_template_info = []
for mail_template_id in elem.mail_template_ids:
mail_template_info.append({"id": mail_template_id.id, "name": mail_template_id.name})
activity_type_infos.append([elem.id, elem.name, mail_template_info])
return {
'activity_types': activity_type_infos,
'activity_res_ids': sorted(res_id_to_deadline, key=lambda item: res_id_to_deadline[item]),
'grouped_activities': activity_data,
}
# ----------------------------------------------------------------------
# TOOLS
# ----------------------------------------------------------------------
def _classify_by_model(self):
""" To ease batch computation of various activities related methods they
are classified by model. Activities not linked to a valid record through
res_model / res_id are ignored.
:return dict: for each model having at least one activity in self, have
a sub-dict containing
* activities: activities related to that model;
* record IDs: record linked to the activities of that model, in same
order;
"""
data_by_model = {}
for activity in self.filtered(lambda act: act.res_model and act.res_id):
if activity.res_model not in data_by_model:
data_by_model[activity.res_model] = {
'activities': self.env['mail.activity'],
'record_ids': [],
}
data_by_model[activity.res_model]['activities'] += activity
data_by_model[activity.res_model]['record_ids'].append(activity.res_id)
return data_by_model
def _prepare_next_activity_values(self):
""" Prepare the next activity values based on the current activity record and applies _onchange methods
:returns a dict of values for the new activity
"""
self.ensure_one()
vals = self.default_get(self.fields_get())
vals.update({
'previous_activity_type_id': self.activity_type_id.id,
'res_id': self.res_id,
'res_model': self.res_model,
'res_model_id': self.env['ir.model']._get(self.res_model).id,
})
virtual_activity = self.new(vals)
virtual_activity._onchange_previous_activity_type_id()
virtual_activity._onchange_activity_type_id()
return virtual_activity._convert_to_write(virtual_activity._cache)

View file

@ -0,0 +1,505 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
import logging
import pytz
from odoo import api, fields, models
from odoo.osv import expression
_logger = logging.getLogger(__name__)
class MailActivityMixin(models.AbstractModel):
""" Mail Activity Mixin is a mixin class to use if you want to add activities
management on a model. It works like the mail.thread mixin. It defines
an activity_ids one2many field toward activities using res_id and res_model_id.
Various related / computed fields are also added to have a global status of
activities on documents.
Activities come with a new JS widget for the form view. It is integrated in the
Chatter widget although it is a separate widget. It displays activities linked
to the current record and allow to schedule, edit and mark done activities.
Just include field activity_ids in the div.oe-chatter to use it.
There is also a kanban widget defined. It defines a small widget to integrate
in kanban vignettes. It allow to manage activities directly from the kanban
view. Use widget="kanban_activity" on activitiy_ids field in kanban view to
use it.
Some context keys allow to control the mixin behavior. Use those in some
specific cases like import
* ``mail_activity_automation_skip``: skip activities automation; it means
no automated activities will be generated, updated or unlinked, allowing
to save computation and avoid generating unwanted activities;
"""
_name = 'mail.activity.mixin'
_description = 'Activity Mixin'
def _default_activity_type(self):
"""Define a default fallback activity type when requested xml id wasn't found.
Can be overriden to specify the default activity type of a model.
It is only called in in activity_schedule() for now.
"""
return self.env['mail.activity']._default_activity_type_for_model(self._name)
activity_ids = fields.One2many(
'mail.activity', 'res_id', 'Activities',
auto_join=True,
groups="base.group_user",)
activity_state = fields.Selection([
('overdue', 'Overdue'),
('today', 'Today'),
('planned', 'Planned')], string='Activity State',
compute='_compute_activity_state',
search='_search_activity_state',
groups="base.group_user",
help='Status based on activities\nOverdue: Due date is already passed\n'
'Today: Activity date is today\nPlanned: Future activities.')
activity_user_id = fields.Many2one(
'res.users', 'Responsible User',
related='activity_ids.user_id', readonly=False,
search='_search_activity_user_id',
groups="base.group_user")
activity_type_id = fields.Many2one(
'mail.activity.type', 'Next Activity Type',
related='activity_ids.activity_type_id', readonly=False,
search='_search_activity_type_id',
groups="base.group_user")
activity_type_icon = fields.Char('Activity Type Icon', related='activity_ids.icon')
activity_date_deadline = fields.Date(
'Next Activity Deadline',
compute='_compute_activity_date_deadline', search='_search_activity_date_deadline',
compute_sudo=False, readonly=True, store=False,
groups="base.group_user")
my_activity_date_deadline = fields.Date(
'My Activity Deadline',
compute='_compute_my_activity_date_deadline', search='_search_my_activity_date_deadline',
compute_sudo=False, readonly=True, groups="base.group_user")
activity_summary = fields.Char(
'Next Activity Summary',
related='activity_ids.summary', readonly=False,
search='_search_activity_summary',
groups="base.group_user",)
activity_exception_decoration = fields.Selection([
('warning', 'Alert'),
('danger', 'Error')],
compute='_compute_activity_exception_type',
search='_search_activity_exception_decoration',
help="Type of the exception activity on record.")
activity_exception_icon = fields.Char('Icon', help="Icon to indicate an exception activity.",
compute='_compute_activity_exception_type')
@api.depends('activity_ids.activity_type_id.decoration_type', 'activity_ids.activity_type_id.icon')
def _compute_activity_exception_type(self):
# prefetch all activity types for all activities, this will avoid any query in loops
self.mapped('activity_ids.activity_type_id.decoration_type')
for record in self:
activity_type_ids = record.activity_ids.mapped('activity_type_id')
exception_activity_type_id = False
for activity_type_id in activity_type_ids:
if activity_type_id.decoration_type == 'danger':
exception_activity_type_id = activity_type_id
break
if activity_type_id.decoration_type == 'warning':
exception_activity_type_id = activity_type_id
record.activity_exception_decoration = exception_activity_type_id and exception_activity_type_id.decoration_type
record.activity_exception_icon = exception_activity_type_id and exception_activity_type_id.icon
def _search_activity_exception_decoration(self, operator, operand):
return [('activity_ids.activity_type_id.decoration_type', operator, operand)]
@api.depends('activity_ids.state')
def _compute_activity_state(self):
for record in self:
states = record.activity_ids.mapped('state')
if 'overdue' in states:
record.activity_state = 'overdue'
elif 'today' in states:
record.activity_state = 'today'
elif 'planned' in states:
record.activity_state = 'planned'
else:
record.activity_state = False
def _search_activity_state(self, operator, value):
all_states = {'overdue', 'today', 'planned', False}
if operator == '=':
search_states = {value}
elif operator == '!=':
search_states = all_states - {value}
elif operator == 'in':
search_states = set(value)
elif operator == 'not in':
search_states = all_states - set(value)
reverse_search = False
if False in search_states:
# If we search "activity_state = False", they might be a lot of records
# (million for some models), so instead of returning the list of IDs
# [(id, 'in', ids)] we will reverse the domain and return something like
# [(id, 'not in', ids)], so the list of ids is as small as possible
reverse_search = True
search_states = all_states - search_states
# Use number in the SQL query for performance purpose
integer_state_value = {
'overdue': -1,
'today': 0,
'planned': 1,
False: None,
}
search_states_int = {integer_state_value.get(s or False) for s in search_states}
query = """
SELECT res_id
FROM (
SELECT res_id,
-- Global activity state
MIN(
-- Compute the state of each individual activities
-- -1: overdue
-- 0: today
-- 1: planned
SIGN(EXTRACT(day from (
mail_activity.date_deadline - DATE_TRUNC('day', %(today_utc)s AT TIME ZONE res_partner.tz)
)))
)::INT AS activity_state
FROM mail_activity
LEFT JOIN res_users
ON res_users.id = mail_activity.user_id
LEFT JOIN res_partner
ON res_partner.id = res_users.partner_id
WHERE mail_activity.res_model = %(res_model_table)s
GROUP BY res_id
) AS res_record
WHERE %(search_states_int)s @> ARRAY[activity_state]
"""
self._cr.execute(
query,
{
'today_utc': pytz.utc.localize(datetime.utcnow()),
'res_model_table': self._name,
'search_states_int': list(search_states_int)
},
)
return [('id', 'not in' if reverse_search else 'in', [r[0] for r in self._cr.fetchall()])]
@api.depends('activity_ids.date_deadline')
def _compute_activity_date_deadline(self):
for record in self:
record.activity_date_deadline = fields.first(record.activity_ids).date_deadline
def _search_activity_date_deadline(self, operator, operand):
if operator == '=' and not operand:
return [('activity_ids', '=', False)]
return [('activity_ids.date_deadline', operator, operand)]
@api.model
def _search_activity_user_id(self, operator, operand):
return [('activity_ids.user_id', operator, operand)]
@api.model
def _search_activity_type_id(self, operator, operand):
return [('activity_ids.activity_type_id', operator, operand)]
@api.model
def _search_activity_summary(self, operator, operand):
return [('activity_ids.summary', operator, operand)]
@api.depends('activity_ids.date_deadline', 'activity_ids.user_id')
@api.depends_context('uid')
def _compute_my_activity_date_deadline(self):
for record in self:
record.my_activity_date_deadline = next((
activity.date_deadline
for activity in record.activity_ids
if activity.user_id.id == record.env.uid
), False)
def _search_my_activity_date_deadline(self, operator, operand):
activity_ids = self.env['mail.activity']._search([
('date_deadline', operator, operand),
('res_model', '=', self._name),
('user_id', '=', self.env.user.id)
])
return [('activity_ids', 'in', activity_ids)]
def write(self, vals):
# Delete activities of archived record.
if 'active' in vals and vals['active'] is False:
self.env['mail.activity'].sudo().search(
[('res_model', '=', self._name), ('res_id', 'in', self.ids)]
).unlink()
return super(MailActivityMixin, self).write(vals)
def unlink(self):
""" Override unlink to delete records activities through (res_model, res_id). """
record_ids = self.ids
result = super(MailActivityMixin, self).unlink()
self.env['mail.activity'].sudo().search(
[('res_model', '=', self._name), ('res_id', 'in', record_ids)]
).unlink()
return result
def _read_progress_bar(self, domain, group_by, progress_bar):
group_by_fname = group_by.partition(':')[0]
if not (progress_bar['field'] == 'activity_state' and self._fields[group_by_fname].store):
return super()._read_progress_bar(domain, group_by, progress_bar)
# optimization for 'activity_state'
# explicitly check access rights, since we bypass the ORM
self.check_access_rights('read')
self._flush_search(domain, fields=[group_by_fname], order='id')
self.env['mail.activity'].flush_model(['res_model', 'res_id', 'user_id', 'date_deadline'])
self.env['res.users'].flush_model(['partner_id'])
self.env['res.partner'].flush_model(['tz'])
query = self._where_calc(domain)
self._apply_ir_rules(query, 'read')
gb = group_by.partition(':')[0]
annotated_groupbys = [
self._read_group_process_groupby(gb, query)
for gb in [group_by, 'activity_state']
]
groupby_dict = {gb['groupby']: gb for gb in annotated_groupbys}
for gb in annotated_groupbys:
if gb['field'] == 'activity_state':
gb['qualified_field'] = '"_last_activity_state"."activity_state"'
groupby_terms, _orderby_terms = self._read_group_prepare('activity_state', [], annotated_groupbys, query)
select_terms = [
'%s as "%s"' % (gb['qualified_field'], gb['groupby'])
for gb in annotated_groupbys
]
from_clause, where_clause, where_params = query.get_sql()
tz = self._context.get('tz') or self.env.user.tz or 'UTC'
select_query = """
SELECT 1 AS id, count(*) AS "__count", {fields}
FROM {from_clause}
JOIN (
SELECT res_id,
CASE
WHEN min(date_deadline - (now() AT TIME ZONE COALESCE(res_partner.tz, %s))::date) > 0 THEN 'planned'
WHEN min(date_deadline - (now() AT TIME ZONE COALESCE(res_partner.tz, %s))::date) < 0 THEN 'overdue'
WHEN min(date_deadline - (now() AT TIME ZONE COALESCE(res_partner.tz, %s))::date) = 0 THEN 'today'
ELSE null
END AS activity_state
FROM mail_activity
JOIN res_users ON (res_users.id = mail_activity.user_id)
JOIN res_partner ON (res_partner.id = res_users.partner_id)
WHERE res_model = '{model}'
GROUP BY res_id
) AS "_last_activity_state" ON ("{table}".id = "_last_activity_state".res_id)
WHERE {where_clause}
GROUP BY {group_by}
""".format(
fields=', '.join(select_terms),
from_clause=from_clause,
model=self._name,
table=self._table,
where_clause=where_clause or '1=1',
group_by=', '.join(groupby_terms),
)
num_from_params = from_clause.count('%s')
where_params[num_from_params:num_from_params] = [tz] * 3 # timezone after from parameters
self.env.cr.execute(select_query, where_params)
fetched_data = self.env.cr.dictfetchall()
self._read_group_resolve_many2x_fields(fetched_data, annotated_groupbys)
data = [
{key: self._read_group_prepare_data(key, val, groupby_dict)
for key, val in row.items()}
for row in fetched_data
]
return [
self._read_group_format_result(vals, annotated_groupbys, [group_by], domain)
for vals in data
]
def toggle_active(self):
""" Before archiving the record we should also remove its ongoing
activities. Otherwise they stay in the systray and concerning archived
records it makes no sense. """
record_to_deactivate = self.filtered(lambda rec: rec[rec._active_name])
if record_to_deactivate:
# use a sudo to bypass every access rights; all activities should be removed
self.env['mail.activity'].sudo().search([
('res_model', '=', self._name),
('res_id', 'in', record_to_deactivate.ids)
]).unlink()
return super(MailActivityMixin, self).toggle_active()
def activity_send_mail(self, template_id):
""" Automatically send an email based on the given mail.template, given
its ID. """
template = self.env['mail.template'].browse(template_id).exists()
if not template:
return False
for record in self:
record.message_post_with_template(
template_id,
composition_mode='comment'
)
return True
def activity_search(self, act_type_xmlids='', user_id=None, additional_domain=None):
""" Search automated activities on current record set, given a list of activity
types xml IDs. It is useful when dealing with specific types involved in automatic
activities management.
:param act_type_xmlids: list of activity types xml IDs
:param user_id: if set, restrict to activities of that user_id;
:param additional_domain: if set, filter on that domain;
"""
if self.env.context.get('mail_activity_automation_skip'):
return self.env['mail.activity']
Data = self.env['ir.model.data'].sudo()
activity_types_ids = [type_id for type_id in (Data._xmlid_to_res_id(xmlid, raise_if_not_found=False) for xmlid in act_type_xmlids) if type_id]
if not any(activity_types_ids):
return self.env['mail.activity']
domain = [
'&', '&', '&',
('res_model', '=', self._name),
('res_id', 'in', self.ids),
('automated', '=', True),
('activity_type_id', 'in', activity_types_ids)
]
if user_id:
domain = expression.AND([domain, [('user_id', '=', user_id)]])
if additional_domain:
domain = expression.AND([domain, additional_domain])
return self.env['mail.activity'].search(domain)
def activity_schedule(self, act_type_xmlid='', date_deadline=None, summary='', note='', **act_values):
""" Schedule an activity on each record of the current record set.
This method allow to provide as parameter act_type_xmlid. This is an
xml_id of activity type instead of directly giving an activity_type_id.
It is useful to avoid having various "env.ref" in the code and allow
to let the mixin handle access rights.
:param date_deadline: the day the activity must be scheduled on
the timezone of the user must be considered to set the correct deadline
"""
if self.env.context.get('mail_activity_automation_skip'):
return False
if not date_deadline:
date_deadline = fields.Date.context_today(self)
if isinstance(date_deadline, datetime):
_logger.warning("Scheduled deadline should be a date (got %s)", date_deadline)
if act_type_xmlid:
activity_type_id = self.env['ir.model.data']._xmlid_to_res_id(act_type_xmlid, raise_if_not_found=False)
if activity_type_id:
activity_type = self.env['mail.activity.type'].browse(activity_type_id)
else:
activity_type = self._default_activity_type()
else:
activity_type_id = act_values.get('activity_type_id', False)
activity_type = self.env['mail.activity.type'].browse(activity_type_id) if activity_type_id else self.env['mail.activity.type']
model_id = self.env['ir.model']._get(self._name).id
create_vals_list = []
for record in self:
create_vals = {
'activity_type_id': activity_type.id,
'summary': summary or activity_type.summary,
'automated': True,
'note': note or activity_type.default_note,
'date_deadline': date_deadline,
'res_model_id': model_id,
'res_id': record.id,
}
create_vals.update(act_values)
if not create_vals.get('user_id'):
create_vals['user_id'] = activity_type.default_user_id.id or self.env.uid
create_vals_list.append(create_vals)
return self.env['mail.activity'].create(create_vals_list)
def _activity_schedule_with_view(self, act_type_xmlid='', date_deadline=None, summary='', views_or_xmlid='', render_context=None, **act_values):
""" Helper method: Schedule an activity on each record of the current record set.
This method allow to the same mecanism as `activity_schedule`, but provide
2 additionnal parameters:
:param views_or_xmlid: record of ir.ui.view or string representing the xmlid
of the qweb template to render
:type views_or_xmlid: string or recordset
:param render_context: the values required to render the given qweb template
:type render_context: dict
"""
if self.env.context.get('mail_activity_automation_skip'):
return False
view_ref = views_or_xmlid.id if isinstance(views_or_xmlid, models.BaseModel) else views_or_xmlid
render_context = render_context or dict()
activities = self.env['mail.activity']
for record in self:
render_context['object'] = record
note = self.env['ir.qweb']._render(view_ref, render_context, minimal_qcontext=True, raise_if_not_found=False)
activities += record.activity_schedule(act_type_xmlid=act_type_xmlid, date_deadline=date_deadline, summary=summary, note=note, **act_values)
return activities
def activity_reschedule(self, act_type_xmlids, user_id=None, date_deadline=None, new_user_id=None):
""" Reschedule some automated activities. Activities to reschedule are
selected based on type xml ids and optionally by user. Purpose is to be
able to
* update the deadline to date_deadline;
* update the responsible to new_user_id;
"""
if self.env.context.get('mail_activity_automation_skip'):
return False
Data = self.env['ir.model.data'].sudo()
activity_types_ids = [Data._xmlid_to_res_id(xmlid, raise_if_not_found=False) for xmlid in act_type_xmlids]
activity_types_ids = [act_type_id for act_type_id in activity_types_ids if act_type_id]
if not any(activity_types_ids):
return False
activities = self.activity_search(act_type_xmlids, user_id=user_id)
if activities:
write_vals = {}
if date_deadline:
write_vals['date_deadline'] = date_deadline
if new_user_id:
write_vals['user_id'] = new_user_id
activities.write(write_vals)
return activities
def activity_feedback(self, act_type_xmlids, user_id=None, feedback=None, attachment_ids=None):
""" Set activities as done, limiting to some activity types and
optionally to a given user. """
if self.env.context.get('mail_activity_automation_skip'):
return False
Data = self.env['ir.model.data'].sudo()
activity_types_ids = [Data._xmlid_to_res_id(xmlid, raise_if_not_found=False) for xmlid in act_type_xmlids]
activity_types_ids = [act_type_id for act_type_id in activity_types_ids if act_type_id]
if not any(activity_types_ids):
return False
activities = self.activity_search(act_type_xmlids, user_id=user_id)
if activities:
activities.action_feedback(feedback=feedback, attachment_ids=attachment_ids)
return True
def activity_unlink(self, act_type_xmlids, user_id=None):
""" Unlink activities, limiting to some activity types and optionally
to a given user. """
if self.env.context.get('mail_activity_automation_skip'):
return False
Data = self.env['ir.model.data'].sudo()
activity_types_ids = [Data._xmlid_to_res_id(xmlid, raise_if_not_found=False) for xmlid in act_type_xmlids]
activity_types_ids = [act_type_id for act_type_id in activity_types_ids if act_type_id]
if not any(activity_types_ids):
return False
self.activity_search(act_type_xmlids, user_id=user_id).unlink()
return True

View file

@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class MailActivityType(models.Model):
""" Activity Types are used to categorize activities. Each type is a different
kind of activity e.g. call, mail, meeting. An activity can be generic i.e.
available for all models using activities; or specific to a model in which
case res_model field should be used. """
_name = 'mail.activity.type'
_description = 'Activity Type'
_rec_name = 'name'
_order = 'sequence, id'
def _get_model_selection(self):
return [
(model.model, model.name)
for model in self.env['ir.model'].sudo().search(
['&', ('is_mail_thread', '=', True), ('transient', '=', False)])
]
name = fields.Char('Name', required=True, translate=True)
summary = fields.Char('Default Summary', translate=True)
sequence = fields.Integer('Sequence', default=10)
active = fields.Boolean(default=True)
create_uid = fields.Many2one('res.users', index=True)
delay_count = fields.Integer(
'Schedule', default=0,
help='Number of days/week/month before executing the action. It allows to plan the action deadline.')
delay_unit = fields.Selection([
('days', 'days'),
('weeks', 'weeks'),
('months', 'months')], string="Delay units", help="Unit of delay", required=True, default='days')
delay_label = fields.Char(compute='_compute_delay_label')
delay_from = fields.Selection([
('current_date', 'after completion date'),
('previous_activity', 'after previous activity deadline')], string="Delay Type", help="Type of delay", required=True, default='previous_activity')
icon = fields.Char('Icon', help="Font awesome icon e.g. fa-tasks")
decoration_type = fields.Selection([
('warning', 'Alert'),
('danger', 'Error')], string="Decoration Type",
help="Change the background color of the related activities of this type.")
res_model = fields.Selection(selection=_get_model_selection, string="Model",
help='Specify a model if the activity should be specific to a model'
' and not available when managing activities for other models.')
triggered_next_type_id = fields.Many2one(
'mail.activity.type', string='Trigger', compute='_compute_triggered_next_type_id',
inverse='_inverse_triggered_next_type_id', store=True, readonly=False,
domain="['|', ('res_model', '=', False), ('res_model', '=', res_model)]", ondelete='restrict',
help="Automatically schedule this activity once the current one is marked as done.")
chaining_type = fields.Selection([
('suggest', 'Suggest Next Activity'), ('trigger', 'Trigger Next Activity')
], string="Chaining Type", required=True, default="suggest")
suggested_next_type_ids = fields.Many2many(
'mail.activity.type', 'mail_activity_rel', 'activity_id', 'recommended_id', string='Suggest',
domain="['|', ('res_model', '=', False), ('res_model', '=', res_model)]",
compute='_compute_suggested_next_type_ids', inverse='_inverse_suggested_next_type_ids', store=True, readonly=False,
help="Suggest these activities once the current one is marked as done.")
previous_type_ids = fields.Many2many(
'mail.activity.type', 'mail_activity_rel', 'recommended_id', 'activity_id',
domain="['|', ('res_model', '=', False), ('res_model', '=', res_model)]",
string='Preceding Activities')
category = fields.Selection([
('default', 'None'),
('upload_file', 'Upload Document'),
('phonecall', 'Phonecall')
], default='default', string='Action',
help='Actions may trigger specific behavior like opening calendar view or automatically mark as done when a document is uploaded')
mail_template_ids = fields.Many2many('mail.template', string='Email templates')
default_user_id = fields.Many2one("res.users", string="Default User")
default_note = fields.Html(string="Default Note", translate=True)
#Fields for display purpose only
initial_res_model = fields.Selection(selection=_get_model_selection, string='Initial model', compute="_compute_initial_res_model", store=False,
help='Technical field to keep track of the model at the start of editing to support UX related behaviour')
res_model_change = fields.Boolean(string="Model has change", default=False, store=False)
@api.onchange('res_model')
def _onchange_res_model(self):
self.mail_template_ids = self.sudo().mail_template_ids.filtered(lambda template: template.model_id.model == self.res_model)
self.res_model_change = self.initial_res_model and self.initial_res_model != self.res_model
def _compute_initial_res_model(self):
for activity_type in self:
activity_type.initial_res_model = activity_type.res_model
@api.depends('delay_unit', 'delay_count')
def _compute_delay_label(self):
selection_description_values = {
e[0]: e[1] for e in self._fields['delay_unit']._description_selection(self.env)}
for activity_type in self:
unit = selection_description_values[activity_type.delay_unit]
activity_type.delay_label = '%s %s' % (activity_type.delay_count, unit)
@api.depends('chaining_type')
def _compute_suggested_next_type_ids(self):
"""suggested_next_type_ids and triggered_next_type_id should be mutually exclusive"""
for activity_type in self:
if activity_type.chaining_type == 'trigger':
activity_type.suggested_next_type_ids = False
def _inverse_suggested_next_type_ids(self):
for activity_type in self:
if activity_type.suggested_next_type_ids:
activity_type.chaining_type = 'suggest'
@api.depends('chaining_type')
def _compute_triggered_next_type_id(self):
"""suggested_next_type_ids and triggered_next_type_id should be mutually exclusive"""
for activity_type in self:
if activity_type.chaining_type == 'suggest':
activity_type.triggered_next_type_id = False
def _inverse_triggered_next_type_id(self):
for activity_type in self:
if activity_type.triggered_next_type_id:
activity_type.chaining_type = 'trigger'
else:
activity_type.chaining_type = 'suggest'

View file

@ -0,0 +1,279 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import re
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError, UserError
from odoo.tools import is_html_empty, remove_accents
# see rfc5322 section 3.2.3
atext = r"[a-zA-Z0-9!#$%&'*+\-/=?^_`{|}~]"
dot_atom_text = re.compile(r"^%s+(\.%s+)*$" % (atext, atext))
class Alias(models.Model):
"""A Mail Alias is a mapping of an email address with a given Odoo Document
model. It is used by Odoo's mail gateway when processing incoming emails
sent to the system. If the recipient address (To) of the message matches
a Mail Alias, the message will be either processed following the rules
of that alias. If the message is a reply it will be attached to the
existing discussion on the corresponding record, otherwise a new
record of the corresponding model will be created.
This is meant to be used in combination with a catch-all email configuration
on the company's mail server, so that as soon as a new mail.alias is
created, it becomes immediately usable and Odoo will accept email for it.
"""
_name = 'mail.alias'
_description = "Email Aliases"
_rec_name = 'alias_name'
_order = 'alias_model_id, alias_name'
alias_name = fields.Char('Alias Name', copy=False, help="The name of the email alias, e.g. 'jobs' if you want to catch emails for <jobs@example.odoo.com>")
alias_model_id = fields.Many2one('ir.model', 'Aliased Model', required=True, ondelete="cascade",
help="The model (Odoo Document Kind) to which this alias "
"corresponds. Any incoming email that does not reply to an "
"existing record will cause the creation of a new record "
"of this model (e.g. a Project Task)",
# hack to only allow selecting mail_thread models (we might
# (have a few false positives, though)
domain="[('field_id.name', '=', 'message_ids')]")
alias_user_id = fields.Many2one('res.users', 'Owner', default=lambda self: self.env.user,
help="The owner of records created upon receiving emails on this alias. "
"If this field is not set the system will attempt to find the right owner "
"based on the sender (From) address, or will use the Administrator account "
"if no system user is found for that address.")
alias_defaults = fields.Text('Default Values', required=True, default='{}',
help="A Python dictionary that will be evaluated to provide "
"default values when creating new records for this alias.")
alias_force_thread_id = fields.Integer(
'Record Thread ID',
help="Optional ID of a thread (record) to which all incoming messages will be attached, even "
"if they did not reply to it. If set, this will disable the creation of new records completely.")
alias_domain = fields.Char('Alias domain', compute='_compute_alias_domain')
alias_parent_model_id = fields.Many2one(
'ir.model', 'Parent Model',
help="Parent model holding the alias. The model holding the alias reference "
"is not necessarily the model given by alias_model_id "
"(example: project (parent_model) and task (model))")
alias_parent_thread_id = fields.Integer('Parent Record Thread ID', help="ID of the parent record holding the alias (example: project holding the task creation alias)")
alias_contact = fields.Selection([
('everyone', 'Everyone'),
('partners', 'Authenticated Partners'),
('followers', 'Followers only')], default='everyone',
string='Alias Contact Security', required=True,
help="Policy to post a message on the document using the mailgateway.\n"
"- everyone: everyone can post\n"
"- partners: only authenticated partners\n"
"- followers: only followers of the related document or members of following channels\n")
alias_bounced_content = fields.Html(
"Custom Bounced Message", translate=True,
help="If set, this content will automatically be sent out to unauthorized users instead of the default message.")
_sql_constraints = [
('alias_unique', 'UNIQUE(alias_name)', 'Unfortunately this email alias is already used, please choose a unique one')
]
@api.constrains('alias_name')
def _alias_is_ascii(self):
""" The local-part ("display-name" <local-part@domain>) of an
address only contains limited range of ascii characters.
We DO NOT allow anything else than ASCII dot-atom formed
local-part. Quoted-string and internationnal characters are
to be rejected. See rfc5322 sections 3.4.1 and 3.2.3
"""
for alias in self:
if alias.alias_name and not dot_atom_text.match(alias.alias_name):
raise ValidationError(_(
"You cannot use anything else than unaccented latin characters in the alias address (%s).",
alias.alias_name,
))
@api.depends('alias_name')
def _compute_alias_domain(self):
self.alias_domain = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain")
@api.constrains('alias_defaults')
def _check_alias_defaults(self):
for alias in self:
try:
dict(ast.literal_eval(alias.alias_defaults))
except Exception:
raise ValidationError(_('Invalid expression, it must be a literal python dictionary definition e.g. "{\'field\': \'value\'}"'))
@api.model_create_multi
def create(self, vals_list):
""" Creates email.alias records according to the values provided in
``vals`` with 1 alteration:
* ``alias_name`` value may be cleaned by replacing certain unsafe
characters;
:raise UserError: if given alias_name is already assigned or there are
duplicates in given vals_list;
"""
alias_names = [vals['alias_name'] for vals in vals_list if vals.get('alias_name')]
if alias_names:
sanitized_names = self._clean_and_check_unique(alias_names)
for vals in vals_list:
if vals.get('alias_name'):
vals['alias_name'] = sanitized_names[alias_names.index(vals['alias_name'])]
return super(Alias, self).create(vals_list)
def write(self, vals):
""""Raises UserError if given alias name is already assigned"""
if vals.get('alias_name') and self.ids:
if len(self) > 1:
raise UserError(_(
'Email alias %(alias_name)s cannot be used on %(count)d records at the same time. Please update records one by one.',
alias_name=vals['alias_name'], count=len(self)
))
vals['alias_name'] = self._clean_and_check_unique([vals.get('alias_name')])[0]
return super(Alias, self).write(vals)
def name_get(self):
"""Return the mail alias display alias_name, including the implicit
mail catchall domain if exists from config otherwise "New Alias".
e.g. `jobs@mail.odoo.com` or `jobs` or 'New Alias'
"""
res = []
for record in self:
if record.alias_name and record.alias_domain:
res.append((record['id'], "%s@%s" % (record.alias_name, record.alias_domain)))
elif record.alias_name:
res.append((record['id'], "%s" % (record.alias_name)))
else:
res.append((record['id'], _("Inactive Alias")))
return res
def _clean_and_check_mail_catchall_allowed_domains(self, value):
""" The purpose of this system parameter is to avoid the creation
of records from incoming emails with a domain != alias_domain
but that have a pattern matching an internal mail.alias . """
value = [domain.strip().lower() for domain in value.split(',') if domain.strip()]
if not value:
raise ValidationError(_("Value for `mail.catchall.domain.allowed` cannot be validated.\n"
"It should be a comma separated list of domains e.g. example.com,example.org."))
return ",".join(value)
def _clean_and_check_unique(self, names):
"""When an alias name appears to already be an email, we keep the local
part only. A sanitizing / cleaning is also performed on the name. If
name already exists an UserError is raised. """
def _sanitize_alias_name(name):
""" Cleans and sanitizes the alias name """
sanitized_name = remove_accents(name).lower().split('@')[0]
sanitized_name = re.sub(r'[^\w+.]+', '-', sanitized_name)
sanitized_name = re.sub(r'^\.+|\.+$|\.+(?=\.)', '', sanitized_name)
sanitized_name = sanitized_name.encode('ascii', errors='replace').decode()
return sanitized_name
sanitized_names = [_sanitize_alias_name(name) for name in names]
catchall_alias = self.env['ir.config_parameter'].sudo().get_param('mail.catchall.alias')
bounce_alias = self.env['ir.config_parameter'].sudo().get_param('mail.bounce.alias')
alias_domain = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain")
# matches catchall or bounce alias
for sanitized_name in sanitized_names:
if sanitized_name in [catchall_alias, bounce_alias]:
matching_alias_name = '%s@%s' % (sanitized_name, alias_domain) if alias_domain else sanitized_name
raise UserError(
_('The e-mail alias %(matching_alias_name)s is already used as %(alias_duplicate)s alias. Please choose another alias.',
matching_alias_name=matching_alias_name,
alias_duplicate=_('catchall') if sanitized_name == catchall_alias else _('bounce'))
)
# matches existing alias
domain = [('alias_name', 'in', sanitized_names)]
if self:
domain += [('id', 'not in', self.ids)]
matching_alias = self.search(domain, limit=1)
if not matching_alias:
return sanitized_names
sanitized_alias_name = _sanitize_alias_name(matching_alias.alias_name)
matching_alias_name = '%s@%s' % (sanitized_alias_name, alias_domain) if alias_domain else sanitized_alias_name
if matching_alias.alias_parent_model_id and matching_alias.alias_parent_thread_id:
# If parent model and parent thread ID both are set, display document name also in the warning
document_name = self.env[matching_alias.alias_parent_model_id.model].sudo().browse(matching_alias.alias_parent_thread_id).display_name
raise UserError(
_('The e-mail alias %(matching_alias_name)s is already used by the %(document_name)s %(model_name)s. Choose another alias or change it on the other document.',
matching_alias_name=matching_alias_name,
document_name=document_name,
model_name=matching_alias.alias_parent_model_id.name)
)
raise UserError(
_('The e-mail alias %(matching_alias_name)s is already linked with %(alias_model_name)s. Choose another alias or change it on the linked model.',
matching_alias_name=matching_alias_name,
alias_model_name=matching_alias.alias_model_id.name)
)
def open_document(self):
if not self.alias_model_id or not self.alias_force_thread_id:
return False
return {
'view_mode': 'form',
'res_model': self.alias_model_id.model,
'res_id': self.alias_force_thread_id,
'type': 'ir.actions.act_window',
}
def open_parent_document(self):
if not self.alias_parent_model_id or not self.alias_parent_thread_id:
return False
return {
'view_mode': 'form',
'res_model': self.alias_parent_model_id.model,
'res_id': self.alias_parent_thread_id,
'type': 'ir.actions.act_window',
}
def _get_alias_bounced_body_fallback(self, message_dict):
contact_description = self._get_alias_contact_description()
default_email = self.env.company.partner_id.email_formatted if self.env.company.partner_id.email else self.env.company.name
return Markup(
_("""<p>Dear Sender,<br /><br />
The message below could not be accepted by the address %(alias_display_name)s.
Only %(contact_description)s are allowed to contact it.<br /><br />
Please make sure you are using the correct address or contact us at %(default_email)s instead.<br /><br />
Kind Regards,</p>"""
)) % {
'alias_display_name': self.display_name,
'contact_description': contact_description,
'default_email': default_email,
}
def _get_alias_contact_description(self):
if self.alias_contact == 'partners':
return _('addresses linked to registered partners')
return _('some specific addresses')
def _get_alias_bounced_body(self, message_dict):
"""Get the body of the email return in case of bounced email.
:param message_dict: dictionary of mail values
"""
lang_author = False
if message_dict.get('author_id'):
try:
lang_author = self.env['res.partner'].browse(message_dict['author_id']).lang
except:
pass
if lang_author:
self = self.with_context(lang=lang_author)
if not is_html_empty(self.alias_bounced_content):
body = self.alias_bounced_content
else:
body = self._get_alias_bounced_body_fallback(message_dict)
return self.env['ir.qweb']._render('mail.mail_bounce_alias_security', {
'body': body,
'message': message_dict
}, minimal_qcontext=True)

View file

@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
class AliasMixin(models.AbstractModel):
""" A mixin for models that inherits mail.alias. This mixin initializes the
alias_id column in database, and manages the expected one-to-one
relation between your model and mail aliases.
"""
_name = 'mail.alias.mixin'
_inherits = {'mail.alias': 'alias_id'}
_description = 'Email Aliases Mixin'
ALIAS_WRITEABLE_FIELDS = ['alias_name', 'alias_contact', 'alias_defaults', 'alias_bounced_content']
alias_id = fields.Many2one('mail.alias', string='Alias', ondelete="restrict", required=True)
# --------------------------------------------------
# CRUD
# --------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
""" Create a record with each ``vals`` or ``vals_list`` and create a corresponding alias. """
# prepare all alias values
alias_vals_list, record_vals_list = [], []
for vals in vals_list:
new_alias = not vals.get('alias_id')
if new_alias:
alias_vals, record_vals = self._alias_filter_fields(vals)
alias_vals.update(self._alias_get_creation_values())
alias_vals_list.append(alias_vals)
record_vals_list.append(record_vals)
# create all aliases
alias_ids = []
if alias_vals_list:
alias_ids = iter(self.env['mail.alias'].sudo().create(alias_vals_list).ids)
# update alias values in create vals directly
valid_vals_list = []
record_vals_iter = iter(record_vals_list)
for vals in vals_list:
new_alias = not vals.get('alias_id')
if new_alias:
record_vals = next(record_vals_iter)
record_vals['alias_id'] = next(alias_ids)
valid_vals_list.append(record_vals)
else:
valid_vals_list.append(vals)
records = super(AliasMixin, self).create(valid_vals_list)
for record in records:
record.alias_id.sudo().write(record._alias_get_creation_values())
return records
def write(self, vals):
""" Split writable fields of mail.alias and other fields alias fields will
write with sudo and the other normally """
alias_vals, record_vals = self._alias_filter_fields(vals, filters=self.ALIAS_WRITEABLE_FIELDS)
if record_vals:
super(AliasMixin, self).write(record_vals)
if alias_vals and (record_vals or self.check_access_rights('write', raise_exception=False)):
self.mapped('alias_id').sudo().write(alias_vals)
return True
def unlink(self):
""" Delete the given records, and cascade-delete their corresponding alias. """
aliases = self.mapped('alias_id')
res = super(AliasMixin, self).unlink()
aliases.sudo().unlink()
return res
@api.returns(None, lambda value: value[0])
def copy_data(self, default=None):
data = super(AliasMixin, self).copy_data(default)[0]
for fields_not_writable in set(self.env['mail.alias']._fields.keys()) - set(self.ALIAS_WRITEABLE_FIELDS):
if fields_not_writable in data:
del data[fields_not_writable]
return [data]
def _init_column(self, name):
""" Create aliases for existing rows. """
super(AliasMixin, self)._init_column(name)
if name == 'alias_id':
# as 'mail.alias' records refer to 'ir.model' records, create
# aliases after the reflection of models
self.pool.post_init(self._init_column_alias_id)
def _init_column_alias_id(self):
# both self and the alias model must be present in 'ir.model'
child_ctx = {
'active_test': False, # retrieve all records
'prefetch_fields': False, # do not prefetch fields on records
}
child_model = self.sudo().with_context(child_ctx)
for record in child_model.search([('alias_id', '=', False)]):
# create the alias, and link it to the current record
alias = self.env['mail.alias'].sudo().create(record._alias_get_creation_values())
record.with_context(mail_notrack=True).alias_id = alias
_logger.info('Mail alias created for %s %s (id %s)',
record._name, record.display_name, record.id)
# --------------------------------------------------
# MIXIN TOOL OVERRIDE METHODS
# --------------------------------------------------
def _alias_get_creation_values(self):
""" Return values to create an alias, or to write on the alias after its
creation.
"""
return {
'alias_parent_thread_id': self.id if self.id else False,
'alias_parent_model_id': self.env['ir.model']._get(self._name).id,
}
def _alias_filter_fields(self, values, filters=False):
""" Split the vals dict into two dictionnary of vals, one for alias
field and the other for other fields """
if not filters:
filters = self.env['mail.alias']._fields.keys()
alias_values, record_values = {}, {}
for fname in values.keys():
if fname in filters:
alias_values[fname] = values.get(fname)
else:
record_values[fname] = values.get(fname)
return alias_values, record_values

View file

@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools, _
from odoo.exceptions import UserError
class MailBlackList(models.Model):
""" Model of blacklisted email addresses to stop sending emails."""
_name = 'mail.blacklist'
_inherit = ['mail.thread']
_description = 'Mail Blacklist'
_rec_name = 'email'
email = fields.Char(string='Email Address', required=True, index='trigram', help='This field is case insensitive.',
tracking=True)
active = fields.Boolean(default=True, tracking=True)
_sql_constraints = [
('unique_email', 'unique (email)', 'Email address already exists!')
]
@api.model_create_multi
def create(self, values):
# First of all, extract values to ensure emails are really unique (and don't modify values in place)
new_values = []
all_emails = []
for value in values:
email = tools.email_normalize(value.get('email'))
if not email:
raise UserError(_('Invalid email address %r', value['email']))
if email in all_emails:
continue
all_emails.append(email)
new_value = dict(value, email=email)
new_values.append(new_value)
""" To avoid crash during import due to unique email, return the existing records if any """
sql = '''SELECT email, id FROM mail_blacklist WHERE email = ANY(%s)'''
emails = [v['email'] for v in new_values]
self._cr.execute(sql, (emails,))
bl_entries = dict(self._cr.fetchall())
to_create = [v for v in new_values if v['email'] not in bl_entries]
# TODO DBE Fixme : reorder ids according to incoming ids.
results = super(MailBlackList, self).create(to_create)
return self.env['mail.blacklist'].browse(bl_entries.values()) | results
def write(self, values):
if 'email' in values:
values['email'] = tools.email_normalize(values['email'])
return super(MailBlackList, self).write(values)
def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
""" Override _search in order to grep search on email field and make it
lower-case and sanitized """
if args:
new_args = []
for arg in args:
if isinstance(arg, (list, tuple)) and arg[0] == 'email' and isinstance(arg[2], str):
normalized = tools.email_normalize(arg[2])
if normalized:
new_args.append([arg[0], arg[1], normalized])
else:
new_args.append(arg)
else:
new_args.append(arg)
else:
new_args = args
return super(MailBlackList, self)._search(new_args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid)
def _add(self, email):
normalized = tools.email_normalize(email)
record = self.env["mail.blacklist"].with_context(active_test=False).search([('email', '=', normalized)])
if len(record) > 0:
record.action_unarchive()
else:
record = self.create({'email': email})
return record
def action_remove_with_reason(self, email, reason=None):
record = self._remove(email)
if reason:
record.message_post(body=_("Unblacklisting Reason: %s", reason))
return record
def _remove(self, email):
normalized = tools.email_normalize(email)
record = self.env["mail.blacklist"].with_context(active_test=False).search([('email', '=', normalized)])
if len(record) > 0:
record.action_archive()
else:
record = record.create({'email': email, 'active': False})
return record
def mail_action_blacklist_remove(self):
return {
'name': _('Are you sure you want to unblacklist this Email Address?'),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'mail.blacklist.remove',
'target': 'new',
}
def action_add(self):
self._add(self.email)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,242 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from werkzeug.exceptions import NotFound
from odoo import api, fields, models, _
from odoo.exceptions import AccessError
from odoo.osv import expression
class ChannelMember(models.Model):
_name = 'mail.channel.member'
_description = 'Listeners of a Channel'
_table = 'mail_channel_member'
_rec_names_search = ['partner_id', 'guest_id']
_bypass_create_check = {}
# identity
partner_id = fields.Many2one('res.partner', string='Recipient', ondelete='cascade', index=True)
guest_id = fields.Many2one(string="Guest", comodel_name='mail.guest', ondelete='cascade', readonly=True, index=True)
partner_email = fields.Char('Email', related='partner_id.email', related_sudo=False)
# channel
channel_id = fields.Many2one('mail.channel', string='Channel', ondelete='cascade', readonly=True, required=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")
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='mail.channel.rtc.session', inverse_name='channel_member_id')
rtc_inviting_session_id = fields.Many2one('mail.channel.rtc.session', string='Ringing session')
@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,
mail_channel_member.id
FROM mail_message
INNER JOIN mail_channel_member
ON mail_channel_member.channel_id = mail_message.res_id
WHERE mail_message.model = 'mail.channel'
AND mail_message.message_type NOT IN ('notification', 'user_notification')
AND (
mail_message.id > mail_channel_member.seen_message_id
OR mail_channel_member.seen_message_id IS NULL
)
AND mail_channel_member.id IN %(ids)s
GROUP BY mail_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
def name_get(self):
return [(record.id, record.partner_id.name or record.guest_id.name) for record in self]
def init(self):
self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS mail_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 mail_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):
"""Similar access rule as the access rule of the mail channel.
It can not be implemented in XML, because when the record will be created, the
partner will be added in the channel and the security rule will always authorize
the creation.
"""
if not self.env.is_admin() and not self.env.context.get('mail_create_bypass_create_check') is self._bypass_create_check:
for vals in vals_list:
if 'channel_id' in vals:
channel_id = self.env['mail.channel'].browse(vals['channel_id'])
if not channel_id._can_invite(vals.get('partner_id')):
raise AccessError(_('This user can not be added in this channel'))
return super().create(vals_list)
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):
self.sudo().rtc_session_ids.unlink()
return super().unlink()
@api.model
def _get_as_sudo_from_request_or_raise(self, request, channel_id):
channel_member = self._get_as_sudo_from_request(request=request, channel_id=channel_id)
if not channel_member:
raise NotFound()
return channel_member
@api.model
def _get_as_sudo_from_request(self, request, channel_id):
""" Seeks a channel member matching the provided `channel_id` and the
current user or guest.
:param channel_id: The id of the channel of which the user/guest is
expected to be member.
:type channel_id: int
:return: A record set containing the channel member if found, or an
empty record set otherwise. In case of guest, the record is returned
with the 'guest' record in the context.
:rtype: mail.channel.member
"""
if request.session.uid:
return self.env['mail.channel.member'].sudo().search([('channel_id', '=', channel_id), ('partner_id', '=', self.env.user.partner_id.id)], limit=1)
guest = self.env['mail.guest']._get_guest_from_request(request)
if guest:
return guest.env['mail.channel.member'].sudo().search([('channel_id', '=', channel_id), ('guest_id', '=', guest.id)], limit=1)
return self.env['mail.channel.member'].sudo()
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._mail_channel_member_format().get(member)
formatted_member['isTyping'] = is_typing
notifications.append([member.channel_id, 'mail.channel.member/typing_status', formatted_member])
notifications.append([member.channel_id.uuid, 'mail.channel.member/typing_status', formatted_member]) # notify livechat users
self.env['bus.bus']._sendmany(notifications)
def _mail_channel_member_format(self, fields=None):
if not fields:
fields = {'id': True, 'channel': {}, 'persona': {}}
members_formatted_data = {}
for member in self:
data = {}
if 'id' in fields:
data['id'] = member.id
if 'channel' in fields:
data['channel'] = member.channel_id._channel_format(fields=fields.get('channel')).get(member.channel_id)
if 'persona' in fields:
if member.partner_id:
persona = {'partner': member._get_partner_data(fields=fields.get('persona', {}).get('partner'))}
if member.guest_id:
persona = {'guest': member.guest_id.sudo()._guest_format(fields=fields.get('persona', {}).get('guest')).get(member.guest_id)}
data['persona'] = persona
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(fields=fields).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['mail.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)
res = {
'iceServers': self.env['mail.ice.server']._get_ice_servers() or False,
'rtcSessions': [
('insert', [rtc_session_sudo._mail_rtc_session_format() for rtc_session_sudo in current_rtc_sessions]),
('insert-and-unlink', [{'id': missing_rtc_session_sudo.id} for missing_rtc_session_sudo in outdated_rtc_sessions]),
],
'sessionId': rtc_session.id,
}
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'] = [('insert', list(invited_members._mail_channel_member_format(fields={'id': True, 'channel': {}, 'persona': {'partner': {'id', 'name', 'im_status'}, 'guest': {'id', 'name', 'im_status'}}}).values()))]
return res
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['mail.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['mail.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.thread/insert', {
'id': self.channel_id.id,
'model': 'mail.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': 'mail.channel'}
channel_data['invitedMembers'] = [('insert', list(members._mail_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.thread/insert', channel_data)
return members

View file

@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
class MailRtcSession(models.Model):
_name = 'mail.channel.rtc.session'
_description = 'Mail RTC session'
channel_member_id = fields.Many2one('mail.channel.member', required=True, ondelete='cascade')
channel_id = fields.Many2one('mail.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, 'mail.channel/rtc_sessions_update', {
'id': channel.id,
'rtcSessions': [('insert', 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()
notifications = [(channel, 'mail.channel/rtc_sessions_update', {
'id': channel.id,
'rtcSessions': [('insert-and-unlink', [{'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, 'mail.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 valid_values})
session_data = self._mail_rtc_session_format()
self.env['bus.bus']._sendone(self.channel_id, 'mail.channel.rtc.session/insert', session_data)
@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):
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 mail.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['mail.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, 'mail.channel.rtc.session/peer_notification', payload) for target, payload in payload_by_target.items()])
def _mail_rtc_session_format(self, fields=None):
self.ensure_one()
if not fields:
fields = {'id': True, 'channelMember': {'id': True, 'channel': {}, 'persona': {'partner': {'id', 'name', 'im_status'}, 'guest': {'id', 'name', 'im_status'}}}, 'isCameraOn': True, 'isDeaf': True, 'isSelfMuted': True, 'isScreenSharingOn': True}
vals = {}
if 'id' in fields:
vals['id'] = self.id
if 'channelMember' in fields:
vals['channelMember'] = self.channel_member_id._mail_channel_member_format(fields=fields.get('channelMember')).get(self.channel_member_id)
if 'isCameraOn' in fields:
vals['isCameraOn'] = self.is_camera_on
if 'isDeaf' in fields:
vals['isDeaf'] = self.is_deaf
if 'isSelfMuted' in fields:
vals['isSelfMuted'] = self.is_muted
if 'isScreenSharingOn' in fields:
vals['isScreenSharingOn'] = self.is_screen_sharing_on
return vals
def _mail_rtc_session_format_by_channel(self):
data = {}
for rtc_session in self:
data.setdefault(rtc_session.channel_id, []).append(rtc_session._mail_rtc_session_format())
return data
@api.model
def _inactive_rtc_session_domain(self):
return [('write_date', '<', fields.Datetime.now() - relativedelta(minutes=1))]

View file

@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools, _
class MailComposerMixin(models.AbstractModel):
""" Mixin used to edit and render some fields used when sending emails or
notifications based on a mail template.
Main current purpose is to hide details related to subject and body computation
and rendering based on a mail.template. It also give the base tools to control
who is allowed to edit body, notably when dealing with templating language
like inline_template or qweb.
It is meant to evolve in a near future with upcoming support of qweb and fine
grain control of rendering access.
"""
_name = 'mail.composer.mixin'
_inherit = 'mail.render.mixin'
_description = 'Mail Composer Mixin'
# Content
subject = fields.Char('Subject', compute='_compute_subject', readonly=False, store=True, compute_sudo=False)
body = fields.Html('Contents', compute='_compute_body', render_engine='qweb', store=True, readonly=False, sanitize=False, compute_sudo=False)
template_id = fields.Many2one('mail.template', 'Mail Template', domain="[('model', '=', render_model)]")
# Access
is_mail_template_editor = fields.Boolean('Is Editor', compute='_compute_is_mail_template_editor')
can_edit_body = fields.Boolean('Can Edit Body', compute='_compute_can_edit_body')
@api.depends('template_id')
def _compute_subject(self):
for composer_mixin in self:
if composer_mixin.template_id:
composer_mixin.subject = composer_mixin.template_id.subject
elif not composer_mixin.subject:
composer_mixin.subject = False
@api.depends('template_id')
def _compute_body(self):
for composer_mixin in self:
if composer_mixin.template_id:
composer_mixin.body = composer_mixin.template_id.body_html
elif not composer_mixin.body:
composer_mixin.body = False
@api.depends_context('uid')
def _compute_is_mail_template_editor(self):
is_mail_template_editor = self.env.is_admin() or self.env.user.has_group('mail.group_mail_template_editor')
for record in self:
record.is_mail_template_editor = is_mail_template_editor
@api.depends('template_id', 'is_mail_template_editor')
def _compute_can_edit_body(self):
for record in self:
record.can_edit_body = (
record.is_mail_template_editor
or not record.template_id
)
def _render_field(self, field, *args, **kwargs):
"""Render the given field on the given records.
This method bypass the rights when needed to
be able to render the template values in mass mode.
"""
if field not in self._fields:
raise ValueError(_("The field %s does not exist on the model %s", field, self._name))
composer_value = self[field]
if (
not self.template_id
or self.is_mail_template_editor
):
# Do not need to bypass the verification
return super(MailComposerMixin, self)._render_field(field, *args, **kwargs)
template_field = 'body_html' if field == 'body' else field
assert template_field in self.template_id._fields
template_value = self.template_id[template_field]
if field == 'body':
sanitized_template_value = tools.html_sanitize(template_value)
if not self.can_edit_body or composer_value in (sanitized_template_value, template_value):
# Take the previous body which we can trust without HTML editor reformatting
self.body = self.template_id.body_html
return super(MailComposerMixin, self.sudo())._render_field(field, *args, **kwargs)
elif composer_value == template_value:
# The value is the same as the mail template so we trust it
return super(MailComposerMixin, self.sudo())._render_field(field, *args, **kwargs)
return super(MailComposerMixin, self)._render_field(field, *args, **kwargs)

View file

@ -0,0 +1,485 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
import itertools
from odoo import api, fields, models, Command
class Followers(models.Model):
""" mail_followers holds the data related to the follow mechanism inside
Odoo. Partners can choose to follow documents (records) of any kind
that inherits from mail.thread. Following documents allow to receive
notifications for new messages. A subscription is characterized by:
:param: res_model: model of the followed objects
:param: res_id: ID of resource (may be 0 for every objects)
"""
_name = 'mail.followers'
_rec_name = 'partner_id'
_log_access = False
_description = 'Document Followers'
# Note. There is no integrity check on model names for performance reasons.
# However, followers of unlinked models are deleted by models themselves
# (see 'ir.model' inheritance).
res_model = fields.Char(
'Related Document Model Name', required=True, index=True)
res_id = fields.Many2oneReference(
'Related Document ID', index=True, help='Id of the followed resource', model_field='res_model')
partner_id = fields.Many2one(
'res.partner', string='Related Partner', index=True, ondelete='cascade', required=True, domain=[('type', '!=', 'private')])
subtype_ids = fields.Many2many(
'mail.message.subtype', string='Subtype',
help="Message subtypes followed, meaning subtypes that will be pushed onto the user's Wall.")
name = fields.Char('Name', related='partner_id.name')
email = fields.Char('Email', related='partner_id.email')
is_active = fields.Boolean('Is Active', related='partner_id.active')
def _invalidate_documents(self, vals_list=None):
""" Invalidate the cache of the documents followed by ``self``.
Modifying followers change access rights to individual documents. As the
cache may contain accessible/inaccessible data, one has to refresh it.
"""
to_invalidate = defaultdict(list)
for record in (vals_list or [{'res_model': rec.res_model, 'res_id': rec.res_id} for rec in self]):
if record.get('res_id'):
to_invalidate[record.get('res_model')].append(record.get('res_id'))
@api.model_create_multi
def create(self, vals_list):
res = super(Followers, self).create(vals_list)
res._invalidate_documents(vals_list)
return res
def write(self, vals):
if 'res_model' in vals or 'res_id' in vals:
self._invalidate_documents()
res = super(Followers, self).write(vals)
if any(x in vals for x in ['res_model', 'res_id', 'partner_id']):
self._invalidate_documents()
return res
def unlink(self):
self._invalidate_documents()
return super(Followers, self).unlink()
_sql_constraints = [
('mail_followers_res_partner_res_model_id_uniq', 'unique(res_model,res_id,partner_id)', 'Error, a partner cannot follow twice the same object.'),
]
# --------------------------------------------------
# Private tools methods to fetch followers data
# --------------------------------------------------
def _get_recipient_data(self, records, message_type, subtype_id, pids=None):
""" Private method allowing to fetch recipients data based on a subtype.
Purpose of this method is to fetch all data necessary to notify recipients
in a single query. It fetches data from
* followers (partners and channels) of records that follow the given
subtype if records and subtype are set;
* partners if pids is given;
:param records: fetch data from followers of ``records`` that follow
``subtype_id``;
:param message_type: mail.message.message_type in order to allow custom
behavior depending on it (SMS for example);
:param subtype_id: mail.message.subtype to check against followers;
:param pids: additional set of partner IDs from which to fetch recipient
data independently from following status;
:return dict: recipients data based on record.ids if given, else a generic
'0' key to keep a dict-like return format. Each item is a dict based on
recipients partner ids formatted like
{'active': whether partner is active;
'id': res.partner ID;
'is_follower': True if linked to a record and if partner is a follower;
'lang': lang of the partner;
'groups': groups of the partner's user. If several users exist preference
is given to internal user, then share users. In case of multiples
users of same kind groups are unioned;
'notif': notification type ('inbox' or 'email'). Overrides may change
this value (e.g. 'sms' in sms module);
'share': if partner is a customer (no user or share user);
'ushare': if partner has users, whether all are shared (public or portal);
'type': summary of partner 'usage' (portal, customer, internal user);
}
"""
self.env['mail.followers'].flush_model(['partner_id', 'subtype_ids'])
self.env['mail.message.subtype'].flush_model(['internal'])
self.env['res.users'].flush_model(['notification_type', 'active', 'partner_id', 'groups_id'])
self.env['res.partner'].flush_model(['active', 'partner_share'])
self.env['res.groups'].flush_model(['users'])
# if we have records and a subtype: we have to fetch followers, unless being
# in user notification mode (contact only pids)
if message_type != 'user_notification' and records and subtype_id:
query = """
WITH sub_followers AS (
SELECT fol.partner_id AS pid,
fol.id AS fid,
fol.res_id AS res_id,
TRUE as is_follower,
COALESCE(subrel.follow, FALSE) AS subtype_follower,
COALESCE(subrel.internal, FALSE) AS internal
FROM mail_followers fol
LEFT JOIN LATERAL (
SELECT TRUE AS follow,
subtype.internal AS internal
FROM mail_followers_mail_message_subtype_rel m
LEFT JOIN mail_message_subtype subtype ON subtype.id = m.mail_message_subtype_id
WHERE m.mail_followers_id = fol.id AND m.mail_message_subtype_id = %s
) subrel ON TRUE
WHERE fol.res_model = %s
AND fol.res_id IN %s
UNION ALL
SELECT res_partner.id AS pid,
0 AS fid,
0 AS res_id,
FALSE as is_follower,
FALSE as subtype_follower,
FALSE as internal
FROM res_partner
WHERE res_partner.id = ANY(%s)
)
SELECT partner.id as pid,
partner.active as active,
partner.lang as lang,
partner.partner_share as pshare,
sub_user.uid as uid,
COALESCE(sub_user.share, FALSE) as ushare,
COALESCE(sub_user.notification_type, 'email') as notif,
sub_user.groups as groups,
sub_followers.res_id as res_id,
sub_followers.is_follower as _insert_followerslower
FROM res_partner partner
JOIN sub_followers ON sub_followers.pid = partner.id
AND (sub_followers.internal IS NOT TRUE OR partner.partner_share IS NOT TRUE)
LEFT JOIN LATERAL (
SELECT users.id AS uid,
users.share AS share,
users.notification_type AS notification_type,
ARRAY_AGG(groups_rel.gid) FILTER (WHERE groups_rel.gid IS NOT NULL) AS groups
FROM res_users users
LEFT JOIN res_groups_users_rel groups_rel ON groups_rel.uid = users.id
WHERE users.partner_id = partner.id AND users.active
GROUP BY users.id,
users.share,
users.notification_type
ORDER BY users.share ASC NULLS FIRST, users.id ASC
FETCH FIRST ROW ONLY
) sub_user ON TRUE
WHERE sub_followers.subtype_follower OR partner.id = ANY(%s)
"""
params = [subtype_id, records._name, tuple(records.ids), list(pids or []), list(pids or [])]
self.env.cr.execute(query, tuple(params))
res = self.env.cr.fetchall()
# partner_ids and records: no sub query for followers but check for follower status
elif pids and records:
params = []
query = """
SELECT partner.id as pid,
partner.active as active,
partner.lang as lang,
partner.partner_share as pshare,
sub_user.uid as uid,
COALESCE(sub_user.share, FALSE) as ushare,
COALESCE(sub_user.notification_type, 'email') as notif,
sub_user.groups as groups,
ARRAY_AGG(fol.res_id) FILTER (WHERE fol.res_id IS NOT NULL) AS res_ids
FROM res_partner partner
LEFT JOIN mail_followers fol ON fol.partner_id = partner.id
AND fol.res_model = %s
AND fol.res_id IN %s
LEFT JOIN LATERAL (
SELECT users.id AS uid,
users.share AS share,
users.notification_type AS notification_type,
ARRAY_AGG(groups_rel.gid) FILTER (WHERE groups_rel.gid IS NOT NULL) AS groups
FROM res_users users
LEFT JOIN res_groups_users_rel groups_rel ON groups_rel.uid = users.id
WHERE users.partner_id = partner.id AND users.active
GROUP BY users.id,
users.share,
users.notification_type
ORDER BY users.share ASC NULLS FIRST, users.id ASC
FETCH FIRST ROW ONLY
) sub_user ON TRUE
WHERE partner.id IN %s
GROUP BY partner.id,
sub_user.uid,
sub_user.share,
sub_user.notification_type,
sub_user.groups
"""
params = [records._name, tuple(records.ids), tuple(pids)]
self.env.cr.execute(query, tuple(params))
simplified_res = self.env.cr.fetchall()
# simplified query contains res_ids -> flatten it by making it a list
# with res_id and add follower status
res = []
for item in simplified_res:
res_ids = item[-1]
if not res_ids: # keep res_ids Falsy (global), set as not follower
flattened = [list(item) + [False]]
else: # generate an entry for each res_id with partner being follower
flattened = [list(item[:-1]) + [res_id, True]
for res_id in res_ids]
res += flattened
# only partner ids: no follower status involved, fetch only direct recipients information
elif pids:
query = """
SELECT partner.id as pid,
partner.active as active,
partner.lang as lang,
partner.partner_share as pshare,
sub_user.uid as uid,
COALESCE(sub_user.share, FALSE) as ushare,
COALESCE(sub_user.notification_type, 'email') as notif,
sub_user.groups as groups,
0 as res_id,
FALSE as is_follower
FROM res_partner partner
LEFT JOIN LATERAL (
SELECT users.id AS uid,
users.share AS share,
users.notification_type AS notification_type,
ARRAY_AGG(groups_rel.gid) FILTER (WHERE groups_rel.gid IS NOT NULL) AS groups
FROM res_users users
LEFT JOIN res_groups_users_rel groups_rel ON groups_rel.uid = users.id
WHERE users.partner_id = partner.id AND users.active
GROUP BY users.id,
users.share,
users.notification_type
ORDER BY users.share ASC NULLS FIRST, users.id ASC
FETCH FIRST ROW ONLY
) sub_user ON TRUE
WHERE partner.id IN %s
GROUP BY partner.id,
sub_user.uid,
sub_user.share,
sub_user.notification_type,
sub_user.groups
"""
params = [tuple(pids)]
self.env.cr.execute(query, tuple(params))
res = self.env.cr.fetchall()
else:
res = []
res_ids = records.ids if records else [0]
doc_infos = dict((res_id, {}) for res_id in res_ids)
for (partner_id, is_active, lang, pshare, uid, ushare, notif, groups, res_id, is_follower) in res:
to_update = [res_id] if res_id else res_ids
for res_id_to_update in to_update:
# avoid updating already existing information, unnecessary dict update
if not res_id and partner_id in doc_infos[res_id_to_update]:
continue
follower_data = {
'active': is_active,
'id': partner_id,
'is_follower': is_follower,
'lang': lang,
'groups': set(groups or []),
'notif': notif,
'share': pshare,
'uid': uid,
'ushare': ushare,
}
# additional information
if follower_data['ushare']: # any type of share user
follower_data['type'] = 'portal'
elif follower_data['share']: # no user, is share -> customer (partner only)
follower_data['type'] = 'customer'
else: # has a user not share -> internal user
follower_data['type'] = 'user'
doc_infos[res_id_to_update][partner_id] = follower_data
return doc_infos
def _get_subscription_data(self, doc_data, pids, include_pshare=False, include_active=False):
""" Private method allowing to fetch follower data from several documents of a given model.
Followers can be filtered given partner IDs and channel IDs.
:param doc_data: list of pair (res_model, res_ids) that are the documents from which we
want to have subscription data;
:param pids: optional partner to filter; if None take all, otherwise limitate to pids
:param include_pshare: optional join in partner to fetch their share status
:param include_active: optional join in partner to fetch their active flag
:return: list of followers data which is a list of tuples containing
follower ID,
document ID,
partner ID,
followed subtype IDs,
share status of partner (returned only if include_pshare is True)
active flag status of partner (returned only if include_active is True)
"""
self.env['mail.followers'].flush_model()
self.env['res.partner'].flush_model()
# base query: fetch followers of given documents
where_clause = ' OR '.join(['fol.res_model = %s AND fol.res_id IN %s'] * len(doc_data))
where_params = list(itertools.chain.from_iterable((rm, tuple(rids)) for rm, rids in doc_data))
# additional: filter on optional pids
sub_where = []
if pids:
sub_where += ["fol.partner_id IN %s"]
where_params.append(tuple(pids))
elif pids is not None:
sub_where += ["fol.partner_id IS NULL"]
if sub_where:
where_clause += "AND (%s)" % " OR ".join(sub_where)
query = """
SELECT fol.id, fol.res_id, fol.partner_id, array_agg(subtype.id)%s%s
FROM mail_followers fol
%s
LEFT JOIN mail_followers_mail_message_subtype_rel fol_rel ON fol_rel.mail_followers_id = fol.id
LEFT JOIN mail_message_subtype subtype ON subtype.id = fol_rel.mail_message_subtype_id
WHERE %s
GROUP BY fol.id%s%s""" % (
', partner.partner_share' if include_pshare else '',
', partner.active' if include_active else '',
'LEFT JOIN res_partner partner ON partner.id = fol.partner_id' if (include_pshare or include_active) else '',
where_clause,
', partner.partner_share' if include_pshare else '',
', partner.active' if include_active else ''
)
self.env.cr.execute(query, tuple(where_params))
return self.env.cr.fetchall()
# --------------------------------------------------
# Private tools methods to generate new subscription
# --------------------------------------------------
def _insert_followers(self, res_model, res_ids,
partner_ids, subtypes=None,
customer_ids=None, check_existing=True, existing_policy='skip'):
""" Main internal method allowing to create or update followers for documents, given a
res_model and the document res_ids. This method does not handle access rights. This is the
role of the caller to ensure there is no security breach.
:param subtypes: see ``_add_followers``. If not given, default ones are computed.
:param customer_ids: see ``_add_default_followers``
:param check_existing: see ``_add_followers``;
:param existing_policy: see ``_add_followers``;
"""
sudo_self = self.sudo().with_context(default_partner_id=False)
if not subtypes: # no subtypes -> default computation, no force, skip existing
new, upd = self._add_default_followers(
res_model, res_ids, partner_ids,
customer_ids=customer_ids,
check_existing=check_existing,
existing_policy=existing_policy)
else:
new, upd = self._add_followers(
res_model, res_ids,
partner_ids, subtypes,
check_existing=check_existing,
existing_policy=existing_policy)
if new:
sudo_self.create([
dict(values, res_id=res_id)
for res_id, values_list in new.items()
for values in values_list
])
for fol_id, values in upd.items():
sudo_self.browse(fol_id).write(values)
def _add_default_followers(self, res_model, res_ids, partner_ids, customer_ids=None,
check_existing=True, existing_policy='skip'):
""" Shortcut to ``_add_followers`` that computes default subtypes. Existing
followers are skipped as their subscription is considered as more important
compared to new default subscription.
:param customer_ids: optional list of partner ids that are customers. It is used if computing
default subtype is necessary and allow to avoid the check of partners being customers (no
user or share user). It is just a matter of saving queries if the info is already known;
:param check_existing: see ``_add_followers``;
:param existing_policy: see ``_add_followers``;
:return: see ``_add_followers``
"""
if not partner_ids:
return dict(), dict()
default, _, external = self.env['mail.message.subtype'].default_subtypes(res_model)
if partner_ids and customer_ids is None:
customer_ids = self.env['res.partner'].sudo().search([('id', 'in', partner_ids), ('partner_share', '=', True)]).ids
p_stypes = dict((pid, external.ids if pid in customer_ids else default.ids) for pid in partner_ids)
return self._add_followers(res_model, res_ids, partner_ids, p_stypes, check_existing=check_existing, existing_policy=existing_policy)
def _add_followers(self, res_model, res_ids, partner_ids, subtypes,
check_existing=False, existing_policy='skip'):
""" Internal method that generates values to insert or update followers. Callers have to
handle the result, for example by making a valid ORM command, inserting or updating directly
follower records, ... This method returns two main data
* first one is a dict which keys are res_ids. Value is a list of dict of values valid for
creating new followers for the related res_id;
* second one is a dict which keys are follower ids. Value is a dict of values valid for
updating the related follower record;
:param subtypes: optional subtypes for new partner followers. This
is a dict whose keys are partner IDs and value subtype IDs for that
partner.
:param channel_subtypes: optional subtypes for new channel followers. This
is a dict whose keys are channel IDs and value subtype IDs for that
channel.
:param check_existing: if True, check for existing followers for given
documents and handle them according to existing_policy parameter.
Setting to False allows to save some computation if caller is sure
there are no conflict for followers;
:param existing policy: if check_existing, tells what to do with already
existing followers:
* skip: simply skip existing followers, do not touch them;
* force: update existing with given subtypes only;
* replace: replace existing with new subtypes (like force without old / new follower);
* update: gives an update dict allowing to add missing subtypes (no subtype removal);
"""
_res_ids = res_ids or [0]
data_fols, doc_pids = dict(), dict((i, set()) for i in _res_ids)
if check_existing and res_ids:
for fid, rid, pid, sids in self._get_subscription_data([(res_model, res_ids)], partner_ids or None):
if existing_policy != 'force':
if pid:
doc_pids[rid].add(pid)
data_fols[fid] = (rid, pid, sids)
if existing_policy == 'force':
self.sudo().browse(data_fols.keys()).unlink()
new, update = dict(), dict()
for res_id in _res_ids:
for partner_id in set(partner_ids or []):
if partner_id not in doc_pids[res_id]:
new.setdefault(res_id, list()).append({
'res_model': res_model,
'partner_id': partner_id,
'subtype_ids': [Command.set(subtypes[partner_id])],
})
elif existing_policy in ('replace', 'update'):
fol_id, sids = next(((key, val[2]) for key, val in data_fols.items() if val[0] == res_id and val[1] == partner_id), (False, []))
new_sids = set(subtypes[partner_id]) - set(sids)
old_sids = set(sids) - set(subtypes[partner_id])
update_cmd = []
if fol_id and new_sids:
update_cmd += [Command.link(sid) for sid in new_sids]
if fol_id and old_sids and existing_policy == 'replace':
update_cmd += [Command.unlink(sid) for sid in old_sids]
if update_cmd:
update[fol_id] = {'subtype_ids': update_cmd}
return new, update

View file

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models, tools
class MailGatewayAllowed(models.Model):
"""List of trusted email address which won't have the quota restriction.
The incoming emails have a restriction of the number of records they can
create with alias, defined by the 2 systems parameters;
- mail.gateway.loop.minutes
- mail.gateway.loop.threshold
But we might have some legit use cases for which we want to receive a ton of emails
from an automated-source. This model stores those trusted source and this restriction
won't apply to them.
"""
_description = 'Mail Gateway Allowed'
_name = 'mail.gateway.allowed'
email = fields.Char('Email Address')
email_normalized = fields.Char(
string='Normalized Email', compute='_compute_email_normalized', store=True, index=True)
@api.depends('email')
def _compute_email_normalized(self):
for record in self:
record.email_normalized = tools.email_normalize(record.email)
@api.model
def get_empty_list_help(self, help_message):
get_param = self.env['ir.config_parameter'].sudo().get_param
LOOP_MINUTES = int(get_param('mail.gateway.loop.minutes', 120))
LOOP_THRESHOLD = int(get_param('mail.gateway.loop.threshold', 20))
return _('''
<p class="o_view_nocontent_smiling_face">
Add addresses to the Allowed List
</p><p>
To protect you from spam and reply loops, Odoo automatically blocks emails
coming to your gateway past a threshold of <b>%i</b> emails every <b>%i</b>
minutes. If there are some addresses from which you need to receive very frequent
updates, you can however add them below and Odoo will let them go through.
</p>
''', LOOP_THRESHOLD, LOOP_MINUTES)

View file

@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import pytz
import uuid
from odoo.tools import consteq
from odoo import _, api, fields, models
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
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='mail.channel', relation='mail_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_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
return self.env['mail.guest']
def _get_guest_from_request(self, request):
parts = request.httprequest.cookies.get(self._cookie_name, '').split(self._cookie_separator)
if len(parts) != 2:
return self.env['mail.guest']
guest_id, guest_access_token = parts
if not guest_id or not guest_access_token:
return self.env['mail.guest']
guest = self.env['mail.guest'].browse(int(guest_id)).sudo().exists()
if not guest or not guest.access_token or not consteq(guest.access_token, guest_access_token):
return self.env['mail.guest']
if not guest.timezone:
timezone = self._get_timezone_from_request(request)
if timezone:
guest._update_timezone(timezone)
return guest.sudo(False).with_context(guest=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
}
bus_notifs = [(channel, 'mail.guest/insert', guest_data) for channel in self.channel_ids]
bus_notifs.append((self, 'mail.guest/insert', 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()
partner_root = self.env.ref('base.partner_root')
return {
'channels': self.channel_ids.channel_info(),
'companyName': self.env.company.name,
'currentGuest': {
'id': self.id,
'name': self.name,
},
'current_partner': False,
'current_user_id': False,
'current_user_settings': False,
'hasLinkPreviewFeature': self.env['mail.link.preview']._is_link_preview_enabled(),
'menu_id': False,
'needaction_inbox_counter': False,
'partner_root': {
'id': partner_root.id,
'name': partner_root.name,
},
'shortcodes': [],
'starred_counter': False,
}
def _guest_format(self, fields=None):
if not fields:
fields = {'id': True, 'name': True, 'im_status': 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
guests_formatted_data[guest] = data
return guests_formatted_data

View file

@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo.addons.mail.tools.credentials import get_twilio_credentials
import logging
import requests
_logger = logging.getLogger(__name__)
class MailIceServer(models.Model):
_name = 'mail.ice.server'
_description = 'ICE server'
server_type = fields.Selection([('stun', 'stun:'), ('turn', 'turn:')], string='Type', required=True, default='stun')
uri = fields.Char('URI', required=True)
username = fields.Char()
credential = fields.Char()
def _get_local_ice_servers(self):
"""
:return: List of up to 5 dict, each of which representing a stun or turn server
"""
# firefox has a hard cap of 5 ice servers
ice_servers = self.sudo().search([], limit=5)
formatted_ice_servers = []
for ice_server in ice_servers:
formatted_ice_server = {
'urls': '%s:%s' % (ice_server.server_type, ice_server.uri),
}
if ice_server.username:
formatted_ice_server['username'] = ice_server.username
if ice_server.credential:
formatted_ice_server['credential'] = ice_server.credential
formatted_ice_servers.append(formatted_ice_server)
return formatted_ice_servers
def _get_ice_servers(self):
"""
:return: List of dict, each of which representing a stun or turn server,
formatted as expected by the specifications of RTCConfiguration.iceServers
"""
if self.env['ir.config_parameter'].sudo().get_param('mail.use_twilio_rtc_servers'):
(account_sid, auth_token) = get_twilio_credentials(self.env)
if account_sid and auth_token:
url = f'https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Tokens.json'
response = requests.post(url, auth=(account_sid, auth_token), timeout=60)
if response.ok:
response_content = response.json()
if response_content:
return response_content['ice_servers']
else:
_logger.warning(f"Failed to obtain TURN servers, status code: {response.status_code}, content: {response.content}.")
return self._get_local_ice_servers()

View file

@ -0,0 +1,155 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from dateutil.relativedelta import relativedelta
from lxml import html, etree
from urllib.parse import urlparse
import requests
from odoo import api, models, fields
class LinkPreview(models.Model):
_name = 'mail.link.preview'
_description = "Store link preview data"
message_id = fields.Many2one('mail.message', string='Message', index=True, ondelete='cascade', required=True)
source_url = fields.Char('URL', required=True)
og_type = fields.Char('Type')
og_title = fields.Char('Title')
og_image = fields.Char('Image')
og_description = fields.Text('Description')
og_mimetype = fields.Char('MIME type')
image_mimetype = fields.Char('Image MIME type')
create_date = fields.Datetime(index=True)
@api.model
def _create_link_previews(self, message):
if not message.body:
return
tree = html.fromstring(message.body)
urls = tree.xpath('//a[not(@data-oe-model)]/@href')
link_previews = self.env['mail.link.preview']
requests_session = requests.Session()
# Some websites are blocking non browser user agent.
requests_session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0'
})
for url in set(urls):
if len(link_previews) >= 5:
break
link_previews |= self.env['mail.link.preview']._create_link_preview(url, message.id, requests_session)
if not link_previews:
return
guest = self.env['mail.guest']._get_guest_from_context()
if message.model == 'mail.channel' and message.res_id:
target = self.env['mail.channel'].browse(message.res_id)
elif self.env.user._is_public() and guest:
target = guest
else:
target = self.env.user.partner_id
self.env['bus.bus']._sendmany([(target, 'mail.link.preview/insert', link_previews._link_preview_format())])
@api.model
def _create_link_preview(self, url, message_id, request_session):
if self._is_domain_throttled(url):
return self.env['mail.link.preview']
link_preview_data = self._get_link_preview_from_url(url, request_session)
if link_preview_data:
link_preview_data['message_id'] = message_id
return self.create(link_preview_data)
return self.env['mail.link.preview']
def _delete_and_notify(self):
notifications = []
guest = self.env['mail.guest']._get_guest_from_context()
for link_preview in self:
if link_preview.message_id.model == 'mail.channel' and link_preview.message_id.res_id:
target = self.env['mail.channel'].browse(link_preview.message_id.res_id)
elif self.env.user._is_public() and guest:
target = guest
else:
target = self.env.user.partner_id
notifications.append((target, 'mail.link.preview/delete', {'id': link_preview.id}))
self.env['bus.bus']._sendmany(notifications)
self.unlink()
@api.model
def _is_link_preview_enabled(self):
link_preview_throttle = int(self.env['ir.config_parameter'].sudo().get_param('mail.link_preview_throttle', 99))
return link_preview_throttle > 0
@api.model
def _is_domain_throttled(self, url):
domain = urlparse(url).netloc
date_interval = fields.Datetime.to_string((datetime.now() - relativedelta(seconds=10)))
call_counter = self.search_count([
('source_url', 'ilike', domain),
('create_date', '>', date_interval),
])
link_preview_throttle = int(self.env['ir.config_parameter'].get_param('mail.link_preview_throttle', 99))
return call_counter > link_preview_throttle
@api.model
def _get_link_preview_from_url(self, url, request_session):
try:
response = request_session.head(url, timeout=3, allow_redirects=True)
except requests.exceptions.RequestException:
return False
if response.status_code != requests.codes.ok:
return False
image_mimetype = (
'image/bmp',
'image/gif',
'image/jpeg',
'image/png',
'image/tiff',
'image/x-icon',
)
if not response.headers.get('Content-Type'):
return False
# Content-Type header can return a charset, but we just need the
# mimetype (eg: image/jpeg;charset=ISO-8859-1)
content_type = response.headers['Content-Type'].split(';')
if response.headers['Content-Type'].startswith(image_mimetype):
return {
'image_mimetype': content_type[0],
'source_url': url,
}
if response.headers['Content-Type'].startswith('text/html'):
return self._get_link_preview_from_html(url, request_session)
return False
def _get_link_preview_from_html(self, url, request_session):
response = request_session.get(url, timeout=3)
parser = etree.HTMLParser(encoding=response.encoding)
tree = html.fromstring(response.content, parser=parser)
og_title = tree.xpath('//meta[@property="og:title"]/@content')
if not og_title:
return False
og_description = tree.xpath('//meta[@property="og:description"]/@content')
og_type = tree.xpath('//meta[@property="og:type"]/@content')
og_image = tree.xpath('//meta[@property="og:image"]/@content')
og_mimetype = tree.xpath('//meta[@property="og:image:type"]/@content')
return {
'og_description': og_description[0] if og_description else None,
'og_image': og_image[0] if og_image else None,
'og_mimetype': og_mimetype[0] if og_mimetype else None,
'og_title': og_title[0],
'og_type': og_type[0] if og_type else None,
'source_url': url,
}
def _link_preview_format(self):
return [{
'id': preview.id,
'message': {'id': preview.message_id.id},
'image_mimetype': preview.image_mimetype,
'og_description': preview.og_description,
'og_image': preview.og_image,
'og_mimetype': preview.og_mimetype,
'og_title': preview.og_title,
'og_type': preview.og_type,
'source_url': preview.source_url,
} for preview in self]

View file

@ -0,0 +1,641 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import base64
import datetime
import logging
import psycopg2
import smtplib
import threading
import re
import pytz
from collections import defaultdict
from dateutil.parser import parse
from odoo import _, api, fields, models
from odoo import tools
from odoo.addons.base.models.ir_mail_server import MailDeliveryException
_logger = logging.getLogger(__name__)
class MailMail(models.Model):
""" Model holding RFC2822 email messages to send. This model also provides
facilities to queue and send new email messages. """
_name = 'mail.mail'
_description = 'Outgoing Mails'
_inherits = {'mail.message': 'mail_message_id'}
_order = 'id desc'
_rec_name = 'subject'
@api.model
def default_get(self, fields):
# protection for `default_type` values leaking from menu action context (e.g. for invoices)
# To remove when automatic context propagation is removed in web client
if self._context.get('default_type') not in self._fields['message_type'].base_field.selection:
self = self.with_context(dict(self._context, default_type=None))
if self._context.get('default_state') not in self._fields['state'].base_field.selection:
self = self.with_context(dict(self._context, default_state='outgoing'))
return super(MailMail, self).default_get(fields)
# content
mail_message_id = fields.Many2one('mail.message', 'Message', required=True, ondelete='cascade', index=True, auto_join=True)
mail_message_id_int = fields.Integer(compute='_compute_mail_message_id_int', compute_sudo=True)
body_html = fields.Text('Text Contents', help="Rich-text/HTML message")
body_content = fields.Html('Rich-text Contents', sanitize=True, compute='_compute_body_content', search="_search_body_content")
references = fields.Text('References', help='Message references, such as identifiers of previous messages', readonly=1)
headers = fields.Text('Headers', copy=False)
restricted_attachment_count = fields.Integer('Restricted attachments', compute='_compute_restricted_attachments')
unrestricted_attachment_ids = fields.Many2many('ir.attachment', string='Unrestricted Attachments',
compute='_compute_restricted_attachments', inverse='_inverse_unrestricted_attachment_ids')
# Auto-detected based on create() - if 'mail_message_id' was passed then this mail is a notification
# and during unlink() we will not cascade delete the parent and its attachments
is_notification = fields.Boolean('Notification Email', help='Mail has been created to notify people of an existing mail.message')
# recipients: include inactive partners (they may have been archived after
# the message was sent, but they should remain visible in the relation)
email_to = fields.Text('To', help='Message recipients (emails)')
email_cc = fields.Char('Cc', help='Carbon copy message recipients')
recipient_ids = fields.Many2many('res.partner', string='To (Partners)',
context={'active_test': False})
# process
state = fields.Selection([
('outgoing', 'Outgoing'),
('sent', 'Sent'),
('received', 'Received'),
('exception', 'Delivery Failed'),
('cancel', 'Cancelled'),
], 'Status', readonly=True, copy=False, default='outgoing')
failure_type = fields.Selection(selection=[
# generic
("unknown", "Unknown error"),
# mail
("mail_email_invalid", "Invalid email address"),
("mail_email_missing", "Missing email"),
("mail_smtp", "Connection failed (outgoing mail server problem)"),
# mass mode
("mail_bl", "Blacklisted Address"),
("mail_optout", "Opted Out"),
("mail_dup", "Duplicated Email"),
], string='Failure type')
failure_reason = fields.Text(
'Failure Reason', readonly=1, copy=False,
help="Failure reason. This is usually the exception thrown by the email server, stored to ease the debugging of mailing issues.")
auto_delete = fields.Boolean(
'Auto Delete',
help="This option permanently removes any track of email after it's been sent, including from the Technical menu in the Settings, in order to preserve storage space of your Odoo database.")
# Unused since v16, to remove in master.
to_delete = fields.Boolean('To Delete', help='If set, the mail will be deleted during the next Email Queue CRON run.')
scheduled_date = fields.Datetime('Scheduled Send Date',
help="If set, the queue manager will send the email after the date. If not set, the email will be send as soon as possible. Unless a timezone is specified, it is considered as being in UTC timezone.")
fetchmail_server_id = fields.Many2one('fetchmail.server', "Inbound Mail Server", readonly=True)
def _compute_body_content(self):
for mail in self:
mail.body_content = mail.body_html
def _compute_mail_message_id_int(self):
for mail in self:
mail.mail_message_id_int = mail.mail_message_id.id
@api.depends('attachment_ids')
def _compute_restricted_attachments(self):
"""We might not have access to all the attachments of the emails.
Compute the attachments we have access to,
and the number of attachments we do not have access to.
"""
IrAttachment = self.env['ir.attachment']
for mail_sudo, mail in zip(self.sudo(), self):
mail.unrestricted_attachment_ids = IrAttachment._filter_attachment_access(mail_sudo.attachment_ids.ids)
mail.restricted_attachment_count = len(mail_sudo.attachment_ids) - len(mail.unrestricted_attachment_ids)
def _inverse_unrestricted_attachment_ids(self):
"""We can only remove the attachments we have access to."""
IrAttachment = self.env['ir.attachment']
for mail_sudo, mail in zip(self.sudo(), self):
restricted_attaments = mail_sudo.attachment_ids - IrAttachment._filter_attachment_access(mail_sudo.attachment_ids.ids)
mail_sudo.attachment_ids = restricted_attaments | mail.unrestricted_attachment_ids
def _search_body_content(self, operator, value):
return [('body_html', operator, value)]
@api.model
def fields_get(self, *args, **kwargs):
# related selection will fetch translations from DB
# selections added in stable won't be in DB -> add them on the related model if not already added
message_type_field = self.env['mail.message']._fields['message_type']
if 'auto_comment' not in {value for value, name in message_type_field.get_description(self.env)['selection']}:
self._fields_get_message_type_update_selection(message_type_field.selection)
return super().fields_get(*args, **kwargs)
def _fields_get_message_type_update_selection(self, selection):
"""Update the field selection for message type on mail.message to match the runtime values.
DO NOT USE it is only there for a stable fix and should not be used for any reason other than hotfixing.
"""
self.env['ir.model.fields'].invalidate_model(['selection_ids'])
self.env['ir.model.fields.selection'].sudo()._update_selection('mail.message', 'message_type', selection)
self.env.registry.clear_caches()
@api.model_create_multi
def create(self, values_list):
# notification field: if not set, set if mail comes from an existing mail.message
for values in values_list:
if 'is_notification' not in values and values.get('mail_message_id'):
values['is_notification'] = True
if values.get('scheduled_date'):
parsed_datetime = self._parse_scheduled_datetime(values['scheduled_date'])
values['scheduled_date'] = parsed_datetime.replace(tzinfo=None) if parsed_datetime else False
else:
values['scheduled_date'] = False # void string crashes
new_mails = super(MailMail, self).create(values_list)
new_mails_w_attach = self
for mail, values in zip(new_mails, values_list):
if values.get('attachment_ids'):
new_mails_w_attach += mail
if new_mails_w_attach:
new_mails_w_attach.mapped('attachment_ids').check(mode='read')
return new_mails
def write(self, vals):
if vals.get('scheduled_date'):
parsed_datetime = self._parse_scheduled_datetime(vals['scheduled_date'])
vals['scheduled_date'] = parsed_datetime.replace(tzinfo=None) if parsed_datetime else False
res = super(MailMail, self).write(vals)
if vals.get('attachment_ids'):
for mail in self:
mail.attachment_ids.check(mode='read')
return res
def unlink(self):
# cascade-delete the parent message for all mails that are not created for a notification
mail_msg_cascade_ids = [mail.mail_message_id.id for mail in self if not mail.is_notification]
res = super(MailMail, self).unlink()
if mail_msg_cascade_ids:
self.env['mail.message'].browse(mail_msg_cascade_ids).unlink()
return res
@api.model
def _add_inherited_fields(self):
"""Allow to bypass ACLs for some mail message fields.
This trick add a related_sudo on the inherits fields, it can't be done with
>>> subject = fields.Char(related='mail_message_id.subject', related_sudo=True)
because the field of <mail.message> will be fetched two times (one time before of
the inherits, and a second time because of the related), and so it will add extra
SQL queries.
"""
super()._add_inherited_fields()
for field in ('email_from', 'reply_to', 'subject'):
self._fields[field].related_sudo = True
def action_retry(self):
self.filtered(lambda mail: mail.state == 'exception').mark_outgoing()
def action_open_document(self):
""" Opens the related record based on the model and ID """
self.ensure_one()
return {
'res_id': self.res_id,
'res_model': self.model,
'target': 'current',
'type': 'ir.actions.act_window',
'view_mode': 'form',
}
def mark_outgoing(self):
return self.write({'state': 'outgoing'})
def cancel(self):
return self.write({'state': 'cancel'})
@api.model
def process_email_queue(self, ids=None):
"""Send immediately queued messages, committing after each
message is sent - this is not transactional and should
not be called during another transaction!
:param list ids: optional list of emails ids to send. If passed
no search is performed, and these ids are used
instead.
:param dict context: if a 'filters' key is present in context,
this value will be used as an additional
filter to further restrict the outgoing
messages to send (by default all 'outgoing'
messages are sent).
"""
filters = [
'&',
('state', '=', 'outgoing'),
'|',
('scheduled_date', '=', False),
('scheduled_date', '<=', datetime.datetime.utcnow()),
]
if 'filters' in self._context:
filters.extend(self._context['filters'])
# TODO: make limit configurable
filtered_ids = self.search(filters, limit=10000).ids
if not ids:
ids = filtered_ids
else:
ids = list(set(filtered_ids) & set(ids))
ids.sort()
res = None
try:
# auto-commit except in testing mode
auto_commit = not getattr(threading.current_thread(), 'testing', False)
res = self.browse(ids).send(auto_commit=auto_commit)
except Exception:
_logger.exception("Failed processing mail queue")
return res
def _postprocess_sent_message(self, success_pids, failure_reason=False, failure_type=None):
"""Perform any post-processing necessary after sending ``mail``
successfully, including deleting it completely along with its
attachment if the ``auto_delete`` flag of the mail was set.
Overridden by subclasses for extra post-processing behaviors.
:return: True
"""
notif_mails_ids = [mail.id for mail in self if mail.is_notification]
if notif_mails_ids:
notifications = self.env['mail.notification'].search([
('notification_type', '=', 'email'),
('mail_mail_id', 'in', notif_mails_ids),
('notification_status', 'not in', ('sent', 'canceled'))
])
if notifications:
# find all notification linked to a failure
failed = self.env['mail.notification']
if failure_type:
failed = notifications.filtered(lambda notif: notif.res_partner_id not in success_pids)
(notifications - failed).sudo().write({
'notification_status': 'sent',
'failure_type': '',
'failure_reason': '',
})
if failed:
failed.sudo().write({
'notification_status': 'exception',
'failure_type': failure_type,
'failure_reason': failure_reason,
})
messages = notifications.mapped('mail_message_id').filtered(lambda m: m.is_thread_message())
# TDE TODO: could be great to notify message-based, not notifications-based, to lessen number of notifs
messages._notify_message_notification_update() # notify user that we have a failure
if not failure_type or failure_type in ['mail_email_invalid', 'mail_email_missing']: # if we have another error, we want to keep the mail.
self.sudo().filtered(lambda mail: mail.auto_delete).unlink()
return True
def _parse_scheduled_datetime(self, scheduled_datetime):
""" Taking an arbitrary datetime (either as a date, a datetime or a string)
try to parse it and return a datetime timezoned to UTC.
If no specific timezone information is given, we consider it as being
given in UTC, as all datetime values given to the server. Trying to
guess its timezone based on user or flow would be strange as this is
not standard. When manually creating datetimes for mail.mail scheduled
date, business code should ensure either a timezone info is set, either
it is converted into UTC.
Using yearfirst when parsing str datetimes eases parser's job when
dealing with the hard-to-parse trio (01/04/09 -> ?). In most use cases
year will be given first as this is the expected default formatting.
:return datetime: parsed datetime (or False if parser failed)
"""
if isinstance(scheduled_datetime, datetime.datetime):
parsed_datetime = scheduled_datetime
elif isinstance(scheduled_datetime, datetime.date):
parsed_datetime = datetime.combine(scheduled_datetime, datetime.time.min)
else:
try:
parsed_datetime = parse(scheduled_datetime, yearfirst=True)
except (ValueError, TypeError):
parsed_datetime = False
if parsed_datetime:
if not parsed_datetime.tzinfo:
parsed_datetime = pytz.utc.localize(parsed_datetime)
else:
try:
parsed_datetime = parsed_datetime.astimezone(pytz.utc)
except Exception:
pass
return parsed_datetime
# ------------------------------------------------------
# mail_mail formatting, tools and send mechanism
# ------------------------------------------------------
def _send_prepare_body(self):
"""Return a specific ir_email body. The main purpose of this method
is to be inherited to add custom content depending on some module."""
self.ensure_one()
return self.body_html or ''
def _send_prepare_values(self, partner=None):
"""Return a dictionary for specific email values, depending on a
partner, or generic to the whole recipients given by mail.email_to.
:param Model partner: specific recipient partner
"""
self.ensure_one()
body = self._send_prepare_body()
body_alternative = tools.html2plaintext(body)
if partner:
email_to_normalized = tools.email_normalize_all(partner.email)
email_to = [
tools.formataddr((partner.name or "False", email or "False"))
for email in email_to_normalized or [partner.email]
]
else:
email_to_normalized = tools.email_normalize_all(self.email_to)
email_to = tools.email_split_and_format_normalize(self.email_to)
# email_cc is added to the "to" when invoking send_email
email_to_normalized += tools.email_normalize_all(self.email_cc)
res = {
'body': body,
'body_alternative': body_alternative,
'email_to': email_to,
'email_to_normalized': email_to_normalized,
}
return res
def _split_by_mail_configuration(self):
"""Group the <mail.mail> based on their "email_from" and their "mail_server_id".
The <mail.mail> will have the "same sending configuration" if they have the same
mail server or the same mail from. For performance purpose, we can use an SMTP
session in batch and therefore we need to group them by the parameter that will
influence the mail server used.
The same "sending configuration" may repeat in order to limit batch size
according to the `mail.session.batch.size` system parameter.
Return iterators over
mail_server_id, email_from, Records<mail.mail>.ids
"""
mail_values = self.read(['id', 'email_from', 'mail_server_id'])
# First group the <mail.mail> per mail_server_id and per email_from
group_per_email_from = defaultdict(list)
for values in mail_values:
# protect against ill-formatted email_from when formataddr was used on an already formatted email
emails_from = tools.email_split_and_format_normalize(values['email_from'])
email_from = emails_from[0] if emails_from else values['email_from']
mail_server_id = values['mail_server_id'][0] if values['mail_server_id'] else False
group_per_email_from[mail_server_id, email_from].append(values['id'])
# Then find the mail server for each email_from and group the <mail.mail>
# per mail_server_id and smtp_from
mail_servers = self.env['ir.mail_server'].sudo().search([], order='sequence')
group_per_smtp_from = defaultdict(list)
for (mail_server_id, email_from), mail_ids in group_per_email_from.items():
if not mail_server_id:
mail_server, smtp_from = self.env['ir.mail_server']._find_mail_server(email_from, mail_servers)
mail_server_id = mail_server.id if mail_server else False
else:
smtp_from = email_from
group_per_smtp_from[mail_server_id, smtp_from].extend(mail_ids)
sys_params = self.env['ir.config_parameter'].sudo()
batch_size = int(sys_params.get_param('mail.session.batch.size', 1000))
for (mail_server_id, smtp_from), record_ids in group_per_smtp_from.items():
for batch_ids in tools.split_every(batch_size, record_ids):
yield mail_server_id, smtp_from, batch_ids
def send(self, auto_commit=False, raise_exception=False):
""" Sends the selected emails immediately, ignoring their current
state (mails that have already been sent should not be passed
unless they should actually be re-sent).
Emails successfully delivered are marked as 'sent', and those
that fail to be deliver are marked as 'exception', and the
corresponding error mail is output in the server logs.
:param bool auto_commit: whether to force a commit of the mail status
after sending each mail (meant only for scheduler processing);
should never be True during normal transactions (default: False)
:param bool raise_exception: whether to raise an exception if the
email sending process has failed
:return: True
"""
for mail_server_id, smtp_from, batch_ids in self._split_by_mail_configuration():
smtp_session = None
try:
smtp_session = self.env['ir.mail_server'].connect(mail_server_id=mail_server_id, smtp_from=smtp_from)
except Exception as exc:
if raise_exception:
# To be consistent and backward compatible with mail_mail.send() raised
# exceptions, it is encapsulated into an Odoo MailDeliveryException
raise MailDeliveryException(_('Unable to connect to SMTP Server'), exc)
else:
batch = self.browse(batch_ids)
batch.write({'state': 'exception', 'failure_reason': exc})
batch._postprocess_sent_message(success_pids=[], failure_type="mail_smtp")
else:
self.browse(batch_ids)._send(
auto_commit=auto_commit,
raise_exception=raise_exception,
smtp_session=smtp_session)
_logger.info(
'Sent batch %s emails via mail server ID #%s',
len(batch_ids), mail_server_id)
finally:
if smtp_session:
try:
smtp_session.quit()
except smtplib.SMTPServerDisconnected:
_logger.info(
"Ignoring SMTPServerDisconnected while trying to quit non open session"
)
def _send(self, auto_commit=False, raise_exception=False, smtp_session=None):
IrMailServer = self.env['ir.mail_server']
IrAttachment = self.env['ir.attachment']
for mail_id in self.ids:
success_pids = []
failure_type = None
processing_pid = None
mail = None
try:
mail = self.browse(mail_id)
if mail.state != 'outgoing':
continue
# remove attachments if user send the link with the access_token
body = mail.body_html or ''
attachments = mail.attachment_ids
for link in re.findall(r'/web/(?:content|image)/([0-9]+)', body):
attachments = attachments - IrAttachment.browse(int(link))
# load attachment binary data with a separate read(), as prefetching all
# `datas` (binary field) could bloat the browse cache, triggerring
# soft/hard mem limits with temporary data.
attachments = [(a['name'], base64.b64decode(a['datas']), a['mimetype'])
for a in attachments.sudo().read(['name', 'datas', 'mimetype']) if a['datas'] is not False]
# specific behavior to customize the send email for notified partners
email_list = []
if mail.email_to:
email_list.append(mail._send_prepare_values())
for partner in mail.recipient_ids:
values = mail._send_prepare_values(partner=partner)
values['partner_id'] = partner
email_list.append(values)
# headers
headers = {}
ICP = self.env['ir.config_parameter'].sudo()
bounce_alias = ICP.get_param("mail.bounce.alias")
catchall_domain = ICP.get_param("mail.catchall.domain")
if bounce_alias and catchall_domain:
headers['Return-Path'] = '%s@%s' % (bounce_alias, catchall_domain)
if mail.headers:
try:
headers.update(ast.literal_eval(mail.headers))
except Exception:
pass
# Writing on the mail object may fail (e.g. lock on user) which
# would trigger a rollback *after* actually sending the email.
# To avoid sending twice the same email, provoke the failure earlier
mail.write({
'state': 'exception',
'failure_reason': _('Error without exception. Probably due to sending an email without computed recipients.'),
})
# Update notification in a transient exception state to avoid concurrent
# update in case an email bounces while sending all emails related to current
# mail record.
notifs = self.env['mail.notification'].search([
('notification_type', '=', 'email'),
('mail_mail_id', 'in', mail.ids),
('notification_status', 'not in', ('sent', 'canceled'))
])
if notifs:
notif_msg = _('Error without exception. Probably due to concurrent access update of notification records. Please see with an administrator.')
notifs.sudo().write({
'notification_status': 'exception',
'failure_type': 'unknown',
'failure_reason': notif_msg,
})
# `test_mail_bounce_during_send`, force immediate update to obtain the lock.
# see rev. 56596e5240ef920df14d99087451ce6f06ac6d36
notifs.flush_recordset(['notification_status', 'failure_type', 'failure_reason'])
# protect against ill-formatted email_from when formataddr was used on an already formatted email
emails_from = tools.email_split_and_format_normalize(mail.email_from)
email_from = emails_from[0] if emails_from else mail.email_from
# build an RFC2822 email.message.Message object and send it without queuing
res = None
# TDE note: could be great to pre-detect missing to/cc and skip sending it
# to go directly to failed state update
for email in email_list:
# give indication to 'send_mail' about emails already considered
# as being valid
email_to_normalized = email.pop('email_to_normalized', [])
# support headers specific to the specific outgoing email
if email.get('headers'):
email_headers = headers.copy()
try:
email_headers.update(email.get('headers'))
except Exception: # noqa: BLE001
pass
else:
email_headers = headers
msg = IrMailServer.build_email(
email_from=email_from,
email_to=email.get('email_to'),
subject=mail.subject,
body=email.get('body'),
body_alternative=email.get('body_alternative'),
email_cc=tools.email_split_and_format_normalize(mail.email_cc),
reply_to=mail.reply_to,
attachments=attachments,
message_id=mail.message_id,
references=mail.references,
object_id=mail.res_id and ('%s-%s' % (mail.res_id, mail.model)),
subtype='html',
subtype_alternative='plain',
headers=email_headers)
processing_pid = email.pop("partner_id", None)
try:
# 'send_validated_to' restricts emails found by 'extract_rfc2822_addresses'
res = IrMailServer.with_context(send_validated_to=email_to_normalized).send_email(
msg, mail_server_id=mail.mail_server_id.id, smtp_session=smtp_session)
if processing_pid:
success_pids.append(processing_pid)
processing_pid = None
except AssertionError as error:
if str(error) == IrMailServer.NO_VALID_RECIPIENT:
# if we have a list of void emails for email_list -> email missing, otherwise generic email failure
if not email.get('email_to') and failure_type != "mail_email_invalid":
failure_type = "mail_email_missing"
else:
failure_type = "mail_email_invalid"
# No valid recipient found for this particular
# mail item -> ignore error to avoid blocking
# delivery to next recipients, if any. If this is
# the only recipient, the mail will show as failed.
_logger.info("Ignoring invalid recipients for mail.mail %s: %s",
mail.message_id, email.get('email_to'))
else:
raise
if res: # mail has been sent at least once, no major exception occurred
mail.write({'state': 'sent', 'message_id': res, 'failure_reason': False})
_logger.info(
"Mail (mail.mail) with ID %r and Message-Id %r from %r to (redacted) %s successfully sent",
mail.id,
mail.message_id,
tools.email_normalize(msg['from']), # FROM should not change, so last msg good enough
', '.join(
repr(tools.mail.email_anonymize(tools.email_normalize(e)))
for m in email_list for e in m['email_to']
),
)
_logger.info("Total emails tried by SMTP: %s", len(email_list))
# /!\ can't use mail.state here, as mail.refresh() will cause an error
# see revid:odo@openerp.com-20120622152536-42b2s28lvdv3odyr in 6.1
mail._postprocess_sent_message(success_pids=success_pids, failure_type=failure_type)
except MemoryError:
# prevent catching transient MemoryErrors, bubble up to notify user or abort cron job
# instead of marking the mail as failed
_logger.exception(
'MemoryError while processing mail with ID %r and Msg-Id %r. Consider raising the --limit-memory-hard startup option',
mail.id, mail.message_id)
# mail status will stay on ongoing since transaction will be rollback
raise
except (psycopg2.Error, smtplib.SMTPServerDisconnected):
# If an error with the database or SMTP session occurs, chances are that the cursor
# or SMTP session are unusable, causing further errors when trying to save the state.
_logger.exception(
'Exception while processing mail with ID %r and Msg-Id %r.',
mail.id, mail.message_id)
raise
except Exception as e:
failure_reason = tools.ustr(e)
_logger.exception('failed sending mail (id: %s) due to %s', mail.id, failure_reason)
mail.write({'state': 'exception', 'failure_reason': failure_reason})
mail._postprocess_sent_message(success_pids=success_pids, failure_reason=failure_reason, failure_type='unknown')
if raise_exception:
if isinstance(e, (AssertionError, UnicodeEncodeError)):
if isinstance(e, UnicodeEncodeError):
value = "Invalid text: %s" % e.object
else:
value = '. '.join(e.args)
raise MailDeliveryException(value)
raise
if auto_commit is True:
self._cr.commit()
return True

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class MailMessageReaction(models.Model):
_name = 'mail.message.reaction'
_description = 'Message Reaction'
_order = 'id desc'
_log_access = False
message_id = fields.Many2one(string="Message", comodel_name='mail.message', ondelete='cascade', required=True, readonly=True)
content = fields.Char(string="Content", required=True, readonly=True)
partner_id = fields.Many2one(string="Reacting Partner", comodel_name='res.partner', ondelete='cascade', readonly=True)
guest_id = fields.Many2one(string="Reacting Guest", comodel_name='mail.guest', ondelete='cascade', readonly=True)
def init(self):
self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS mail_message_reaction_partner_unique ON %s (message_id, content, partner_id) WHERE partner_id IS NOT NULL" % self._table)
self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS mail_message_reaction_guest_unique ON %s (message_id, content, 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 message reaction must be from a partner or from a guest."),
]

View file

@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
from datetime import datetime
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class MailMessageSchedule(models.Model):
""" Mail message notification schedule queue.
This model is used to store the mail messages scheduled. So we can
delay the sending of the notifications. A scheduled date field already
exists on the <mail.mail> but it does not allow us to delay the sending
of the <bus.bus> notifications.
"""
_name = 'mail.message.schedule'
_description = 'Scheduled Messages'
_order = 'scheduled_datetime DESC, id DESC'
_rec_name = 'mail_message_id'
mail_message_id = fields.Many2one(
'mail.message', string='Message',
ondelete='cascade', required=True)
notification_parameters = fields.Text('Notification Parameter')
scheduled_datetime = fields.Datetime(
'Scheduled Send Date', required=True,
help='Datetime at which notification should be sent.')
@api.model_create_multi
def create(self, vals_list):
schedules = super().create(vals_list)
if schedules:
self.env.ref('mail.ir_cron_send_scheduled_message')._trigger_list(
set(schedules.mapped('scheduled_datetime'))
)
return schedules
@api.model
def _send_notifications_cron(self):
messages_scheduled = self.env['mail.message.schedule'].search(
[('scheduled_datetime', '<=', datetime.utcnow())]
)
if messages_scheduled:
_logger.info('Send %s scheduled messages', len(messages_scheduled))
messages_scheduled._send_notifications()
def force_send(self):
""" Launch notification process independently from the expected date. """
return self._send_notifications()
def _send_notifications(self, default_notify_kwargs=None):
""" Send notification for scheduled messages.
:param dict default_notify_kwargs: optional parameters to propagate to
``notify_thread``. Those are default values overridden by content of
``notification_parameters`` field.
"""
for model, schedules in self._group_by_model().items():
if model:
records = self.env[model].browse(schedules.mapped('mail_message_id.res_id'))
else:
records = [self.env['mail.thread']] * len(schedules)
for record, schedule in zip(records, schedules):
notify_kwargs = dict(default_notify_kwargs or {}, skip_existing=True)
try:
schedule_notify_kwargs = json.loads(schedule.notification_parameters)
except Exception:
pass
else:
schedule_notify_kwargs.pop('scheduled_date', None)
notify_kwargs.update(schedule_notify_kwargs)
record._notify_thread(schedule.mail_message_id, msg_vals=False, **notify_kwargs)
self.unlink()
return True
@api.model
def _send_message_notifications(self, messages, default_notify_kwargs=None):
""" Send scheduled notification for given messages.
:param <mail.message> messages: scheduled sending related to those messages
will be sent now;
:param dict default_notify_kwargs: optional parameters to propagate to
``notify_thread``. Those are default values overridden by content of
``notification_parameters`` field.
:return bool: False if no schedule has been found, True otherwise
"""
messages_scheduled = self.search(
[('mail_message_id', 'in', messages.ids)]
)
if not messages_scheduled:
return False
messages_scheduled._send_notifications(default_notify_kwargs=default_notify_kwargs)
return True
@api.model
def _update_message_scheduled_datetime(self, messages, new_datetime):
""" Update scheduled datetime for scheduled sending related to messages.
:param <mail.message> messages: scheduled sending related to those messages
will be updated. Missing one are skipped;
:param datetime new_datetime: new datetime for sending. New triggers
are created based on it;
:return bool: False if no schedule has been found, True otherwise
"""
messages_scheduled = self.search(
[('mail_message_id', 'in', messages.ids)]
)
if not messages_scheduled:
return False
messages_scheduled.scheduled_datetime = new_datetime
self.env.ref('mail.ir_cron_send_scheduled_message')._trigger(new_datetime)
return True
def _group_by_model(self):
grouped = {}
for schedule in self:
model = schedule.mail_message_id.model if schedule.mail_message_id.model and schedule.mail_message_id.res_id else False
if model not in grouped:
grouped[model] = schedule
else:
grouped[model] += schedule
return grouped

View file

@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools
class MailMessageSubtype(models.Model):
""" Class holding subtype definition for messages. Subtypes allow to tune
the follower subscription, allowing only some subtypes to be pushed
on the Wall. """
_name = 'mail.message.subtype'
_description = 'Message subtypes'
_order = 'sequence, id'
name = fields.Char(
'Message Type', required=True, translate=True,
help='Message subtype gives a more precise type on the message, '
'especially for system notifications. For example, it can be '
'a notification related to a new record (New), or to a stage '
'change in a process (Stage change). Message subtypes allow to '
'precisely tune the notifications the user want to receive on its wall.')
description = fields.Text(
'Description', translate=True, prefetch=True,
help='Description that will be added in the message posted for this '
'subtype. If void, the name will be added instead.')
internal = fields.Boolean(
'Internal Only',
help='Messages with internal subtypes will be visible only by employees, aka members of base_user group')
parent_id = fields.Many2one(
'mail.message.subtype', string='Parent', ondelete='set null',
help='Parent subtype, used for automatic subscription. This field is not '
'correctly named. For example on a project, the parent_id of project '
'subtypes refers to task-related subtypes.')
relation_field = fields.Char(
'Relation field',
help='Field used to link the related model to the subtype model when '
'using automatic subscription on a related document. The field '
'is used to compute getattr(related_document.relation_field).')
res_model = fields.Char('Model', help="Model the subtype applies to. If False, this subtype applies to all models.")
default = fields.Boolean('Default', default=True, help="Activated by default when subscribing.")
sequence = fields.Integer('Sequence', default=1, help="Used to order subtypes.")
hidden = fields.Boolean('Hidden', help="Hide the subtype in the follower options")
track_recipients = fields.Boolean('Track Recipients',
help="Whether to display all the recipients or only the important ones.")
@api.model_create_multi
def create(self, vals_list):
self.clear_caches()
return super(MailMessageSubtype, self).create(vals_list)
def write(self, vals):
self.clear_caches()
return super(MailMessageSubtype, self).write(vals)
def unlink(self):
self.clear_caches()
return super(MailMessageSubtype, self).unlink()
@tools.ormcache('model_name')
def _get_auto_subscription_subtypes(self, model_name):
""" Return data related to auto subscription based on subtype matching.
Here model_name indicates child model (like a task) on which we want to
make subtype matching based on its parents (like a project).
Example with tasks and project :
* generic: discussion, res_model = False
* task: new, res_model = project.task
* project: task_new, parent_id = new, res_model = project.project, field = project_id
Returned data
* child_ids: all subtypes that are generic or related to task (res_model = False or model_name)
* def_ids: default subtypes ids (either generic or task specific)
* all_int_ids: all internal-only subtypes ids (generic or task or project)
* parent: dict(parent subtype id, child subtype id), i.e. {task_new.id: new.id}
* relation: dict(parent_model, relation_fields), i.e. {'project.project': ['project_id']}
"""
child_ids, def_ids = list(), list()
all_int_ids = list()
parent, relation = dict(), dict()
subtypes = self.sudo().search([
'|', '|', ('res_model', '=', False),
('res_model', '=', model_name),
('parent_id.res_model', '=', model_name)
])
for subtype in subtypes:
if not subtype.res_model or subtype.res_model == model_name:
child_ids += subtype.ids
if subtype.default:
def_ids += subtype.ids
elif subtype.relation_field:
parent[subtype.id] = subtype.parent_id.id
relation.setdefault(subtype.res_model, set()).add(subtype.relation_field)
# required for backward compatibility
if subtype.internal:
all_int_ids += subtype.ids
return child_ids, def_ids, all_int_ids, parent, relation
@api.model
def default_subtypes(self, model_name):
""" Retrieve the default subtypes (all, internal, external) for the given model. """
subtype_ids, internal_ids, external_ids = self._default_subtypes(model_name)
return self.browse(subtype_ids), self.browse(internal_ids), self.browse(external_ids)
@tools.ormcache('self.env.uid', 'self.env.su', 'model_name')
def _default_subtypes(self, model_name):
domain = [('default', '=', True),
'|', ('res_model', '=', model_name), ('res_model', '=', False)]
subtypes = self.search(domain)
internal = subtypes.filtered('internal')
return subtypes.ids, internal.ids, (subtypes - internal).ids

View file

@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
from odoo.exceptions import AccessError
from odoo.tools.translate import _
class MailNotification(models.Model):
_name = 'mail.notification'
_table = 'mail_notification'
_rec_name = 'res_partner_id'
_log_access = False
_description = 'Message Notifications'
# origin
author_id = fields.Many2one('res.partner', 'Author', ondelete='set null')
mail_message_id = fields.Many2one('mail.message', 'Message', index=True, ondelete='cascade', required=True)
mail_mail_id = fields.Many2one('mail.mail', 'Mail', index=True, help='Optional mail_mail ID. Used mainly to optimize searches.')
# recipient
res_partner_id = fields.Many2one('res.partner', 'Recipient', index=True, ondelete='cascade')
# status
notification_type = fields.Selection([
('inbox', 'Inbox'), ('email', 'Email')
], string='Notification Type', default='inbox', index=True, required=True)
notification_status = fields.Selection([
('ready', 'Ready to Send'),
('sent', 'Sent'),
('bounce', 'Bounced'),
('exception', 'Exception'),
('canceled', 'Canceled')
], string='Status', default='ready', index=True)
is_read = fields.Boolean('Is Read', index=True)
read_date = fields.Datetime('Read Date', copy=False)
failure_type = fields.Selection(selection=[
# generic
("unknown", "Unknown error"),
# mail
("mail_email_invalid", "Invalid email address"),
("mail_email_missing", "Missing email address"),
("mail_smtp", "Connection failed (outgoing mail server problem)"),
], string='Failure type')
failure_reason = fields.Text('Failure reason', copy=False)
_sql_constraints = [
# email notification: partner is required
('notification_partner_required',
"CHECK(notification_type NOT IN ('email', 'inbox') OR res_partner_id IS NOT NULL)",
'Customer is required for inbox / email notification'),
]
# ------------------------------------------------------------
# CRUD
# ------------------------------------------------------------
def init(self):
self._cr.execute("""
CREATE INDEX IF NOT EXISTS mail_notification_res_partner_id_is_read_notification_status_mail_message_id
ON mail_notification (res_partner_id, is_read, notification_status, mail_message_id);
CREATE INDEX IF NOT EXISTS mail_notification_author_id_notification_status_failure
ON mail_notification (author_id, notification_status)
WHERE notification_status IN ('bounce', 'exception');
""")
self.env.cr.execute(
"""CREATE UNIQUE INDEX IF NOT EXISTS unique_mail_message_id_res_partner_id_if_set
ON %s (mail_message_id, res_partner_id)
WHERE res_partner_id IS NOT NULL""" % self._table
)
@api.model_create_multi
def create(self, vals_list):
messages = self.env['mail.message'].browse(vals['mail_message_id'] for vals in vals_list)
messages.check_access_rights('read')
messages.check_access_rule('read')
for vals in vals_list:
if vals.get('is_read'):
vals['read_date'] = fields.Datetime.now()
return super(MailNotification, self).create(vals_list)
def write(self, vals):
if ('mail_message_id' in vals or 'res_partner_id' in vals) and not self.env.is_admin():
raise AccessError(_("Can not update the message or recipient of a notification."))
if vals.get('is_read'):
vals['read_date'] = fields.Datetime.now()
return super(MailNotification, self).write(vals)
@api.model
def _gc_notifications(self, max_age_days=180):
domain = [
('is_read', '=', True),
('read_date', '<', fields.Datetime.now() - relativedelta(days=max_age_days)),
('res_partner_id.partner_share', '=', False),
('notification_status', 'in', ('sent', 'canceled'))
]
records = self.search(domain, limit=models.GC_UNLINK_LIMIT)
if len(records) >= models.GC_UNLINK_LIMIT:
self.env.ref('base.autovacuum_job')._trigger()
return records.unlink()
# ------------------------------------------------------------
# TOOLS
# ------------------------------------------------------------
def format_failure_reason(self):
self.ensure_one()
if self.failure_type != 'unknown':
return dict(self._fields['failure_type'].selection).get(self.failure_type, _('No Error'))
else:
return _("Unknown error") + ": %s" % (self.failure_reason or '')
# ------------------------------------------------------------
# DISCUSS
# ------------------------------------------------------------
def _filtered_for_web_client(self):
"""Returns only the notifications to show on the web client."""
def _filter_unimportant_notifications(notif):
if notif.notification_status in ['bounce', 'exception', 'canceled'] \
or notif.res_partner_id.partner_share:
return True
subtype = notif.mail_message_id.subtype_id
return not subtype or subtype.track_recipients
return self.filtered(_filter_unimportant_notifications)
def _notification_format(self):
"""Returns the current notifications in the format expected by the web
client."""
return [{
'id': notif.id,
'notification_type': notif.notification_type,
'notification_status': notif.notification_status,
'failure_type': notif.failure_type,
'res_partner_id': [notif.res_partner_id.id, notif.res_partner_id.display_name] if notif.res_partner_id else False,
} for notif in self]

View file

@ -0,0 +1,583 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import babel
import copy
import logging
import re
import traceback
from lxml import html
from markupsafe import Markup
from werkzeug import urls
from odoo import _, api, fields, models, tools
from odoo.addons.base.models.ir_qweb import QWebException
from odoo.exceptions import UserError, AccessError
from odoo.tools import is_html_empty, safe_eval
from odoo.tools.rendering_tools import convert_inline_template_to_qweb, parse_inline_template, render_inline_template, template_env_globals
_logger = logging.getLogger(__name__)
def format_date(env, date, pattern=False, lang_code=False):
try:
return tools.format_date(env, date, date_format=pattern, lang_code=lang_code)
except babel.core.UnknownLocaleError:
return date
def format_datetime(env, dt, tz=False, dt_format='medium', lang_code=False):
try:
return tools.format_datetime(env, dt, tz=tz, dt_format=dt_format, lang_code=lang_code)
except babel.core.UnknownLocaleError:
return dt
def format_time(env, time, tz=False, time_format='medium', lang_code=False):
try:
return tools.format_time(env, time, tz=tz, time_format=time_format, lang_code=lang_code)
except babel.core.UnknownLocaleError:
return time
class MailRenderMixin(models.AbstractModel):
_name = 'mail.render.mixin'
_description = 'Mail Render Mixin'
# If True, we trust the value on the model for rendering
# If False, we need the group "Template Editor" to render the model fields
_unrestricted_rendering = False
# language for rendering
lang = fields.Char(
'Language',
help="Optional translation language (ISO code) to select when sending out an email. "
"If not set, the english version will be used. This should usually be a placeholder expression "
"that provides the appropriate language, e.g. {{ object.partner_id.lang }}.")
# rendering context
render_model = fields.Char("Rendering Model", compute='_compute_render_model', store=False)
def _compute_render_model(self):
""" Give the target model for rendering. Void by default as models
inheriting from ``mail.render.mixin`` should define how to find this
model. """
self.render_model = False
@api.model
def _build_expression(self, field_name, sub_field_name, null_value):
"""Returns a placeholder expression for use in a template field,
based on the values provided in the placeholder assistant.
:param field_name: main field name
:param sub_field_name: sub field name (M2O)
:param null_value: default value if the target value is empty
:return: final placeholder expression """
expression = ''
if field_name:
expression = "{{ object." + field_name
if sub_field_name:
expression += "." + sub_field_name
if null_value:
expression += " or '''%s'''" % null_value
expression += " }}"
return expression
# ------------------------------------------------------------
# ORM
# ------------------------------------------------------------
def _valid_field_parameter(self, field, name):
# allow specifying rendering options directly from field when using the render mixin
return name in ['render_engine', 'render_options'] or super()._valid_field_parameter(field, name)
@api.model_create_multi
def create(self, values_list):
record = super().create(values_list)
if self._unrestricted_rendering:
# If the rendering is unrestricted (e.g. mail.template),
# check the user is part of the mail editor group to create a new template if the template is dynamic
record._check_access_right_dynamic_template()
return record
def write(self, vals):
super().write(vals)
if self._unrestricted_rendering:
# If the rendering is unrestricted (e.g. mail.template),
# check the user is part of the mail editor group to modify a template if the template is dynamic
self._check_access_right_dynamic_template()
return True
def _update_field_translations(self, fname, translations, digest=None):
res = super()._update_field_translations(fname, translations, digest)
if self._unrestricted_rendering:
for lang in translations:
# If the rendering is unrestricted (e.g. mail.template),
# check the user is part of the mail editor group to modify a template if the template is dynamic
self.with_context(lang=lang)._check_access_right_dynamic_template()
return res
# ------------------------------------------------------------
# TOOLS
# ------------------------------------------------------------
def _replace_local_links(self, html, base_url=None):
""" Replace local links by absolute links. It is required in various
cases, for example when sending emails on chatter or sending mass
mailings. It replaces
* href of links (mailto will not match the regex)
* src of images/v:fill/v:image (base64 hardcoded data will not match the regex)
* styling using url like background-image: url or background="url"
It is done using regex because it is shorter than using an html parser
to create a potentially complex soupe and hope to have a result that
has not been harmed.
"""
if not html:
return html
wrapper = Markup if isinstance(html, Markup) else str
html = tools.ustr(html)
if isinstance(html, Markup):
wrapper = Markup
def _sub_relative2absolute(match):
# compute here to do it only if really necessary + cache will ensure it is done only once
# if not base_url
if not _sub_relative2absolute.base_url:
_sub_relative2absolute.base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
return match.group(1) + urls.url_join(_sub_relative2absolute.base_url, match.group(2))
_sub_relative2absolute.base_url = base_url
html = re.sub(r"""(<(?:img|v:fill|v:image)(?=\s)[^>]*\ssrc=")(/[^/][^"]+)""", _sub_relative2absolute, html)
html = re.sub(r"""(<a(?=\s)[^>]*\shref=")(/[^/][^"]+)""", _sub_relative2absolute, html)
html = re.sub(r"""(<[\w-]+(?=\s)[^>]*\sbackground=")(/[^/][^"]+)""", _sub_relative2absolute, html)
html = re.sub(re.compile(
r"""( # Group 1: element up to url in style
<[^>]+\bstyle=" # Element with a style attribute
[^"]+\burl\( # Style attribute contains "url(" style
(?:&\#34;|'|&quot;|&\#39;)?) # url style may start with (escaped) quote: capture it
( # Group 2: url itself
/(?:[^'")]|(?!&\#34;)|(?!&\#39;))+ # stop at the first closing quote
)""", re.VERBOSE), _sub_relative2absolute, html)
return wrapper(html)
@api.model
def _render_encapsulate(self, layout_xmlid, html, add_context=None, context_record=None):
template_ctx = {
'body': html,
'record_name': context_record.display_name if context_record else '',
'model_description': self.env['ir.model']._get(context_record._name).display_name if context_record else False,
'company': context_record['company_id'] if (context_record and 'company_id' in context_record) else self.env.company,
'record': context_record,
}
if add_context:
template_ctx.update(**add_context)
html = self.env['ir.qweb']._render(layout_xmlid, template_ctx, minimal_qcontext=True, raise_if_not_found=False)
if not html:
_logger.warning('QWeb template %s not found when rendering encapsulation template.' % (layout_xmlid))
html = self.env['mail.render.mixin']._replace_local_links(html)
return html
@api.model
def _prepend_preview(self, html, preview):
""" Prepare the email body before sending. Add the text preview at the
beginning of the mail. The preview text is displayed bellow the mail
subject of most mail client (gmail, outlook...).
:param html: html content for which we want to prepend a preview
:param preview: the preview to add before the html content
:return: html with preprended preview
"""
if preview:
preview = preview.strip()
preview_markup = convert_inline_template_to_qweb(preview)
if preview:
html_preview = Markup("""
<div style="display:none;font-size:1px;height:0px;width:0px;opacity:0;">
{}
</div>
""").format(preview_markup)
return tools.prepend_html_content(html, html_preview)
return html
# ------------------------------------------------------------
# SECURITY
# ------------------------------------------------------------
def _is_dynamic(self):
for template in self.sudo():
for fname, field in template._fields.items():
engine = getattr(field, 'render_engine', 'inline_template')
if engine in ('qweb', 'qweb_view'):
if self._is_dynamic_template_qweb(template[fname]):
return True
else:
if self._is_dynamic_template_inline_template(template[fname]):
return True
return False
@api.model
def _is_dynamic_template_qweb(self, template_src):
if template_src:
try:
node = html.fragment_fromstring(template_src, create_parent='div')
self.env["ir.qweb"].with_context(raise_on_code=True)._compile(node)
except QWebException as e:
if isinstance(e.__cause__, PermissionError):
return True
raise
return False
@api.model
def _is_dynamic_template_inline_template(self, template_txt):
if template_txt:
template_instructions = parse_inline_template(str(template_txt))
if len(template_instructions) > 1 or template_instructions[0][1]:
return True
return False
def _check_access_right_dynamic_template(self):
if not self.env.su and not self.env.user.has_group('mail.group_mail_template_editor') and self._is_dynamic():
group = self.env.ref('mail.group_mail_template_editor')
raise AccessError(_('Only users belonging to the "%s" group can modify dynamic templates.', group.name))
# ------------------------------------------------------------
# RENDERING
# ------------------------------------------------------------
@api.model
def _render_eval_context(self):
""" Evaluation context used in all rendering engines. Contains
* ``user``: current user browse record;
* ``ctx```: current context;
* various formatting tools;
"""
render_context = {
'format_date': lambda date, date_format=False, lang_code=False: format_date(self.env, date, date_format, lang_code),
'format_datetime': lambda dt, tz=False, dt_format=False, lang_code=False: format_datetime(self.env, dt, tz, dt_format, lang_code),
'format_time': lambda time, tz=False, time_format=False, lang_code=False: format_time(self.env, time, tz, time_format, lang_code),
'format_amount': lambda amount, currency, lang_code=False: tools.format_amount(self.env, amount, currency, lang_code),
'format_duration': lambda value: tools.format_duration(value),
'user': self.env.user,
'ctx': self._context,
'is_html_empty': is_html_empty,
}
render_context.update(copy.copy(template_env_globals))
return render_context
@api.model
def _render_template_qweb(self, template_src, model, res_ids,
add_context=None, options=None):
""" Render a raw QWeb template.
:param str template_src: raw QWeb template to render;
:param str model: see ``MailRenderMixin._render_template()``;
:param list res_ids: see ``MailRenderMixin._render_template()``;
:param dict add_context: additional context to give to renderer. It
allows to add or update values to base rendering context generated
by ``MailRenderMixin._render_eval_context()``;
:param dict options: options for rendering (not used currently);
:return dict: {res_id: string of rendered template based on record}
:notice: Experimental. Use at your own risks only.
"""
results = dict.fromkeys(res_ids, u"")
if not template_src:
return results
# prepare template variables
variables = self._render_eval_context()
if add_context:
variables.update(**add_context)
is_restricted = not self._unrestricted_rendering and not self.env.is_admin() and not self.env.user.has_group('mail.group_mail_template_editor')
for record in self.env[model].browse(res_ids):
variables['object'] = record
try:
render_result = self.env['ir.qweb']._render(
html.fragment_fromstring(template_src, create_parent='div'),
variables,
raise_on_code=is_restricted,
**(options or {})
)
# remove the rendered tag <div> that was added in order to wrap potentially multiples nodes into one.
render_result = render_result[5:-6]
except Exception as e:
if isinstance(e, QWebException) and isinstance(e.__cause__, PermissionError):
group = self.env.ref('mail.group_mail_template_editor')
raise AccessError(_('Only users belonging to the "%s" group can modify dynamic templates.', group.name)) from e
_logger.info("Failed to render template : %s", template_src, exc_info=True)
raise UserError(_("Failed to render QWeb template : %s\n\n%s)", template_src, traceback.format_exc())) from e
results[record.id] = render_result
return results
@api.model
def _render_template_qweb_view(self, view_xmlid, model, res_ids,
add_context=None, options=None):
""" Render a QWeb template based on an ir.ui.view content.
In addition to the generic evaluation context available, some other
variables are added:
* ``object``: record based on which the template is rendered;
:param str view_xmlid: source QWeb template. It should be a string
XmlID allowing to fetch an ``ir.ui.view``;
:param str model: see ``MailRenderMixin._render_template()``;
:param list res_ids: see ``MailRenderMixin._render_template()``;
:param dict add_context: additional context to give to renderer. It
allows to add or update values to base rendering context generated
by ``MailRenderMixin._render_eval_context()``;
:param dict options: options for rendering (not used currently);
:return dict: {res_id: string of rendered template based on record}
"""
# prevent wrong values (rendering on a void record set, ...)
if any(r is None for r in res_ids):
raise ValueError(_('Template rendering should be called on a valid record IDs.'))
results = {}
# prepare template variables
variables = self._render_eval_context()
if add_context:
variables.update(**add_context)
for record in self.env[model].browse(res_ids):
variables['object'] = record
try:
render_result = self.env['ir.qweb']._render(view_xmlid, variables, minimal_qcontext=True, raise_if_not_found=False, **(options or {}))
results[record.id] = render_result
except Exception as e:
_logger.info("Failed to render template : %s", view_xmlid, exc_info=True)
raise UserError(_("Failed to render template : %s") % view_xmlid)
return results
@api.model
def _render_template_inline_template(self, template_txt, model, res_ids,
add_context=None, options=None):
""" Render a string-based template on records given by a model and a list
of IDs, using inline_template.
In addition to the generic evaluation context available, some other
variables are added:
* ``object``: record based on which the template is rendered;
:param str template_txt: template text to render
:param str model: see ``MailRenderMixin._render_template()``;
:param list res_ids: see ``MailRenderMixin._render_template()``;
:param dict add_context: additional context to give to renderer. It
allows to add or update values to base rendering context generated
by ``MailRenderMixin._render_inline_template_eval_context()``;
:param dict options: options for rendering;
:return dict: {res_id: string of rendered template based on record}
"""
# prevent wrong values (rendering on a void record set, ...)
if any(r is None for r in res_ids):
raise ValueError(_('Template rendering should be called on a valid record IDs.'))
results = dict.fromkeys(res_ids, u"")
if not template_txt:
return results
template_instructions = parse_inline_template(str(template_txt))
is_dynamic = len(template_instructions) > 1 or template_instructions[0][1]
if (not self._unrestricted_rendering and is_dynamic and not self.env.is_admin() and
not self.env.user.has_group('mail.group_mail_template_editor')):
group = self.env.ref('mail.group_mail_template_editor')
raise AccessError(_('Only users belonging to the "%s" group can modify dynamic templates.', group.name))
if not is_dynamic:
# Either the content is a raw text without placeholders, either we fail to
# detect placeholders code. In both case we skip the rendering and return
# the raw content, so even if we failed to detect dynamic code,
# non "mail_template_editor" users will not gain rendering tools available
# only for template specific group users
return {record_id: template_instructions[0][0] for record_id in res_ids}
# prepare template variables
variables = self._render_eval_context()
if add_context:
variables.update(**add_context)
for record in self.env[model].browse(res_ids):
variables['object'] = record
try:
results[record.id] = render_inline_template(template_instructions, variables)
except Exception as e:
_logger.info("Failed to render inline_template: \n%s", str(template_txt), exc_info=True)
raise UserError(_("Failed to render inline_template template : %s)", e))
return results
@api.model
def _render_template_postprocess(self, rendered):
""" Tool method for post processing. In this method we ensure local
links ('/shop/Basil-1') are replaced by global links ('https://www.
mygarden.com/shop/Basil-1').
:param rendered: result of ``_render_template``;
:return dict: updated version of rendered per record ID;
"""
# TODO make this a parameter
model = self.env.context.get('mail_render_postprocess_model')
res_ids = list(rendered.keys())
for res_id, rendered_html in rendered.items():
base_url = None
if model:
base_url = self.env[model].browse(res_id).with_prefetch(res_ids).get_base_url()
rendered[res_id] = self._replace_local_links(rendered_html, base_url)
return rendered
@api.model
def _render_template(self, template_src, model, res_ids, engine='inline_template',
add_context=None, options=None, post_process=False):
""" Render the given string on records designed by model / res_ids using
the given rendering engine. Possible engine are small_web, qweb, or
qweb_view.
:param str template_src: template text to render or xml id of a qweb view;
:param str model: model name of records on which we want to perform
rendering (aka 'crm.lead');
:param list res_ids: list of ids of records. All should belong to the
Odoo model given by model;
:param string engine: inline_template, qweb or qweb_view;
:param dict add_context: additional context to give to renderer. It
allows to add or update values to base rendering context generated
by ``MailRenderMixin._render_<engine>_eval_context()``;
:param dict options: options for rendering;
:param boolean post_process: perform a post processing on rendered result
(notably html links management). See``_render_template_postprocess``;
:return dict: {res_id: string of rendered template based on record}
"""
if not isinstance(res_ids, (list, tuple)):
raise ValueError(_('Template rendering should be called only using on a list of IDs.'))
if engine not in ('inline_template', 'qweb', 'qweb_view'):
raise ValueError(_('Template rendering supports only inline_template, qweb, or qweb_view (view or raw).'))
if engine == 'qweb_view':
rendered = self._render_template_qweb_view(template_src, model, res_ids,
add_context=add_context, options=options)
elif engine == 'qweb':
rendered = self._render_template_qweb(template_src, model, res_ids,
add_context=add_context, options=options)
else:
rendered = self._render_template_inline_template(template_src, model, res_ids,
add_context=add_context, options=options)
if post_process:
rendered = self.with_context(mail_render_postprocess_model=model)._render_template_postprocess(rendered)
return rendered
def _render_lang(self, res_ids, engine='inline_template'):
""" Given some record ids, return the lang for each record based on
lang field of template or through specific context-based key. Lang is
computed by performing a rendering on res_ids, based on self.render_model.
:param list res_ids: list of ids of records. All should belong to the
Odoo model given by model;
:param string engine: inline_template or qweb_view;
:return dict: {res_id: lang code (i.e. en_US)}
"""
self.ensure_one()
if not isinstance(res_ids, (list, tuple)):
raise ValueError(_('Template rendering for language should be called with a list of IDs.'))
rendered_langs = self._render_template(self.lang, self.render_model, res_ids, engine=engine)
return dict(
(res_id, lang)
for res_id, lang in rendered_langs.items()
)
def _classify_per_lang(self, res_ids, engine='inline_template'):
""" Given some record ids, return for computed each lang a contextualized
template and its subset of res_ids.
:param list res_ids: list of ids of records (all belonging to same model
defined by self.render_model)
:param string engine: inline_template, qweb, or qweb_view;
:return dict: {lang: (template with lang=lang_code if specific lang computed
or template, res_ids targeted by that language}
"""
self.ensure_one()
if self.env.context.get('template_preview_lang'):
lang_to_res_ids = {self.env.context['template_preview_lang']: res_ids}
else:
lang_to_res_ids = {}
for res_id, lang in self._render_lang(res_ids, engine=engine).items():
lang_to_res_ids.setdefault(lang, []).append(res_id)
return dict(
(lang, (self.with_context(lang=lang) if lang else self, lang_res_ids))
for lang, lang_res_ids in lang_to_res_ids.items()
)
def _render_field(self, field, res_ids, engine='inline_template',
compute_lang=False, set_lang=False,
add_context=None, options=None, post_process=False):
""" Given some record ids, render a template located on field on all
records. ``field`` should be a field of self (i.e. ``body_html`` on
``mail.template``). res_ids are record IDs linked to ``model`` field
on self.
:param field: a field name existing on self;
:param list res_ids: list of ids of records (all belonging to same model
defined by ``self.render_model``)
:param string engine: inline_template, qweb, or qweb_view;
:param boolean compute_lang: compute language to render on translated
version of the template instead of default (probably english) one.
Language will be computed based on ``self.lang``;
:param string set_lang: force language for rendering. It should be a
valid lang code matching an activate res.lang. Checked only if
``compute_lang`` is False;
:param dict add_context: additional context to give to renderer;
:param dict options: options for rendering;
:param boolean post_process: perform a post processing on rendered result
(notably html links management). See``_render_template_postprocess``);
:return dict: {res_id: string of rendered template based on record}
"""
if options is None:
options = {}
self.ensure_one()
if compute_lang:
templates_res_ids = self._classify_per_lang(res_ids)
elif set_lang:
templates_res_ids = {set_lang: (self.with_context(lang=set_lang), res_ids)}
else:
templates_res_ids = {self._context.get('lang'): (self, res_ids)}
# rendering options
engine = getattr(self._fields[field], 'render_engine', engine)
options.update(**getattr(self._fields[field], 'render_options', {}))
post_process = options.get('post_process') or post_process
return dict(
(res_id, rendered)
for lang, (template, tpl_res_ids) in templates_res_ids.items()
for res_id, rendered in template._render_template(
template[field], template.render_model, tpl_res_ids, engine=engine,
add_context=add_context, options=options, post_process=post_process
).items()
)

View file

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class MailShortcode(models.Model):
""" Shortcode
Canned Responses, allowing the user to defined shortcuts in its message. Should be applied before storing message in database.
Emoji allowing replacing text with image for visual effect. Should be applied when the message is displayed (only for final rendering).
These shortcodes are global and are available for every user.
"""
_name = 'mail.shortcode'
_description = 'Canned Response / Shortcode'
source = fields.Char('Shortcut', required=True, index='trigram',
help="Shortcut that will automatically be substituted with longer content in your messages."
" Type ':' followed by the name of your shortcut (e.g. :hello) to use in your messages.")
substitution = fields.Text('Substitution', required=True,
help="Content that will automatically replace the shortcut of your choosing. This content can still be adapted before sending your message.")
description = fields.Char('Description')
message_ids = fields.Many2one('mail.message', string="Messages", store=False)

View file

@ -0,0 +1,403 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import logging
from odoo import _, api, fields, models, tools, Command
from odoo.exceptions import UserError
from odoo.tools import is_html_empty
_logger = logging.getLogger(__name__)
class MailTemplate(models.Model):
"Templates for sending email"
_name = "mail.template"
_inherit = ['mail.render.mixin', 'template.reset.mixin']
_description = 'Email Templates'
_order = 'name'
_unrestricted_rendering = True
@api.model
def default_get(self, fields):
res = super(MailTemplate, self).default_get(fields)
if res.get('model'):
res['model_id'] = self.env['ir.model']._get(res.pop('model')).id
return res
# description
name = fields.Char('Name', translate=True)
description = fields.Text(
'Template description', translate=True,
help="This field is used for internal description of the template's usage.")
active = fields.Boolean(default=True)
template_category = fields.Selection(
[('base_template', 'Base Template'),
('hidden_template', 'Hidden Template'),
('custom_template', 'Custom Template')],
compute="_compute_template_category", search="_search_template_category")
model_id = fields.Many2one('ir.model', 'Applies to')
model = fields.Char('Related Document Model', related='model_id.model', index=True, store=True, readonly=True)
subject = fields.Char('Subject', translate=True, prefetch=True, help="Subject (placeholders may be used here)")
email_from = fields.Char('From',
help="Sender address (placeholders may be used here). If not set, the default "
"value will be the author's email alias if configured, or email address.")
# recipients
use_default_to = fields.Boolean(
'Default recipients',
help="Default recipients of the record:\n"
"- partner (using id on a partner or the partner_id field) OR\n"
"- email (using email_from or email field)")
email_to = fields.Char('To (Emails)', help="Comma-separated recipient addresses (placeholders may be used here)")
partner_to = fields.Char('To (Partners)',
help="Comma-separated ids of recipient partners (placeholders may be used here)")
email_cc = fields.Char('Cc', help="Carbon copy recipients (placeholders may be used here)")
reply_to = fields.Char('Reply To', help="Email address to which replies will be redirected when sending emails in mass; only used when the reply is not logged in the original discussion thread.")
# content
body_html = fields.Html('Body', render_engine='qweb', translate=True, prefetch=True, sanitize=False)
attachment_ids = fields.Many2many('ir.attachment', 'email_template_attachment_rel', 'email_template_id',
'attachment_id', 'Attachments',
help="You may attach files to this template, to be added to all "
"emails created from this template")
report_name = fields.Char('Report Filename', translate=True, prefetch=True,
help="Name to use for the generated report file (may contain placeholders)\n"
"The extension can be omitted and will then come from the report type.")
report_template = fields.Many2one('ir.actions.report', 'Optional report to print and attach')
# options
mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing Mail Server', readonly=False,
help="Optional preferred server for outgoing mails. If not set, the highest "
"priority one will be used.")
scheduled_date = fields.Char('Scheduled Date', help="If set, the queue manager will send the email after the date. If not set, the email will be send as soon as possible. You can use dynamic expression.")
auto_delete = fields.Boolean(
'Auto Delete', default=True,
help="This option permanently removes any track of email after it's been sent, including from the Technical menu in the Settings, in order to preserve storage space of your Odoo database.")
# contextual action
ref_ir_act_window = fields.Many2one('ir.actions.act_window', 'Sidebar action', readonly=True, copy=False,
help="Sidebar action to make this template available on records "
"of the related document model")
# access
can_write = fields.Boolean(compute='_compute_can_write',
help='The current user can edit the template.')
# Overrides of mail.render.mixin
@api.depends('model')
def _compute_render_model(self):
for template in self:
template.render_model = template.model
@api.depends_context('uid')
def _compute_can_write(self):
writable_templates = self._filter_access_rules('write')
for template in self:
template.can_write = template in writable_templates
@api.depends('active', 'description')
def _compute_template_category(self):
""" Base templates (or master templates) are active templates having
a description and an XML ID. User defined templates (no xml id),
templates without description or archived templates are not
base templates anymore. """
deactivated = self.filtered(lambda template: not template.active)
if deactivated:
deactivated.template_category = 'hidden_template'
remaining = self - deactivated
if remaining:
template_external_ids = remaining.get_external_id()
for template in remaining:
if bool(template_external_ids[template.id]) and template.description:
template.template_category = 'base_template'
elif bool(template_external_ids[template.id]):
template.template_category = 'hidden_template'
else:
template.template_category = 'custom_template'
@api.model
def _search_template_category(self, operator, value):
if operator in ['in', 'not in'] and isinstance(value, list):
value_templates = self.env['mail.template'].search([]).filtered(
lambda t: t.template_category in value
)
return [('id', operator, value_templates.ids)]
if operator in ['=', '!='] and isinstance(value, str):
value_templates = self.env['mail.template'].search([]).filtered(
lambda t: t.template_category == value
)
return [('id', 'in' if operator == "=" else 'not in', value_templates.ids)]
raise NotImplementedError(_('Operation not supported'))
# ------------------------------------------------------------
# CRUD
# ------------------------------------------------------------
def _fix_attachment_ownership(self):
for record in self:
record.attachment_ids.write({'res_model': record._name, 'res_id': record.id})
return self
@api.model_create_multi
def create(self, vals_list):
return super().create(vals_list)\
._fix_attachment_ownership()
def write(self, vals):
super().write(vals)
self._fix_attachment_ownership()
return True
def unlink(self):
self.unlink_action()
return super(MailTemplate, self).unlink()
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
default = dict(default or {},
name=_("%s (copy)", self.name))
return super(MailTemplate, self).copy(default=default)
def unlink_action(self):
for template in self:
if template.ref_ir_act_window:
template.ref_ir_act_window.unlink()
return True
def create_action(self):
ActWindow = self.env['ir.actions.act_window']
view = self.env.ref('mail.email_compose_message_wizard_form')
for template in self:
button_name = _('Send Mail (%s)', template.name)
action = ActWindow.create({
'name': button_name,
'type': 'ir.actions.act_window',
'res_model': 'mail.compose.message',
'context': "{'default_composition_mode': 'mass_mail', 'default_template_id' : %d, 'default_use_template': True}" % (template.id),
'view_mode': 'form,tree',
'view_id': view.id,
'target': 'new',
'binding_model_id': template.model_id.id,
})
template.write({'ref_ir_act_window': action.id})
return True
# ------------------------------------------------------------
# MESSAGE/EMAIL VALUES GENERATION
# ------------------------------------------------------------
def generate_recipients(self, results, res_ids):
"""Generates the recipients of the template. Default values can ben generated
instead of the template values if requested by template or context.
Emails (email_to, email_cc) can be transformed into partners if requested
in the context. """
self.ensure_one()
if self.use_default_to or self._context.get('tpl_force_default_to'):
records = self.env[self.model].browse(res_ids).sudo()
default_recipients = records._message_get_default_recipients()
for res_id, recipients in default_recipients.items():
results[res_id].pop('partner_to', None)
results[res_id].update(recipients)
records_company = None
if self._context.get('tpl_partners_only') and self.model and results and 'company_id' in self.env[self.model]._fields:
records = self.env[self.model].browse(results.keys()).read(['company_id'])
records_company = {rec['id']: (rec['company_id'][0] if rec['company_id'] else None) for rec in records}
for res_id, values in results.items():
partner_ids = values.get('partner_ids', list())
if self._context.get('tpl_partners_only'):
mails = tools.email_split(values.pop('email_to', '')) + tools.email_split(values.pop('email_cc', ''))
Partner = self.env['res.partner']
if records_company:
Partner = Partner.with_context(default_company_id=records_company[res_id])
for mail in mails:
partner = Partner.find_or_create(mail)
partner_ids.append(partner.id)
partner_to = values.pop('partner_to', '')
if partner_to:
# placeholders could generate '', 3, 2 due to some empty field values
tpl_partner_ids = [int(pid.strip()) for pid in partner_to.split(',') if (pid and pid.strip().isdigit())]
partner_ids += self.env['res.partner'].sudo().browse(tpl_partner_ids).exists().ids
results[res_id]['partner_ids'] = partner_ids
return results
def generate_email(self, res_ids, fields):
"""Generates an email from the template for given the given model based on
records given by res_ids.
:param res_id: id of the record to use for rendering the template (model
is taken from template definition)
:returns: a dict containing all relevant fields for creating a new
mail.mail entry, with one extra key ``attachments``, in the
format [(report_name, data)] where data is base64 encoded.
"""
self.ensure_one()
multi_mode = True
if isinstance(res_ids, int):
res_ids = [res_ids]
multi_mode = False
results = dict()
for lang, (template, template_res_ids) in self._classify_per_lang(res_ids).items():
for field in fields:
generated_field_values = template._render_field(
field, template_res_ids,
post_process=(field == 'body_html')
)
for res_id, field_value in generated_field_values.items():
results.setdefault(res_id, dict())[field] = field_value
# compute recipients
if any(field in fields for field in ['email_to', 'partner_to', 'email_cc']):
results = template.generate_recipients(results, template_res_ids)
# update values for all res_ids
for res_id in template_res_ids:
values = results[res_id]
if values.get('body_html'):
values['body'] = tools.html_sanitize(values['body_html'])
# if asked in fields to return, parse generated date into tz agnostic UTC as expected by ORM
scheduled_date = values.pop('scheduled_date', None)
if 'scheduled_date' in fields and scheduled_date:
parsed_datetime = self.env['mail.mail']._parse_scheduled_datetime(scheduled_date)
values['scheduled_date'] = parsed_datetime.replace(tzinfo=None) if parsed_datetime else False
# technical settings
values.update(
mail_server_id=template.mail_server_id.id or False,
auto_delete=template.auto_delete,
model=template.model,
res_id=res_id or False,
attachment_ids=[attach.id for attach in template.attachment_ids],
)
# Add report in attachments: generate once for all template_res_ids
if template.report_template:
for res_id in template_res_ids:
attachments = []
report_name = template._render_field('report_name', [res_id])[res_id]
report = template.report_template
report_service = report.report_name
if report.report_type in ['qweb-html', 'qweb-pdf']:
result, report_format = self.env['ir.actions.report']._render_qweb_pdf(report, [res_id])
else:
res = self.env['ir.actions.report']._render(report, [res_id])
if not res:
raise UserError(_('Unsupported report type %s found.', report.report_type))
result, report_format = res
# TODO in trunk, change return format to binary to match message_post expected format
result = base64.b64encode(result)
if not report_name:
report_name = 'report.' + report_service
ext = "." + report_format
if not report_name.endswith(ext):
report_name += ext
attachments.append((report_name, result))
results[res_id]['attachments'] = attachments
return multi_mode and results or results[res_ids[0]]
# ------------------------------------------------------------
# EMAIL
# ------------------------------------------------------------
def _send_check_access(self, res_ids):
records = self.env[self.model].browse(res_ids)
records.check_access_rights('read')
records.check_access_rule('read')
def send_mail(self, res_id, force_send=False, raise_exception=False, email_values=None,
email_layout_xmlid=False):
""" Generates a new mail.mail. Template is rendered on record given by
res_id and model coming from template.
:param int res_id: id of the record to render the template
:param bool force_send: send email immediately; otherwise use the mail
queue (recommended);
:param dict email_values: update generated mail with those values to further
customize the mail;
:param str email_layout_xmlid: optional notification layout to encapsulate the
generated email;
:returns: id of the mail.mail that was created """
# Grant access to send_mail only if access to related document
self.ensure_one()
self._send_check_access([res_id])
Attachment = self.env['ir.attachment'] # TDE FIXME: should remove default_type from context
# create a mail_mail based on values, without attachments
values = self.generate_email(
res_id,
['subject', 'body_html',
'email_from',
'email_cc', 'email_to', 'partner_to', 'reply_to',
'auto_delete', 'scheduled_date']
)
values['recipient_ids'] = [Command.link(pid) for pid in values.get('partner_ids', list())]
values['attachment_ids'] = [Command.link(aid) for aid in values.get('attachment_ids', list())]
values.update(email_values or {})
attachment_ids = values.pop('attachment_ids', [])
attachments = values.pop('attachments', [])
# add a protection against void email_from
if 'email_from' in values and not values.get('email_from'):
values.pop('email_from')
# encapsulate body
if email_layout_xmlid and values['body_html']:
record = self.env[self.model].browse(res_id)
model = self.env['ir.model']._get(record._name)
if self.lang:
lang = self._render_lang([res_id])[res_id]
model = model.with_context(lang=lang)
template_ctx = {
# message
'message': self.env['mail.message'].sudo().new(dict(body=values['body_html'], record_name=record.display_name)),
'subtype': self.env['mail.message.subtype'].sudo(),
# record
'model_description': model.display_name,
'record': record,
'record_name': False,
'subtitles': False,
# user / environment
'company': 'company_id' in record and record['company_id'] or self.env.company,
'email_add_signature': False,
'signature': '',
'website_url': '',
# tools
'is_html_empty': is_html_empty,
}
body = model.env['ir.qweb']._render(email_layout_xmlid, template_ctx, minimal_qcontext=True, raise_if_not_found=False)
if not body:
_logger.warning(
'QWeb template %s not found when sending template %s. Sending without layout.',
email_layout_xmlid,
self.name
)
values['body_html'] = self.env['mail.render.mixin']._replace_local_links(body)
mail = self.env['mail.mail'].sudo().create(values)
# manage attachments
for attachment in attachments:
attachment_data = {
'name': attachment[0],
'datas': attachment[1],
'type': 'binary',
'res_model': 'mail.message',
'res_id': mail.mail_message_id.id,
}
attachment_ids.append((4, Attachment.create(attachment_data).id))
if attachment_ids:
mail.write({'attachment_ids': attachment_ids})
if force_send:
mail.send(raise_exception=raise_exception)
return mail.id # TDE CLEANME: return mail + api.returns ?

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools, _
from odoo.exceptions import AccessError, UserError
class MailBlackListMixin(models.AbstractModel):
""" Mixin that is inherited by all model with opt out. This mixin stores a normalized
email based on primary_email field.
A normalized email is considered as :
- having a left part + @ + a right part (the domain can be without '.something')
- being lower case
- having no name before the address. Typically, having no 'Name <>'
Ex:
- Formatted Email : 'Name <NaMe@DoMaIn.CoM>'
- Normalized Email : 'name@domain.com'
The primary email field can be specified on the parent model, if it differs from the default one ('email')
The email_normalized field can than be used on that model to search quickly on emails (by simple comparison
and not using time consuming regex anymore).
Using this email_normalized field, blacklist status is computed.
Mail Thread capabilities are required for this mixin. """
_name = 'mail.thread.blacklist'
_inherit = ['mail.thread']
_description = 'Mail Blacklist mixin'
_primary_email = 'email'
email_normalized = fields.Char(
string='Normalized Email', compute="_compute_email_normalized", compute_sudo=True,
store=True, invisible=True,
help="This field is used to search on email address as the primary email field can contain more than strictly an email address.")
# Note : is_blacklisted sould only be used for display. As the compute is not depending on the blacklist,
# once read, it won't be re-computed again if the blacklist is modified in the same request.
is_blacklisted = fields.Boolean(
string='Blacklist', compute="_compute_is_blacklisted", compute_sudo=True, store=False,
search="_search_is_blacklisted", groups="base.group_user",
help="If the email address is on the blacklist, the contact won't receive mass mailing anymore, from any list")
# messaging
message_bounce = fields.Integer('Bounce', help="Counter of the number of bounced emails for this contact", default=0)
@api.depends(lambda self: [self._primary_email])
def _compute_email_normalized(self):
self._assert_primary_email()
for record in self:
record.email_normalized = tools.email_normalize(record[self._primary_email], strict=False)
@api.model
def _search_is_blacklisted(self, operator, value):
# Assumes operator is '=' or '!=' and value is True or False
self.flush_model(['email_normalized'])
self.env['mail.blacklist'].flush_model(['email', 'active'])
self._assert_primary_email()
if operator != '=':
if operator == '!=' and isinstance(value, bool):
value = not value
else:
raise NotImplementedError()
if value:
query = """
SELECT m.id
FROM mail_blacklist bl
JOIN %s m
ON m.email_normalized = bl.email AND bl.active
"""
else:
query = """
SELECT m.id
FROM %s m
LEFT JOIN mail_blacklist bl
ON m.email_normalized = bl.email AND bl.active
WHERE bl.id IS NULL
"""
self._cr.execute((query + " FETCH FIRST ROW ONLY") % self._table)
res = self._cr.fetchall()
if not res:
return [(0, '=', 1)]
return [('id', 'inselect', (query % self._table, []))]
@api.depends('email_normalized')
def _compute_is_blacklisted(self):
# TODO : Should remove the sudo as compute_sudo defined on methods.
# But if user doesn't have access to mail.blacklist, doen't work without sudo().
blacklist = set(self.env['mail.blacklist'].sudo().search([
('email', 'in', self.mapped('email_normalized'))]).mapped('email'))
for record in self:
record.is_blacklisted = record.email_normalized in blacklist
def _assert_primary_email(self):
if not hasattr(self, "_primary_email") or not isinstance(self._primary_email, str):
raise UserError(_('Invalid primary email field on model %s', self._name))
if self._primary_email not in self._fields or self._fields[self._primary_email].type != 'char':
raise UserError(_('Invalid primary email field on model %s', self._name))
def _message_receive_bounce(self, email, partner):
""" Override of mail.thread generic method. Purpose is to increment the
bounce counter of the record. """
super(MailBlackListMixin, self)._message_receive_bounce(email, partner)
for record in self:
record.message_bounce = record.message_bounce + 1
def _message_reset_bounce(self, email):
""" Override of mail.thread generic method. Purpose is to reset the
bounce counter of the record. """
super(MailBlackListMixin, self)._message_reset_bounce(email)
self.write({'message_bounce': 0})
def mail_action_blacklist_remove(self):
# wizard access rights currently not working as expected and allows users without access to
# open this wizard, therefore we check to make sure they have access before the wizard opens.
can_access = self.env['mail.blacklist'].check_access_rights('write', raise_exception=False)
if can_access:
return {
'name': _('Are you sure you want to unblacklist this Email Address?'),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'mail.blacklist.remove',
'target': 'new',
}
else:
raise AccessError(_("You do not have the access right to unblacklist emails. Please contact your administrator."))
@api.model
def _detect_loop_sender_domain(self, email_from_normalized):
"""Return the domain to be used to detect duplicated records created by alias.
:param email_from_normalized: FROM of the incoming email, normalized
"""
return [('email_normalized', '=', email_from_normalized)]

View file

@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models, tools
class MailCCMixin(models.AbstractModel):
_name = 'mail.thread.cc'
_inherit = 'mail.thread'
_description = 'Email CC management'
email_cc = fields.Char('Email cc')
def _mail_cc_sanitized_raw_dict(self, cc_string):
'''return a dict of sanitize_email:raw_email from a string of cc'''
if not cc_string:
return {}
return {
tools.email_normalize(email): tools.formataddr((name, tools.email_normalize(email)))
for (name, email) in tools.email_split_tuples(cc_string)
}
@api.model
def message_new(self, msg_dict, custom_values=None):
if custom_values is None:
custom_values = {}
cc_values = {
'email_cc': ", ".join(self._mail_cc_sanitized_raw_dict(msg_dict.get('cc')).values()),
}
cc_values.update(custom_values)
return super(MailCCMixin, self).message_new(msg_dict, cc_values)
def message_update(self, msg_dict, update_vals=None):
'''Adds cc email to self.email_cc while trying to keep email as raw as possible but unique'''
if update_vals is None:
update_vals = {}
cc_values = {}
new_cc = self._mail_cc_sanitized_raw_dict(msg_dict.get('cc'))
if new_cc:
old_cc = self._mail_cc_sanitized_raw_dict(self.email_cc)
new_cc.update(old_cc)
cc_values['email_cc'] = ", ".join(new_cc.values())
cc_values.update(update_vals)
return super(MailCCMixin, self).message_update(msg_dict, cc_values)
def _message_get_suggested_recipients(self):
recipients = super(MailCCMixin, self)._message_get_suggested_recipients()
for record in self:
if record.email_cc:
for email in tools.email_split_and_format(record.email_cc):
record._message_add_suggested_recipient(recipients, email=email, reason=_('CC Email'))
return recipients

View file

@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from odoo import api, fields, models
class MailTracking(models.Model):
_name = 'mail.tracking.value'
_description = 'Mail Tracking Value'
_rec_name = 'field'
_order = 'tracking_sequence asc'
field = fields.Many2one('ir.model.fields', required=True, readonly=1, index=True, ondelete='cascade')
field_desc = fields.Char('Field Description', required=True, readonly=1)
field_type = fields.Char('Field Type')
field_groups = fields.Char(compute='_compute_field_groups')
old_value_integer = fields.Integer('Old Value Integer', readonly=1)
old_value_float = fields.Float('Old Value Float', readonly=1)
old_value_monetary = fields.Float('Old Value Monetary', readonly=1)
old_value_char = fields.Char('Old Value Char', readonly=1)
old_value_text = fields.Text('Old Value Text', readonly=1)
old_value_datetime = fields.Datetime('Old Value DateTime', readonly=1)
new_value_integer = fields.Integer('New Value Integer', readonly=1)
new_value_float = fields.Float('New Value Float', readonly=1)
new_value_monetary = fields.Float('New Value Monetary', readonly=1)
new_value_char = fields.Char('New Value Char', readonly=1)
new_value_text = fields.Text('New Value Text', readonly=1)
new_value_datetime = fields.Datetime('New Value Datetime', readonly=1)
currency_id = fields.Many2one('res.currency', 'Currency', readonly=True, ondelete='set null',
help="Used to display the currency when tracking monetary values")
mail_message_id = fields.Many2one('mail.message', 'Message ID', required=True, index=True, ondelete='cascade')
tracking_sequence = fields.Integer('Tracking field sequence', readonly=1, default=100)
@api.depends('mail_message_id', 'field')
def _compute_field_groups(self):
for tracking in self:
model = self.env[tracking.field.model]
field = model._fields.get(tracking.field.name)
tracking.field_groups = field.groups if field else 'base.group_system'
@api.model
def create_tracking_values(self, initial_value, new_value, col_name, col_info, tracking_sequence, model_name):
tracked = True
field = self.env['ir.model.fields']._get(model_name, col_name)
if not field:
return
values = {'field': field.id, 'field_desc': col_info['string'], 'field_type': col_info['type'], 'tracking_sequence': tracking_sequence}
if col_info['type'] in ['integer', 'float', 'char', 'text', 'datetime', 'monetary']:
values.update({
'old_value_%s' % col_info['type']: initial_value,
'new_value_%s' % col_info['type']: new_value
})
elif col_info['type'] == 'date':
values.update({
'old_value_datetime': initial_value and fields.Datetime.to_string(datetime.combine(fields.Date.from_string(initial_value), datetime.min.time())) or False,
'new_value_datetime': new_value and fields.Datetime.to_string(datetime.combine(fields.Date.from_string(new_value), datetime.min.time())) or False,
})
elif col_info['type'] == 'boolean':
values.update({
'old_value_integer': initial_value,
'new_value_integer': new_value
})
elif col_info['type'] == 'selection':
values.update({
'old_value_char': initial_value and dict(col_info['selection']).get(initial_value, initial_value) or '',
'new_value_char': new_value and dict(col_info['selection'])[new_value] or ''
})
elif col_info['type'] == 'many2one':
values.update({
'old_value_integer': initial_value and initial_value.id or 0,
'new_value_integer': new_value and new_value.id or 0,
'old_value_char': initial_value and initial_value.sudo().name_get()[0][1] or '',
'new_value_char': new_value and new_value.sudo().name_get()[0][1] or ''
})
else:
tracked = False
if tracked:
return values
return {}
def _tracking_value_format(self):
tracking_values = [{
'changedField': tracking.field_desc,
'id': tracking.id,
'newValue': {
'currencyId': tracking.currency_id.id,
'fieldType': tracking.field_type,
'value': tracking._get_new_display_value()[0],
},
'oldValue': {
'currencyId': tracking.currency_id.id,
'fieldType': tracking.field_type,
'value': tracking._get_old_display_value()[0],
},
} for tracking in self]
return tracking_values
def _get_display_value(self, prefix):
assert prefix in ('new', 'old')
result = []
for record in self:
if record.field_type in ['integer', 'float', 'char', 'text', 'monetary']:
result.append(record[f'{prefix}_value_{record.field_type}'])
elif record.field_type == 'datetime':
if record[f'{prefix}_value_datetime']:
new_datetime = record[f'{prefix}_value_datetime']
result.append(f'{new_datetime}Z')
else:
result.append(record[f'{prefix}_value_datetime'])
elif record.field_type == 'date':
if record[f'{prefix}_value_datetime']:
new_date = record[f'{prefix}_value_datetime']
result.append(fields.Date.to_string(new_date))
else:
result.append(record[f'{prefix}_value_datetime'])
elif record.field_type == 'boolean':
result.append(bool(record[f'{prefix}_value_integer']))
else:
result.append(record[f'{prefix}_value_char'])
return result
def _get_old_display_value(self):
# grep : # old_value_integer | old_value_datetime | old_value_char
return self._get_display_value('old')
def _get_new_display_value(self):
# grep : # new_value_integer | new_value_datetime | new_value_char
return self._get_display_value('new')

View file

@ -0,0 +1,290 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from lxml.builder import E
from markupsafe import Markup
from odoo import api, models, tools, _
import logging
_logger = logging.getLogger(__name__)
class BaseModel(models.AbstractModel):
_inherit = 'base'
def _valid_field_parameter(self, field, name):
# allow tracking on abstract models; see also 'mail.thread'
return (
name == 'tracking' and self._abstract
or super()._valid_field_parameter(field, name)
)
# ------------------------------------------------------------
# GENERIC MAIL FEATURES
# ------------------------------------------------------------
def _mail_track(self, tracked_fields, initial):
""" For a given record, fields to check (tuple column name, column info)
and initial values, return a valid command to create tracking values.
:param tracked_fields: fields_get of updated fields on which tracking
is checked and performed;
:param initial: dict of initial values for each updated fields;
:return: a tuple (changes, tracking_value_ids) where
changes: set of updated column names;
tracking_value_ids: a list of ORM (0, 0, values) commands to create
``mail.tracking.value`` records;
Override this method on a specific model to implement model-specific
behavior. Also consider inheriting from ``mail.thread``. """
self.ensure_one()
changes = set() # contains onchange tracked fields that changed
tracking_value_ids = []
# generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
for col_name, col_info in tracked_fields.items():
if col_name not in initial:
continue
initial_value = initial[col_name]
new_value = self[col_name]
if new_value != initial_value and (new_value or initial_value): # because browse null != False
tracking_sequence = getattr(self._fields[col_name], 'tracking',
getattr(self._fields[col_name], 'track_sequence', 100)) # backward compatibility with old parameter name
if tracking_sequence is True:
tracking_sequence = 100
tracking = self.env['mail.tracking.value'].create_tracking_values(initial_value, new_value, col_name, col_info, tracking_sequence, self._name)
if tracking:
if tracking['field_type'] == 'monetary':
tracking['currency_id'] = self[col_info['currency_field']].id
tracking_value_ids.append([0, 0, tracking])
changes.add(col_name)
return changes, tracking_value_ids
def _message_get_default_recipients(self):
""" Generic implementation for finding default recipient to mail on
a recordset. This method is a generic implementation available for
all models as we could send an email through mail templates on models
not inheriting from mail.thread.
Override this method on a specific model to implement model-specific
behavior. Also consider inheriting from ``mail.thread``. """
res = {}
for record in self:
recipient_ids, email_to, email_cc = [], False, False
if 'partner_id' in record and record.partner_id:
recipient_ids.append(record.partner_id.id)
else:
found_email = False
if 'email_from' in record and record.email_from:
found_email = record.email_from
elif 'partner_email' in record and record.partner_email:
found_email = record.partner_email
elif 'email' in record and record.email:
found_email = record.email
elif 'email_normalized' in record and record.email_normalized:
found_email = record.email_normalized
if found_email:
email_to = ','.join(tools.email_normalize_all(found_email))
if not email_to: # keep value to ease debug / trace update
email_to = found_email
res[record.id] = {'partner_ids': recipient_ids, 'email_to': email_to, 'email_cc': email_cc}
return res
def _notify_get_reply_to(self, default=None):
""" Returns the preferred reply-to email address when replying to a thread
on documents. This method is a generic implementation available for
all models as we could send an email through mail templates on models
not inheriting from mail.thread.
Reply-to is formatted like "MyCompany MyDocument <reply.to@domain>".
Heuristic it the following:
* search for specific aliases as they always have priority; it is limited
to aliases linked to documents (like project alias for task for example);
* use catchall address;
* use default;
This method can be used as a generic tools if self is a void recordset.
Override this method on a specific model to implement model-specific
behavior. Also consider inheriting from ``mail.thread``.
An example would be tasks taking their reply-to alias from their project.
:param default: default email if no alias or catchall is found;
:return result: dictionary. Keys are record IDs and value is formatted
like an email "Company_name Document_name <reply_to@email>"/
"""
_records = self
model = _records._name if _records and _records._name != 'mail.thread' else False
res_ids = _records.ids if _records and model else []
_res_ids = res_ids or [False] # always have a default value located in False
alias_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain")
result = dict.fromkeys(_res_ids, False)
result_email = dict()
doc_names = dict()
if alias_domain:
if model and res_ids:
if not doc_names:
doc_names = dict((rec.id, rec.display_name) for rec in _records)
mail_aliases = self.env['mail.alias'].sudo().search([
('alias_parent_model_id.model', '=', model),
('alias_parent_thread_id', 'in', res_ids),
('alias_name', '!=', False)])
# take only first found alias for each thread_id, to match order (1 found -> limit=1 for each res_id)
for alias in mail_aliases:
result_email.setdefault(alias.alias_parent_thread_id, '%s@%s' % (alias.alias_name, alias_domain))
# left ids: use catchall
left_ids = set(_res_ids) - set(result_email)
if left_ids:
catchall = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.alias")
if catchall:
result_email.update(dict((rid, '%s@%s' % (catchall, alias_domain)) for rid in left_ids))
for res_id in result_email:
result[res_id] = self._notify_get_reply_to_formatted_email(
result_email[res_id],
doc_names.get(res_id) or '',
)
left_ids = set(_res_ids) - set(result_email)
if left_ids:
result.update(dict((res_id, default) for res_id in left_ids))
return result
def _notify_get_reply_to_formatted_email(self, record_email, record_name):
""" Compute formatted email for reply_to and try to avoid refold issue
with python that splits the reply-to over multiple lines. It is due to
a bad management of quotes (missing quotes after refold). This appears
therefore only when having quotes (aka not simple names, and not when
being unicode encoded).
Another edge-case produces a linebreak (CRLF) immediately after the
colon character separating the header name from the header value.
This creates an issue in certain DKIM tech stacks that will
incorrectly read the reply-to value as empty and fail the verification.
To avoid that issue when formataddr would return more than 68 chars we
return a simplified name/email to try to stay under 68 chars. If not
possible we return only the email and skip the formataddr which causes
the issue in python. We do not use hacks like crop the name part as
encoding and quoting would be error prone.
"""
length_limit = 68 # 78 - len('Reply-To: '), 78 per RFC
# address itself is too long : return only email and log warning
if len(record_email) >= length_limit:
_logger.warning('Notification email address for reply-to is longer than 68 characters. '
'This might create non-compliant folding in the email header in certain DKIM '
'verification tech stacks. It is advised to shorten it if possible. '
'Record name (if set): %s '
'Reply-To: %s ', record_name, record_email)
return record_email
if 'company_id' in self and len(self.company_id) == 1:
company_name = self.sudo().company_id.name
else:
company_name = self.env.company.name
# try company_name + record_name, or record_name alone (or company_name alone)
name = f"{company_name} {record_name}" if record_name else company_name
formatted_email = tools.formataddr((name, record_email))
if len(formatted_email) > length_limit:
formatted_email = tools.formataddr((record_name or company_name, record_email))
if len(formatted_email) > length_limit:
formatted_email = record_email
return formatted_email
# ------------------------------------------------------------
# ALIAS MANAGEMENT
# ------------------------------------------------------------
def _alias_get_error_message(self, message, message_dict, alias):
""" Generic method that takes a record not necessarily inheriting from
mail.alias.mixin. """
author = self.env['res.partner'].browse(message_dict.get('author_id', False))
if alias.alias_contact == 'followers':
if not self.ids:
return _('incorrectly configured alias (unknown reference record)')
if not hasattr(self, "message_partner_ids"):
return _('incorrectly configured alias')
if not author or author not in self.message_partner_ids:
return _('restricted to followers')
elif alias.alias_contact == 'partners' and not author:
return _('restricted to known authors')
return False
# ------------------------------------------------------------
# ACTIVITY
# ------------------------------------------------------------
@api.model
def _get_default_activity_view(self):
""" Generates an empty activity view.
:returns: a activity view as an lxml document
:rtype: etree._Element
"""
field = E.field(name=self._rec_name_fallback())
activity_box = E.div(field, {'t-name': "activity-box"})
templates = E.templates(activity_box)
return E.activity(templates, string=self._description)
# ------------------------------------------------------------
# GATEWAY: NOTIFICATION
# ------------------------------------------------------------
def _mail_get_message_subtypes(self):
return self.env['mail.message.subtype'].search([
'&', ('hidden', '=', False),
'|', ('res_model', '=', self._name), ('res_model', '=', False)])
def _notify_by_email_get_headers(self):
""" Generate the email headers based on record """
if not self:
return {}
self.ensure_one()
return {
'X-Odoo-Objects': "%s-%s" % (self._name, self.id),
}
# ------------------------------------------------------------
# TOOLS
# ------------------------------------------------------------
def _get_html_link(self, title=None):
"""Generate the record html reference for chatter use.
:param str title: optional reference title, the record display_name
is used if not provided. The title/display_name will be escaped.
:returns: generated html reference,
in the format <a href data-oe-model="..." data-oe-id="...">title</a>
:rtype: str
"""
self.ensure_one()
return Markup("<a href=# data-oe-model='%s' data-oe-id='%s'>%s</a>") % (
self._name, self.id, title or self.display_name)
# ------------------------------------------------------
# CONTROLLERS
# ------------------------------------------------------
def _get_mail_redirect_suggested_company(self):
""" Return the suggested company to be set on the context
in case of a mail redirection to the record. To avoid multi
company issues when clicking on a link sent by email, this
could be called to try setting the most suited company on
the allowed_company_ids in the context. This method can be
overridden, for example on the hr.leave model, where the
most suited company is the company of the leave type, as
specified by the ir.rule.
"""
if 'company_id' in self:
return self.company_id
return False

View file

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, fields, tools
class Company(models.Model):
_name = 'res.company'
_inherit = 'res.company'
catchall_email = fields.Char(string="Catchall Email", compute="_compute_catchall")
catchall_formatted = fields.Char(string="Catchall", compute="_compute_catchall")
# the compute method is sudo'ed because it needs to access res.partner records
# portal users cannot access those (but they should be able to read the company email address)
email_formatted = fields.Char(string="Formatted Email",
compute="_compute_email_formatted", compute_sudo=True)
@api.depends('name')
def _compute_catchall(self):
ConfigParameter = self.env['ir.config_parameter'].sudo()
alias = ConfigParameter.get_param('mail.catchall.alias')
domain = ConfigParameter.get_param('mail.catchall.domain')
if alias and domain:
for company in self:
company.catchall_email = '%s@%s' % (alias, domain)
company.catchall_formatted = tools.formataddr((company.name, company.catchall_email))
else:
for company in self:
company.catchall_email = ''
company.catchall_formatted = ''
@api.depends('partner_id.email_formatted', 'catchall_formatted')
def _compute_email_formatted(self):
for company in self:
if company.partner_id.email_formatted:
company.email_formatted = company.partner_id.email_formatted
elif company.catchall_formatted:
company.email_formatted = company.catchall_formatted
else:
company.email_formatted = ''

View file

@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
from odoo import _, fields, models
from odoo.exceptions import UserError
class ResConfigSettings(models.TransientModel):
""" Inherit the base settings to add a counter of failed email + configure
the alias domain. """
_inherit = 'res.config.settings'
fail_counter = fields.Integer('Fail Mail', compute="_compute_fail_counter")
alias_domain = fields.Char(
'Alias Domain', config_parameter='mail.catchall.domain',
help="If you have setup a catch-all email domain redirected to the Odoo server, enter the domain name here.")
module_google_gmail = fields.Boolean('Support Gmail Authentication')
module_microsoft_outlook = fields.Boolean('Support Outlook Authentication')
restrict_template_rendering = fields.Boolean(
'Restrict Template Rendering',
config_parameter='mail.restrict.template.rendering',
help='Users will still be able to render templates.\n'
'However only Mail Template Editors will be able to create new dynamic templates or modify existing ones.')
use_twilio_rtc_servers = fields.Boolean(
'Use Twilio ICE servers',
help="If you want to use twilio as TURN/STUN server provider",
config_parameter='mail.use_twilio_rtc_servers',
)
twilio_account_sid = fields.Char(
'Twilio Account SID',
config_parameter='mail.twilio_account_sid',
)
twilio_account_token = fields.Char(
'Twilio Account Auth Token',
config_parameter='mail.twilio_account_token',
)
primary_color = fields.Char(related='company_id.primary_color', string="Header Color", readonly=False)
secondary_color = fields.Char(related='company_id.secondary_color', string="Button Color", readonly=False)
def _compute_fail_counter(self):
previous_date = fields.Datetime.now() - datetime.timedelta(days=30)
self.fail_counter = self.env['mail.mail'].sudo().search_count([
('date', '>=', previous_date),
('state', '=', 'exception'),
])
def open_email_layout(self):
layout = self.env.ref('mail.mail_notification_layout', raise_if_not_found=False)
if not layout:
raise UserError(_("This layout seems to no longer exist."))
return {
'type': 'ir.actions.act_window',
'name': _('Mail Layout'),
'view_mode': 'form',
'res_id': layout.id,
'res_model': 'ir.ui.view',
}
def open_mail_templates(self):
return self.env['ir.actions.actions']._for_xml_id('mail.action_email_template_tree_all')

View file

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ResGroups(models.Model):
""" Update of res.groups class
- if adding users from a group, check mail.channels linked to this user
group and subscribe them. This is done by overriding the write method.
"""
_name = 'res.groups'
_inherit = 'res.groups'
_description = 'Access Groups'
def write(self, vals):
res = super(ResGroups, self).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['mail.channel'].search([('group_ids', 'in', self._ids)])._subscribe_users_automatically()
return res

View file

@ -0,0 +1,247 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models, tools
from odoo.osv import expression
class Partner(models.Model):
""" Update partner to add a field about notification preferences. Add a generic opt-out field that can be used
to restrict usage of automatic email templates. """
_name = "res.partner"
_inherit = ['res.partner', 'mail.activity.mixin', 'mail.thread.blacklist']
_mail_flat_thread = False
# override to add and order tracking
email = fields.Char(tracking=1)
phone = fields.Char(tracking=2)
parent_id = fields.Many2one(tracking=3)
user_id = fields.Many2one(tracking=4)
vat = fields.Char(tracking=5)
# channels
channel_ids = fields.Many2many('mail.channel', 'mail_channel_member', 'partner_id', 'channel_id', string='Channels', copy=False)
def _compute_im_status(self):
super()._compute_im_status()
odoobot_id = self.env['ir.model.data']._xmlid_to_res_id('base.partner_root')
odoobot = self.env['res.partner'].browse(odoobot_id)
if odoobot in self:
odoobot.im_status = 'bot'
# pseudo computes
def _get_needaction_count(self):
""" compute the number of needaction of the current partner """
self.ensure_one()
self.env['mail.notification'].flush_model(['is_read', 'res_partner_id'])
self.env.cr.execute("""
SELECT count(*) as needaction_count
FROM mail_notification R
WHERE R.res_partner_id = %s AND (R.is_read = false OR R.is_read IS NULL)""", (self.id,))
return self.env.cr.dictfetchall()[0].get('needaction_count')
# ------------------------------------------------------------
# MESSAGING
# ------------------------------------------------------------
def _mail_get_partners(self):
return dict((partner.id, partner) for partner in self)
def _message_get_suggested_recipients(self):
recipients = super(Partner, self)._message_get_suggested_recipients()
for partner in self:
partner._message_add_suggested_recipient(recipients, partner=partner, reason=_('Partner Profile'))
return recipients
def _message_get_default_recipients(self):
return {
r.id:
{'partner_ids': [r.id],
'email_to': False,
'email_cc': False
}
for r in self
}
# ------------------------------------------------------------
# ORM
# ------------------------------------------------------------
@api.model
def _get_view_cache_key(self, view_id=None, view_type='form', **options):
"""Add context variable force_email in the key as _get_view depends on it."""
key = super()._get_view_cache_key(view_id, view_type, **options)
return key + (self._context.get('force_email'),)
@api.model
@api.returns('self', lambda value: value.id)
def find_or_create(self, email, assert_valid_email=False):
""" Override to use the email_normalized field. """
if not email:
raise ValueError(_('An email is required for find_or_create to work'))
parsed_name, parsed_email = self._parse_partner_name(email)
if not parsed_email and assert_valid_email:
raise ValueError(_('%(email)s is not recognized as a valid email. This is required to create a new customer.'))
if parsed_email:
email_normalized = tools.email_normalize(parsed_email)
if email_normalized:
partners = self.search([('email_normalized', '=', email_normalized)], limit=1)
if partners:
return partners
# We don't want to call `super()` to avoid searching twice on the email
# Especially when the search `email =ilike` cannot be as efficient as
# a search on email_normalized with a btree index
# If you want to override `find_or_create()` your module should depend on `mail`
create_values = {self._rec_name: parsed_name or parsed_email}
if parsed_email: # otherwise keep default_email in context
create_values['email'] = parsed_email
return self.create(create_values)
# ------------------------------------------------------------
# DISCUSS
# ------------------------------------------------------------
def mail_partner_format(self, fields=None):
partners_format = dict()
if not fields:
fields = {'id': True, 'name': True, 'email': True, 'active': True, 'im_status': True, 'user': {}}
for partner in self:
data = {}
if 'id' in fields:
data['id'] = partner.id
if 'name' in fields:
data['name'] = partner.name
if 'email' in fields:
data['email'] = partner.email
if 'active' in fields:
data['active'] = partner.active
if 'im_status' in fields:
data['im_status'] = partner.im_status
if 'user' in fields:
internal_users = partner.user_ids - partner.user_ids.filtered('share')
main_user = internal_users[0] if len(internal_users) > 0 else partner.user_ids[0] if len(partner.user_ids) > 0 else self.env['res.users']
data['user'] = {
"id": main_user.id,
"isInternalUser": not main_user.share,
} if main_user else [('clear',)]
if not self.env.user._is_internal():
data.pop('email', None)
partners_format[partner] = data
return partners_format
def _message_fetch_failed(self):
"""Returns first 100 messages, sent by the current partner, that have errors, in
the format expected by the web client."""
self.ensure_one()
notifications = self.env['mail.notification'].search([
('author_id', '=', self.id),
('notification_status', 'in', ('bounce', 'exception')),
('mail_message_id.message_type', '!=', 'user_notification'),
('mail_message_id.model', '!=', False),
('mail_message_id.res_id', '!=', 0),
], limit=100)
return notifications.mail_message_id._message_notification_format()
def _get_channels_as_member(self):
"""Returns the channels of the partner."""
self.ensure_one()
channels = self.env['mail.channel']
# get the channels and groups
channels |= self.env['mail.channel'].search([
('channel_type', 'in', ('channel', 'group')),
('channel_partner_ids', 'in', [self.id]),
])
# get the pinned direct messages
channels |= self.env['mail.channel'].search([
('channel_type', '=', 'chat'),
('channel_member_ids', 'in', self.env['mail.channel.member'].sudo()._search([
('partner_id', '=', self.id),
('is_pinned', '=', True),
])),
])
return channels
@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)],
[('type', '!=', 'private')],
[('user_ids', '!=', False)],
[('user_ids.active', '=', True)],
[('user_ids.share', '=', False)],
])
if channel_id:
channel = self.env['mail.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(self, search, limit=8, channel_id=None):
""" 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.
If channel_id is given, only members of this channel are returned.
The return format is a list of partner data (as per returned by `mail_partner_format()`).
"""
search_dom = expression.OR([[('name', 'ilike', search)], [('email', 'ilike', search)]])
search_dom = expression.AND([[('active', '=', True), ('type', '!=', 'private')], search_dom])
if channel_id:
search_dom = expression.AND([[('channel_ids', 'in', channel_id)], search_dom])
domain_is_user = expression.AND([[('user_ids', '!=', False), ('user_ids.active', '=', True)], search_dom])
priority_conditions = [
expression.AND([domain_is_user, [('partner_share', '=', False)]]), # Search partners that are internal users
domain_is_user, # Search partners that are users
search_dom, # Search partners that are not users
]
partners = self.env['res.partner']
for domain in priority_conditions:
remaining_limit = limit - len(partners)
if remaining_limit <= 0:
break
# We are using _search to avoid the default order that is
# automatically added by the search method. "Order by" makes the query
# really slow.
query = self._search(expression.AND([[('id', 'not in', partners.ids)], domain]), limit=remaining_limit)
partners |= self.browse(query)
partners_format = partners.mail_partner_format()
if channel_id:
member_by_partner = {member.partner_id: member for member in self.env['mail.channel.member'].search([('channel_id', '=', channel_id), ('partner_id', 'in', partners.ids)])}
for partner in partners:
partners_format.get(partner)['persona'] = {
'channelMembers': [('insert', member_by_partner.get(partner)._mail_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 im_search(self, name, limit=20):
""" Search partner with a name and return its id, name and im_status.
Note : the user must be logged
:param name : the partner name to search
:param limit : the limit of result to return
"""
# This method is supposed to be used only in the context of channel creation or
# extension via an invite. As both of these actions require the 'create' access
# right, we check this specific ACL.
users = self.env['res.users'].search([
('id', '!=', self.env.user.id),
('name', 'ilike', name),
('active', '=', True),
('share', '=', False),
], order='name, id', limit=limit)
return list(users.partner_id.mail_partner_format().values())

View file

@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from odoo import _, api, fields, models, modules, tools
from odoo.addons.base.models.res_users import is_selection_groups
class Users(models.Model):
""" Update of res.users class
- add a preference about sending emails about notifications
- make a new user follow itself
- add a welcome message
- add suggestion preference
- if adding groups to a user, check mail.channels linked to this user
group, and the user. This is done by overriding the write method.
"""
_name = 'res.users'
_inherit = ['res.users']
notification_type = fields.Selection([
('email', 'Handle by Emails'),
('inbox', 'Handle in Odoo')],
'Notification', required=True, default='email',
compute='_compute_notification_type', store=True, readonly=False,
help="Policy on how to handle Chatter notifications:\n"
"- Handle by Emails: notifications are sent to your email address\n"
"- Handle in Odoo: notifications appear in your Odoo Inbox")
res_users_settings_ids = fields.One2many('res.users.settings', 'user_id')
# Provide a target for relateds that is not a x2Many field.
res_users_settings_id = fields.Many2one('res.users.settings', string="Settings", compute='_compute_res_users_settings_id', search='_search_res_users_settings_id')
_sql_constraints = [(
"notification_type",
"CHECK (notification_type = 'email' OR NOT share)",
"Only internal user can receive notifications in Odoo",
)]
@api.depends('share')
def _compute_notification_type(self):
for user in self:
# Only the internal users can receive notifications in Odoo
if user.share or not user.notification_type:
user.notification_type = 'email'
@api.depends('res_users_settings_ids')
def _compute_res_users_settings_id(self):
for user in self:
user.res_users_settings_id = user.res_users_settings_ids and user.res_users_settings_ids[0]
@api.model
def _search_res_users_settings_id(self, operator, operand):
return [('res_users_settings_ids', operator, operand)]
# ------------------------------------------------------------
# CRUD
# ------------------------------------------------------------
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS + ['notification_type']
@property
def SELF_WRITEABLE_FIELDS(self):
return super().SELF_WRITEABLE_FIELDS + ['notification_type']
@api.model_create_multi
def create(self, vals_list):
users = super(Users, self).create(vals_list)
# log a portal status change (manual tracking)
log_portal_access = not self._context.get('mail_create_nolog') and not self._context.get('mail_notrack')
if log_portal_access:
for user in users:
if user.has_group('base.group_portal'):
body = user._get_portal_access_update_body(True)
user.partner_id.message_post(
body=body,
message_type='notification',
subtype_xmlid='mail.mt_note'
)
# Auto-subscribe to channels unless skip explicitly requested
if not self.env.context.get('mail_channel_nosubscribe'):
self.env['mail.channel'].search([('group_ids', 'in', users.groups_id.ids)])._subscribe_users_automatically()
return users
def write(self, vals):
log_portal_access = 'groups_id' in vals and not self._context.get('mail_create_nolog') and not self._context.get('mail_notrack')
user_portal_access_dict = {
user.id: user.has_group('base.group_portal')
for user in self
} if log_portal_access else {}
write_res = super(Users, self).write(vals)
# log a portal status change (manual tracking)
if log_portal_access:
for user in self:
user_has_group = user.has_group('base.group_portal')
portal_access_changed = user_has_group != user_portal_access_dict[user.id]
if portal_access_changed:
body = user._get_portal_access_update_body(user_has_group)
user.partner_id.message_post(
body=body,
message_type='notification',
subtype_xmlid='mail.mt_note'
)
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['mail.channel'].search([('group_ids', 'in', user_group_ids)])._subscribe_users_automatically()
elif sel_groups:
self.env['mail.channel'].search([('group_ids', 'in', sel_groups)])._subscribe_users_automatically()
return write_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.
We do not un-subscribes users from public channels because in most common cases,
public channels are mailing list (e-mail based) and so users should always receive
updates from public channels until they manually un-subscribe themselves.
"""
current_cm = self.env['mail.channel.member'].sudo().search([
('partner_id', 'in', self.partner_id.ids),
])
current_cm.filtered(
lambda cm: (cm.channel_id.channel_type == 'channel' and cm.channel_id.group_public_id)
).unlink()
def _get_portal_access_update_body(self, access_granted):
body = _('Portal Access Granted') if access_granted else _('Portal Access Revoked')
if self.partner_id.email:
return '%s (%s)' % (body, self.partner_id.email)
return body
def _deactivate_portal_user(self, **post):
"""Blacklist the email of the user after deleting it.
Log a note on the related partner so we know why it's archived.
"""
current_user = self.env.user
for user in self:
user.partner_id._message_log(
body=_('Archived because %(user_name)s (#%(user_id)s) deleted the portal account',
user_name=current_user.name, user_id=current_user.id)
)
if post.get('request_blacklist'):
users_to_blacklist = [(user, user.email) for user in self.filtered(
lambda user: tools.email_normalize(user.email))]
else:
users_to_blacklist = []
super(Users, self)._deactivate_portal_user(**post)
for user, user_email in users_to_blacklist:
blacklist = self.env['mail.blacklist']._add(user_email)
blacklist._message_log(
body=_('Blocked by deletion of portal account %(portal_user_name)s by %(user_name)s (#%(user_id)s)',
user_name=current_user.name, user_id=current_user.id,
portal_user_name=user.name),
)
# ------------------------------------------------------------
# DISCUSS
# ------------------------------------------------------------
def _init_messaging(self):
self.ensure_one()
partner_root = self.env.ref('base.partner_root')
values = {
'channels': self.partner_id._get_channels_as_member().channel_info(),
'companyName': self.env.company.name,
'currentGuest': False,
'current_partner': self.partner_id.mail_partner_format().get(self.partner_id),
'current_user_id': self.id,
'current_user_settings': self.env['res.users.settings']._find_or_create_for_user(self)._res_users_settings_format(),
'hasLinkPreviewFeature': self.env['mail.link.preview']._is_link_preview_enabled(),
'internalUserGroupId': self.env.ref('base.group_user').id,
'menu_id': self.env['ir.model.data']._xmlid_to_res_id('mail.menu_root_discuss'),
'needaction_inbox_counter': self.partner_id._get_needaction_count(),
'partner_root': partner_root.sudo().mail_partner_format().get(partner_root),
'shortcodes': self.env['mail.shortcode'].sudo().search_read([], ['source', 'substitution']),
'starred_counter': self.env['mail.message'].search_count([('starred_partner_ids', 'in', self.partner_id.ids)]),
}
return values
@api.model
def systray_get_activities(self):
activities = self.env["mail.activity"].search([("user_id", "=", self.env.uid)])
activities_by_record_by_model_name = defaultdict(lambda: defaultdict(lambda: self.env["mail.activity"]))
for activity in activities:
record = self.env[activity.res_model].browse(activity.res_id)
activities_by_record_by_model_name[activity.res_model][record] += activity
model_ids = list({self.env["ir.model"]._get(name).id for name in activities_by_record_by_model_name.keys()})
user_activities = {}
for model_name, activities_by_record in activities_by_record_by_model_name.items():
domain = [("id", "in", list({r.id for r in activities_by_record.keys()}))]
allowed_records = self.env[model_name].search(domain)
if not allowed_records:
continue
module = self.env[model_name]._original_module
icon = module and modules.module.get_module_icon(module)
model = self.env["ir.model"]._get(model_name).with_prefetch(model_ids)
user_activities[model_name] = {
"id": model.id,
"name": model.name,
"model": model_name,
"type": "activity",
"icon": icon,
"total_count": 0,
"today_count": 0,
"overdue_count": 0,
"planned_count": 0,
"actions": [
{
"icon": "fa-clock-o",
"name": "Summary",
}
],
}
for record, activities in activities_by_record.items():
if record not in allowed_records:
continue
for activity in activities:
user_activities[model_name]["%s_count" % activity.state] += 1
if activity.state in ("today", "overdue"):
user_activities[model_name]["total_count"] += 1
return list(user_activities.values())

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