19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:39 +01:00
parent 38c6088dcc
commit d9452d2060
243 changed files with 30797 additions and 10815 deletions

View file

@ -1,24 +1,32 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_controller_attachment
from . import test_controller_binary
from . import test_controller_thread
from . import test_invite
from . import test_ir_actions
from . import test_ir_attachment
from . import test_mail_activity
from . import test_mail_activity_mixin
from . import test_mail_activity_plan
from . import test_mail_alias
from . import test_mail_composer
from . import test_mail_composer_mixin
from . import test_mail_followers
from . import test_mail_gateway
from . import test_mail_flow
from . import test_mail_mail
from . import test_mail_management
from . import test_mail_message
from . import test_mail_message_security
from . import test_mail_mail
from . import test_mail_gateway
from . import test_mail_multicompany
from . import test_mail_push
from . import test_mail_scheduled_message
from . import test_mail_security
from . import test_mail_thread_internals
from . import test_mail_thread_mixins
from . import test_mail_template
from . import test_mail_template_preview
from . import test_message_management
from . import test_message_post
from . import test_message_track
from . import test_performance
from . import test_ui
from . import test_mail_management
from . import test_mail_security

View file

@ -1,15 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mail.tests.common import MailCommon
from odoo.tests.common import TransactionCase
class TestMailCommon(MailCommon):
""" Main entry point for functional tests. Kept to ease backward
compatibility. """
class TestRecipients(TransactionCase):
@classmethod
@ -25,13 +19,11 @@ class TestRecipients(TransactionCase):
'name': 'Valid Lelitre',
'email': 'valid.lelitre@agrolait.com',
'country_id': cls.env.ref('base.be').id,
'mobile': '0456001122',
'phone': False,
'phone': '0456001122',
})
cls.partner_2 = Partner.create({
'name': 'Valid Poilvache',
'email': 'valid.other@gmail.com',
'country_id': cls.env.ref('base.be').id,
'mobile': '+32 456 22 11 00',
'phone': False,
'phone': '+32 456 22 11 00',
})

View file

@ -0,0 +1,35 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo
from odoo.addons.mail.tests.common_controllers import MailControllerAttachmentCommon
@odoo.tests.tagged("-at_install", "post_install", "mail_controller")
class TestAttachmentController(MailControllerAttachmentCommon):
def test_independent_attachment_delete(self):
"""Test access to delete an attachment whether or not limited `ownership_token` is sent"""
self._execute_subtests_delete(self.all_users, token=True, allowed=True)
self._execute_subtests_delete(self.user_admin, token=False, allowed=True)
self._execute_subtests_delete(
(self.guest, self.user_employee, self.user_portal, self.user_public),
token=False,
allowed=False,
)
def test_attachment_delete_linked_to_thread(self):
"""Test access to delete an attachment associated with a thread
whether or not limited `ownership_token` is sent"""
thread = self.env["mail.test.simple"].create({"name": "Test"})
self._execute_subtests_delete(self.all_users, token=True, allowed=True, thread=thread)
self._execute_subtests_delete(
(self.user_admin, self.user_employee),
token=False,
allowed=True,
thread=thread,
)
self._execute_subtests_delete(
(self.guest, self.user_portal, self.user_public),
token=False,
allowed=False,
thread=thread,
)

View file

@ -0,0 +1,47 @@
from odoo.addons.mail.tests.common_controllers import MailControllerBinaryCommon
from odoo.tests import tagged
@tagged("-at_install", "post_install", "mail_controller")
class TestPublicBinaryController(MailControllerBinaryCommon):
def test_avatar_no_public(self):
"""Test access to open a guest / partner avatar who hasn't sent a message on a
public record."""
for source in (self.guest_2, self.user_employee_nopartner.partner_id):
self._execute_subtests(
source, (
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
)
)
def test_avatar_private(self):
"""Test access to open a partner avatar who has sent a message on a private record."""
document = self.env["mail.test.simple.unfollow"].create({"name": "Test"})
self._post_message(document, self.user_employee_nopartner)
self._execute_subtests(
self.user_employee_nopartner.partner_id, (
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
)
)
def test_avatar_public(self):
"""Test access to open a guest avatar who has sent a message on a public record."""
document = self.env["mail.test.access.public"].create({"name": "Test"})
for author, source in ((self.guest_2, self.guest_2), (self.user_employee_nopartner, self.user_employee_nopartner.partner_id)):
self._post_message(document, author)
self._execute_subtests(
source,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
),
)

View file

@ -0,0 +1,167 @@
import json
from odoo import http
from odoo.addons.mail.tests.common_controllers import MailControllerThreadCommon
from odoo.tests import tagged
from odoo.tools import mute_logger
@tagged("-at_install", "post_install", "mail_controller")
class TestMessageController(MailControllerThreadCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.test_public_record = cls.env["mail.test.access.public"].create({"name": "Public Channel", "email": "john@test.be", "mobile": "+32455001122"})
@mute_logger("odoo.http")
def test_thread_attachment_hijack(self):
att = self.env["ir.attachment"].create({
"name": "arguments_for_firing_marc_demo",
"res_id": 0,
"res_model": "mail.compose.message",
})
self.authenticate(self.user_employee.login, self.user_employee.login)
record = self.env["mail.test.access.public"].create({"name": "Public Channel"})
record.with_user(self.user_employee).write({'name': 'updated'}) # can access, update, ...
# if this test breaks, it might be due to a change in /web/content, or the default rules for accessing an attachment. This is not an issue but it makes this test irrelevant.
self.assertFalse(self.url_open(f"/web/content/{att.id}").ok)
response = self.url_open(
url="/mail/message/post",
headers={"Content-Type": "application/json"}, # route called as demo
data=json.dumps(
{
"params": {
"post_data": {
"attachment_ids": [att.id], # demo does not have access to this attachment id
"body": "",
"message_type": "comment",
"partner_ids": [],
"subtype_xmlid": "mail.mt_comment",
},
"thread_id": record.id,
"thread_model": record._name,
}
},
),
)
self.assertNotIn(
"arguments_for_firing_marc_demo", response.text
) # demo should not be able to see the name of the document
def test_thread_partner_from_email_authenticated(self):
self.authenticate(self.user_employee.login, self.user_employee.login)
res3 = self.url_open(
url="/mail/partner/from_email",
data=json.dumps(
{
"params": {
"thread_model": self.test_public_record._name,
"thread_id": self.test_public_record.id,
"emails": ["john@test.be"],
},
}
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res3.status_code, 200)
self.assertEqual(
1,
self.env["res.partner"].search_count([('email', '=', "john@test.be"), ('phone', '=', "+32455001122")]),
"authenticated users can create a partner from an email",
)
# should not create another partner with same email
res4 = self.url_open(
url="/mail/partner/from_email",
data=json.dumps(
{
"params": {
"thread_model": self.test_public_record._name,
"thread_id": self.test_public_record.id,
"emails": ["john@test.be"],
},
}
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res4.status_code, 200)
self.assertEqual(
1,
self.env["res.partner"].search_count([('email', '=', "john@test.be")]),
"'mail/partner/from_email' does not create another user if there's already a user with matching email",
)
self.test_public_record.write({'email': 'john2@test.be'})
res5 = self.url_open(
url="/mail/message/post",
data=json.dumps(
{
"params": {
"thread_model": self.test_public_record._name,
"thread_id": self.test_public_record.id,
"post_data": {
"body": "test",
"partner_emails": ["john2@test.be"],
},
},
}
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res5.status_code, 200)
self.assertEqual(
1,
self.env["res.partner"].search_count([('email', '=', "john2@test.be"), ('phone', '=', "+32455001122")]),
"authenticated users can create a partner from an email from message_post",
)
# should not create another partner with same email
res6 = self.url_open(
url="/mail/message/post",
data=json.dumps(
{
"params": {
"thread_model": self.test_public_record._name,
"thread_id": self.test_public_record.id,
"post_data": {
"body": "test",
"partner_emails": ["john2@test.be"],
},
},
}
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res6.status_code, 200)
self.assertEqual(
1,
self.env["res.partner"].search_count([('email', '=', "john2@test.be")]),
"'mail/message/post' does not create another user if there's already a user with matching email",
)
def test_thread_post_archived_record(self):
self.authenticate(self.user_employee.login, self.user_employee.login)
archived_partner = self.env["res.partner"].create({"name": "partner", "active": False})
# 1. posting a message
data = self.make_jsonrpc_request("/mail/message/post", {
"thread_model": "res.partner",
"thread_id": archived_partner.id,
"post_data": {
"body": "A great message",
}
})
message = next(filter(lambda m: m["id"] == data["message_id"], data["store_data"]["mail.message"]))
self.assertEqual(["markup", "<p>A great message</p>"], message["body"])
# 2. attach a file
response = self.url_open(
"/mail/attachment/upload",
{
"csrf_token": http.Request.csrf_token(self),
"thread_id": archived_partner.id,
"thread_model": "res.partner",
},
files={"ufile": b""},
)
self.assertEqual(response.status_code, 200)

View file

@ -1,13 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.test_mail.tests.common import TestMailCommon
from odoo.addons.mail.tests.common import MailCommon
from odoo.tests import tagged
from odoo.tools import mute_logger
@tagged('mail_followers')
class TestInvite(TestMailCommon):
class TestInvite(MailCommon):
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_invite_email(self):
@ -16,18 +16,38 @@ class TestInvite(TestMailCommon):
'name': 'Valid Lelitre',
'email': 'valid.lelitre@agrolait.com'})
mail_invite = self.env['mail.wizard.invite'].with_context({
mail_invite = self.env['mail.followers.edit'].with_context({
'default_res_model': 'mail.test.simple',
'default_res_id': test_record.id
}).with_user(self.user_employee).create({
'partner_ids': [(4, test_partner.id), (4, self.user_admin.partner_id.id)],
'send_mail': True})
with self.mock_mail_gateway():
mail_invite.add_followers()
'default_res_ids': [test_record.id],
}).with_user(self.user_employee).create({'partner_ids': [(4, test_partner.id), (4, self.user_admin.partner_id.id)],
'notify': True})
with self.mock_mail_app(), self.mock_mail_gateway():
mail_invite.edit_followers()
# check added followers and that emails were sent
# Check added followers and that notifications are sent.
# Admin notification preference is inbox so the notification must be of inbox type
# while partner_employee must receive it by email.
self.assertEqual(test_record.message_partner_ids,
test_partner | self.user_admin.partner_id)
self.assertEqual(len(self._new_msgs), 1)
self.assertEqual(len(self._mails), 1)
self.assertSentEmail(self.partner_employee, [test_partner])
self.assertSentEmail(self.partner_employee, [self.partner_admin])
self.assertEqual(len(self._mails), 2)
self.assertNotSentEmail([self.partner_admin])
self.assertNotified(
self._new_msgs[0],
[{'partner': self.partner_admin, 'type': 'inbox', 'is_read': False}]
)
# Remove followers
mail_remove = self.env['mail.followers.edit'].with_context({
'default_res_model': 'mail.test.simple',
'default_res_ids': [test_record.id],
}).with_user(self.user_employee).create({
"operation": "remove",
'partner_ids': [(4, test_partner.id), (4, self.user_admin.partner_id.id)]})
with self.mock_mail_app(), self.mock_mail_gateway():
mail_remove.edit_followers()
# Check removed followers and that notifications are sent.
self.assertEqual(test_record.message_partner_ids, self.env["res.partner"])

View file

@ -2,13 +2,13 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.base.tests.test_ir_actions import TestServerActionsBase
from odoo.addons.test_mail.tests.common import TestMailCommon
from odoo.addons.mail.tests.common import MailCommon
from odoo.tests import tagged
from odoo.tools import mute_logger
@tagged('ir_actions')
class TestServerActionsEmail(TestMailCommon, TestServerActionsBase):
class TestServerActionsEmail(MailCommon, TestServerActionsBase):
def setUp(self):
super(TestServerActionsEmail, self).setUp()
@ -61,6 +61,17 @@ class TestServerActionsEmail(TestMailCommon, TestServerActionsBase):
self.action.with_context(self.context).run()
self.assertEqual(self.test_partner.message_partner_ids, self.env.ref('base.partner_admin') | random_partner)
def test_action_followers_warning(self):
self.test_partner.message_unsubscribe(self.test_partner.message_partner_ids.ids)
self.action.write({
'state': 'followers',
"followers_type": "generic",
"followers_partner_field_name": "user_id.name"
})
self.assertEqual(self.action.warning, "The field 'Salesperson > Name' is not a partner field.")
self.action.write({"followers_partner_field_name": "parent_id.child_ids"})
self.assertEqual(self.action.warning, False)
def test_action_message_post(self):
# initial state
self.assertEqual(len(self.test_partner.message_ids), 1,
@ -78,7 +89,10 @@ class TestServerActionsEmail(TestMailCommon, TestServerActionsBase):
with self.assertSinglePostNotifications(
[{'partner': self.test_partner, 'type': 'email', 'status': 'ready'}],
message_info={'content': 'Hello %s' % self.test_partner.name,
'message_type': 'notification',
'mail_mail_values': {
'author_id': self.env.user.partner_id,
},
'message_type': 'auto_comment',
'subtype': 'mail.mt_comment',
}
):
@ -95,7 +109,7 @@ class TestServerActionsEmail(TestMailCommon, TestServerActionsBase):
with self.assertSinglePostNotifications(
[{'partner': self.test_partner, 'type': 'email', 'status': 'ready'}],
message_info={'content': 'Hello %s' % self.test_partner.name,
'message_type': 'notification',
'message_type': 'auto_comment',
'subtype': 'mail.mt_note',
}
):
@ -117,6 +131,18 @@ class TestServerActionsEmail(TestMailCommon, TestServerActionsBase):
self.assertEqual(self.env['mail.activity'].search_count([]), before_count + 1)
self.assertEqual(self.env['mail.activity'].search_count([('summary', '=', 'TestNew')]), 1)
def test_action_next_activity_warning(self):
self.action.write({
'state': 'next_activity',
'activity_user_type': 'generic',
"activity_user_field_name": "user_id.name",
'activity_type_id': self.env.ref('mail.mail_activity_data_meeting').id,
'activity_summary': 'TestNew',
})
self.assertEqual(self.action.warning, "The field 'Salesperson > Name' is not a user field.")
self.action.write({"activity_user_field_name": "parent_id.user_id"})
self.assertEqual(self.action.warning, False)
def test_action_next_activity_due_date(self):
""" Make sure we don't crash if a due date is set without a type. """
self.action.write({
@ -132,3 +158,66 @@ class TestServerActionsEmail(TestMailCommon, TestServerActionsBase):
self.assertFalse(run_res, 'ir_actions_server: create next activity action correctly finished should return False')
self.assertEqual(self.env['mail.activity'].search_count([]), before_count + 1)
self.assertEqual(self.env['mail.activity'].search_count([('summary', '=', 'TestNew')]), 1)
def test_action_next_activity_from_x2m_user(self):
self.test_partner.user_ids = self.user_demo | self.user_admin
self.action.write({
'state': 'next_activity',
'activity_user_type': 'generic',
'activity_user_field_name': 'user_ids',
'activity_type_id': self.env.ref('mail.mail_activity_data_meeting').id,
'activity_summary': 'TestNew',
})
before_count = self.env['mail.activity'].search_count([])
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: create next activity action correctly finished should return False')
self.assertEqual(self.env['mail.activity'].search_count([]), before_count + 1)
self.assertRecordValues(
self.env['mail.activity'].search([('res_model', '=', 'res.partner'), ('res_id', '=', self.test_partner.id)]),
[{
'summary': 'TestNew',
'user_id': self.user_demo.id, # the first user found
}],
)
@mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink')
def test_action_send_mail_without_mail_thread(self):
""" Check running a server action to send an email with custom layout on a non mail.thread model """
no_thread_record = self.env['mail.test.nothread'].create({'name': 'Test NoMailThread', 'customer_id': self.test_partner.id})
no_thread_template = self._create_template(
'mail.test.nothread',
{
'email_from': 'someone@example.com',
'partner_to': '{{ object.customer_id.id }}',
'subject': 'About {{ object.name }}',
'body_html': '<p>Hello <t t-out="object.name"/></p>',
'email_layout_xmlid': 'mail.mail_notification_layout',
}
)
# update action: send an email
self.action.write({
'mail_post_method': 'email',
'state': 'mail_post',
'model_id': self.env['ir.model'].search([('model', '=', 'mail.test.nothread')], limit=1).id,
'model_name': 'mail.test.nothread',
'template_id': no_thread_template.id,
})
with self.mock_mail_gateway(), self.mock_mail_app():
action_ctx = {
'active_model': 'mail.test.nothread',
'active_id': no_thread_record.id,
}
self.action.with_context(action_ctx).run()
mail = self.assertMailMail(
self.test_partner,
None,
content='Hello Test NoMailThread',
fields_values={
'email_from': 'someone@example.com',
'subject': 'About Test NoMailThread',
}
)
self.assertNotIn('Powered by', mail.body_html, 'Body should contain the notification layout')

View file

@ -0,0 +1,61 @@
import base64
from odoo.addons.mail.tests.common import MailCommon
from odoo.tests import tagged, users
@tagged("ir_attachment")
class TestAttachment(MailCommon):
@users("employee")
def test_register_as_main_attachment(self):
""" Test 'register_as_main_attachment', especially the multi support """
records_model1 = self.env["mail.test.simple.main.attachment"].create([
{
"name": f"First model {idx}",
}
for idx in range(5)
])
records_model2 = self.env["mail.test.gateway.main.attachment"].create([
{
"name": f"Second model {idx}",
}
for idx in range(5)
])
record_nomain = self.env["mail.test.simple"].create({"name": "No Main Attachment"})
attachments = self.env["ir.attachment"].create([
{
"datas": base64.b64encode(b'AttContent'),
"name": f"AttachName_{record.name}.pdf",
"mimetype": "application/pdf",
"res_id": record.id,
"res_model": record._name,
}
for record in records_model1
] + [
{
"datas": base64.b64encode(b'AttContent'),
"name": f"AttachName_{record.name}.pdf",
"mimetype": "application/pdf",
"res_id": record.id,
"res_model": record._name,
}
for record in records_model2
] + [
{
"datas": base64.b64encode(b'AttContent'),
"name": "AttachName_free.pdf",
"mimetype": "application/pdf",
}, {
"datas": base64.b64encode(b'AttContent'),
"name": f"AttachName_{record_nomain.name}.pdf",
"mimetype": "application/pdf",
"res_id": record_nomain.id,
"res_model": record_nomain._name,
}
])
attachments.register_as_main_attachment()
for record, attachment in zip(records_model1, attachments[:5]):
self.assertEqual(record.message_main_attachment_id, attachment)
for record, attachment in zip(records_model2, attachments[5:10]):
self.assertEqual(record.message_main_attachment_id, attachment)

View file

@ -0,0 +1,730 @@
from datetime import date, datetime, timedelta, timezone
from dateutil.relativedelta import relativedelta
from freezegun import freeze_time
from unittest.mock import patch
import pytz
import random
from odoo import fields, tests
from odoo.addons.mail.models.mail_activity import MailActivity
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.test_mail.tests.test_mail_activity import TestActivityCommon
from odoo.tests import tagged, users
from odoo.tools import mute_logger
@tagged('mail_activity', 'mail_activity_mixin')
class TestActivityMixin(TestActivityCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_utc = mail_new_test_user(
cls.env,
name='User UTC',
login='User UTC',
)
cls.user_utc.tz = 'UTC'
cls.user_australia = mail_new_test_user(
cls.env,
name='user Australia',
login='user Australia',
)
cls.user_australia.tz = 'Australia/Sydney'
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_activity_mixin(self):
self.user_employee.tz = self.user_admin.tz
with self.with_user('employee'):
self.test_record = self.env['mail.test.activity'].browse(self.test_record.id)
self.assertEqual(len(self.test_record.message_ids), 1)
self.assertEqual(self.test_record.env.user, self.user_employee)
now_utc = datetime.now(pytz.UTC)
now_user = now_utc.astimezone(pytz.timezone(self.env.user.tz or 'UTC'))
today_user = now_user.date()
# Test various scheduling of activities
act1 = self.test_record.activity_schedule(
'test_mail.mail_act_test_todo',
today_user + relativedelta(days=1),
user_id=self.user_admin.id)
self.assertEqual(act1.automated, True)
act_type = self.env.ref('test_mail.mail_act_test_todo')
self.assertEqual(self.test_record.activity_summary, act_type.summary)
self.assertEqual(self.test_record.activity_state, 'planned')
self.assertEqual(self.test_record.activity_user_id, self.user_admin)
act2 = self.test_record.activity_schedule(
'test_mail.mail_act_test_meeting',
today_user + relativedelta(days=-1),
user_id=self.user_employee.id,
)
self.assertEqual(self.test_record.activity_state, 'overdue')
# `activity_user_id` is defined as `fields.Many2one('res.users', 'Responsible User', related='activity_ids.user_id')`
# it therefore relies on the natural order of `activity_ids`, according to which activity comes first.
# As we just created the activity, its not yet in the right order.
# We force it by invalidating it so it gets fetched from database, in the right order.
self.test_record.invalidate_recordset(['activity_ids'])
self.assertEqual(self.test_record.activity_user_id, self.user_employee)
act3 = self.test_record.activity_schedule(
'test_mail.mail_act_test_todo',
today_user + relativedelta(days=3),
user_id=self.user_employee.id,
)
self.assertEqual(self.test_record.activity_state, 'overdue')
# `activity_user_id` is defined as `fields.Many2one('res.users', 'Responsible User', related='activity_ids.user_id')`
# it therefore relies on the natural order of `activity_ids`, according to which activity comes first.
# As we just created the activity, its not yet in the right order.
# We force it by invalidating it so it gets fetched from database, in the right order.
self.test_record.invalidate_recordset(['activity_ids'])
self.assertEqual(self.test_record.activity_user_id, self.user_employee)
self.test_record.invalidate_recordset()
self.assertEqual(self.test_record.activity_ids, act1 | act2 | act3)
# Perform todo activities for admin
self.test_record.activity_feedback(
['test_mail.mail_act_test_todo'],
user_id=self.user_admin.id,
feedback='Test feedback 1',
)
self.assertEqual(self.test_record.activity_ids, act2 | act3)
self.assertFalse(act1.active)
# Reschedule all activities, should update the record state
self.assertEqual(self.test_record.activity_state, 'overdue')
self.test_record.activity_reschedule(
['test_mail.mail_act_test_meeting', 'test_mail.mail_act_test_todo'],
date_deadline=today_user + relativedelta(days=3)
)
self.assertEqual(self.test_record.activity_state, 'planned')
# Perform todo activities for remaining people
self.test_record.activity_feedback(
['test_mail.mail_act_test_todo'],
feedback='Test feedback 2')
self.assertFalse(act3.active)
# Setting activities as done should delete them and post messages
self.assertEqual(self.test_record.activity_ids, act2)
self.assertEqual(len(self.test_record.message_ids), 3)
self.assertEqual(len(self.test_record.message_ids), 3)
feedback2, feedback1, _create_log = self.test_record.message_ids
self.assertEqual((feedback2 + feedback1).subtype_id, self.env.ref('mail.mt_activities'))
# Unlink meeting activities
self.test_record.activity_unlink(['test_mail.mail_act_test_meeting'])
# Canceling activities should simply remove them
self.assertEqual(self.test_record.activity_ids, self.env['mail.activity'])
self.assertEqual(len(self.test_record.message_ids), 3, 'Should not produce additional message')
self.assertFalse(self.test_record.activity_state)
self.assertFalse(act2.exists())
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_activity_mixin_not_only_automated(self):
# Schedule activity and create manual activity
act_type_todo = self.env.ref('test_mail.mail_act_test_todo')
auto_act = self.test_record.activity_schedule(
'test_mail.mail_act_test_todo',
date_deadline=date.today() + relativedelta(days=1),
)
man_act = self.env['mail.activity'].create({
'activity_type_id': act_type_todo.id,
'res_id': self.test_record.id,
'res_model_id': self.env['ir.model']._get_id(self.test_record._name),
'date_deadline': date.today() + relativedelta(days=1)
})
self.assertEqual(auto_act.automated, True)
self.assertEqual(man_act.automated, False)
# Test activity reschedule on not only automated activities
self.test_record.activity_reschedule(
['test_mail.mail_act_test_todo'],
date_deadline=date.today() + relativedelta(days=2),
only_automated=False
)
self.assertEqual(auto_act.date_deadline, date.today() + relativedelta(days=2))
self.assertEqual(man_act.date_deadline, date.today() + relativedelta(days=2))
# Test activity feedback on not only automated activities
self.test_record.activity_feedback(
['test_mail.mail_act_test_todo'],
feedback='Test feedback',
only_automated=False
)
self.assertEqual(self.test_record.activity_ids, self.env['mail.activity'])
self.assertFalse(auto_act.active)
self.assertFalse(man_act.active)
# Test activity unlink on not only automated activities
auto_act = self.test_record.activity_schedule(
'test_mail.mail_act_test_todo',
)
man_act = self.env['mail.activity'].create({
'activity_type_id': act_type_todo.id,
'res_id': self.test_record.id,
'res_model_id': self.env['ir.model']._get_id(self.test_record._name)
})
self.test_record.activity_unlink(['test_mail.mail_act_test_todo'], only_automated=False)
self.assertEqual(self.test_record.activity_ids, self.env['mail.activity'])
self.assertFalse(auto_act.exists())
self.assertFalse(man_act.exists())
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_activity_mixin_archive(self):
rec = self.test_record.with_user(self.user_employee)
new_act = rec.activity_schedule(
'test_mail.mail_act_test_todo',
user_id=self.user_admin.id,
)
self.assertEqual(rec.activity_ids, new_act)
rec.action_archive()
self.assertEqual(rec.active, False)
self.assertEqual(rec.activity_ids, new_act)
rec.action_unarchive()
self.assertEqual(rec.active, True)
self.assertEqual(rec.activity_ids, new_act)
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_activity_mixin_archive_user(self):
"""
Test when archiving an user, we unlink all his related activities
"""
test_users = self.env['res.users']
for i in range(5):
test_users += mail_new_test_user(self.env, name=f'test_user_{i}', login=f'test_password_{i}')
for user in test_users:
self.test_record.activity_schedule(user_id=user.id)
archived_users = self.env['res.users'].browse(x.id for x in random.sample(test_users, 2)) # pick 2 users to archive
archived_users.action_archive()
active_users = test_users - archived_users
# archive user with company disabled
user_admin = self.user_admin
user_employee_c2 = self.user_employee_c2
self.assertIn(self.company_2, user_admin.company_ids)
self.test_record.env['ir.rule'].create({
'model_id': self.env.ref('test_mail.model_mail_test_activity').id,
'domain_force': "[('company_id', 'in', company_ids)]"
})
self.test_record.activity_schedule(user_id=user_employee_c2.id)
user_employee_c2.with_user(user_admin).with_context(
allowed_company_ids=(user_admin.company_ids - self.company_2).ids
).action_archive()
archived_users += user_employee_c2
self.assertFalse(any(archived_users.mapped('active')), "Users should be archived.")
# activities of active users shouldn't be touched, each has exactly 1 activity present
activities = self.env['mail.activity'].search([('user_id', 'in', active_users.ids)])
self.assertEqual(len(activities), 3, "We should have only 3 activities in total linked to our active users")
self.assertEqual(activities.mapped('user_id'), active_users,
"We should have 3 different users linked to the activities of the active users")
# ensure the user's activities are removed
activities = self.env['mail.activity'].search([('user_id', 'in', archived_users.ids)])
self.assertFalse(activities, "Activities of archived users should be deleted.")
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_activity_mixin_reschedule_user(self):
rec = self.test_record.with_user(self.user_employee)
rec.activity_schedule(
'test_mail.mail_act_test_todo',
user_id=self.user_admin.id)
self.assertEqual(rec.activity_ids[0].user_id, self.user_admin)
# reschedule its own should not alter other's activities
rec.activity_reschedule(
['test_mail.mail_act_test_todo'],
user_id=self.user_employee.id,
new_user_id=self.user_employee.id)
self.assertEqual(rec.activity_ids[0].user_id, self.user_admin)
rec.activity_reschedule(
['test_mail.mail_act_test_todo'],
user_id=self.user_admin.id,
new_user_id=self.user_employee.id)
self.assertEqual(rec.activity_ids[0].user_id, self.user_employee)
@users('employee')
def test_feedback_w_attachments(self):
test_record = self.env['mail.test.activity'].browse(self.test_record.ids)
activity = self.env['mail.activity'].create({
'activity_type_id': 1,
'res_id': test_record.id,
'res_model_id': self.env['ir.model']._get_id('mail.test.activity'),
'summary': 'Test',
})
attachments = self.env['ir.attachment'].create([{
'name': 'test',
'res_name': 'test',
'res_model': 'mail.activity',
'res_id': activity.id,
'datas': 'test',
}, {
'name': 'test2',
'res_name': 'test',
'res_model': 'mail.activity',
'res_id': activity.id,
'datas': 'testtest',
}])
# Checking if the attachment has been forwarded to the message
# when marking an activity as "Done"
activity.action_feedback()
activity_message = test_record.message_ids[0]
self.assertEqual(set(activity_message.attachment_ids.ids), set(attachments.ids))
for attachment in attachments:
self.assertEqual(attachment.res_id, activity_message.id)
self.assertEqual(attachment.res_model, activity_message._name)
@users('employee')
def test_feedback_chained_current_date(self):
frozen_now = datetime(2021, 10, 10, 14, 30, 15)
test_record = self.env['mail.test.activity'].browse(self.test_record.ids)
first_activity = self.env['mail.activity'].create({
'activity_type_id': self.env.ref('test_mail.mail_act_test_chained_1').id,
'date_deadline': frozen_now + relativedelta(days=-2),
'res_id': test_record.id,
'res_model_id': self.env['ir.model']._get_id('mail.test.activity'),
'summary': 'Test',
})
first_activity_id = first_activity.id
with freeze_time(frozen_now):
first_activity.action_feedback(feedback='Done')
self.assertFalse(first_activity.active)
# check chained activity
new_activity = test_record.activity_ids
self.assertNotEqual(new_activity.id, first_activity_id)
self.assertEqual(new_activity.summary, 'Take the second step.')
self.assertEqual(new_activity.date_deadline, frozen_now.date() + relativedelta(days=10))
@users('employee')
def test_feedback_chained_previous(self):
self.env.ref('test_mail.mail_act_test_chained_2').sudo().write({'delay_from': 'previous_activity'})
frozen_now = datetime(2021, 10, 10, 14, 30, 15)
test_record = self.env['mail.test.activity'].browse(self.test_record.ids)
first_activity = self.env['mail.activity'].create({
'activity_type_id': self.env.ref('test_mail.mail_act_test_chained_1').id,
'date_deadline': frozen_now + relativedelta(days=-2),
'res_id': test_record.id,
'res_model_id': self.env['ir.model']._get_id('mail.test.activity'),
'summary': 'Test',
})
first_activity_id = first_activity.id
with freeze_time(frozen_now):
first_activity.action_feedback(feedback='Done')
self.assertFalse(first_activity.active)
# check chained activity
new_activity = test_record.activity_ids
self.assertNotEqual(new_activity.id, first_activity_id)
self.assertEqual(new_activity.summary, 'Take the second step.')
self.assertEqual(new_activity.date_deadline, frozen_now.date() + relativedelta(days=8),
'New deadline should take into account original activity deadline, not current date')
def test_mail_activity_state(self):
"""Create 3 activity for 2 different users in 2 different timezones.
User UTC (+0h)
User Australia (+11h)
Today datetime: 1/1/2020 16h
Activity 1 & User UTC
1/1/2020 - 16h UTC -> The state is today
Activity 2 & User Australia
1/1/2020 - 16h UTC
2/1/2020 - 1h Australia -> State is overdue
Activity 3 & User UTC
1/1/2020 - 23h UTC -> The state is today
"""
record = self.env['mail.test.activity'].create({'name': 'Record'})
with freeze_time(datetime(2020, 1, 1, 16)):
today_utc = datetime.today()
activity_1 = self.env['mail.activity'].create({
'summary': 'Test',
'activity_type_id': 1,
'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id,
'res_id': record.id,
'date_deadline': today_utc,
'user_id': self.user_utc.id,
})
activity_2 = activity_1.copy()
activity_2.user_id = self.user_australia
activity_3 = activity_1.copy()
activity_3.date_deadline += relativedelta(hours=7)
self.assertEqual(activity_1.state, 'today')
self.assertEqual(activity_2.state, 'overdue')
self.assertEqual(activity_3.state, 'today')
@users('employee')
def test_mail_activity_mixin_search_activity_user_id_false(self):
"""Test the search method on the "activity_user_id" when searching for non-set user"""
MailTestActivity = self.env['mail.test.activity']
test_records = self.test_record | self.test_record_2
self.assertFalse(test_records.activity_ids)
self.assertEqual(MailTestActivity.search([('activity_user_id', '=', False)]), test_records)
self.env['mail.activity'].create({
'summary': 'Test',
'activity_type_id': self.env.ref('test_mail.mail_act_test_todo').id,
'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id,
'res_id': self.test_record.id,
})
self.assertEqual(MailTestActivity.search([('activity_user_id', '!=', True)]), self.test_record_2)
def test_mail_activity_mixin_search_exception_decoration(self):
"""Test the search on "activity_exception_decoration".
Domain ('activity_exception_decoration', '!=', False) should only return
records that have at least one warning/danger activity.
"""
record_warning, record_normal, _ = self.test_record, self.test_record_2, self.env['mail.test.activity'].create({'name': 'No activities'})
record_warning.activity_schedule('mail.mail_activity_data_warning', user_id=self.env.user.id)
record_normal.activity_schedule('test_mail.mail_act_test_todo', user_id=self.env.user.id)
records = self.env['mail.test.activity'].search([('activity_exception_decoration', '!=', False)])
self.assertEqual(records, record_warning)
@mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests')
def test_mail_activity_mixin_search_state_basic(self):
"""Test the search method on the "activity_state".
Test all the operators and also test the case where the "activity_state" is
different because of the timezone. There's also a tricky case for which we
"reverse" the domain for performance purpose.
"""
# Create some records without activity schedule on it for testing
self.env['mail.test.activity'].create([
{'name': 'Record %i' % record_i}
for record_i in range(5)
])
origin_1, origin_2 = self.env['mail.test.activity'].search([], limit=2)
activity_type = self.env.ref('test_mail.mail_act_test_todo')
with freeze_time(datetime(2020, 1, 1, 16)):
today_utc = datetime.today()
origin_1_activity_1 = self.env['mail.activity'].create({
'summary': 'Test',
'activity_type_id': activity_type.id,
'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id,
'res_id': origin_1.id,
'date_deadline': today_utc,
'user_id': self.user_utc.id,
})
origin_1_activity_2 = origin_1_activity_1.copy()
origin_1_activity_2.user_id = self.user_australia
origin_1_activity_3 = origin_1_activity_1.copy()
origin_1_activity_3.date_deadline += relativedelta(hours=8)
self.assertEqual(origin_1_activity_1.state, 'today')
self.assertEqual(origin_1_activity_2.state, 'overdue')
self.assertEqual(origin_1_activity_3.state, 'today')
origin_2_activity_1 = self.env['mail.activity'].create({
'summary': 'Test',
'activity_type_id': activity_type.id,
'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id,
'res_id': origin_2.id,
'date_deadline': today_utc + relativedelta(hours=8),
'user_id': self.user_utc.id,
})
origin_2_activity_2 = origin_2_activity_1.copy()
origin_2_activity_2.user_id = self.user_australia
origin_2_activity_3 = origin_2_activity_1.copy()
origin_2_activity_3.date_deadline -= relativedelta(hours=8)
origin_2_activity_4 = origin_2_activity_1.copy()
origin_2_activity_4.date_deadline = datetime(2020, 1, 2, 0, 0, 0)
self.assertEqual(origin_2_activity_1.state, 'planned')
self.assertEqual(origin_2_activity_2.state, 'today')
self.assertEqual(origin_2_activity_3.state, 'today')
self.assertEqual(origin_2_activity_4.state, 'planned')
all_activity_mixin_record = self.env['mail.test.activity'].search([])
result = self.env['mail.test.activity'].search([('activity_state', '=', 'today')])
self.assertTrue(len(result) > 0)
self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: p.activity_state == 'today'))
result = self.env['mail.test.activity'].search([('activity_state', 'in', ('today', 'overdue'))])
self.assertTrue(len(result) > 0)
self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: p.activity_state in ('today', 'overdue')))
result = self.env['mail.test.activity'].search([('activity_state', 'not in', ('today'))])
self.assertTrue(len(result) > 0)
self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: p.activity_state != 'today'))
result = self.env['mail.test.activity'].search([('activity_state', '=', False)])
self.assertTrue(len(result) >= 3, "There is more than 3 records without an activity schedule on it")
self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: not p.activity_state))
result = self.env['mail.test.activity'].search([('activity_state', 'not in', ('planned', 'overdue', 'today'))])
self.assertTrue(len(result) >= 3, "There is more than 3 records without an activity schedule on it")
self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: not p.activity_state))
# test tricky case when the domain will be reversed in the search method
# because of falsy value
result = self.env['mail.test.activity'].search([('activity_state', 'not in', ('today', False))])
self.assertTrue(len(result) > 0)
self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: p.activity_state not in ('today', False)))
result = self.env['mail.test.activity'].search([('activity_state', 'in', ('today', False))])
self.assertTrue(len(result) > 0)
self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: p.activity_state in ('today', False)))
# Check that activity done are not taken into account by group and search by activity_state.
Model = self.env['mail.test.activity']
search_params = {
'domain': [('id', 'in', (origin_1 | origin_2).ids), ('activity_state', '=', 'overdue')]}
read_group_params = {
'domain': [('id', 'in', (origin_1 | origin_2).ids)],
'groupby': ['activity_state'],
'aggregates': ['__count'],
}
self.assertEqual(Model.search(**search_params), origin_1)
self.assertEqual(
{(e['activity_state'], e['__count']) for e in Model.formatted_read_group(**read_group_params)},
{('today', 1), ('overdue', 1)})
origin_1_activity_2.action_feedback(feedback='Done')
self.assertFalse(Model.search(**search_params))
self.assertEqual(
{(e['activity_state'], e['__count']) for e in Model.formatted_read_group(**read_group_params)},
{('today', 2)})
@mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests')
def test_mail_activity_mixin_search_state_different_day_but_close_time(self):
"""Test the case where there's less than 24 hours between the deadline and now_tz,
but one day of difference (e.g. 23h 01/01/2020 & 1h 02/02/2020). So the state
should be "planned" and not "today". This case was tricky to implement in SQL
that's why it has its own test.
"""
# Create some records without activity schedule on it for testing
self.env['mail.test.activity'].create([
{'name': 'Record %i' % record_i}
for record_i in range(5)
])
origin_1 = self.env['mail.test.activity'].search([], limit=1)
with freeze_time(datetime(2020, 1, 1, 23)):
today_utc = datetime.today()
origin_1_activity_1 = self.env['mail.activity'].create({
'summary': 'Test',
'activity_type_id': 1,
'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id,
'res_id': origin_1.id,
'date_deadline': today_utc + relativedelta(hours=2),
'user_id': self.user_utc.id,
})
self.assertEqual(origin_1_activity_1.state, 'planned')
result = self.env['mail.test.activity'].search([('activity_state', '=', 'today')])
self.assertNotIn(origin_1, result, 'The activity state miss calculated during the search')
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_my_activity_flow_employee(self):
Activity = self.env['mail.activity']
date_today = date.today()
Activity.create({
'activity_type_id': self.env.ref('test_mail.mail_act_test_todo').id,
'date_deadline': date_today,
'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id,
'res_id': self.test_record.id,
'user_id': self.user_admin.id,
})
Activity.create({
'activity_type_id': self.env.ref('test_mail.mail_act_test_call').id,
'date_deadline': date_today + relativedelta(days=1),
'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id,
'res_id': self.test_record.id,
'user_id': self.user_employee.id,
})
test_record_1 = self.env['mail.test.activity'].with_context(self._test_context).create({'name': 'Test 1'})
test_record_1_late_activity = Activity.create({
'activity_type_id': self.env.ref('test_mail.mail_act_test_todo').id,
'date_deadline': date_today,
'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id,
'res_id': test_record_1.id,
'user_id': self.user_employee.id,
})
with self.with_user('employee'):
record = self.env['mail.test.activity'].search([('my_activity_date_deadline', '=', date_today)])
self.assertEqual(test_record_1, record)
test_record_1_late_activity._action_done()
record = self.env['mail.test.activity'].with_context(active_test=False).search([
('my_activity_date_deadline', '=', date_today)
])
self.assertFalse(record, "Should not find record if the only late activity is done")
@users('employee')
def test_record_unlink(self):
test_record = self.test_record.with_user(self.env.user)
act1 = test_record.activity_schedule(summary='Active', user_id=self.env.uid)
act2 = test_record.activity_schedule(summary='Archived', active=False, user_id=self.env.uid)
test_record.unlink()
self.assertFalse((act1 + act2).exists(), 'Removing records should remove activities, even archived')
@users('employee')
def test_record_unlinked_orphan_activities(self):
"""Test the fix preventing error on corrupted database where activities without related record are present."""
test_record = self.env['mail.test.activity'].with_context(
self._test_context).create({'name': 'Test'}).with_user(self.user_employee)
act = test_record.activity_schedule("test_mail.mail_act_test_todo", summary='Orphan activity')
act.action_done()
# Delete the record while preventing the cascade deletion of the activity to simulate a corrupted database
with patch.object(MailActivity, 'unlink', lambda self: None):
test_record.unlink()
self.assertTrue(act.exists())
self.assertFalse(act.active)
self.assertFalse(test_record.exists())
self.env.invalidate_all()
self.assertEqual(
self.env['mail.activity'].with_user(self.user_admin).with_context(active_test=False).search(
[('active', '=', False)]), act,
'Should consider unassigned activity on removed record = access without crash'
)
self.env.invalidate_all()
_dummy = act.with_user(self.user_admin).read(['summary'])
@tests.tagged('mail_activity', 'mail_activity_mixin')
class TestORM(TestActivityCommon):
"""Test for read_progress_bar"""
def test_groupby_activity_state_progress_bar_behavior(self):
""" Test activity_state groupby logic on mail.test.lead when 'activity_state'
is present multiple times in the groupby field list. """
lead_timedelta_setup = [0, 0, -2, -2, -2, 2]
leads = self.env["mail.test.lead"].create([
{"name": f"CRM Lead {i}"}
for i in range(1, len(lead_timedelta_setup) + 1)
])
with freeze_time("2025-05-21 10:00:00"):
self.env["mail.activity"].create([
{
"date_deadline": datetime.now(timezone.utc) + timedelta(days=delta_days),
"res_id": lead.id,
"res_model_id": self.env["ir.model"]._get_id("mail.test.lead"),
"summary": f"Test activity for CRM lead {lead.id}",
"user_id": self.env.user.id,
} for lead, delta_days in zip(leads, lead_timedelta_setup)
])
# grouping by 'activity_state' and 'activity_state' as the progress bar
domain = [("name", "!=", "")]
groupby = "activity_state"
progress_bar = {
"field": "activity_state",
"colors": {
"overdue": "danger",
"today": "warning",
"planned": "success",
},
}
progressbars = self.env["mail.test.lead"].read_progress_bar(
domain=domain, group_by=groupby, progress_bar=progress_bar,
)
self.assertEqual(len(progressbars), 3)
expected_progressbars = {
"overdue": {"overdue": 3, "today": 0, "planned": 0},
"today": {"overdue": 0, "today": 2, "planned": 0},
"planned": {"overdue": 0, "today": 0, "planned": 1},
}
self.assertEqual(dict(progressbars), expected_progressbars)
def test_week_grouping(self):
"""The labels associated to each record in read_progress_bar should match
the ones from read_group, even in edge cases like en_US locale on sundays
"""
MailTestActivityCtx = self.env['mail.test.activity'].with_context({"lang": "en_US"})
# Don't mistake fields date and date_deadline:
# * date is just a random value
# * date_deadline defines activity_state
with freeze_time("2024-09-24 10:00:00"):
self.env['mail.test.activity'].create({
'date': '2021-05-02',
'name': "Yesterday, all my troubles seemed so far away",
}).activity_schedule(
'test_mail.mail_act_test_todo',
summary="Make another test super asap (yesterday)",
date_deadline=fields.Date.context_today(MailTestActivityCtx) - timedelta(days=7),
user_id=self.env.uid,
)
self.env['mail.test.activity'].create({
'date': '2021-05-09',
'name': "Things we said today",
}).activity_schedule(
'test_mail.mail_act_test_todo',
summary="Make another test asap",
date_deadline=fields.Date.context_today(MailTestActivityCtx),
user_id=self.env.uid,
)
self.env['mail.test.activity'].create({
'date': '2021-05-16',
'name': "Tomorrow Never Knows",
}).activity_schedule(
'test_mail.mail_act_test_todo',
summary="Make a test tomorrow",
date_deadline=fields.Date.context_today(MailTestActivityCtx) + timedelta(days=7),
user_id=self.env.uid,
)
domain = [('date', "!=", False)]
groupby = "date:week"
progress_bar = {
'field': 'activity_state',
'colors': {
"overdue": 'danger',
"today": 'warning',
"planned": 'success',
}
}
# call read_group to compute group names
groups = MailTestActivityCtx.formatted_read_group(domain, groupby=[groupby])
progressbars = MailTestActivityCtx.read_progress_bar(domain, group_by=groupby, progress_bar=progress_bar)
self.assertEqual(len(groups), 3)
self.assertEqual(len(progressbars), 3)
# format the read_progress_bar result to get a dictionary under this
# format: {activity_state: group_name}; the original format
# (after read_progress_bar) is {group_name: {activity_state: count}}
pg_groups = {
next(state for state, count in data.items() if count): group_name
for group_name, data in progressbars.items()
}
self.assertEqual(groups[0][groupby][0], pg_groups["overdue"])
self.assertEqual(groups[1][groupby][0], pg_groups["today"])
self.assertEqual(groups[2][groupby][0], pg_groups["planned"])

View file

@ -0,0 +1,410 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
from freezegun import freeze_time
from odoo import fields
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.mail.tests.common_activity import ActivityScheduleCase
from odoo.exceptions import UserError, ValidationError
from odoo.tests import Form, tagged, users
from odoo.tools.misc import format_date
@tagged('mail_activity', 'mail_activity_plan')
class TestActivitySchedule(ActivityScheduleCase):
""" Test plan and activity schedule
- activity scheduling on a single record and in batch
- plan scheduling on a single record and in batch
- plan creation and consistency
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
# add some triggered and suggested next activitities
cls.test_type_1, cls.test_type_2, cls.test_type_3 = cls.env['mail.activity.type'].create([
{'name': 'TestAct1', 'res_model': 'mail.test.activity',},
{'name': 'TestAct2', 'res_model': 'mail.test.activity',},
{'name': 'TestAct3', 'res_model': 'mail.test.activity',},
])
cls.test_type_1.write({
'chaining_type': 'trigger',
'delay_count': 2,
'delay_from': 'current_date',
'delay_unit': 'days',
'triggered_next_type_id': cls.test_type_2.id,
})
cls.test_type_2.write({
'chaining_type': 'suggest',
'delay_count': 3,
'delay_unit': 'weeks',
'suggested_next_type_ids': [(4, cls.test_type_1.id), (4, cls.test_type_3.id)],
})
# prepare plans
cls.plan_party = cls.env['mail.activity.plan'].create({
'name': 'Test Plan A Party',
'res_model': 'mail.test.activity',
'template_ids': [
(0, 0, {
'activity_type_id': cls.activity_type_todo.id,
'delay_count': 1,
'delay_from': 'before_plan_date',
'delay_unit': 'days',
'responsible_type': 'on_demand',
'sequence': 10,
'summary': 'Book a place',
}), (0, 0, {
'activity_type_id': cls.activity_type_todo.id,
'delay_count': 1,
'delay_from': 'after_plan_date',
'delay_unit': 'weeks',
'responsible_id': cls.user_admin.id,
'responsible_type': 'other',
'sequence': 20,
'summary': 'Invite special guest',
}),
],
})
cls.plan_onboarding = cls.env['mail.activity.plan'].create({
'name': 'Test Onboarding',
'res_model': 'mail.test.activity',
'template_ids': [
(0, 0, {
'activity_type_id': cls.activity_type_todo.id,
'delay_count': 3,
'delay_from': 'before_plan_date',
'delay_unit': 'days',
'responsible_id': cls.user_admin.id,
'responsible_type': 'other',
'sequence': 10,
'summary': 'Plan training',
}), (0, 0, {
'activity_type_id': cls.activity_type_todo.id,
'delay_count': 2,
'delay_from': 'after_plan_date',
'delay_unit': 'weeks',
'responsible_id': cls.user_admin.id,
'responsible_type': 'other',
'sequence': 20,
'summary': 'Training',
}),
]
})
# test records
cls.reference_now = fields.Datetime.from_string('2023-09-30 14:00:00')
cls.test_records = cls.env['mail.test.activity'].create([
{
'date': cls.reference_now + timedelta(days=(idx - 10)),
'email_from': f'customer.activity.{idx}@test.example.com',
'name': f'test_record_{idx}'
} for idx in range(5)
])
# some big dict comparisons
cls.maxDiff = None
@users('employee')
def test_activity_schedule(self):
""" Test schedule of an activity on a single or multiple records. """
test_records_all = [self.test_records[0], self.test_records[:3]]
# sanity check: new activity created without specifying activiy type
# will have default type of the available activity type with the lowest sequence, then lowest id
self.assertTrue(self.activity_type_todo.sequence < self.activity_type_call.sequence)
for test_idx, test_case in enumerate(['mono', 'multi']):
test_records = test_records_all[test_idx].with_env(self.env)
with self.subTest(test_case=test_case, test_records=test_records):
# 1. SCHEDULE ACTIVITIES
with freeze_time(self.reference_now):
form = self._instantiate_activity_schedule_wizard(test_records)
form.summary = 'Write specification'
form.note = '<p>Useful link ...</p>'
form.activity_user_id = self.user_admin
with self._mock_activities():
form.save().action_schedule_activities()
for record in test_records:
self.assertActivityCreatedOnRecord(record, {
'activity_type_id': self.activity_type_todo,
'automated': False,
'date_deadline': self.reference_now.date() + timedelta(days=4), # activity type delay
'note': '<p>Useful link ...</p>',
'summary': 'Write specification',
'user_id': self.user_admin,
})
# 2. LOG DONE ACTIVITIES
with freeze_time(self.reference_now):
form = self._instantiate_activity_schedule_wizard(test_records)
form.activity_type_id = self.activity_type_call
form.activity_user_id = self.user_admin
with self._mock_activities(), freeze_time(self.reference_now):
form.save().with_context(
mail_activity_quick_update=True
).action_schedule_activities_done()
for record in test_records:
self.assertActivityDoneOnRecord(record, self.activity_type_call)
# 3. CONTINUE WITH SCHEDULE ACTIVITIES
# implies deadline addition on top of previous activities
with freeze_time(self.reference_now):
form = self._instantiate_activity_schedule_wizard(test_records)
form.activity_type_id = self.activity_type_call
form.activity_user_id = self.user_admin
with self._mock_activities():
form.save().with_context(
mail_activity_quick_update=True
).action_schedule_activities()
for record in test_records:
self.assertActivityCreatedOnRecord(record, {
'activity_type_id': self.activity_type_call,
'automated': False,
'date_deadline': self.reference_now.date() + timedelta(days=1), # activity call delay
'note': False,
'summary': 'TodoSumCallSummary',
'user_id': self.user_admin,
})
# global activity creation from tests
self.assertEqual(len(self.test_records[0].activity_ids), 4)
self.assertEqual(len(self.test_records[1].activity_ids), 2)
self.assertEqual(len(self.test_records[2].activity_ids), 2)
self.assertEqual(len(self.test_records[3].activity_ids), 0)
self.assertEqual(len(self.test_records[4].activity_ids), 0)
@users('admin')
def test_activity_schedule_rights_upload(self):
user = mail_new_test_user(
self.env,
groups='base.group_public',
login='bert',
name='Bert Tartignole',
)
demo_record = self.env['mail.test.access'].create({'access': 'admin', 'name': 'Record'})
form = self._instantiate_activity_schedule_wizard(demo_record)
form.activity_type_id = self.env.ref('test_mail.mail_act_test_upload_document')
with self.assertRaises(UserError):
form.activity_user_id = user
form.save()
@users('employee')
def test_activity_schedule_norecord(self):
""" Test scheduling free activities, supported if assigned user. """
scheduler = self._instantiate_activity_schedule_wizard(None)
self.assertEqual(scheduler.activity_type_id, self.activity_type_todo)
with self._mock_activities():
scheduler.save().action_schedule_activities()
self.assertActivityValues(self._new_activities, {
'res_id': False,
'res_model': False,
'summary': 'TodoSummary',
'user_id': self.user_employee,
})
# cannot scheduler unassigned personal activities
scheduler = self._instantiate_activity_schedule_wizard(None)
scheduler = scheduler.save()
with self.assertRaises(ValidationError):
scheduler.activity_user_id = False
def test_plan_copy(self):
"""Test plan copy"""
copied_plan = self.plan_onboarding.copy()
self.assertEqual(copied_plan.name, f'{self.plan_onboarding.name} (copy)')
self.assertEqual(len(copied_plan.template_ids), len(self.plan_onboarding.template_ids))
@users('employee')
def test_plan_mode(self):
""" Test the plan_mode that allows to preselect a compatible plan. """
test_record = self.test_records[0].with_env(self.env)
context = {
'active_id': test_record.id,
'active_ids': test_record.ids,
'active_model': test_record._name
}
plan_mode_context = {**context, 'plan_mode': True}
with Form(self.env['mail.activity.schedule'].with_context(context)) as form:
self.assertFalse(form.plan_id)
with Form(self.env['mail.activity.schedule'].with_context(plan_mode_context)) as form:
self.assertEqual(form.plan_id, self.plan_party)
# should select only model-plans
self.plan_party.res_model = 'res.partner'
with Form(self.env['mail.activity.schedule'].with_context(plan_mode_context)) as form:
self.assertEqual(form.plan_id, self.plan_onboarding)
@users('admin')
def test_plan_next_activities(self):
""" Test that next activities are displayed correctly. """
test_plan = self.env['mail.activity.plan'].create({
'name': 'Test Plan',
'res_model': 'mail.test.activity',
'template_ids': [
(0, 0, {'activity_type_id': self.test_type_1.id}),
(0, 0, {'activity_type_id': self.test_type_2.id}),
(0, 0, {'activity_type_id': self.test_type_3.id}),
],
})
# Assert expected next activities
expected_next_activities = [['TestAct2'], ['TestAct1', 'TestAct3'], []]
for template, expected_names in zip(test_plan.template_ids, expected_next_activities, strict=True):
self.assertEqual(template.next_activity_ids.mapped('name'), expected_names)
# Test the plan summary
with self.subTest(test_case='Check plan summary'), \
freeze_time(self.reference_now):
form = self._instantiate_activity_schedule_wizard(self.test_records[0])
form.plan_id = test_plan
expected_values = [
{'description': 'TestAct1', 'deadline': datetime(2023, 9, 30).date()},
{'description': 'TestAct2', 'deadline': datetime(2023, 10, 21).date()},
{'description': 'TestAct2', 'deadline': datetime(2023, 9, 30).date()},
{'description': 'TestAct1', 'deadline': datetime(2023, 10, 2).date()},
{'description': 'TestAct3', 'deadline': datetime(2023, 9, 30).date()},
{'description': 'TestAct3', 'deadline': datetime(2023, 9, 30).date()},
]
for line, expected in zip(form.plan_schedule_line_ids._records, expected_values):
with self.subTest(line=line, expected_values=expected):
self.assertEqual(line['line_description'], expected['description'])
self.assertEqual(line['line_date_deadline'], expected['deadline'])
@users('employee')
def test_plan_schedule(self):
""" Test schedule of a plan on a single or multiple records. """
test_records_all = [self.test_records[0], self.test_records[:3]]
for test_idx, test_case in enumerate(['mono', 'multi']):
test_records = test_records_all[test_idx].with_env(self.env)
with self.subTest(test_case=test_case, test_records=test_records), \
freeze_time(self.reference_now):
# No plan_date specified (-> self.reference_now is used), No responsible specified
form = self._instantiate_activity_schedule_wizard(test_records)
self.assertFalse(form.plan_schedule_line_ids)
form.plan_id = self.plan_onboarding
expected_values = [
{'description': 'Plan training', 'deadline': datetime(2023, 9, 27).date()},
{'description': 'Training', 'deadline': datetime(2023, 10, 14).date()},
]
for line, expected in zip(form.plan_schedule_line_ids._records, expected_values):
self.assertEqual(line['line_description'], expected['description'])
self.assertEqual(line['line_date_deadline'], expected['deadline'])
self.assertTrue(form._get_modifier('plan_on_demand_user_id', 'invisible'))
form.plan_id = self.plan_party
expected_values = [
{'description': 'Book a place', 'deadline': datetime(2023, 9, 29).date()},
{'description': 'Invite special guest', 'deadline': datetime(2023, 10, 7).date()},
]
for line, expected in zip(form.plan_schedule_line_ids._records, expected_values):
self.assertEqual(line['line_description'], expected['description'])
self.assertEqual(line['line_date_deadline'], expected['deadline'])
self.assertFalse(form._get_modifier('plan_on_demand_user_id', 'invisible'))
with self._mock_activities():
form.save().action_schedule_plan()
self.assertPlanExecution(
self.plan_party, test_records,
expected_deadlines=[(self.reference_now + relativedelta(days=-1)).date(),
(self.reference_now + relativedelta(days=7)).date()])
# plan_date specified, responsible specified
plan_date = self.reference_now.date() + relativedelta(days=14)
responsible_id = self.user_admin
form = self._instantiate_activity_schedule_wizard(test_records)
form.plan_id = self.plan_party
form.plan_date = plan_date
form.plan_on_demand_user_id = self.env['res.users']
self.assertTrue(form.has_error)
self.assertIn(f'No responsible specified for {self.activity_type_todo.name}: Book a place',
form.error)
form.plan_on_demand_user_id = responsible_id
self.assertFalse(form.has_error)
deadline_1 = plan_date + relativedelta(days=-1)
deadline_2 = plan_date + relativedelta(days=7)
expected_values = [
{'description': 'Book a place', 'deadline': deadline_1},
{'description': 'Invite special guest', 'deadline': deadline_2},
]
for line, expected in zip(form.plan_schedule_line_ids._records, expected_values):
self.assertEqual(line['line_description'], expected['description'])
self.assertEqual(line['line_date_deadline'], expected['deadline'])
with self._mock_activities():
form.save().action_schedule_plan()
self.assertPlanExecution(
self.plan_party, test_records,
expected_deadlines=[plan_date + relativedelta(days=-1),
plan_date + relativedelta(days=7)],
expected_responsible=responsible_id)
@users('admin')
def test_plan_setup_model_consistency(self):
""" Test the model consistency of a plan.
Model consistency between activity_type - activity_template - plan:
- a plan is restricted to a model
- a plan contains activity plan templates which can be limited to some model
through activity type
"""
# Setup independent activities type to avoid interference with existing data
activity_type_1, activity_type_2, activity_type_3 = self.env['mail.activity.type'].create([
{'name': 'Todo'},
{'name': 'Call'},
{'name': 'Partner-specific', 'res_model': 'res.partner'},
])
test_plan = self.env['mail.activity.plan'].create({
'name': 'Test Plan',
'res_model': 'mail.test.activity',
'template_ids': [
(0, 0, {'activity_type_id': activity_type_1.id}),
(0, 0, {'activity_type_id': activity_type_2.id})
],
})
# ok, all activities generic
test_plan.res_model = 'res.partner'
test_plan.res_model = 'mail.test.activity'
with self.assertRaises(
ValidationError,
msg='Cannot set activity type to res.partner as linked to a plan of another model'):
activity_type_1.res_model = 'res.partner'
activity_type_1.res_model = 'mail.test.activity'
with self.assertRaises(
ValidationError,
msg='Cannot set plan to res.partner as using activities linked to another model'):
test_plan.res_model = 'res.partner'
with self.assertRaises(
ValidationError,
msg='Cannot create activity template for res.partner as linked to a plan of another model'):
self.env['mail.activity.plan.template'].create({
'activity_type_id': activity_type_3.id,
'plan_id': test_plan.id,
})
@users('admin')
def test_plan_setup_validation(self):
""" Test plan consistency. """
plan = self.env['mail.activity.plan'].create({
'name': 'test',
'res_model': 'mail.test.activity',
})
template = self.env['mail.activity.plan.template'].create({
'activity_type_id': self.activity_type_todo.id,
'plan_id': plan.id,
'responsible_type': 'other',
'responsible_id': self.user_admin.id,
})
template.responsible_type = 'on_demand'
self.assertFalse(template.responsible_id)
with self.assertRaises(
ValidationError, msg='When selecting responsible "other", you must specify a responsible.'):
template.responsible_type = 'other'
template.write({'responsible_type': 'other', 'responsible_id': self.user_admin})

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients
from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user
from odoo.addons.test_mail.tests.common import TestRecipients
from odoo.exceptions import AccessError
from odoo.tests import tagged
from odoo.tests.common import users
@tagged('mail_composer_mixin')
class TestMailComposerMixin(TestMailCommon, TestRecipients):
class TestMailComposerMixin(MailCommon, TestRecipients):
@classmethod
def setUpClass(cls):
super(TestMailComposerMixin, cls).setUpClass()
# ensure employee can create partners, necessary for templates
cls.user_employee.write({
'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)],
})
super().setUpClass()
cls.mail_template = cls.env['mail.template'].create({
'body_html': '<p>EnglishBody for <t t-out="object.name"/></p>',
@ -30,6 +27,22 @@ class TestMailComposerMixin(TestMailCommon, TestRecipients):
'customer_id': cls.partner_1.id,
})
# Enable group-based template management
cls.env['ir.config_parameter'].set_param('mail.restrict.template.rendering', True)
# User without the group "mail.group_mail_template_editor"
cls.user_rendering_restricted = mail_new_test_user(
cls.env,
company_id=cls.company_admin.id,
groups='base.group_user',
login='user_rendering_restricted',
name='Code Template Restricted User',
notification_type='inbox',
signature='--\nErnest'
)
cls.user_rendering_restricted.group_ids -= cls.env.ref('mail.group_mail_template_editor')
cls.user_employee.group_ids += cls.env.ref('mail.group_mail_template_editor')
cls._activate_multi_lang(
layout_arch_db='<body><t t-out="message.body"/> English Layout for <t t-esc="model_description"/></body>',
lang_code='es_ES',
@ -41,19 +54,86 @@ class TestMailComposerMixin(TestMailCommon, TestRecipients):
def test_content_sync(self):
""" Test updating template updates the dynamic fields accordingly. """
source = self.test_record.with_env(self.env)
template = self.mail_template.with_env(self.env)
template_void = template.copy()
template_void.write({
'body_html': '<p><br /></p>',
'lang': False,
'subject': False,
})
composer = self.env['mail.test.composer.mixin'].create({
'name': 'Invite',
'template_id': template.id,
'source_ids': [(4, source.id)],
})
self.assertEqual(composer.body, template.body_html)
self.assertTrue(composer.body_has_template_value)
self.assertEqual(composer.lang, template.lang)
self.assertEqual(composer.subject, template.subject)
# check rendering
body = composer._render_field('body', source.ids)[source.id]
self.assertEqual(body, f'<p>EnglishBody for {source.name}</p>')
subject = composer._render_field('subject', source.ids)[source.id]
self.assertEqual(subject, f'EnglishSubject for {source.name}')
# manual values > template default values
composer.write({
'body': '<p>CustomBody for <t t-out="object.name"/></p>',
'subject': 'CustomSubject for {{ object.name }}',
})
self.assertFalse(composer.body_has_template_value)
body = composer._render_field('body', source.ids)[source.id]
self.assertEqual(body, f'<p>CustomBody for {source.name}</p>')
subject = composer._render_field('subject', source.ids)[source.id]
self.assertEqual(subject, f'CustomSubject for {source.name}')
# template with void values: should not force void (TODO)
composer.template_id = template_void.id
self.assertEqual(composer.body, '<p>CustomBody for <t t-out="object.name"/></p>')
self.assertFalse(composer.body_has_template_value)
self.assertEqual(composer.lang, template.lang)
self.assertEqual(composer.subject, 'CustomSubject for {{ object.name }}')
# reset template TOOD should reset
composer.write({'template_id': False})
self.assertFalse(composer.body)
self.assertFalse(composer.body_has_template_value)
self.assertFalse(composer.lang)
self.assertFalse(composer.subject)
@users("user_rendering_restricted")
def test_mail_composer_mixin_render_lang(self):
""" Test _render_lang when rendering is involved, depending on template
editor rights. """
source = self.test_record.with_env(self.env)
composer = self.env['mail.test.composer.mixin'].create({
'description': '<p>Description for <t t-esc="object.name"/></p>',
'name': 'Invite',
'template_id': self.mail_template.id,
'source_ids': [(4, source.id)],
})
self.assertEqual(composer.body, self.mail_template.body_html)
self.assertEqual(composer.subject, self.mail_template.subject)
self.assertFalse(composer.lang, 'Fixme: lang is not propagated currently')
subject = composer._render_field('subject', source.ids)[source.id]
self.assertEqual(subject, f'EnglishSubject for {source.name}')
body = composer._render_field('body', source.ids)[source.id]
self.assertEqual(body, f'<p>EnglishBody for {source.name}</p>')
# _render_lang should be ok when content is the same as template
rendered = composer._render_lang(source.ids)
self.assertEqual(rendered, {source.id: self.partner_1.lang})
# _render_lang should crash when content is dynamic and not coming from template
composer.lang = " {{ 'en_US' }}"
with self.assertRaises(AccessError):
rendered = composer._render_lang(source.ids)
# _render_lang should not crash when content is not coming from template
# but not dynamic and/or is actually the default computed based on partner
for lang_value, expected in [
(False, self.partner_1.lang), ("", self.partner_1.lang), ("fr_FR", "fr_FR")
]:
with self.subTest(lang_value=lang_value):
composer.lang = lang_value
rendered = composer._render_lang(source.ids)
self.assertEqual(rendered, {source.id: expected})
@users("employee")
def test_rendering_custom(self):
@ -84,7 +164,6 @@ class TestMailComposerMixin(TestMailCommon, TestRecipients):
source = self.test_record.with_env(self.env)
composer = self.env['mail.test.composer.mixin'].create({
'description': '<p>Description for <t t-esc="object.name"/></p>',
'lang': '{{ object.customer_id.lang }}',
'name': 'Invite',
'template_id': self.mail_template.id,
'source_ids': [(4, source.id)],
@ -103,11 +182,22 @@ class TestMailComposerMixin(TestMailCommon, TestRecipients):
# ask for dynamic language computation
subject = composer._render_field('subject', source.ids, compute_lang=True)[source.id]
self.assertEqual(subject, f'EnglishSubject for {source.name}',
'Fixme: translations are not done, as taking composer translations and not template one')
self.assertEqual(subject, f'SpanishSubject for {source.name}',
'Translation comes from the template, as both values equal')
body = composer._render_field('body', source.ids, compute_lang=True)[source.id]
self.assertEqual(body, f'<p>EnglishBody for {source.name}</p>',
'Fixme: translations are not done, as taking composer translations and not template one'
)
self.assertEqual(body, f'<p>SpanishBody for {source.name}</p>',
'Translation comes from the template, as both values equal')
description = composer._render_field('description', source.ids)[source.id]
self.assertEqual(description, f'<p>Description for {source.name}</p>')
# check default computation when 'lang' is void -> actually rerouted to template lang
composer.lang = False
subject = composer._render_field('subject', source.ids, compute_lang=True)[source.id]
self.assertEqual(subject, f'SpanishSubject for {source.name}',
'Translation comes from the template, as both values equal')
# check default computation when 'lang' is void in both -> main customer lang
self.mail_template.lang = False
subject = composer._render_field('subject', source.ids, compute_lang=True)[source.id]
self.assertEqual(subject, f'SpanishSubject for {source.name}',
'Translation comes from customer lang, being default when no value is rendered')

View file

@ -0,0 +1,558 @@
from odoo.addons.mail.tests.common import mail_new_test_user, MailCommon
from odoo.addons.test_mail.data.test_mail_data import MAIL_TEMPLATE, MAIL_TEMPLATE_SHORT
from odoo.addons.test_mail.tests.common import TestRecipients
from odoo.tools.mail import formataddr
from odoo.tests import tagged
@tagged('mail_gateway', 'mail_flow', 'post_install', '-at_install')
class TestMailFlow(MailCommon, TestRecipients):
""" Test flows matching business cases with incoming / outgoing emails. """
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_employee_2 = mail_new_test_user(
cls.env,
company_id=cls.user_employee.company_id.id,
email='eglantine@example.com',
groups='base.group_user,base.group_partner_manager',
login='employee2',
name='Eglantine Employee',
notification_type='email',
signature='--\nEglantine',
)
cls.partner_employee_2 = cls.user_employee_2.partner_id
cls.user_employee_3 = mail_new_test_user(
cls.env,
company_id=cls.user_employee.company_id.id,
email='emmanuel@example.com',
groups='base.group_user,base.group_partner_manager',
login='employee3',
name='Emmanuel Employee',
notification_type='email',
signature='--\nEmmanuel',
)
cls.partner_employee_3 = cls.user_employee_3.partner_id
cls.user_portal = cls._create_portal_user()
cls.partner_portal = cls.user_portal.partner_id
cls.test_emails = [
# emails only
'"Sylvie Lelitre" <sylvie.lelitre@zboing.com>',
'"Josiane Quichopoils" <accounting@zboing.com>',
'pay@zboing.com',
'invoicing@zboing.com',
# existing partners
'"Robert Brutijus" <robert@zboing.com>',
# existing portal users
'"Portal Zboing" <portal@zboing.com>',
]
cls.test_emails_normalized = [
'sylvie.lelitre@zboing.com', 'accounting@zboing.com', 'invoicing@zboing.com',
'pay@zboing.com', 'robert@zboing.com', 'portal@zboing.com',
]
cls.customer_zboing = cls.env['res.partner'].create({
'email': cls.test_emails[4],
'name': 'Robert Brutijus',
'phone': '+32455335577',
})
cls.user_portal_zboing = mail_new_test_user(
cls.env,
email=cls.test_emails[5],
groups='base.group_portal',
login='portal_zboing',
name='Portal Zboing',
)
cls.customer_portal_zboing = cls.user_portal_zboing.partner_id
# lead@test.mycompany.com will cause the creation of new mail.test.lead
cls.mail_test_lead_model = cls.env['ir.model']._get('mail.test.lead')
cls.alias = cls.env['mail.alias'].create({
'alias_domain_id': cls.mail_alias_domain.id,
'alias_contact': 'everyone',
'alias_model_id': cls.mail_test_lead_model.id,
'alias_name': 'lead',
})
# help@test.mycompany.com will cause the creation of new mail.test.ticket.mc
cls.ticket_template = cls.env['mail.template'].create({
'auto_delete': True,
'body_html': '<p>Received <t t-out="object.name"/></p>',
'email_from': '{{ object.user_id.email_formatted or user.email_formatted }}',
'lang': '{{ object.customer_id.lang }}',
'model_id': cls.env['ir.model']._get_id('mail.test.ticket.partner'),
'name': 'Received',
'subject': 'Received {{ object.name }}',
'use_default_to': True,
})
cls.container = cls.env['mail.test.container.mc'].create({
# triggers automatic answer yay !
'alias_defaults': {'state': 'new', 'state_template_id': cls.ticket_template.id},
'alias_name': 'help',
'company_id': cls.user_employee.company_id.id,
'name': 'help',
})
cls.container.alias_id.write({
'alias_model_id': cls.env['ir.model']._get_id('mail.test.ticket.partner')
})
def test_assert_initial_values(self):
""" Assert base values for tests """
self.assertEqual(
self.env['res.partner'].search([('email_normalized', 'in', self.test_emails_normalized)]),
self.customer_zboing + self.customer_portal_zboing,
)
def test_lead_email_to_email(self):
""" Test email-to-email (e.g. gmail) usage """
self.user_employee.notification_type = 'email'
lead = self.env['mail.test.lead'].with_user(self.user_employee).create({
'partner_id': self.customer_zboing.id,
})
# employee posts, pinging the customer
recipients = lead._message_get_suggested_recipients(
reply_discussion=True, no_create=False,
)
self.assertEqual(recipients, [{
'create_values': {},
'email': self.customer_zboing.email_normalized,
'name': self.customer_zboing.name,
'partner_id': self.customer_zboing.id,
}])
with self.mock_mail_gateway(), self.mock_mail_app():
emp_msg = lead.message_post(
body='Hello @customer',
message_type='comment',
partner_ids=[recipients[0]['partner_id']],
subtype_xmlid='mail.mt_comment',
)
reply_to_emp = emp_msg.reply_to
self.assertEqual(reply_to_emp, formataddr((self.user_employee.name, f'{self.alias_catchall}@{self.alias_domain}')))
self.assertSMTPEmailsSent(
mail_server=self.mail_server_notification,
msg_from=formataddr(
(self.partner_employee.name, f'{self.default_from}@{self.alias_domain}')
),
smtp_from=self.mail_server_notification.from_filter,
smtp_to_list=[self.customer_zboing.email_normalized],
msg_to_lst=[self.customer_zboing.email_formatted],
)
# customer replies from their email reader, adds a CC and someone in the To
cust_reply = self.gateway_mail_reply_from_smtp_email(
MAIL_TEMPLATE_SHORT, [self.customer_zboing.email_normalized], reply_all=True,
add_to_lst=[self.test_emails[0]], cc=self.test_emails[1],
)
self.assertMailNotifications(
cust_reply,
[
{
'content': "Eli alla à l'eau",
'message_type': 'email',
'message_values': {
'author_id': self.customer_zboing,
'email_from': self.customer_zboing.email_formatted,
'incoming_email_cc': self.test_emails[1],
# be sure to not have catchall.test inside the incoming_email_to !
'incoming_email_to': self.test_emails[0],
'notified_partner_ids': self.user_employee.partner_id,
# only recognized partners
'partner_ids': self.env['res.partner'],
'reply_to': formataddr((self.customer_zboing.name, f'{self.alias_catchall}@{self.alias_domain}')),
'subject': 'Re: False',
'subtype_id': self.env.ref('mail.mt_comment'),
},
'notif': [{'partner': self.user_employee.partner_id, 'type': 'email'}],
},
],
)
self.assertSMTPEmailsSent(
mail_server=self.mail_server_notification,
msg_from=formataddr(
(self.customer_zboing.name, f'{self.default_from}@{self.alias_domain}')
),
smtp_from=self.mail_server_notification.from_filter,
smtp_to_list=[self.user_employee.email_normalized],
# customers in To/Cc of reply added in envelope to keep them in discussions
msg_to_lst=[self.user_employee.email_formatted, self.test_emails[0], self.test_emails[1]],
msg_cc_lst=[],
)
# employee replies from their email reader, adds their colleague
emp_reply = self.gateway_mail_reply_from_smtp_email(
MAIL_TEMPLATE_SHORT, [self.user_employee.email_normalized], reply_all=True,
cc=self.partner_employee_2.email_formatted,
)
self.assertMailNotifications(
emp_reply,
[
{
'content': "Eli alla à l'eau",
'message_type': 'email',
'message_values': {
'author_id': self.partner_employee,
'email_from': self.partner_employee.email_formatted,
'incoming_email_cc': self.partner_employee_2.email_formatted,
# be sure not to have catchall reply-to ! customers are in 'To' due to Reply-All
'incoming_email_to': f'{self.test_emails[0]}, {self.test_emails[1]}',
'notified_partner_ids': self.customer_zboing,
# only recognized partners
'partner_ids': self.partner_employee_2,
'subject': 'Re: Re: False',
'subtype_id': self.env.ref('mail.mt_comment'),
},
# partner_employee_2 received an email, hence no duplicate notification
'notif': [{'partner': self.customer_zboing, 'type': 'email'}],
},
],
)
self.assertSMTPEmailsSent(
mail_server=self.mail_server_notification,
msg_from=formataddr(
(self.partner_employee.name, f'{self.default_from}@{self.alias_domain}')
),
smtp_from=self.mail_server_notification.from_filter,
smtp_to_list=[self.customer_zboing.email_normalized],
# customers are still in discussion
msg_to_lst=[self.customer_zboing.email_formatted, self.partner_employee_2.email_formatted, self.test_emails[0], self.test_emails[1]],
msg_cc_lst=[],
)
def test_lead_mailgateway(self):
""" Flow of this test
* incoming email creating a lead -> email set as first message
* a salesperson is assigned
* - he adds followers (internal and portal)
* - he replies through chatter, using suggested recipients
* - customer replies, adding other people
Tested features
* cc / to support
* suggested recipients computation
* outgoing SMTP envelope
Recipients
* incoming: From: sylvie (email) - To: employee, accounting (email) - Cc: pay (email), portal (portal)
* reply: creates partner for sylvie and pay through suggested recipients
* customer reply: Cc: invoicing (email) and robert (partner)
"""
# incoming customer email: lead alias + recipients (to + cc)
# ------------------------------------------------------------
email_to = f'lead@{self.alias_domain}, {self.test_emails[1]}, {self.partner_employee.email_formatted}'
email_to_filtered = f'{self.test_emails[1]}, {self.partner_employee.email_formatted}'
email_cc = f'{self.test_emails[2]}, {self.test_emails[5]}'
with self.mock_mail_gateway(), self.mock_mail_app():
lead = self.format_and_process(
MAIL_TEMPLATE,
self.test_emails[0],
email_to,
cc=email_cc,
subject='Inquiry',
target_model='mail.test.lead',
)
self.assertEqual(lead.email_cc, email_cc, 'Filled by mail.thread.cc mixin')
self.assertEqual(lead.email_from, self.test_emails[0])
self.assertEqual(lead.name, 'Inquiry')
self.assertFalse(lead.partner_id)
# followers
self.assertFalse(lead.message_partner_ids)
# messages
self.assertEqual(len(lead.message_ids), 1, 'Incoming email should be only message, no creation message')
incoming_email = lead.message_ids
self.assertMailNotifications(
incoming_email,
[
{
'content': 'Please call me as soon as possible',
'message_type': 'email',
'message_values': {
'author_id': self.env['res.partner'],
'email_from': self.test_emails[0],
'incoming_email_cc': email_cc,
'incoming_email_to': email_to_filtered,
'mail_server_id': self.env['ir.mail_server'],
'parent_id': self.env['mail.message'],
'notified_partner_ids': self.env['res.partner'],
# only recognized partners
'partner_ids': self.partner_employee + self.customer_portal_zboing,
'subject': 'Inquiry',
'subtype_id': self.env.ref('mail.mt_comment'),
},
'notif': [], # no notif, mailgateway sets recipients without notification
},
],
)
# user is assigned, should notify him
with self.mock_mail_gateway(), self.mock_mail_app():
lead.write({'user_id': self.user_employee.id})
lead_as_emp = lead.with_user(self.user_employee.id)
self.assertEqual(lead_as_emp.message_partner_ids, self.partner_employee)
# adds other employee and a portal customer as followers
lead_as_emp.message_subscribe(partner_ids=(self.partner_employee_2 + self.partner_portal).ids)
self.assertEqual(lead_as_emp.message_partner_ids, self.partner_employee + self.partner_employee_2 + self.partner_portal)
# updates some customer information
lead_as_emp.write({
'customer_name': 'Sylvie Lelitre (Zboing)',
'phone': '+32455001122',
'lang_code': 'fr_FR',
})
# uses Chatter: fetches suggested recipients, post a message
# - checks all suggested: email_cc field, primary email
# ------------------------------------------------------------
suggested_all = lead_as_emp._message_get_suggested_recipients(
reply_discussion=True, no_create=False,
)
partner_sylvie = self.env['res.partner'].search(
[('email_normalized', '=', 'sylvie.lelitre@zboing.com')]
)
partner_pay = self.env['res.partner'].search(
[('email_normalized', '=', 'pay@zboing.com')]
)
partner_accounting = self.env['res.partner'].search(
[('email_normalized', '=', 'accounting@zboing.com')]
)
expected_all = [
{ # existing partners come first
'create_values': {},
'email': 'portal@zboing.com',
'name': 'Portal Zboing',
'partner_id': self.customer_portal_zboing.id,
},
{ # primary email comes first
'create_values': {},
'email': 'sylvie.lelitre@zboing.com',
'name': 'Sylvie Lelitre (Zboing)',
'partner_id': partner_sylvie.id,
},
{ # mail.thread.cc: email_cc field
'create_values': {},
'email': 'pay@zboing.com',
'name': 'pay@zboing.com',
'partner_id': partner_pay.id,
},
{ # reply message
'create_values': {},
'email': 'accounting@zboing.com',
'name': 'Josiane Quichopoils',
'partner_id': partner_accounting.id,
},
]
for suggested, expected in zip(suggested_all, expected_all):
self.assertDictEqual(suggested, expected)
# finally post the message with recipients
with self.mock_mail_gateway():
responsible_answer = lead_as_emp.message_post(
body='<p>Well received !',
partner_ids=(partner_sylvie + partner_pay + partner_accounting + self.customer_portal_zboing).ids,
message_type='comment',
subject=f'Re: {lead.name}',
subtype_id=self.env.ref('mail.mt_comment').id,
)
self.assertEqual(lead_as_emp.message_partner_ids, self.partner_employee + self.partner_employee_2 + self.partner_portal)
external_partners = partner_sylvie + partner_pay + partner_accounting + self.customer_portal_zboing + self.partner_portal
internal_partners = self.partner_employee + self.partner_employee_2
self.assertMailNotifications(
responsible_answer,
[
{
'content': 'Well received !',
'mail_mail_values': {
'mail_server_id': self.env['ir.mail_server'], # no specified server
},
'message_type': 'comment',
'message_values': {
'author_id': self.partner_employee,
'email_from': self.partner_employee.email_formatted,
'incoming_email_cc': False,
'incoming_email_to': False,
'mail_server_id': self.env['ir.mail_server'],
# followers + recipients - author
'notified_partner_ids': external_partners + self.partner_employee_2,
'parent_id': incoming_email,
# matches posted message
'partner_ids': partner_sylvie + partner_pay + partner_accounting + self.customer_portal_zboing,
'reply_to': formataddr((
self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}'
)),
'subtype_id': self.env.ref('mail.mt_comment'),
},
'notif': [
{'partner': partner_sylvie, 'type': 'email'},
{'partner': partner_pay, 'type': 'email'},
{'partner': partner_accounting, 'type': 'email'},
{'partner': self.customer_portal_zboing, 'type': 'email'},
{'partner': self.partner_employee_2, 'type': 'email'},
{'partner': self.partner_portal, 'type': 'email'},
],
},
],
)
# expected Msg['To'] : Reply-All behavior: actual recipient, then
# all "not internal partners" and catchall (to receive answers)
for partner in responsible_answer.notified_partner_ids:
exp_msg_to_partners = partner | external_partners
exp_msg_to = exp_msg_to_partners.mapped('email_formatted')
with self.subTest(name=partner.name):
self.assertSMTPEmailsSent(
mail_server=self.mail_server_notification,
msg_from=formataddr(
(self.partner_employee.name, f'{self.default_from}@{self.alias_domain}')
),
smtp_from=self.mail_server_notification.from_filter,
smtp_to_list=[partner.email_normalized],
msg_to_lst=exp_msg_to,
)
# customer replies using "Reply All" + adds new people
# added: Cc: invoicing (email) and robert (partner)
# ------------------------------------------------------------
self.gateway_mail_reply_from_smtp_email(
MAIL_TEMPLATE, [partner_sylvie.email_normalized], reply_all=True,
cc=f'{self.test_emails[3]}, {self.test_emails[4]}', # used mainly for existing partners currently
)
external_partners += self.customer_zboing # added in CC just above
self.assertEqual(len(lead.message_ids), 3, 'Incoming email + chatter reply + customer reply')
self.assertEqual(
lead.message_partner_ids,
internal_partners + self.partner_portal,
'Mail gateway: author (partner_sylvie) should not added in followers if external')
customer_reply = lead.message_ids[0]
self.assertMailNotifications(
customer_reply,
[
{
'content': 'Please call me as soon as possible',
'message_type': 'email',
'message_values': {
'author_id': partner_sylvie,
'email_from': partner_sylvie.email_formatted,
# Cc: received email CC - an email still not partnerized (invoicing) and customer_zboing
'incoming_email_cc': f'{self.test_emails[3]}, {self.test_emails[4]}',
# To: received email Msg-To - customer who replies + email Reply-To
'incoming_email_to': ', '.join((external_partners - partner_sylvie - self.customer_zboing).mapped('email_formatted')),
'mail_server_id': self.env['ir.mail_server'],
# notified: followers - already mailed, aka internal only
'notified_partner_ids': internal_partners,
'parent_id': responsible_answer,
# same reasoning as email_to/cc
'partner_ids': external_partners - partner_sylvie,
'reply_to': formataddr((
partner_sylvie.name, f'{self.alias_catchall}@{self.alias_domain}'
)),
'subject': f'Re: Re: {lead.name}',
'subtype_id': self.env.ref('mail.mt_comment'),
},
# portal was already in email_to, hence not notified twice through odoo
'notif': [
{'partner': self.partner_employee, 'type': 'inbox'},
{'partner': self.partner_employee_2, 'type': 'email'},
],
},
],
)
def test_ticket_mailgateway(self):
""" Flow of this test
* incoming email creating a ticket in 'new' state
* automatic answer based on template
"""
# incoming customer email: help alias + recipients (to + cc)
# ------------------------------------------------------------
email_to = f'help@{self.alias_domain}, {self.test_emails[1]}, {self.partner_employee.email_formatted}'
email_to_filtered = f'{self.test_emails[1]}, {self.partner_employee.email_formatted}'
email_cc = f'{self.test_emails[2]}, {self.test_emails[5]}'
with self.mock_mail_gateway(), self.mock_mail_app():
ticket = self.format_and_process(
MAIL_TEMPLATE,
self.test_emails[0],
email_to,
cc=email_cc,
subject='Inquiry',
target_model='mail.test.ticket.partner',
)
self.flush_tracking()
# author -> partner, as automatic email creates partner
partner_sylvie = self.env['res.partner'].search([('email_normalized', '=', 'sylvie.lelitre@zboing.com')])
self.assertTrue(partner_sylvie, 'Acknowledgement template should create a partner for incoming email')
self.assertEqual(partner_sylvie.email, 'sylvie.lelitre@zboing.com', 'Should parse name/email correctly')
self.assertEqual(partner_sylvie.name, 'sylvie.lelitre@zboing.com', 'TDE FIXME: should parse name/email correctly')
# create ticket
self.assertEqual(ticket.container_id, self.container)
self.assertEqual(
ticket.customer_id, partner_sylvie,
'Should put partner as customer, due to after hook')
self.assertEqual(ticket.email_from, self.test_emails[0])
self.assertEqual(ticket.name, 'Inquiry')
self.assertEqual(ticket.state, 'new', 'Should come from alias defaults')
self.assertEqual(ticket.state_template_id, self.ticket_template, 'Should come from alias defaults')
# followers
self.assertFalse(ticket.message_partner_ids)
# messages
self.assertEqual(len(ticket.message_ids), 3, 'Incoming email + Acknowledgement + Tracking')
# first message: incoming email
incoming_email = ticket.message_ids[2]
self.assertMailNotifications(
incoming_email,
[
{
'content': 'Please call me as soon as possible',
'message_type': 'email',
'message_values': {
'author_id': self.env['res.partner'],
'email_from': self.test_emails[0],
# coming from incoming email
'incoming_email_cc': email_cc,
'incoming_email_to': email_to_filtered,
'mail_server_id': self.env['ir.mail_server'],
'parent_id': self.env['mail.message'],
'notified_partner_ids': self.env['res.partner'],
# only recognized partners
'partner_ids': self.partner_employee + self.customer_portal_zboing,
'subject': 'Inquiry',
# subtype from '_creation_subtype'
'subtype_id': self.env.ref('test_mail.st_mail_test_ticket_partner_new'),
},
'notif': [], # no notif, mailgateway sets recipients without notification
},
],
)
# second message: acknowledgement
acknowledgement = ticket.message_ids[1]
self.assertMailNotifications(
acknowledgement,
[
{
'content': f'Received {ticket.name}',
'message_type': 'auto_comment',
'message_values': {
# defined by template, root is the cron user as no responsible
'author_id': self.partner_root,
'email_from': self.partner_root.email_formatted,
'incoming_email_cc': False,
'incoming_email_to': False,
'mail_server_id': self.env['ir.mail_server'],
# no followers, hence only template default_to
'notified_partner_ids': partner_sylvie,
'parent_id': incoming_email,
# no followers, hence only template default_to
'partner_ids': partner_sylvie,
'subject': f'Received {ticket.name}',
# subtype from '_track_template'
'subtype_id': self.env.ref('mail.mt_note'),
},
'notif': [
{'partner': partner_sylvie, 'type': 'email',},
],
},
],
)

View file

@ -1,14 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.test_mail.tests.common import TestMailCommon
import re
from unittest.mock import patch
from urllib.parse import urlparse
from markupsafe import Markup
from odoo import Command
from odoo.addons.mail.models.mail_mail import _UNFOLLOW_REGEX
from odoo.addons.mail.tests.common import MailCommon
from odoo.exceptions import AccessError
from odoo.tests import tagged, users
from odoo.tests.common import HttpCase
from odoo.tools import mute_logger
@tagged('mail_followers')
class BaseFollowersTest(TestMailCommon):
class BaseFollowersTest(MailCommon):
@classmethod
def setUpClass(cls):
@ -16,9 +25,6 @@ class BaseFollowersTest(TestMailCommon):
cls.test_record = cls.env['mail.test.simple'].with_context(cls._test_context).create({'name': 'Test', 'email_from': 'ignasse@example.com'})
cls._create_portal_user()
# allow employee to update partners
cls.user_employee.write({'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)]})
Subtype = cls.env['mail.message.subtype']
# global
cls.mt_al_def = Subtype.create({'name': 'mt_al_def', 'default': True, 'res_model': False})
@ -52,6 +58,8 @@ class BaseFollowersTest(TestMailCommon):
followed_after = self.env['mail.test.simple'].search([('message_partner_ids', 'in', partner.ids)])
self.assertTrue(partner in test_record.message_partner_ids)
self.assertEqual(followed_before + test_record, followed_after)
with self.assertRaisesRegex(AccessError, 'Portal users can only filter threads'):
self.env['mail.test.simple'].with_user(self.user_portal).search([('message_partner_ids', 'in', partner.ids)])
def test_field_followers(self):
test_record = self.test_record.with_user(self.user_employee)
@ -141,7 +149,7 @@ class BaseFollowersTest(TestMailCommon):
'name': 'Valid Lelitre',
'email': 'valid.lelitre@agrolait.com',
'country_id': self.env.ref('base.be').id,
'mobile': '0456001122',
'phone': '0456001122',
'active': False,
})
document = self.env['mail.test.simple'].browse(self.test_record.id)
@ -195,6 +203,18 @@ class BaseFollowersTest(TestMailCommon):
test_record.write({'message_partner_ids': [(4, partner0.id), (4, partner1.id)]})
self.assertEqual(test_record.message_follower_ids.partner_id, partner1)
# Test when the method inverse is called in batch
other_record = test_record.create({
'name': 'Other',
})
records = test_record + other_record
records.message_partner_ids = (partner2 + partner3)
self.assertEqual(records.message_partner_ids, partner2 + partner3)
records.message_partner_ids -= partner2
self.assertEqual(records.message_partner_ids, partner3)
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.models')
def test_followers_inverse_message_partner_access_rights(self):
""" Make sure we're not bypassing security checks by setting a partner
@ -218,14 +238,16 @@ class BaseFollowersTest(TestMailCommon):
@users('employee')
def test_followers_private_address(self):
""" Test standard API does not subscribe private addresses """
private_address = self.env['res.partner'].sudo().create({
""" Test standard API does subscribe IDs the user can't read """
other_company = self.env['res.company'].sudo().create({'name': 'Other Company'})
private_address = self.env['res.partner'].create({
'name': 'Private Address',
'type': 'private',
'company_id': other_company.id,
})
self.env.user.write({'company_ids': [(3, other_company.id)]})
document = self.env['mail.test.simple'].browse(self.test_record.id)
document.message_subscribe(partner_ids=(self.partner_portal | private_address).ids)
self.assertEqual(document.message_follower_ids.partner_id, self.partner_portal)
self.assertEqual(document.message_follower_ids.partner_id, self.partner_portal | private_address)
# works through low-level API
document._message_subscribe(partner_ids=(self.partner_portal | private_address).ids)
@ -255,7 +277,7 @@ class BaseFollowersTest(TestMailCommon):
@tagged('mail_followers')
class AdvancedFollowersTest(TestMailCommon):
class AdvancedFollowersTest(MailCommon):
@classmethod
def setUpClass(cls):
@ -288,6 +310,10 @@ class AdvancedFollowersTest(TestMailCommon):
'name': 'Default track subtype', 'default': True, 'internal': False,
'res_model': 'mail.test.track'
})
cls.sub_track_parent_def = Subtype.create({
'name': 'Parent track subtype', 'default': False, 'res_model': 'mail.test.track',
'parent_id': cls.sub_track_def.id, 'relation_field': 'parent_id'
})
# mail.test.container subtypes (aka: project records)
cls.umb_nodef = Subtype.create({
@ -327,7 +353,18 @@ class AdvancedFollowersTest(TestMailCommon):
def test_auto_subscribe_create(self):
""" Creator of records are automatically added as followers """
self.assertEqual(self.test_track.message_partner_ids, self.user_employee.partner_id)
for user, should_subscribe in [
(self.user_root, False),
(self.user_employee, True),
(self.user_portal, False),
]:
with self.subTest(user_name=user.name):
# sudo, as done through mailgateway for example
if user == self.user_portal:
new_rec = self.env['mail.test.track'].with_user(user).sudo().create({})
else:
new_rec = self.env['mail.test.track'].with_user(user).create({})
self.assertEqual(new_rec.message_partner_ids, user.partner_id if should_subscribe else self.env['res.partner'])
@mute_logger('odoo.models.unlink')
def test_auto_subscribe_inactive(self):
@ -355,19 +392,27 @@ class AdvancedFollowersTest(TestMailCommon):
'Does not subscribe inactive partner')
def test_auto_subscribe_post(self):
""" People posting a message are automatically added as followers """
self.test_track.with_user(self.user_admin).message_post(body='Coucou hibou', message_type='comment')
self.assertEqual(self.test_track.message_partner_ids, self.user_employee.partner_id | self.user_admin.partner_id)
def test_auto_subscribe_post_email(self):
""" People posting an email are automatically added as followers """
self.test_track.with_user(self.user_admin).message_post(body='Coucou hibou', message_type='email')
self.assertEqual(self.test_track.message_partner_ids, self.user_employee.partner_id | self.user_admin.partner_id)
def test_auto_subscribe_not_on_notification(self):
""" People posting an automatic notification are not subscribed """
self.test_track.with_user(self.user_admin).message_post(body='Coucou hibou', message_type='notification')
self.assertEqual(self.test_track.message_partner_ids, self.user_employee.partner_id)
""" People posting a discussion message are automatically added as
followers """
record = self.test_track.with_user(self.user_admin)
for message_type, subtype, should_subscribe in [
('comment', self.env.ref('mail.mt_note'), False),
('comment', self.env.ref('mail.mt_comment'), True),
('email_outgoing', self.env.ref('mail.mt_note'), False),
('email_outgoing', self.env.ref('mail.mt_comment'), True),
('notification', self.env.ref('mail.mt_comment'), False),
]:
with self.subTest(message_type=message_type, subtype_name=subtype.name):
record.message_unsubscribe(partner_ids=self.user_admin.partner_id.ids)
record.message_post(
body=f'Posting with {message_type} {subtype.name}',
message_type=message_type,
subtype_id=subtype.id,
)
if should_subscribe:
self.assertIn(self.user_admin.partner_id, record.message_partner_ids)
else:
self.assertNotIn(self.user_admin.partner_id, record.message_partner_ids)
def test_auto_subscribe_responsible(self):
""" Responsibles are tracked and added as followers """
@ -465,8 +510,23 @@ class AdvancedFollowersTest(TestMailCommon):
'AutoSubscribe: at create auto subscribe as creator + from parent take both subtypes'
)
container.message_follower_ids = [Command.clear()]
parent_track = self.env['mail.test.track'].with_user(self.user_employee).create({
'name': 'Task-Like',
'container_id': container.id,
})
child_track = self.env['mail.test.track'].with_user(self.user_admin).create({
'name': 'Task-Like Test-sub-task',
'parent_id': parent_track.id,
'container_id': container.id,
})
self.assertIn(self.user_employee.partner_id, child_track.message_follower_ids.partner_id, 'The partner from the parent has not been added as follower.')
@tagged('mail_followers')
class AdvancedResponsibleNotifiedTest(MailCommon):
class AdvancedResponsibleNotifiedTest(TestMailCommon):
def setUp(self):
super(AdvancedResponsibleNotifiedTest, self).setUp()
@ -478,7 +538,7 @@ class AdvancedResponsibleNotifiedTest(TestMailCommon):
def test_auto_subscribe_notify_email(self):
""" Responsible is notified when assigned """
partner = self.env['res.partner'].create({"name": "demo1", "email": "demo1@test.com"})
partner = self.env['res.partner'].create({"name": "demo1", "email": "demo1@test.mycompany.com"})
notified_user = self.env['res.users'].create({
'login': 'demo1',
'partner_id': partner.id,
@ -512,7 +572,7 @@ class AdvancedResponsibleNotifiedTest(TestMailCommon):
@tagged('mail_followers', 'post_install', '-at_install')
class RecipientsNotificationTest(TestMailCommon):
class RecipientsNotificationTest(MailCommon):
""" Test advanced and complex recipients computation / notification, such
as multiple users, batch computation, ... Post install because we need the
registry to be ready to send notifications."""
@ -539,12 +599,12 @@ class RecipientsNotificationTest(TestMailCommon):
'phone': '+32455998877',
})
cls.user_1, cls.user_2 = cls.env['res.users'].with_context(no_reset_password=True).create([
{'groups_id': [(4, cls.env.ref('base.group_portal').id)],
{'group_ids': [(4, cls.env.ref('base.group_portal').id)],
'login': '_login_portal',
'notification_type': 'email',
'partner_id': cls.common_partner.id,
},
{'groups_id': [(4, cls.env.ref('base.group_user').id)],
{'group_ids': [(4, cls.env.ref('base.group_user').id)],
'login': '_login_internal',
'notification_type': 'inbox',
'partner_id': cls.common_partner.id,
@ -572,8 +632,11 @@ class RecipientsNotificationTest(TestMailCommon):
if not user:
user = next((user for user in partner.user_ids), self.env['res.users'])
self.assertEqual(partner_data['active'], partner.active)
self.assertEqual(partner_data['email_normalized'], partner.email_normalized)
self.assertEqual(partner_data['lang'], partner.lang)
self.assertEqual(partner_data['name'], partner.name)
if user:
self.assertEqual(partner_data['groups'], set(user.groups_id.ids))
self.assertEqual(partner_data['groups'], set(user.all_group_ids.ids))
self.assertEqual(partner_data['notif'], user.notification_type)
self.assertEqual(partner_data['uid'], user.id)
else:
@ -649,21 +712,21 @@ class RecipientsNotificationTest(TestMailCommon):
user_2_1, user_2_2, user_2_3 = self.env['res.users'].sudo().with_context(no_reset_password=True).create([
{'company_ids': [(6, 0, cids)],
'company_id': self.company_admin.id,
'groups_id': [(4, self.env.ref('base.group_portal').id)],
'group_ids': [(4, self.env.ref('base.group_portal').id)],
'login': '_login2_portal',
'notification_type': 'email',
'partner_id': shared_partner.id,
},
{'company_ids': [(6, 0, cids)],
'company_id': self.company_admin.id,
'groups_id': [(4, self.env.ref('base.group_user').id)],
'group_ids': [(4, self.env.ref('base.group_user').id)],
'login': '_login2_internal',
'notification_type': 'inbox',
'partner_id': shared_partner.id,
},
{'company_ids': [(6, 0, cids)],
'company_id': company_other.id,
'groups_id': [(4, self.env.ref('base.group_user').id), (4, self.env.ref('base.group_partner_manager').id)],
'group_ids': [(4, self.env.ref('base.group_user').id), (4, self.env.ref('base.group_partner_manager').id)],
'login': '_login2_manager',
'notification_type': 'inbox',
'partner_id': shared_partner.id,
@ -684,7 +747,7 @@ class RecipientsNotificationTest(TestMailCommon):
'status': 'sent', 'type': 'inbox'}],
message_info={'content': 'User Choice Notification'}):
test.message_post(
body='<p>User Choice Notification</p>',
body=Markup('<p>User Choice Notification</p>'),
message_type='comment',
partner_ids=shared_partner.ids,
subtype_xmlid='mail.mt_comment',
@ -788,3 +851,213 @@ class RecipientsNotificationTest(TestMailCommon):
pids=test_partners.ids
)
self.assertRecipientsData(recipients_data, False, test_partners)
def test_subscribe_post_author(self):
""" Test author is added in followers, unless it is archived / odoobot """
# some automated action post on behalf of author
test_record = self.env['mail.test.simple'].create({'name': 'Test'})
self.partner_root.active = True # edge case, people activating Odoobot partner (not user)
(self.user_1 + self.user_2).active = False # archived users should not be subscribed
self.user_1.partner_id.active = False # archived authors should not be subscribed
self.assertFalse(test_record.message_partner_ids)
for user, author, exp_followers in [
# active user = real author
(self.user_employee, self.user_2.partner_id, self.user_employee.partner_id),
# inactive user -> check for author
(self.user_2, self.user_employee.partner_id, self.user_employee.partner_id),
(self.user_2, self.user_1.partner_id, self.env['res.partner']), # no inactive !
(self.user_2, self.user_root.partner_id, self.env['res.partner']), # no odoobot !
]:
with self.subTest(user=user.name, author=author.name):
test_record.with_user(user).message_post(
author_id=author.id,
body='Youpie',
message_type='comment',
subtype_id=self.env.ref('mail.mt_comment').id,
)
self.assertEqual(test_record.message_partner_ids, exp_followers)
if exp_followers:
test_record.message_unsubscribe(partner_ids=exp_followers.ids)
@tagged('mail_followers', 'post_install', '-at_install')
class UnfollowLinkTest(MailCommon, HttpCase):
""" Test unfollow links, notably used in notification emails """
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_portal = cls._create_portal_user()
cls.partner_portal = cls.user_portal.partner_id
cls.test_record = cls.env['mail.test.simple'].with_context(cls._test_context).create({'name': 'Test'})
cls.test_record_copy = cls.test_record.copy()
cls.test_record_unfollow = cls.env['mail.test.simple.unfollow'].with_context(cls._test_context).create(
{'name': 'unfollow'})
cls.partner_without_user = cls.env['res.partner'].create({
'name': 'Dave',
'email': 'dave@odoo.com',
})
cls.user_employee.write({'notification_type': 'email'})
def _message_unsubscribe_unreadable_record(self, user):
def raise_access_error(*args, **kwargs):
raise AccessError('Unreadable')
with patch.object(self.test_record.__class__, 'check_access', side_effect=raise_access_error):
self.test_record.with_user(user).message_unsubscribe(user.partner_id.ids)
def _test_tampered_unfollow_url(self, record, unfollow_url, partner):
""" Test that tampered urls doesn't work.
Test that:
- when the following parameters are altered, the browsing the URL returns
a 403 and doesn't unsubscribe the partner.
- when trying to use the same URL with another partner, it also returns a
403 and doesn't unsubscribe the other partner.
"""
for param, value in (
('token', '0000000000000000000000000000000000000000'),
('model', 'mail.test.gateway'),
('res_id', self.test_record_copy.id),
('partner_id', self.partner_admin.id),
):
with self.subTest(f'Tampered {param}'):
tampered_unfollow_url = self._url_update_query_parameters(unfollow_url, **{param: value})
response = self.url_open(tampered_unfollow_url)
self.assertEqual(response.status_code, 403)
self.assertIn(partner, record.message_partner_ids)
def _test_unfollow_url(self, record, unfollow_url, partner):
""" Test that the unfollow url works.
Test that: that browsing the unfollow URL unsubscribe the user from the record
"""
with self.subTest('Legitimate unfollow'):
# We test that the URL still work a second time if the user has been re-added
for _ in range(2):
try:
self.assertIn(partner, record.message_partner_ids)
response = self.url_open(unfollow_url)
self.assertEqual(response.status_code, 200)
self.assertNotIn(partner, record.message_partner_ids)
self.assertEqual(urlparse(response.url).path, '/mail/unfollow')
self.assertIn("You are no longer following the document", response.text)
self.assertIn('o_access_record_link', response.text)
finally:
record._message_subscribe(partner_ids=partner.ids)
def test_assert_initial_data(self):
""" Test some initial value. """
record_employee = self.test_record.with_user(self.user_employee)
record_employee.check_access('read')
record_portal = self.test_record.with_user(self.user_portal)
with self.assertRaises(AccessError):
record_portal.check_access('write')
for template_ref in ('mail.mail_notification_layout', 'mail.mail_notification_light'):
with self.subTest(f'Unfollow link in {template_ref}'):
mail_template_arch = self.env.ref(template_ref).arch
self.assertIn('/mail/unfollow', mail_template_arch)
self.assertNotIn('/mail/unfollow', re.sub(_UNFOLLOW_REGEX, '', mail_template_arch))
@users('employee')
@mute_logger('odoo.models')
def test_inbox_unfollow_information(self):
""" Check follow-up information for displaying inbox messages used to
implement "unfollow" in the inbox.
Note that the actual mechanism to unfollow a record from a message is
tested in the client part.
"""
self.user_employee.write({'notification_type': 'inbox'})
test_record = self.env['mail.test.simple'].browse(self.test_record.ids)
_message = test_record.with_user(self.user_admin).message_post(
body="test message",
subtype_id=self.env.ref("mail.mt_comment").id,
partner_ids=self.partner_employee.ids,
)
# The user doesn't follow the record
self.authenticate(self.env.user.login, self.env.user.login)
message_data = self.make_jsonrpc_request("/mail/inbox/messages")["data"]
self.assertFalse(message_data["mail.thread"][0]["selfFollower"])
self.assertFalse(message_data.get("mail.followers"), "Should not have void followers data")
self.assertFalse(test_record.with_user(self.user_employee).message_is_follower)
# The user follows the record
test_record._message_subscribe(partner_ids=self.env.user.partner_id.ids)
follower = test_record.message_follower_ids.filtered(
lambda follower: follower.partner_id == self.env.user.partner_id
)
message_data = self.make_jsonrpc_request("/mail/inbox/messages")["data"]
self.assertEqual(message_data["mail.followers"], [
{
"id": follower.id,
"is_active": True,
"partner_id": self.env.user.partner_id.id,
},
])
self.assertEqual(message_data["mail.thread"][0]["selfFollower"], follower.id, "Should have follower ID")
@mute_logger('odoo.addons.base.models', 'odoo.addons.mail.controllers.mail', 'odoo.http', 'odoo.models')
def test_notification_email_unfollow_link(self):
""" Internal user must receive an unfollow URL, that cannot be tampered
and redirects to the correct page.
"""
for test_partners, test_record, exp_has_url in [
(self.partner_employee, self.test_record, [True]),
# customer should not receive an unfollow URL
(self.partner_without_user, self.test_record, [False]),
(self.partner_portal, self.test_record, [False]),
# always unfollow link (model definition)
(self.partner_without_user, self.test_record_unfollow, [True]),
(self.partner_portal, self.test_record_unfollow, [True]),
# multi partners
(
self.partner_without_user + self.partner_portal + self.partner_employee,
self.test_record, [False, False, True],
),
(
self.partner_without_user + self.partner_portal + self.partner_employee,
self.test_record_unfollow, [True, True, True],
),
]:
with self.subTest(partners=test_partners.mapped('name')):
# Test that the user receives an unfollow URL when following the record
test_record._message_subscribe(partner_ids=test_partners.ids)
unfollow_urls = self._message_post_and_get_unfollow_urls(test_record, test_partners)
for test_partner, unfollow_url, has_url in zip(test_partners, unfollow_urls, exp_has_url):
self.assertEqual(bool(unfollow_url), has_url)
# Test unfollowing URL when user is not logged
if has_url:
self.authenticate(None, None)
self._test_unfollow_url(test_record, unfollow_url, test_partner)
self._test_tampered_unfollow_url(test_record, unfollow_url, test_partner)
if test_partner == self.partner_employee:
# Test unfollowing URL when user is logged
self.authenticate(self.user_employee.login, self.user_employee.login)
self._test_unfollow_url(test_record, unfollow_url, test_partner)
# Test that the user doesn't receive the unfollow URL when not following the record
test_record.message_unsubscribe(partner_ids=test_partners.ids)
unfollow_urls = self._message_post_and_get_unfollow_urls(test_record, test_partners)
for test_partner, unfollow_url in zip(test_partners, unfollow_urls):
self.assertFalse(unfollow_url)
def test_unsubscribe_unreadable(self):
""" Check internal can always unsubscribe form records while portal are
limited to records they can access. Other records are considered as customer
oriented and we don't want to lose emails. """
for user, can_unsubscribe in [
(self.user_employee, True),
(self.user_portal, False),
]:
self.test_record._message_subscribe(partner_ids=user.partner_id.ids)
self.assertIn(user.partner_id, self.test_record.message_partner_ids)
if can_unsubscribe:
self._message_unsubscribe_unreadable_record(user)
self.assertNotIn(user.partner_id, self.test_record.message_partner_ids)
else:
with self.assertRaises(AccessError):
self._message_unsubscribe_unreadable_record(user)

View file

@ -1,12 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients
from odoo.addons.mail.tests.common import MailCommon
from odoo.addons.test_mail.tests.common import TestRecipients
from odoo.tests import tagged
@tagged('mail_management')
class TestMailManagement(TestMailCommon, TestRecipients):
class TestMailManagement(MailCommon, TestRecipients):
@classmethod
def setUpClass(cls):

View file

@ -1,14 +1,162 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.test_mail.tests.common import TestMailCommon
import contextlib
from markupsafe import Markup
from odoo.addons.base.models.ir_mail_server import MailDeliveryException
from odoo.addons.mail.tests.common import mail_new_test_user, MailCommon
from odoo.addons.mail.tools.discuss import Store
from odoo.exceptions import UserError
from odoo.tests.common import tagged, users, HttpCase
from odoo.tools import is_html_empty, mute_logger, formataddr
from odoo.tests import tagged, users
@tagged('mail_message')
class TestMessageValues(TestMailCommon):
@tagged('mail_message', 'mail_controller', 'post_install', '-at_install')
class TestMessageHelpersRobustness(MailCommon, HttpCase):
""" Test message helpers robustness, currently mainly linked to records
being removed from DB due to cascading deletion, which let side records
alive in DB. """
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_employee_2 = mail_new_test_user(
cls.env,
email='eglantine@example.com',
groups='base.group_user',
login='employee2',
notification_type='email',
name='Eglantine Employee',
)
cls.partner_employee_2 = cls.user_employee_2.partner_id
cls.test_records_simple, _partners = cls._create_records_for_batch(
'mail.test.simple', 3,
)
def setUp(self):
super().setUp()
# cleanup db
self.env['mail.notification'].search([('author_id', '=', self.partner_employee.id)]).unlink()
# handy shortcut variables
self.deleted_record = self.test_records_simple[2]
# generate crashed notifications
with mute_logger('odoo.addons.mail.models.mail_mail'), self.mock_mail_gateway():
def _send_email(*args, **kwargs):
raise MailDeliveryException("Some exception")
self.send_email_mocked.side_effect = _send_email
for record in self.test_records_simple.with_user(self.user_employee):
record.message_post(
body="Setup",
message_type='comment',
partner_ids=self.partner_employee_2.ids,
subtype_id=self.env.ref('mail.mt_comment').id,
)
# In the mean time, some FK deletes the record where the message is
# # scheduled, skipping its unlink() override
self.env.cr.execute(
f"DELETE FROM {self.test_records_simple._table} WHERE id = %s", (self.deleted_record.id,)
)
self.env.invalidate_all()
def test_assert_initial_values(self):
notifs_by_employee = self.env['mail.notification'].search([('author_id', '=', self.partner_employee.id)])
self.assertEqual(
set(notifs_by_employee.mapped('mail_message_id.res_id')),
set(self.test_records_simple.ids)
)
self.assertEqual(len(notifs_by_employee), 3)
self.assertTrue(all(notif.notification_status == 'exception' for notif in notifs_by_employee))
self.assertTrue(all(notif.res_partner_id == self.partner_employee_2 for notif in notifs_by_employee))
def test_load_message_failures(self):
self.authenticate(self.user_employee.login, self.user_employee.login)
with contextlib.suppress(Exception), mute_logger('odoo.http', 'odoo.sql_db'): # suppress logged error due to readonly route doing an update
result = self.make_jsonrpc_request("/mail/data", {"fetch_params": ["failures"]})
self.assertEqual(sorted(r['thread']['id'] for r in result['mail.message']), sorted(self.test_records_simple[:2].ids))
self.assertEqual(
sorted(self.env['mail.notification'].search([('author_id', '=', self.partner_employee.id)]).mapped('mail_message_id.res_id')),
sorted((self.test_records_simple - self.deleted_record).ids),
'Should have cleaned notifications linked to unexisting records'
)
def test_load_message_failures_use_display_name(self):
test_record = self.env['mail.test.simple.unnamed'].create({'description': 'Some description'})
test_record.message_subscribe(partner_ids=self.partner_employee_2.ids)
self.authenticate(self.user_employee.login, self.user_employee.password)
msg = test_record.message_post(body='Some body', author_id=self.partner_employee.id)
# simulate failure
self.env['mail.notification'].create({
'author_id': msg.author_id.id,
'mail_message_id': msg.id,
'res_partner_id': self.partner_employee_2.id,
'notification_type': 'email',
'notification_status': 'exception',
'failure_type': 'mail_email_invalid',
})
with contextlib.suppress(Exception), mute_logger('odoo.http', 'odoo.sql_db'): # suppress logged error due to readonly route doing an update
res = self.make_jsonrpc_request("/mail/data", {"fetch_params": ["failures"]})
self.assertEqual(
sorted(t["name"] for t in res["mail.thread"]),
sorted(['Some description'] + (self.test_records_simple - self.deleted_record).mapped('display_name'))
)
def test_message_fetch(self):
# set notifications to unread, so that we can simulate inbox usage
p2_notifications = self.env['mail.notification'].search([('res_partner_id', '=', self.partner_employee_2.id)])
p2_notifications.is_read = False
self.authenticate(self.user_employee_2.login, self.user_employee_2.login)
result = self.make_jsonrpc_request("/mail/inbox/messages", {})['data']
self.assertEqual(
{r['thread']['id'] if r['thread'] else False for r in result['mail.message']},
set((self.test_records_simple - self.deleted_record).ids + [False]),
'Currently reading message on missing record, crash avoided, void thread for missing record'
)
p2_notifications.with_user(self.user_employee_2).mail_message_id.set_message_done()
result = self.make_jsonrpc_request("/mail/history/messages", {})['data']
self.assertEqual(
{r['thread']['id'] if r['thread'] else False for r in result['mail.message']},
set((self.test_records_simple - self.deleted_record).ids + [False]),
'Currently reading message on missing record, crash avoided'
)
def test_message_link_by_employee(self):
record = self.test_records_simple[0]
thread_message = record.message_post(body='Thread Message', message_type='comment')
deleted_message = record.message_post(body='', message_type='comment')
self.authenticate(self.user_employee.login, self.user_employee.login)
with self.subTest(thread_message=thread_message):
expected_url = self.base_url() + f'/odoo/{thread_message.model}/{thread_message.res_id}?highlight_message_id={thread_message.id}'
res = self.url_open(f'/mail/message/{thread_message.id}')
self.assertEqual(res.url, expected_url)
with self.subTest(deleted_message=deleted_message):
res = self.url_open(f'/mail/message/{deleted_message.id}')
def test_notify_cancel_by_type(self):
""" Test canceling notifications, notably when having missing records. """
self.env.invalidate_all()
notifs_by_employee = self.env['mail.notification'].search([('author_id', '=', self.partner_employee.id)])
# do not crash even if removed record
self.test_records_simple.with_user(self.user_employee).notify_cancel_by_type('email')
self.env.invalidate_all()
notifs_by_employee = notifs_by_employee.exists()
self.assertEqual(len(notifs_by_employee), 3, 'Currently keep notifications for missing records')
self.assertTrue(all(notif.notification_status == 'canceled' for notif in notifs_by_employee))
@tagged("mail_message", "post_install", "-at_install")
class TestMessageValues(MailCommon):
@classmethod
def setUpClass(cls):
@ -60,24 +208,28 @@ class TestMessageValues(TestMailCommon):
self.assertFalse(message.sudo().tracking_value_ids)
# Reset body case
record._message_update_content(message, '<p><br /></p>', attachment_ids=message.attachment_ids.ids)
record._message_update_content(
message,
body=Markup("<p><br /></p>"),
attachment_ids=message.attachment_ids.ids,
)
self.assertTrue(is_html_empty(message.body))
self.assertFalse(message.sudo()._filter_empty(), 'Still having attachments')
# Subtype content
note_subtype.sudo().write({'description': 'Very important discussions'})
record._message_update_content(message, '', [])
record._message_update_content(message, body="", attachment_ids=[])
self.assertFalse(message.attachment_ids)
self.assertEqual(message.notified_partner_ids, self.partner_admin)
self.assertEqual(message.starred_partner_ids, self.partner_admin)
self.assertFalse(message.sudo()._filter_empty(), 'Subtype with description')
# Completely void now
# Completely emptied now
note_subtype.sudo().write({'description': ''})
self.assertEqual(message.sudo()._filter_empty(), message)
record._message_update_content(message, '', [])
self.assertFalse(message.notified_partner_ids)
self.assertFalse(message.starred_partner_ids)
record._message_update_content(message.sudo(), body="", attachment_ids=[])
self.assertEqual(message.notified_partner_ids, self.partner_admin) # message still notified (albeit content is removed)
self.assertEqual(message.starred_partner_ids, self.partner_admin) # starred messages stay (albeit content is removed)
# test tracking values
record.write({'user_id': self.user_admin.id})
@ -88,13 +240,13 @@ class TestMessageValues(TestMailCommon):
self.assertFalse(tracking_message.subtype_id.description)
self.assertFalse(tracking_message.sudo()._filter_empty(), 'Has tracking values')
with self.assertRaises(UserError, msg='Tracking values prevent from updating content'):
record._message_update_content(tracking_message, '', [])
record._message_update_content(tracking_message, body="", attachment_ids=[])
@mute_logger('odoo.models.unlink')
def test_mail_message_format_access(self):
def test_mail_message_to_store_access(self):
"""
User that doesn't have access to a record should still be able to fetch
the record_name inside message_format.
the record_name inside message _to_store.
"""
company_2 = self.env['res.company'].create({'name': 'Second Test Company'})
record1 = self.env['mail.test.multi.company'].create({
@ -104,25 +256,78 @@ class TestMessageValues(TestMailCommon):
message = record1.message_post(body='', partner_ids=[self.user_employee.partner_id.id])
# We need to flush and invalidate the ORM cache since the record_name
# is already cached from the creation. Otherwise it will leak inside
# message_format.
# message _to_store.
self.env.flush_all()
self.env.invalidate_all()
res = message.with_user(self.user_employee).message_format()
self.assertEqual(res[0].get('record_name'), 'Test1')
res = Store().add(message.with_user(self.user_employee)).get_result()
self.assertEqual(res["mail.message"][0].get("record_name"), "Test1")
record1.write({"name": "Test2"})
res = message.with_user(self.user_employee).message_format()
self.assertEqual(res[0].get('record_name'), 'Test2')
self.env.flush_all()
self.env.invalidate_all()
res = Store().add(message.with_user(self.user_employee)).get_result()
self.assertEqual(res["mail.message"][0].get('record_name'), 'Test2')
# check model not inheriting from mail.thread -> should not crash
record_nothread = self.env['mail.test.nothread'].create({'name': 'NoThread'})
message = self.env['mail.message'].create({
'model': record_nothread._name,
'res_id': record_nothread.id,
})
formatted = Store().add(message).get_result()["mail.message"][0]
self.assertEqual(formatted['record_name'], record_nothread.name)
def test_records_by_message(self):
record1 = self.env["mail.test.simple"].create({"name": "Test1"})
record2 = self.env["mail.test.simple"].create({"name": "Test1"})
record3 = self.env["mail.test.nothread"].create({"name": "Test2"})
messages = self.env["mail.message"].create(
[
{
"model": record._name,
"res_id": record.id,
}
for record in [record1, record2, record3]
]
)
# methods called on batch of message
records_by_model_name = messages._records_by_model_name()
test_simple_records = records_by_model_name["mail.test.simple"]
self.assertEqual(test_simple_records, record1 + record2)
self.assertEqual(test_simple_records._prefetch_ids, tuple((record1 + record2).ids))
test_no_thread_records = records_by_model_name["mail.test.nothread"]
self.assertEqual(test_no_thread_records, record3)
self.assertEqual(test_no_thread_records._prefetch_ids, tuple(record3.ids))
record_by_message = messages._record_by_message()
m0_records = record_by_message[messages[0]]
self.assertEqual(m0_records, record1)
self.assertEqual(m0_records._prefetch_ids, tuple((record1 + record2).ids))
m1_records = record_by_message[messages[1]]
self.assertEqual(m1_records, record2)
self.assertEqual(m1_records._prefetch_ids, tuple((record1 + record2).ids))
m2_records = record_by_message[messages[2]]
self.assertEqual(m2_records, record3)
self.assertEqual(m2_records._prefetch_ids, tuple(record3.ids))
# methods called on individual message from a batch: prefetch from batch is kept
records_by_model_name = next(iter(messages))._records_by_model_name()
test_simple_records = records_by_model_name["mail.test.simple"]
self.assertEqual(test_simple_records, record1)
self.assertEqual(test_simple_records._prefetch_ids, tuple((record1 + record2).ids))
record_by_message = next(iter(messages))._record_by_message()
m0_records = record_by_message[messages[0]]
self.assertEqual(m0_records, record1)
self.assertEqual(m0_records._prefetch_ids, tuple((record1 + record2).ids))
def test_mail_message_values_body_base64_image(self):
msg = self.env['mail.message'].with_user(self.user_employee).create({
'body': 'taratata <img src="data:image/png;base64,iV/+OkI=" width="2"> <img src="data:image/png;base64,iV/+OkI=" width="2">',
})
self.assertEqual(len(msg.attachment_ids), 1)
attachment = msg.attachment_ids[0]
self.assertEqual(
msg.body,
'<p>taratata <img src="/web/image/{attachment.id}?access_token={attachment.access_token}" alt="image0" width="2"> '
'<img src="/web/image/{attachment.id}?access_token={attachment.access_token}" alt="image0" width="2"></p>'.format(attachment=msg.attachment_ids[0])
f'<p>taratata <img src="/web/image/{attachment.id}?access_token={attachment.access_token}" alt="image0" data-attachment-id="{attachment.id}" width="2"> '
f'<img src="/web/image/{attachment.id}?access_token={attachment.access_token}" alt="image0" data-attachment-id="{attachment.id}" width="2"></p>'
)
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.models')
@ -134,7 +339,7 @@ class TestMessageValues(TestMailCommon):
+ commit linked to this test). """
# name would make it blow up: keep only email
test_record = self.env['mail.test.container'].browse(self.alias_record.ids)
test_record.write({
self.user_employee.write({
'name': 'Super Long Name That People May Enter "Even with an internal quoting of stuff"'
})
msg = self.env['mail.message'].create({
@ -145,40 +350,11 @@ class TestMessageValues(TestMailCommon):
self.assertEqual(msg.reply_to, reply_to_email,
'Reply-To: use only email when formataddr > 68 chars')
# name + company_name would make it blow up: keep record_name in formatting
self.company_admin.name = "Company name being about 33 chars"
test_record.write({'name': 'Name that would be more than 68 with company name'})
msg = self.env['mail.message'].create({
'model': test_record._name,
'res_id': test_record.id
})
self.assertEqual(msg.reply_to, formataddr((test_record.name, reply_to_email)),
'Reply-To: use recordname as name in format if recordname + company > 68 chars')
# no record_name: keep company_name in formatting if ok
test_record.write({'name': ''})
msg = self.env['mail.message'].create({
'model': test_record._name,
'res_id': test_record.id
})
self.assertEqual(msg.reply_to, formataddr((self.env.user.company_id.name, reply_to_email)),
'Reply-To: use company as name in format when no record name and still < 68 chars')
# no record_name and company_name make it blow up: keep only email
self.env.user.company_id.write({'name': 'Super Long Name That People May Enter "Even with an internal quoting of stuff"'})
msg = self.env['mail.message'].create({
'model': test_record._name,
'res_id': test_record.id
})
self.assertEqual(msg.reply_to, reply_to_email,
'Reply-To: use only email when formataddr > 68 chars')
# whatever the record and company names, email is too long: keep only email
test_record.write({
'alias_name': 'Waaaay too long alias name that should make any reply-to blow the 68 characters limit',
'name': 'Short',
})
self.env.user.company_id.write({'name': 'Comp'})
sanitized_alias_name = 'waaaay-too-long-alias-name-that-should-make-any-reply-to-blow-the-68-characters-limit'
msg = self.env['mail.message'].create({
'model': test_record._name,
@ -187,6 +363,7 @@ class TestMessageValues(TestMailCommon):
self.assertEqual(msg.reply_to, f"{sanitized_alias_name}@{self.alias_domain}",
'Reply-To: even a long email is ok as only formataddr is problematic')
@users('employee')
@mute_logger('odoo.models.unlink')
def test_mail_message_values_fromto_no_document_values(self):
msg = self.Message.create({
@ -197,32 +374,26 @@ class TestMessageValues(TestMailCommon):
self.assertEqual(msg.reply_to, 'test.reply@example.com')
self.assertEqual(msg.email_from, 'test.from@example.com')
@users('employee')
@mute_logger('odoo.models.unlink')
def test_mail_message_values_fromto_no_document(self):
msg = self.Message.create({})
self.assertIn('-private', msg.message_id.split('@')[0], 'mail_message: message_id for a void message should be a "private" one')
reply_to_name = self.env.user.company_id.name
reply_to_name = self.user_employee.name
reply_to_email = '%s@%s' % (self.alias_catchall, self.alias_domain)
self.assertEqual(msg.reply_to, formataddr((reply_to_name, reply_to_email)))
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
# no alias domain -> author
self.env['ir.config_parameter'].search([('key', '=', 'mail.catchall.domain')]).unlink()
msg = self.Message.create({})
self.assertIn('-private', msg.message_id.split('@')[0], 'mail_message: message_id for a void message should be a "private" one')
self.assertEqual(msg.reply_to, formataddr((self.user_employee.name, self.user_employee.email)))
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
# no alias catchall, no alias -> author
self.env['ir.config_parameter'].set_param('mail.catchall.domain', self.alias_domain)
self.env['ir.config_parameter'].search([('key', '=', 'mail.catchall.alias')]).unlink()
self.env.company.sudo().alias_domain_id = False
self.assertFalse(self.env.company.catchall_email)
msg = self.Message.create({})
self.assertIn('-private', msg.message_id.split('@')[0], 'mail_message: message_id for a void message should be a "private" one')
self.assertEqual(msg.reply_to, formataddr((self.user_employee.name, self.user_employee.email)))
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
@users('employee')
@mute_logger('odoo.models.unlink')
def test_mail_message_values_fromto_document_alias(self):
msg = self.Message.create({
@ -230,13 +401,15 @@ class TestMessageValues(TestMailCommon):
'res_id': self.alias_record.id
})
self.assertIn('-openerp-%d-mail.test' % self.alias_record.id, msg.message_id.split('@')[0])
reply_to_name = '%s %s' % (self.env.user.company_id.name, self.alias_record.name)
reply_to_name = self.user_employee.name
reply_to_email = '%s@%s' % (self.alias_record.alias_name, self.alias_domain)
self.assertEqual(msg.reply_to, formataddr((reply_to_name, reply_to_email)))
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
# no alias domain -> author
self.env['ir.config_parameter'].search([('key', '=', 'mail.catchall.domain')]).unlink()
# no alias domain, no company catchall -> author
self.alias_record.alias_domain_id = False
self.env.company.sudo().alias_domain_id = False
self.assertFalse(self.env.company.catchall_email)
msg = self.Message.create({
'model': 'mail.test.container',
@ -246,20 +419,18 @@ class TestMessageValues(TestMailCommon):
self.assertEqual(msg.reply_to, formataddr((self.user_employee.name, self.user_employee.email)))
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
# no catchall -> don't care, alias
self.env['ir.config_parameter'].set_param('mail.catchall.domain', self.alias_domain)
self.env['ir.config_parameter'].search([('key', '=', 'mail.catchall.alias')]).unlink()
# alias wins over company, hence no catchall is not an issue
self.alias_record.alias_domain_id = self.mail_alias_domain
msg = self.Message.create({
'model': 'mail.test.container',
'res_id': self.alias_record.id
})
self.assertIn('-openerp-%d-mail.test' % self.alias_record.id, msg.message_id.split('@')[0])
reply_to_name = '%s %s' % (self.env.company.name, self.alias_record.name)
reply_to_email = '%s@%s' % (self.alias_record.alias_name, self.alias_domain)
self.assertEqual(msg.reply_to, formataddr((reply_to_name, reply_to_email)))
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
@users('employee')
@mute_logger('odoo.models.unlink')
def test_mail_message_values_fromto_document_no_alias(self):
test_record = self.env['mail.test.simple'].create({'name': 'Test', 'email_from': 'ignasse@example.com'})
@ -269,17 +440,17 @@ class TestMessageValues(TestMailCommon):
'res_id': test_record.id
})
self.assertIn('-openerp-%d-mail.test.simple' % test_record.id, msg.message_id.split('@')[0])
reply_to_name = '%s %s' % (self.env.user.company_id.name, test_record.name)
reply_to_name = self.user_employee.name
reply_to_email = '%s@%s' % (self.alias_catchall, self.alias_domain)
self.assertEqual(msg.reply_to, formataddr((reply_to_name, reply_to_email)))
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
@users('employee')
@mute_logger('odoo.models.unlink')
def test_mail_message_values_fromto_document_manual_alias(self):
test_record = self.env['mail.test.simple'].create({'name': 'Test', 'email_from': 'ignasse@example.com'})
alias = self.env['mail.alias'].create({
alias = self.env['mail.alias'].sudo().create({
'alias_name': 'MegaLias',
'alias_user_id': False,
'alias_model_id': self.env['ir.model']._get('mail.test.simple').id,
'alias_parent_model_id': self.env['ir.model']._get('mail.test.simple').id,
'alias_parent_thread_id': test_record.id,
@ -291,11 +462,12 @@ class TestMessageValues(TestMailCommon):
})
self.assertIn('-openerp-%d-mail.test.simple' % test_record.id, msg.message_id.split('@')[0])
reply_to_name = '%s %s' % (self.env.user.company_id.name, test_record.name)
reply_to_name = self.user_employee.name
reply_to_email = '%s@%s' % (alias.alias_name, self.alias_domain)
self.assertEqual(msg.reply_to, formataddr((reply_to_name, reply_to_email)))
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
@users('employee')
def test_mail_message_values_fromto_reply_to_force_new(self):
msg = self.Message.create({
'model': 'mail.test.container',
@ -305,3 +477,8 @@ class TestMessageValues(TestMailCommon):
self.assertIn('reply_to', msg.message_id.split('@')[0])
self.assertNotIn('mail.test.container', msg.message_id.split('@')[0])
self.assertNotIn('-%d-' % self.alias_record.id, msg.message_id.split('@')[0])
def test_mail_message_values_misc(self):
""" Test various values on mail.message, notably default values """
msg = self.env['mail.message'].create({'model': self.alias_record._name, 'res_id': self.alias_record.id})
self.assertEqual(msg.message_type, 'comment', 'Message should be comments by default')

View file

@ -1,17 +1,18 @@
import base64
from markupsafe import Markup
from unittest.mock import patch
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo import SUPERUSER_ID
from odoo.addons.mail.tests.common import mail_new_test_user, MailCommon
from odoo.addons.test_mail.models.mail_test_access import MailTestAccess
from odoo.addons.test_mail.tests.common import TestMailCommon
from odoo.addons.test_mail.models.test_mail_models import MailTestSimple
from odoo.exceptions import AccessError
from odoo.tools import mute_logger
from odoo.tests import tagged
from odoo.tests import HttpCase, tagged
class MessageAccessCommon(TestMailCommon):
class MessageAccessCommon(MailCommon, HttpCase):
@classmethod
def setUpClass(cls):
@ -36,6 +37,13 @@ class MessageAccessCommon(TestMailCommon):
name='Chell Gladys',
)
cls.test_subtype_access_internal = cls.env['mail.message.subtype'].create([
{
'internal': True,
'name': 'Test Internal',
},
])
(
cls.record_public, cls.record_portal, cls.record_portal_ro,
cls.record_followers,
@ -133,7 +141,7 @@ class TestMailMessageAccess(MessageAccessCommon):
# - Criterions
# - "private message" (no model, no res_id) -> deprecated
# - follower of document
# - document-based (write or create, using '_get_mail_message_access'
# - document-based (write or create, using '_mail_get_operation_for_mail_message_operation'
# hence '_mail_post_access' by default)
# - notified of parent message
# ------------------------------------------------------------
@ -142,7 +150,7 @@ class TestMailMessageAccess(MessageAccessCommon):
def test_access_create(self):
""" Test 'group_user' creation rules """
# prepare 'notified of parent' condition
admin_msg = self.record_admin.message_ids[0]
admin_msg = self.record_admin.message_ids[-1]
admin_msg.write({'partner_ids': [(4, self.user_employee.partner_id.id)]})
# prepare 'followers' condition
@ -157,6 +165,7 @@ class TestMailMessageAccess(MessageAccessCommon):
(self.env["mail.test.access"], {}, False, 'Private message like is ok'),
# document based
(self.record_internal, {}, False, 'W Access on record'),
(self.record_internal, {'message_type': 'notification'}, False, 'W Access on record, notification does not change anything'),
(self.record_internal_ro, {}, True, 'No W Access on record'),
(self.record_admin, {}, True, 'No access on record (and not notified on first message)'),
(record_admin_fol, {
@ -168,26 +177,30 @@ class TestMailMessageAccess(MessageAccessCommon):
}, False, 'No access on record but reply to notified parent'),
]:
with self.subTest(record=record, msg_vals=msg_vals, reason=reason):
final_vals = dict(
{
'body': 'Test',
'message_type': 'comment',
'subtype_id': self.env.ref('mail.mt_comment').id,
}, **msg_vals
)
if should_crash:
with self.assertRaises(AccessError):
self.env['mail.message'].with_user(self.user_employee).create({
'model': record._name if record else False,
'res_id': record.id if record else False,
'body': 'Test',
**msg_vals,
**final_vals,
})
if record:
with self.assertRaises(AccessError):
record.with_user(self.user_employee).message_post(
body='Test',
subtype_id=self.env.ref('mail.mt_comment').id,
**final_vals,
)
else:
_message = self.env['mail.message'].with_user(self.user_employee).create({
'model': record._name if record else False,
'res_id': record.id if record else False,
'body': 'Test',
**msg_vals,
**final_vals,
})
if record:
# TDE note: due to parent_id flattening, doing message_post
@ -197,27 +210,57 @@ class TestMailMessageAccess(MessageAccessCommon):
if record == self.record_admin and 'parent_id' in msg_vals:
continue
record.with_user(self.user_employee).message_post(
body='Test',
subtype_id=self.env.ref('mail.mt_comment').id,
**msg_vals,
**final_vals,
)
def test_access_create_customized(self):
""" Test '_get_mail_message_access' support """
""" Test '_mail_get_operation_for_mail_message_operation' support """
record = self.env['mail.test.access.custo'].with_user(self.user_employee).create({'name': 'Open'})
for user in self.user_employee + self.user_portal:
_message = record.message_post(
body='A message',
subtype_id=self.env.ref('mail.mt_comment').id,
)
# lock -> see '_get_mail_message_access'
with self.subTest(user_name=user.name):
_message = record.with_user(user).message_post(
# attachments=[('Attachment', b'My attachment')], # FIXME
body='A message',
subtype_id=self.env.ref('mail.mt_comment').id,
)
# lock -> see '_mail_get_operation_for_mail_message_operation'
record.write({'is_locked': True})
record.message_unsubscribe(partner_ids=self.partner_employee.ids) # avoid acl conflict with those follower-based
record.invalidate_model()
for user in self.user_employee + self.user_portal:
with self.assertRaises(AccessError):
_message_portal = record.with_user(self.user_portal).message_post(
with self.subTest(user_name=user.name):
with self.assertRaises(AccessError):
_message = record.with_user(user).message_post(
body='Another portal message',
subtype_id=self.env.ref('mail.mt_comment').id,
)
# readonly -> "read" access sufficient on unlocked records, see '_mail_get_operation_for_mail_message_operation'
record.sudo().write({'is_locked': False, 'is_readonly': True})
record.invalidate_model()
for user in self.user_employee + self.user_portal:
with self.subTest(user_name=user.name):
# cannot write
with self.assertRaises(AccessError):
record.with_user(user).write({'name': 'Can Update'})
# can post
_message = record.with_user(user).message_post(
# attachments=[('Attachment', b'My attachment')], # FIXME
body='Another portal message',
subtype_id=self.env.ref('mail.mt_comment').id,
)
# controller check
self.authenticate(user.login, user.login)
res = self.make_jsonrpc_request(
route="/mail/message/post",
params={
'thread_model': record._name,
'thread_id': record.id,
'post_data': {
'body': "Test",
},
},
)['store_data']
self.assertEqual(len(res['mail.message']), 1)
def test_access_create_mail_post_access(self):
""" Test 'mail_post_access' support that allows creating a message with
@ -270,6 +313,13 @@ class TestMailMessageAccess(MessageAccessCommon):
(self.record_admin, {
'parent_id': admin_msg.id,
}, False, 'No access on record but reply to notified parent'),
# internal = forbidden (internal users only)
(self.record_portal, {'is_internal': True}, True, 'Internal subtype always forbidden'),
(self.record_portal, {'is_internal': True, 'message_type': 'notification'}, False, 'Automatic log accepted'),
(self.record_portal, {'subtype_id': self.env.ref('mail.mt_note').id}, True, 'Internal flag always forbidden'),
(self.record_portal, {'subtype_id': self.test_subtype_access_internal.id}, True, 'Internal flag (custom subtype) always forbidden'),
(self.record_portal, {'message_type': 'notification', 'subtype_id': self.test_subtype_access_internal.id}, False, 'Automatic log accepted'),
(self.record_portal, {'subtype_id': False}, True, 'No subtype = internal = always forbidden'),
]:
with self.subTest(record=record, msg_vals=msg_vals, reason=reason):
if should_crash:
@ -278,6 +328,8 @@ class TestMailMessageAccess(MessageAccessCommon):
'model': record._name if record else False,
'res_id': record.id if record else False,
'body': 'Test',
'message_type': 'comment',
'subtype_id': self.env.ref('mail.mt_comment').id,
**msg_vals,
})
else:
@ -285,6 +337,8 @@ class TestMailMessageAccess(MessageAccessCommon):
'model': record._name if record else False,
'res_id': record.id if record else False,
'body': 'Test',
'message_type': 'comment',
'subtype_id': self.env.ref('mail.mt_comment').id,
**msg_vals,
})
@ -294,6 +348,7 @@ class TestMailMessageAccess(MessageAccessCommon):
'model': self.record_portal._name,
'res_id': self.record_portal.id,
'body': 'Test',
'subtype_id': self.env.ref('mail.mt_comment').id,
})
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
@ -329,18 +384,18 @@ class TestMailMessageAccess(MessageAccessCommon):
test_record.message_subscribe((partner_1 | self.user_admin.partner_id).ids)
message = test_record.message_post(
body='<p>This is First Message</p>',
body=Markup('<p>This is First Message</p>'),
message_type='comment',
subject='Subject',
subtype_xmlid='mail.mt_note',
)
# portal user have no rights to read the message
with self.assertRaises(AccessError):
message.with_user(self.user_portal).read(['subject, body'])
message.with_user(self.user_portal).read(['subject', 'body'])
with patch.object(MailTestSimple, 'check_access_rights', return_value=True):
with patch.object(MailTestSimple, '_check_access', return_value=None):
with self.assertRaises(AccessError):
message.with_user(self.user_portal).read(['subject, body'])
message.with_user(self.user_portal).read(['subject', 'body'])
# parent message is accessible to references notification mail values
# for _notify method and portal user have no rights to send the message for this model
@ -368,8 +423,9 @@ class TestMailMessageAccess(MessageAccessCommon):
# READ
# - Criterions
# - author
# - creator (might post on behalf of someone else)
# - recipients / notified
# - document-based: read, using '_get_mail_message_access'
# - document-based: read, using '_mail_get_operation_for_mail_message_operation'
# - share users: limited to 'not internal' (flag or subtype)
# ------------------------------------------------------------
@ -384,6 +440,9 @@ class TestMailMessageAccess(MessageAccessCommon):
(self.record_admin.message_ids[0], {
'author_id': self.user_employee.partner_id.id,
}, False, 'Author > no access on record'),
(self.record_admin.message_ids[0], {
'create_uid': self.user_employee.id,
}, False, 'Creator > no access on record'),
# notified
(self.record_admin.message_ids[0], {
'notification_ids': [(0, 0, {
@ -400,7 +459,12 @@ class TestMailMessageAccess(MessageAccessCommon):
'parent_id': msg.parent_id.id,
}
with self.subTest(msg=msg, reason=reason):
if msg_vals:
if 'create_uid' in msg_vals:
self.patch(self.env.registry, 'ready', False)
msg.with_user(SUPERUSER_ID).write(msg_vals)
self.patch(self.env.registry, 'ready', True)
self.assertEqual(msg.create_uid.id, msg_vals['create_uid'])
elif msg_vals:
msg.write(msg_vals)
if should_crash:
with self.assertRaises(AccessError):
@ -410,6 +474,35 @@ class TestMailMessageAccess(MessageAccessCommon):
if msg_vals:
msg.write(original_vals)
def test_access_read_customized(self):
""" Test '_mail_get_operation_for_mail_message_operation' support """
records = self.env['mail.test.access.custo'].with_user(self.user_admin).create([
{'name': 'Open'},
{'name': 'Open RO', 'is_readonly': True},
{'is_locked': True, 'name': 'Locked'},
])
messages_all = self.env['mail.message']
for record in records:
messages_all += record.with_user(self.user_admin).message_post(
body=f'AnchorForTest / A message from {self.user_admin.name} on {record.name}',
subtype_id=self.env.ref('mail.mt_comment').id,
)
# lock -> see '_mail_get_operation_for_mail_message_operation', cannot read locked message
# without write access, with is not granted for employees
with self.assertRaises(AccessError): # write access not granted on locked -> cannot read message
messages_all[2].with_user(self.user_employee).read(['subject'])
with self.assertRaises(AccessError): # also working in case of batch ok / not ok
messages_all.with_user(self.user_employee).read(['subject'])
messages_all[0].with_user(self.user_employee).read(['subject'])
messages_all[1].with_user(self.user_employee).read(['subject']) # can read message of readonly
with self.assertRaises(AccessError): # fetch should be symmetric to read
_message = messages_all[2].with_user(self.user_employee).copy_data()
with self.assertRaises(AccessError): # no write access at all
messages_all.with_user(self.user_portal).read(['subject'])
messages_all.with_user(self.user_admin).read(['subject'])
def test_access_read_portal(self):
""" Read access check for portal users """
for msg, msg_vals, should_crash, reason in [
@ -426,17 +519,36 @@ class TestMailMessageAccess(MessageAccessCommon):
'res_partner_id': self.user_portal.partner_id.id,
})],
}, False, 'Notified > no access on record'),
# forbidden
# forbidden: internal (subtype / message)
(self.record_portal.message_ids[0], {
'subtype_id': self.env.ref('mail.mt_note').id,
}, True, 'Note cannot be read by portal users'),
}, True, 'Note (comment) cannot be read by portal users'),
(self.record_portal.message_ids[0], {
'subtype_id': self.test_subtype_access_internal.id,
}, True, 'Internal subtype (comment) cannot be read by portal users'),
(self.record_portal.message_ids[0], {
'message_type': 'email_outgoing',
'subtype_id': self.env.ref('mail.mt_note').id,
}, False, 'Note (email_outgoing) can be read by portal users'),
(self.record_portal.message_ids[0], {
'subtype_id': False,
}, True, 'Pure log (no subtype, even comment) cannot be read by portal users'),
(self.record_portal.message_ids[0], {
'is_internal': True,
}, True, 'Internal message cannot be read by portal users'),
}, True, 'Internal message (comment) cannot be read by portal users'),
(self.record_portal.message_ids[0], {
'is_internal': True,
'message_type': 'notification',
}, False, 'Internal message (notification) can be read by portal users'),
# forbidden: other
(self.record_portal.message_ids[0], {
'message_type': 'user_notification',
}, True, 'User notifications for other people can never be read by portal users'),
]:
original_vals = {
'author_id': msg.author_id.id,
'is_internal': False,
'message_type': msg.message_type,
'notification_ids': [(6, 0, {})],
'parent_id': msg.parent_id.id,
'subtype_id': self.env.ref('mail.mt_comment').id,
@ -444,6 +556,8 @@ class TestMailMessageAccess(MessageAccessCommon):
with self.subTest(msg=msg, reason=reason):
if msg_vals:
msg.write(msg_vals)
self.env.invalidate_all()
if should_crash:
with self.assertRaises(AccessError):
msg.with_user(self.user_portal).read(['body'])
@ -451,6 +565,7 @@ class TestMailMessageAccess(MessageAccessCommon):
msg.with_user(self.user_portal).read(['body'])
if msg_vals:
msg.write(original_vals)
self.env.invalidate_all()
def test_access_read_public(self):
""" Read access check for public users """
@ -479,6 +594,7 @@ class TestMailMessageAccess(MessageAccessCommon):
original_vals = {
'author_id': msg.author_id.id,
'is_internal': False,
'message_type': msg.message_type,
'notification_ids': [(6, 0, {})],
'parent_id': msg.parent_id.id,
'subtype_id': self.env.ref('mail.mt_comment').id,
@ -486,6 +602,8 @@ class TestMailMessageAccess(MessageAccessCommon):
with self.subTest(msg=msg, reason=reason):
if msg_vals:
msg.write(msg_vals)
self.env.invalidate_all()
if should_crash:
with self.assertRaises(AccessError):
msg.with_user(self.user_public).read(['body'])
@ -493,10 +611,11 @@ class TestMailMessageAccess(MessageAccessCommon):
msg.with_user(self.user_public).read(['body'])
if msg_vals:
msg.write(original_vals)
self.env.invalidate_all()
# ------------------------------------------------------------
# UNLINK
# - Criterion: document-based (write or create), using '_get_mail_message_access'
# - Criterion: document-based (write or create), using '_mail_get_operation_for_mail_message_operation'
# ------------------------------------------------------------
def test_access_unlink(self):
@ -548,7 +667,7 @@ class TestMailMessageAccess(MessageAccessCommon):
# - Criterions
# - author
# - recipients / notified
# - document-based (write or create), using '_get_mail_message_access'
# - document-based (write or create), using '_mail_get_operation_for_mail_message_operation'
# ------------------------------------------------------------
def test_access_write(self):
@ -590,13 +709,12 @@ class TestMailMessageAccess(MessageAccessCommon):
""" Test updating message envelope require some privileges """
message = self.record_internal.with_user(self.user_employee).message_ids[0]
message.write({'body': 'Update Me'})
# To change in 18+
message.write({'model': 'res.partner'})
message.sudo().write({'model': self.record_internal._name}) # back to original model
with self.assertRaises(AccessError):
message.write({'model': 'res.partner'})
# To change in 18+
message.write({'partner_ids': [(4, self.user_portal_2.partner_id.id)]})
# To change in 18+
message.write({'res_id': self.record_public.id})
with self.assertRaises(AccessError):
message.write({'res_id': self.record_public.id})
# To change in 18+
message.write({'notification_ids': [
(0, 0, {'res_partner_id': self.user_portal_2.partner_id.id})
@ -672,12 +790,26 @@ class TestMailMessageAccess(MessageAccessCommon):
res_id=self.record_portal.id,
subtype_id=self.ref('mail.mt_comment'),
))
msg_record_portal_internal = self.env['mail.message'].create(dict(base_msg_vals,
body='Internal Comment on Portal',
is_internal=True,
model=self.record_portal._name,
res_id=self.record_portal.id,
subtype_id=self.ref('mail.mt_comment'),
))
msg_record_public = self.env['mail.message'].create(dict(base_msg_vals,
body='Public Comment',
model=self.record_public._name,
res_id=self.record_public.id,
subtype_id=self.ref('mail.mt_comment'),
))
msg_record_public_internal = self.env['mail.message'].create(dict(base_msg_vals,
body='Internal Comment on Public',
is_internal=True,
model=self.record_public._name,
res_id=self.record_public.id,
subtype_id=self.ref('mail.mt_comment'),
))
for (test_user, add_domain), exp_messages in zip([
(self.user_public, []),
@ -686,16 +818,60 @@ class TestMailMessageAccess(MessageAccessCommon):
(self.user_employee, [('body', 'ilike', 'Internal')]),
(self.user_admin, []),
], [
# public: record with access
msg_record_public,
# portal: mentionned + record with access, if published
msgs[0] + msgs[3] + msg_record_portal + msg_record_public,
msgs[1:6] + msg_record_portal + msg_record_public,
msgs[1:6],
msgs[1:] + msg_record_admin + msg_record_portal + msg_record_public
# employee
msgs[1:6] + msg_record_portal + msg_record_portal_internal + msg_record_public + msg_record_public_internal,
msgs[1:6] + msg_record_portal_internal + msg_record_public_internal,
msgs[1:] + msg_record_admin + msg_record_portal + msg_record_portal_internal + msg_record_public + msg_record_public_internal,
]):
with self.subTest(test_user=test_user.name, add_domain=add_domain):
self.env.invalidate_all()
domain = [('subject', 'like', '_ZTest')] + add_domain
self.assertEqual(self.env['mail.message'].with_user(test_user).search(domain), exp_messages)
def test_search_customized(self):
""" Test '_mail_get_operation_for_mail_message_operation' support in search """
records = self.env['mail.test.access.custo'].with_user(self.user_admin).create([
{'name': 'Open'},
{'name': 'Open RO', 'is_readonly': True}, # internal can read thus search
{'name': 'Soonish Locked'},
])
messages_all = self.env['mail.message'].sudo()
for user in self.user_employee + self.user_portal:
for record in records:
new = record.message_post(
body=f'AnchorForSearch / A message from {user.name}',
subtype_id=self.env.ref('mail.mt_comment').id,
)
messages_all += new.sudo()
found_emp = self.env['mail.message'].with_user(self.user_employee).search([
('body', 'ilike', 'AnchorForSearch')
])
self.assertEqual(found_emp, messages_all)
found_por = self.env['mail.message'].with_user(self.user_portal).search([
('body', 'ilike', 'AnchorForSearch')
])
self.assertEqual(found_por, messages_all)
# lock -> locked records need 'write' access, as defined in '_mail_get_operation_for_mail_message_operation'
# hence messages are out of search, symmetrical to reading therm
records[2].write({'is_locked': True, 'name': 'Locked !'})
records[2].flush_recordset()
found_emp = self.env['mail.message'].with_user(self.user_employee).search([
('body', 'ilike', 'AnchorForSearch')
])
self.assertEqual(found_emp, messages_all.filtered(lambda m: m.res_id != records[2].id), 'Should filter like read')
found_emp.read(['subject'])
found_por = self.env['mail.message'].with_user(self.user_portal).search([
('body', 'ilike', 'AnchorForSearch')
])
self.assertEqual(found_por, messages_all.filtered(lambda m: m.res_id != records[2].id), 'Should filter like read')
found_por.read(['subject'])
@tagged('mail_message', 'security', 'post_install', '-at_install')
class TestMessageSubModelAccess(MessageAccessCommon):
@ -762,9 +938,11 @@ class TestMessageSubModelAccess(MessageAccessCommon):
with self.assertRaises(AccessError):
notif_own.write({'res_partner_id': self.user_admin.partner_id.id})
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
def test_mail_notification_portal(self):
""" In any case, portal should not modify notifications """
self.assertFalse(self.env['mail.notification'].with_user(self.user_portal).check_access_rights('write', raise_exception=False))
with self.assertRaises(AccessError):
self.assertFalse(self.env['mail.notification'].with_user(self.user_portal).check_access('write'))
portal_record = self.record_portal.with_user(self.user_portal)
message = portal_record.message_post(
body='Hello People',
@ -776,3 +954,17 @@ class TestMessageSubModelAccess(MessageAccessCommon):
self.assertEqual(len(notifications), 2)
self.assertTrue(bool(notifications.read(['is_read'])), 'Portal can read')
self.assertEqual(notifications.res_partner_id, self.user_portal_2.partner_id + self.user_employee.partner_id)
internal_record = self.record_internal.with_user(self.user_admin)
message = internal_record.message_post(
body='Hello People',
message_type='comment',
partner_ids=self.user_employee.partner_id.ids,
subtype_id=self.env.ref('mail.mt_comment').id,
)
notifications = message.notification_ids.with_user(self.user_portal)
with self.assertRaises(
AccessError,
msg="Portal cannot read notifications unless they are the recipient or the author"
):
notifications.read(['is_read'])

View file

@ -1,28 +1,28 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import json
import socket
from itertools import product
from freezegun import freeze_time
from unittest.mock import patch
from werkzeug.urls import url_parse, url_decode
from werkzeug.urls import url_parse
from odoo.addons.mail.models.mail_message import Message
from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients
from odoo.exceptions import AccessError, UserError
from odoo.addons.mail.models.mail_message import MailMessage
from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user
from odoo.addons.test_mail.models.test_mail_corner_case_models import MailTestMultiCompanyWithActivity
from odoo.addons.test_mail.tests.common import TestRecipients
from odoo.exceptions import AccessError
from odoo.tests import tagged, users, HttpCase
from odoo.tools import formataddr, mute_logger
from odoo.tests.common import JsonRpcException
from odoo.tools import mute_logger
@tagged('multi_company')
class TestMultiCompanySetup(TestMailCommon, TestRecipients):
class TestMailMCCommon(MailCommon, TestRecipients):
@classmethod
def setUpClass(cls):
super(TestMultiCompanySetup, cls).setUpClass()
cls._activate_multi_company()
super().setUpClass()
cls.test_model = cls.env['ir.model']._get('mail.test.gateway')
cls.email_from = '"Sylvie Lelitre" <test.sylvie.lelitre@agrolait.com>'
@ -38,17 +38,16 @@ class TestMultiCompanySetup(TestMailCommon, TestRecipients):
'company_id': cls.user_employee_c2.company_id.id},
])
cls.company_3 = cls.env['res.company'].create({'name': 'ELIT'})
cls.partner_1 = cls.env['res.partner'].with_context(cls._test_context).create({
'name': 'Valid Lelitre',
'email': 'valid.lelitre@agrolait.com',
})
# groups@.. will cause the creation of new mail.test.gateway
cls.alias = cls.env['mail.alias'].create({
'alias_name': 'groups',
'alias_user_id': False,
cls.mail_alias = cls.env['mail.alias'].create({
'alias_contact': 'everyone',
'alias_model_id': cls.test_model.id,
'alias_contact': 'everyone'})
'alias_name': 'groups',
})
# Set a first message on public group to test update and hierarchy
cls.fake_email = cls.env['mail.message'].create({
@ -61,11 +60,23 @@ class TestMultiCompanySetup(TestMailCommon, TestRecipients):
'message_id': '<123456-openerp-%s-mail.test.gateway@%s>' % (cls.test_record.id, socket.gethostname()),
})
cls._create_portal_user()
cls.user_portal_c2 = mail_new_test_user(
cls.env,
groups='base.group_portal',
login='portal_user_c2',
company_id=cls.company_2.id,
name="Portal User C2",
)
def setUp(self):
super(TestMultiCompanySetup, self).setUp()
super().setUp()
# patch registry to simulate a ready environment
self.patch(self.env.registry, 'ready', True)
self.flush_tracking()
@tagged('multi_company')
class TestMultiCompanySetup(TestMailMCCommon, HttpCase):
@users('employee_c2')
@mute_logger('odoo.addons.base.models.ir_rule')
@ -85,26 +96,35 @@ class TestMultiCompanySetup(TestMailCommon, TestRecipients):
with self.assertRaises(AccessError):
test_record_c1.write({'name': 'Cannot Write'})
first_attachment = self.env['ir.attachment'].create({
'company_id': self.user_employee_c2.company_id.id,
'datas': base64.b64encode(b'First attachment'),
'mimetype': 'text/plain',
'name': 'TestAttachmentIDS.txt',
'res_model': 'mail.compose.message',
'res_id': 0,
})
message = test_record_c1.message_post(
attachments=[('testAttachment', b'Test attachment')],
attachments=[('testAttachment', b'First attachment')],
attachment_ids=first_attachment.ids,
body='My Body',
message_type='comment',
subtype_xmlid='mail.mt_comment',
)
self.assertEqual(message.attachment_ids.mapped('name'), ['testAttachment'])
first_attachment = message.attachment_ids
self.assertTrue('testAttachment' in message.attachment_ids.mapped('name'))
self.assertEqual(test_record_c1.message_main_attachment_id, first_attachment)
new_attach = self.env['ir.attachment'].create({
'company_id': self.user_employee_c2.company_id.id,
'datas': base64.b64encode(b'Test attachment'),
'datas': base64.b64encode(b'Second attachment'),
'mimetype': 'text/plain',
'name': 'TestAttachmentIDS.txt',
'res_model': 'mail.compose.message',
'res_id': 0,
})
message = test_record_c1.message_post(
attachments=[('testAttachment', b'Test attachment')],
attachments=[('testAttachment', b'Second attachment')],
attachment_ids=new_attach.ids,
body='My Body',
message_type='comment',
@ -129,19 +149,19 @@ class TestMultiCompanySetup(TestMailCommon, TestRecipients):
# Other company (no access)
# ------------------------------------------------------------
_original_car = Message.check_access_rule
with patch.object(Message, 'check_access_rule',
_original_car = MailMessage._check_access
with patch.object(MailMessage, '_check_access',
autospec=True, side_effect=_original_car) as mock_msg_car:
with self.assertRaises(AccessError):
test_records_mc_c1.message_post(
body='<p>Hello</p>',
force_record_name='CustomName', # avoid ACL on display_name
message_type='comment',
record_name='CustomName', # avoid ACL on display_name
reply_to='custom.reply.to@test.example.com', # avoid ACL in notify_get_reply_to
subtype_xmlid='mail.mt_comment',
)
self.assertEqual(mock_msg_car.call_count, 1,
'Purpose is to raise at msg check access level')
self.assertEqual(mock_msg_car.call_count, 2,
'Check at model level succeeds and check at record level fails')
with self.assertRaises(AccessError):
_name = test_records_mc_c1.name
@ -163,26 +183,17 @@ class TestMultiCompanySetup(TestMailCommon, TestRecipients):
# now able to post as was notified of parent message
test_records_mc_c1.message_post(
body='<p>Hello</p>',
force_record_name='CustomName', # avoid ACL on display_name
message_type='comment',
parent_id=initial_message.id,
record_name='CustomName', # avoid ACL on display_name
reply_to='custom.reply.to@test.example.com', # avoid ACL in notify_get_reply_to
subtype_xmlid='mail.mt_comment',
)
# now able to post as was notified of parent message
attachments = self.env['ir.attachment'].create(
self._generate_attachments_data(
2, 'mail.compose.message', 0,
prefix='Other'
)
)
# record_name and reply_to may generate ACLs issues when computed by
# 'message_post' but should not, hence not specifying them to be sure
# testing the complete flow
test_records_mc_c1.message_post(
attachments=attachments_data,
attachment_ids=attachments.ids,
body='<p>Hello</p>',
message_type='comment',
parent_id=initial_message.id,
@ -204,8 +215,8 @@ class TestMultiCompanySetup(TestMailCommon, TestRecipients):
attachments=attachments_data,
attachment_ids=attachments.ids,
body='<p>Hello</p>',
force_record_name='CustomName', # avoid ACL on display_name
message_type='comment',
record_name='CustomName', # avoid ACL on display_name
reply_to='custom.reply.to@test.example.com', # avoid ACL in notify_get_reply_to
subtype_xmlid='mail.mt_comment',
)
@ -238,71 +249,106 @@ class TestMultiCompanySetup(TestMailCommon, TestRecipients):
attachments=attachments_data,
attachment_ids=attachments.ids,
body='<p>Hello</p>',
force_record_name='CustomName', # avoid ACL on display_name
message_type='comment',
record_name='CustomName', # avoid ACL on display_name
reply_to='custom.reply.to@test.example.com', # avoid ACL in notify_get_reply_to
subtype_xmlid='mail.mt_comment',
)
def test_systray_get_activities(self):
self.env["mail.activity"].search([]).unlink()
user_admin = self.user_admin.with_user(self.user_admin)
test_records = self.env["mail.test.multi.company.with.activity"].create(
[
{"name": "Test1", "company_id": user_admin.company_id.id},
{"name": "Test2", "company_id": self.company_2.id},
]
)
test_records[0].activity_schedule("test_mail.mail_act_test_todo", user_id=user_admin.id)
test_records[1].activity_schedule("test_mail.mail_act_test_todo", user_id=user_admin.id)
test_activity = next(
a for a in user_admin.systray_get_activities()
if a['model'] == 'mail.test.multi.company.with.activity'
)
self.assertEqual(
test_activity,
{
"actions": [{"icon": "fa-clock-o", "name": "Summary"}],
"icon": "/base/static/description/icon.png",
"id": self.env["ir.model"]._get_id("mail.test.multi.company.with.activity"),
"model": "mail.test.multi.company.with.activity",
"name": "Test Multi Company Mail With Activity",
"overdue_count": 0,
"planned_count": 0,
"today_count": 2,
"total_count": 2,
"type": "activity",
}
)
test_activity = next(
a for a in user_admin.with_context(allowed_company_ids=[self.company_2.id]).systray_get_activities()
if a['model'] == 'mail.test.multi.company.with.activity'
)
self.assertEqual(
test_activity,
{
"actions": [{"icon": "fa-clock-o", "name": "Summary"}],
"icon": "/base/static/description/icon.png",
"id": self.env["ir.model"]._get_id("mail.test.multi.company.with.activity"),
"model": "mail.test.multi.company.with.activity",
"name": "Test Multi Company Mail With Activity",
"overdue_count": 0,
"planned_count": 0,
"today_count": 1,
"total_count": 1,
"type": "activity",
}
)
def test_recipients_multi_company(self):
"""Test mentioning a partner with no common company."""
test_records_mc_c2 = self.test_records_mc[1]
with self.assertBus([(self.cr.dbname, "res.partner", self.user_employee_c3.partner_id.id)]):
test_records_mc_c2.with_user(self.user_employee_c2).with_context(
allowed_company_ids=self.company_2.ids
).message_post(
body="Hello @Freudenbergerg",
message_type="comment",
partner_ids=self.user_employee_c3.partner_id.ids,
subtype_xmlid="mail.mt_comment",
)
@tagged('-at_install', 'post_install', 'multi_company', 'mail_controller')
class TestMultiCompanyRedirect(TestMailCommon, HttpCase):
class TestMultiCompanyControllers(TestMailMCCommon, HttpCase):
@classmethod
def setUpClass(cls):
super(TestMultiCompanyRedirect, cls).setUpClass()
cls._activate_multi_company()
@mute_logger('odoo.http')
def test_mail_thread_data(self):
""" Test returned thread data, in MC environment, to test notably MC
access issues on partner, ACL support, ... """
customer_c3 = self.env["res.partner"].create({
"company_id": self.company_3.id,
"name": "C3 Customer",
})
record = self.env["mail.test.multi.company.read"].with_user(self.user_employee_c2).create({
"company_id": self.user_employee_c2.company_id.id,
"name": "Multi Company Record",
})
self.assertEqual(record.company_id, self.company_2)
record.message_subscribe(partner_ids=customer_c3.ids)
with self.assertRaises(AccessError):
customer_c3.with_user(self.user_employee_c2).check_access("read")
self.authenticate(self.user_employee_c2.login, self.user_employee_c2.login)
result = self.make_jsonrpc_request(
"/mail/data", {"fetch_params": [["mail.thread", {
"thread_id": record.id,
"thread_model": record._name,
"request_list": ["followers"],
}]]},
)
self.assertEqual(len(result["mail.followers"]), 2)
self.assertEqual(result["mail.followers"][0]["partner_id"], customer_c3.id)
self.assertEqual(result["mail.thread"][0]["followersCount"], 2)
self.assertTrue(result["mail.thread"][0]["hasWriteAccess"])
self.assertTrue(result["mail.thread"][0]["hasReadAccess"])
self.assertTrue(result["mail.thread"][0]["canPostOnReadonly"])
# check read / write / post access info
for test_user, (has_w, has_r, can_post) in zip(
(self.user_portal, self.user_portal_c2, self.user_employee, self.user_admin),
(
(False, True, True), # currently not really supported actually, should go through portal controllers
(False, True, True), # currently not really supported actually, should go through portal controllers
(False, True, True),
(True, True, True),
),
):
with self.subTest(user_name=test_user.name):
self.authenticate(test_user.login, test_user.login)
# crash if calling using portal users -> dedicated portal routes currently
if test_user in self.user_portal + self.user_portal_c2:
with self.assertRaises(JsonRpcException):
result = self.make_jsonrpc_request(
"/mail/data", {"fetch_params": [["mail.thread", {
"thread_id": record.id,
"thread_model": record._name,
"request_list": ["followers"],
}]]},
)
else:
result = self.make_jsonrpc_request(
"/mail/data", {"fetch_params": [["mail.thread", {
"thread_id": record.id,
"thread_model": record._name,
"request_list": ["followers"],
}]]},
)
self.assertEqual(result["mail.thread"][0]["followersCount"], 2)
self.assertEqual(result["mail.thread"][0]["hasWriteAccess"], has_w)
self.assertEqual(result["mail.thread"][0]["hasReadAccess"], has_r)
self.assertEqual(result["mail.thread"][0]["canPostOnReadonly"], can_post)
record.with_user(self.user_admin).message_post(
body='Hello!',
message_type='comment',
subtype_xmlid='mail.mt_comment',
partner_ids=[self.partner_employee_c2.id, customer_c3.id],
)
self.authenticate(self.user_employee_c2.login, self.user_employee_c2.login)
messages = self.make_jsonrpc_request("/mail/inbox/messages")
self.assertEqual(len(messages['data']['mail.message']), 1)
def test_redirect_to_records(self):
""" Test mail/view redirection in MC environment, notably cids being
@ -336,8 +382,7 @@ class TestMultiCompanyRedirect(TestMailCommon, HttpCase):
if not login:
path = url_parse(response.url).path
self.assertEqual(path, '/web/login')
decoded_fragment = url_decode(url_parse(response.url).fragment)
self.assertNotIn("cids", decoded_fragment)
self.assertNotIn('cids', response.request._cookies)
else:
user = self.env['res.users'].browse(self.session.uid)
self.assertEqual(user.login, login)
@ -346,19 +391,46 @@ class TestMultiCompanyRedirect(TestMailCommon, HttpCase):
# Logged into company main, try accessing record in other
# company -> _redirect_to_record should redirect to
# messaging as the user doesn't have any access
fragment = url_parse(response.url).fragment
action = url_decode(fragment)['action']
self.assertEqual(action, 'mail.action_discuss')
parsed_url = url_parse(response.url)
self.assertEqual(parsed_url.path, '/odoo/action-mail.action_discuss')
else:
# Logged into company main, try accessing record in same
# company -> _redirect_to_record should add company in
# allowed_company_ids
fragment = url_parse(response.url).fragment
cids = url_decode(fragment)['cids']
cids = response.request._cookies.get('cids')
if mc_record.company_id == user.company_id:
self.assertEqual(cids, f'{mc_record.company_id.id}')
else:
self.assertEqual(cids, f'{user.company_id.id},{mc_record.company_id.id}')
self.assertEqual(cids, f'{user.company_id.id}-{mc_record.company_id.id}')
def test_multi_redirect_to_records(self):
""" Test mail/view redirection in MC environment, notably test a user that is
redirected multiple times, the cids needed to access the record are being added
recursivelly when in redirect."""
mc_records = self.env['mail.test.multi.company'].create([
{
'company_id': self.user_employee.company_id.id,
'name': 'Multi Company Record',
},
{
'company_id': self.user_employee_c2.company_id.id,
'name': 'Multi Company Record',
}
])
self.authenticate(self.user_admin.login, self.user_admin.login)
companies = []
for mc_record in mc_records:
with self.subTest(mc_record=mc_record):
response = self.url_open(
f'/mail/view?model={mc_record._name}&res_id={mc_record.id}',
timeout=15
)
self.assertEqual(response.status_code, 200)
cids = response.request._cookies.get('cids')
companies.append(str(mc_record.company_id.id))
self.assertEqual(cids, '-'.join(companies))
def test_redirect_to_records_nothread(self):
""" Test no thread models and redirection """
@ -372,10 +444,10 @@ class TestMultiCompanyRedirect(TestMailCommon, HttpCase):
# when being logged, cids should be based on current user's company unless
# there is an access issue (not tested here, see 'test_redirect_to_records')
self.authenticate(self.user_admin.login, self.user_admin.login)
for test_record in nothreads:
for user_company in self.company_admin, self.company_2:
with self.subTest(record_name=test_record.name, user_company=user_company):
self.authenticate(self.user_admin.login, self.user_admin.login)
self.user_admin.write({'company_id': user_company.id})
response = self.url_open(
f'/mail/view?model={test_record._name}&res_id={test_record.id}',
@ -383,9 +455,8 @@ class TestMultiCompanyRedirect(TestMailCommon, HttpCase):
)
self.assertEqual(response.status_code, 200)
decoded_fragment = url_decode(url_parse(response.url).fragment)
self.assertTrue("cids" in decoded_fragment)
self.assertEqual(decoded_fragment['cids'], str(user_company.id))
self.assertTrue('cids' in response.request._cookies)
self.assertEqual(response.request._cookies.get('cids'), str(user_company.id))
# when being not logged, cids should not be added as redirection after
# logging will be 'mail/view' again
@ -397,40 +468,41 @@ class TestMultiCompanyRedirect(TestMailCommon, HttpCase):
timeout=15
)
self.assertEqual(response.status_code, 200)
decoded_fragment = url_decode(url_parse(response.url).fragment)
self.assertNotIn('cids', decoded_fragment)
self.assertNotIn('cids', response.request._cookies)
def test_mail_message_post_other_company_with_cids(self):
"""
Ensure that a user can post a message on a thread belonging to another
company when:
@tagged("-at_install", "post_install", "multi_company", "mail_controller")
class TestMultiCompanyThreadData(TestMailCommon, HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._activate_multi_company()
- The user has access to both companies via `company_ids`.
- The active company context only includes the other company.
- The target record belongs to a different company than the active one.
def test_mail_thread_data_follower(self):
partner_portal = self.env["res.partner"].create(
{"company_id": self.company_3.id, "name": "portal partner"}
)
record = self.env["mail.test.multi.company"].create({"name": "Multi Company Record"})
record.message_subscribe(partner_ids=partner_portal.ids)
with self.assertRaises(UserError):
partner_portal.with_user(self.user_employee_c2).check_access_rule("read")
self.authenticate(self.user_employee_c2.login, self.user_employee_c2.login)
response = self.url_open(
url="/mail/thread/data",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"params": {
"thread_id": record.id,
"thread_model": "mail.test.multi.company",
"request_list": ["followers"],
}
},
),
)
self.assertEqual(response.status_code, 200)
followers = json.loads(response.content)["result"]["followers"]
self.assertEqual(len(followers), 1)
self.assertEqual(followers[0]["partner"]["id"], partner_portal.id)
This reproduces the scenario where a user receives a notification from a
record in Company A while being active in Company B, and attempts to reply
from the inbox.
"""
self.user_employee_c2.write({'company_ids': [(6, 0, [self.user_employee.company_id.id, self.company_2.id])]})
record_c1 = self.env["mail.test.multi.company"].sudo().create({
"name": "Thread in C1",
"company_id": self.user_employee.company_id.id, # company 1
})
self.authenticate('employee_c2', 'employee_c2')
self.opener.cookies.set('cids', str(self.company_2.id))
payload = {
"thread_model": record_c1._name,
"thread_id": record_c1.id,
"post_data": {
"body": "<p>Reply from inbox</p>",
"message_type": "comment",
"subtype_xmlid": "mail.mt_comment",
},
"context": {
"allowed_company_ids": self.company_2.ids,
}
}
result = self.make_jsonrpc_request("/mail/message/post", payload)
message_data = result["store_data"]["mail.message"][0]
self.assertEqual(message_data["body"], ["markup", "<p>Reply from inbox</p>"])
self.assertTrue(record_c1.message_ids.filtered(lambda m: m.id == message_data["id"]))

View file

@ -0,0 +1,708 @@
import json
import socket
from datetime import datetime, timedelta
import odoo
from odoo.tools.misc import mute_logger
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.mail.tools.jwt import InvalidVapidError
from odoo.addons.mail.tools.web_push import ENCRYPTION_BLOCK_OVERHEAD, ENCRYPTION_HEADER_SIZE
from odoo.addons.sms.tests.common import SMSCommon
from odoo.addons.test_mail.data.test_mail_data import MAIL_TEMPLATE
from odoo.tests import tagged
from markupsafe import Markup
from unittest.mock import patch
from types import SimpleNamespace
@tagged('post_install', '-at_install', 'mail_push')
class TestWebPushNotification(SMSCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_email = cls.user_employee
cls.user_email.notification_type = 'email'
cls.user_inbox = mail_new_test_user(
cls.env, login='user_inbox', groups='base.group_user', name='User Inbox',
notification_type='inbox'
)
cls.record_simple = cls.env['mail.test.simple'].with_context(cls._test_context).create({
'name': 'Test',
'email_from': 'ignasse@example.com'
})
cls.record_simple.message_subscribe(partner_ids=[
cls.user_email.partner_id.id,
cls.user_inbox.partner_id.id,
])
cls.alias_gateway = cls.env['mail.alias'].create({
'alias_contact': 'everyone',
'alias_domain': cls.mail_alias_domain.id,
'alias_model_id': cls.env['ir.model']._get_id('mail.test.gateway.company'),
'alias_name': 'alias.gateway',
})
# generate keys and devices
cls.vapid_public_key = cls.env['mail.push.device'].get_web_push_vapid_public_key()
cls.env['mail.push.device'].sudo().create([
{
'endpoint': f'https://test.odoo.com/webpush/user{(idx + 1)}',
'expiration_time': None,
'keys': json.dumps({
'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A',
'auth': 'DJFdtAgZwrT6yYkUMgUqow'
}),
'partner_id': user.partner_id.id,
} for idx, user in enumerate(cls.user_email + cls.user_inbox)
])
def _trigger_cron_job(self):
self.env.ref('mail.ir_cron_web_push_notification').method_direct_trigger()
def _assert_notification_count_for_cron(self, number_of_notification):
notification_count = self.env['mail.push'].search_count([])
self.assertEqual(notification_count, number_of_notification)
@patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point')
@mute_logger('odoo.tests')
def test_notify_by_push(self, push_to_end_point):
""" When posting a comment, notify both inbox and people outside of Odoo
aka email """
self.record_simple.with_user(self.user_admin).message_post(
body=Markup('<p>Hello</p>'),
message_type='comment',
partner_ids=(self.user_email + self.user_inbox).partner_id.ids,
subtype_xmlid='mail.mt_comment',
)
# not using cron, as max 1 push notif -> direct send
self._assert_notification_count_for_cron(0)
# two recipients, comment notifies both inbox and email people
self.assertEqual(push_to_end_point.call_count, 2)
@patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point')
def test_notify_by_push_channel(self, push_to_end_point):
""" Test various use case with discuss.channel. Chat and group channels
sends push notifications, channel not. """
chat_channel, channel_channel, group_channel = self.env['discuss.channel'].with_user(self.user_email).create([
{
'channel_partner_ids': [
(4, self.user_email.partner_id.id),
(4, self.user_inbox.partner_id.id),
],
'channel_type': channel_type,
'name': f'{channel_type} Message' if channel_type != 'group' else '',
} for channel_type in ['chat', 'channel', 'group']
])
group_channel._add_members(guests=self.guest)
for channel, sender, notification_count in zip(
(chat_channel + channel_channel + group_channel + group_channel),
(self.user_email, self.user_email, self.user_email, self.guest),
(1, 0, 1, 2),
):
with self.subTest(channel_type=channel.channel_type):
if sender == self.guest:
channel_as_sender = channel.with_user(self.env.ref('base.public_user')).with_context(guest=sender)
else:
channel_as_sender = channel.with_user(self.user_email)
# sudo: discuss.channel - guest can post as sudo in a test (simulating RPC without using network)
channel_as_sender.sudo().message_post(
body='Test Push',
message_type='comment',
subtype_xmlid='mail.mt_comment',
)
self.assertEqual(push_to_end_point.call_count, notification_count)
if notification_count > 0:
payload_value = json.loads(push_to_end_point.call_args.kwargs['payload'])
if channel.channel_type == 'chat':
self.assertEqual(payload_value['title'], f'{self.user_email.name}')
elif channel.channel_type == 'group':
self.assertIn(self.user_email.name, payload_value['title'])
self.assertIn(self.user_inbox.name, payload_value['title'])
self.assertIn(self.guest.name, payload_value['title'])
self.assertNotIn("False", payload_value['title'])
else:
self.assertEqual(payload_value['title'], f'#{channel.name}')
icon = (
'/web/static/img/odoo-icon-192x192.png'
if sender == self.guest
else f'/web/image/res.partner/{self.user_email.partner_id.id}/avatar_128'
)
self.assertEqual(payload_value['options']['icon'], icon)
self.assertEqual(payload_value['options']['body'], 'Test Push')
self.assertEqual(payload_value['options']['data']['res_id'], channel.id)
self.assertEqual(payload_value['options']['data']['model'], channel._name)
self.assertEqual(push_to_end_point.call_args.kwargs['device']['endpoint'], 'https://test.odoo.com/webpush/user2')
push_to_end_point.reset_mock()
# Test Direct Message with channel muted -> should skip push notif
now = datetime.now()
self.env['discuss.channel.member'].search([
('partner_id', 'in', (self.user_email.partner_id + self.user_inbox.partner_id).ids),
('channel_id', 'in', (chat_channel + channel_channel + group_channel).ids),
]).write({
'mute_until_dt': now + timedelta(days=5)
})
chat_channel.with_user(self.user_email).message_post(
body='Test',
message_type='comment',
subtype_xmlid='mail.mt_comment',
)
push_to_end_point.assert_not_called()
push_to_end_point.reset_mock()
self.env["discuss.channel.member"].search([
("partner_id", "in", (self.user_email.partner_id + self.user_inbox.partner_id).ids),
("channel_id", "in", (chat_channel + channel_channel + group_channel).ids),
]).write({
"mute_until_dt": False,
})
# Test Channel Message
group_channel.with_user(self.user_email).message_post(
body='Test',
partner_ids=self.user_inbox.partner_id.ids,
message_type='comment',
subtype_xmlid='mail.mt_comment',
)
push_to_end_point.assert_called_once()
@patch.object(odoo.addons.mail.models.mail_thread, "push_to_end_point")
def test_notify_by_push_channel_with_channel_notifications_settings(self, push_to_end_point):
""" Test various use case with the channel notification settings."""
all_test_user = mail_new_test_user(
self.env,
login="all",
name="all",
email="all@example.com",
notification_type="inbox",
groups="base.group_user",
)
mentions_test_user = mail_new_test_user(
self.env,
login="mentions",
name="mentions",
email="mentions@example.com",
notification_type="inbox",
groups="base.group_user",
)
nothing_test_user = mail_new_test_user(
self.env,
login="nothing",
name="nothing",
email="nothing@example.com",
notification_type="inbox",
groups="base.group_user",
)
all_test_user.res_users_settings_ids.write({"channel_notifications": "all"})
nothing_test_user.res_users_settings_ids.write({"channel_notifications": "no_notif"})
# generate devices
self.env["mail.push.device"].sudo().create(
[
{
"endpoint": f"https://test.odoo.com/webpush/user{(idx + 20)}",
"expiration_time": None,
"keys": json.dumps(
{
"p256dh": "BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A",
"auth": "DJFdtAgZwrT6yYkUMgUqow",
}
),
"partner_id": user.partner_id.id,
}
for idx, user in enumerate(all_test_user + mentions_test_user + nothing_test_user)
]
)
channel_channel = self.env["discuss.channel"].with_user(self.user_email).create(
[
{
"channel_partner_ids": [
(4, self.user_email.partner_id.id),
(4, all_test_user.partner_id.id),
(4, mentions_test_user.partner_id.id),
(4, nothing_test_user.partner_id.id),
],
"channel_type": "channel",
"name": "channel",
}
]
)
# normal messages in channel
channel_channel.with_user(self.user_email).message_post(
body="Test Push",
message_type="comment",
subtype_xmlid="mail.mt_comment",
)
push_to_end_point.assert_called_once()
# all_test_user should be notified
self.assertEqual(push_to_end_point.call_args.kwargs["device"]["endpoint"], "https://test.odoo.com/webpush/user20")
push_to_end_point.reset_mock()
# mention messages in channel
channel_channel.with_user(self.user_email).message_post(
body="Test Push @mentions",
message_type="comment",
partner_ids=(all_test_user + mentions_test_user + nothing_test_user).partner_id.ids,
subtype_xmlid="mail.mt_comment",
)
self.assertEqual(push_to_end_point.call_count, 2)
# all_test_user and mentions_test_user should be notified
self.assertEqual(push_to_end_point.call_args_list[0].kwargs["device"]["endpoint"], "https://test.odoo.com/webpush/user20")
self.assertEqual(push_to_end_point.call_args_list[1].kwargs["device"]["endpoint"], "https://test.odoo.com/webpush/user21")
push_to_end_point.reset_mock()
# muted channel
now = datetime.now()
self.env["discuss.channel.member"].search(
[
("partner_id", "in", (all_test_user.partner_id + mentions_test_user.partner_id + nothing_test_user.partner_id).ids),
]
).write(
{
"mute_until_dt": now + timedelta(days=5),
}
)
# normal messages in channel
channel_channel.with_user(self.user_email).message_post(
body="Test Push",
message_type="comment",
subtype_xmlid="mail.mt_comment",
)
push_to_end_point.assert_not_called()
# mention messages in channel
channel_channel.with_user(self.user_email).message_post(
body="Test Push",
message_type="comment",
subtype_xmlid="mail.mt_comment",
)
push_to_end_point.assert_not_called()
@mute_logger('odoo.addons.mail.models.mail_thread')
def test_notify_by_push_mail_gateway(self):
""" Check mail gateway push notifications """
with self.mock_mail_gateway():
test_record = self.format_and_process(
MAIL_TEMPLATE, self.user_email.email_formatted,
f'{self.alias_gateway.display_name}, {self.user_inbox.email_formatted}',
subject='Test Record Creation',
target_model='mail.test.gateway.company',
)
self.assertEqual(len(test_record.message_ids), 1)
self.assertEqual(test_record.message_partner_ids, self.user_email.partner_id)
test_record.message_subscribe(partner_ids=[self.user_inbox.partner_id.id])
for include_as_external, has_notif in ((False, True), (True, False)):
with self.mock_mail_gateway():
to = f'{self.alias_gateway.display_name}'
if include_as_external:
to += f', {self.user_inbox.email_formatted}'
self.format_and_process(
MAIL_TEMPLATE, self.user_email.email_formatted, to,
subject='Repy By Email',
extra=f'In-Reply-To:\r\n\t{test_record.message_ids[-1].message_id}\n',
)
if has_notif:
# user_inbox is notified by Odoo, hence receives a push notification
self.assertPushNotification(
mail_push_count=0, title_content=self.user_email.name,
body_content='Please call me as soon as possible this afternoon!\n\n--\nSylvie',
)
else:
self.assertNoPushNotification()
@mute_logger('odoo.tests')
def test_notify_by_push_message_notify(self):
""" In case of notification, only inbox users are notified """
for recipient, has_notification in [(self.user_email, False), (self.user_inbox, True)]:
with self.subTest(recipient=recipient):
with self.mock_mail_gateway():
self.record_simple.with_user(self.user_admin).message_notify(
body='Test Push Body',
partner_ids=recipient.partner_id.ids,
subject='Test Push Notification',
)
# not using cron, as max 1 push notif -> direct send
self._assert_notification_count_for_cron(0)
if has_notification:
self.assertPushNotification(
mail_push_count=0,
endpoint='https://test.odoo.com/webpush/user2', keys=('vapid_private_key', 'vapid_public_key'),
title=f'{self.user_admin.name}: {self.record_simple.display_name}',
body_content='Test Push Body',
options={
'data': {'model': self.record_simple._name, 'res_id': self.record_simple.id,},
},
)
else:
self.assertNoPushNotification()
@patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point')
@mute_logger('odoo.tests')
def test_notify_call_invitation(self, push_to_end_point):
inviting_user = self.env['res.users'].sudo().create({'name': "Test User", 'login': 'test'})
channel = self.env['discuss.channel'].with_user(inviting_user)._get_or_create_chat(
partners_to=[self.user_email.partner_id.id])
inviting_channel_member = channel.sudo().channel_member_ids.filtered(
lambda channel_member: channel_member.partner_id == inviting_user.partner_id)
inviting_channel_member._rtc_join_call()
push_to_end_point.assert_called_once()
payload_value = json.loads(push_to_end_point.call_args.kwargs['payload'])
self.assertEqual(
payload_value['title'],
"Incoming call",
)
options = payload_value['options']
self.assertTrue(options['requireInteraction'])
self.assertEqual(options['body'], f"Conference: {channel.name}")
self.assertEqual(options['actions'], [
{
"action": "DECLINE",
"type": "button",
"title": "Decline",
},
{
"action": "ACCEPT",
"type": "button",
"title": "Accept",
},
])
data = options['data']
self.assertEqual(data['type'], "CALL")
self.assertEqual(data['res_id'], channel.id)
self.assertEqual(data['model'], "discuss.channel")
push_to_end_point.reset_mock()
inviting_channel_member._rtc_leave_call()
push_to_end_point.assert_called_once()
payload_value = json.loads(push_to_end_point.call_args.kwargs['payload'])
self.assertEqual(payload_value['options']['data']['type'], "CANCEL")
push_to_end_point.reset_mock()
@patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point')
def test_notify_by_push_tracking(self, push_to_end_point):
""" Test tracking message included in push notifications """
container_update_subtype = self.env.ref('test_mail.st_mail_test_ticket_container_upd')
ticket = self.env['mail.test.ticket'].with_user(self.user_email).create({
'name': 'Test',
})
ticket.message_subscribe(
partner_ids=[self.user_email.partner_id.id],
subtype_ids=[container_update_subtype.id],
)
container = self.env['mail.test.container'].create({'name': 'Container'})
ticket.write({
'name': 'Test2',
'email_from': 'noone@example.com',
'container_id': container.id,
})
self.flush_tracking()
self._assert_notification_count_for_cron(0)
push_to_end_point.assert_not_called()
container2 = self.env['mail.test.container'].create({'name': 'Container Two'})
ticket.message_subscribe(
partner_ids=[self.user_inbox.partner_id.id],
subtype_ids=[container_update_subtype.id],
)
ticket.write({
'name': 'Test3',
'email_from': 'noone@example.com',
'container_id': container2.id,
})
self.flush_tracking()
self._assert_notification_count_for_cron(0)
push_to_end_point.assert_called_once()
payload_value = json.loads(push_to_end_point.call_args.kwargs['payload'])
self.assertIn(
f'{container_update_subtype.description}\nContainer: {container.name}{container2.name}',
payload_value['options']['body'],
'Tracking changes should be included in push notif payload'
)
@patch.object(odoo.addons.mail.models.mail_push, 'push_to_end_point')
def test_push_notifications_cron(self, push_to_end_point):
# Add 4 more devices to force sending via cron queue
for index in range(10, 14):
self.env['mail.push.device'].sudo().create([{
'endpoint': 'https://test.odoo.com/webpush/user%d' % index,
'expiration_time': None,
'keys': json.dumps({
'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A',
'auth': 'DJFdtAgZwrT6yYkUMgUqow'
}),
'partner_id': self.user_inbox.partner_id.id,
}])
self.record_simple.with_user(self.user_email).message_notify(
partner_ids=self.user_inbox.partner_id.ids,
body='Test message send via Web Push',
subject='Test Activity',
)
self._assert_notification_count_for_cron(5)
# Force the execution of the cron
self._trigger_cron_job()
self.assertEqual(push_to_end_point.call_count, 5)
@patch.object(odoo.addons.mail.models.mail_thread.Session, 'post',
return_value=SimpleNamespace(**{'status_code': 404, 'text': 'Device Unreachable'}))
def test_push_notifications_error_device_unreachable(self, post):
with mute_logger('odoo.addons.mail.tools.web_push'):
self.record_simple.with_user(self.user_email).message_notify(
partner_ids=self.user_inbox.partner_id.ids,
body='Test message send via Web Push',
subject='Test Activity',
)
self._assert_notification_count_for_cron(0)
post.assert_called_once()
# Test that the unreachable device is deleted from the DB
notification_count = self.env['mail.push.device'].search_count([('endpoint', '=', 'https://test.odoo.com/webpush/user2')])
self.assertEqual(notification_count, 0)
@patch.object(odoo.addons.mail.models.mail_thread.Session, 'post',
return_value=SimpleNamespace(**{'status_code': 201, 'text': 'Ok'}))
def test_push_notifications_error_encryption_simple(self, post):
""" Test to see if all parameters sent to the endpoint are present.
This test doesn't test if the cryptographic values are correct. """
self.record_simple.with_user(self.user_email).message_notify(
partner_ids=self.user_inbox.partner_id.ids,
body='Test message send via Web Push',
subject='Test Activity',
)
self._assert_notification_count_for_cron(0)
post.assert_called_once()
self.assertEqual(post.call_args.args[0], 'https://test.odoo.com/webpush/user2')
self.assertIn('headers', post.call_args.kwargs)
self.assertIn('vapid', post.call_args.kwargs['headers']['Authorization'])
self.assertIn('t=', post.call_args.kwargs['headers']['Authorization'])
self.assertIn('k=', post.call_args.kwargs['headers']['Authorization'])
self.assertEqual('aes128gcm', post.call_args.kwargs['headers']['Content-Encoding'])
self.assertEqual('60', post.call_args.kwargs['headers']['TTL'])
self.assertIn('data', post.call_args.kwargs)
self.assertIn('timeout', post.call_args.kwargs)
@patch.object(odoo.addons.mail.models.mail_thread.Session, 'post',
return_value=SimpleNamespace(status_code=201, text='Ok'))
def test_push_notifications_device_invalid_tld_domain(self, post):
self.env['mail.push.device'].sudo().create([{
'endpoint': 'https://test.odoo.invalid/webpush/user',
'expiration_time': None,
'keys': json.dumps({
'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A',
'auth': 'DJFdtAgZwrT6yYkUMgUqow'
}),
'partner_id': self.user_inbox.partner_id.id,
}])
device_count = self.env['mail.push.device'].search_count([('endpoint', '=', 'https://test.odoo.invalid/webpush/user')])
self.assertEqual(device_count, 1)
self.record_simple.with_user(self.user_email).message_notify(
partner_ids=self.user_inbox.partner_id.ids,
body='Test message send via Web Push',
subject='Test Activity',
)
self._assert_notification_count_for_cron(0)
post.assert_called_once()
# Test that the device with the invalid TLD is deleted from the DB
device_count = self.env['mail.push.device'].search_count([('endpoint', '=', 'https://test.odoo.invalid/webpush/user')])
self.assertEqual(device_count, 0)
@patch.object(odoo.addons.mail.models.mail_thread.Session, 'post', side_effect=ConnectionError("Oops, network error"))
def test_push_notifications_device_raise_exception(self, post):
# Add 4 more devices to force sending via cron queue
for index in range(10, 14):
self.env['mail.push.device'].sudo().create([{
'endpoint': 'https://test.odoo.com/webpush/user%d' % index,
'expiration_time': None,
'keys': json.dumps({
'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A',
'auth': 'DJFdtAgZwrT6yYkUMgUqow'
}),
'partner_id': self.user_inbox.partner_id.id,
}])
self.record_simple.with_user(self.user_email).message_notify(
partner_ids=self.user_inbox.partner_id.ids,
body='Test message send via Web Push',
subject='Test Activity',
)
with self.assertLogs('odoo.addons.mail.models.mail_push', level="ERROR") as capture:
self._assert_notification_count_for_cron(5)
self._trigger_cron_job()
self.assertEqual(capture.output, [
'ERROR:odoo.addons.mail.models.mail_push:An error occurred while trying to send web push: Oops, network error',
] * 5)
def test_push_notification_regenerate_vapid_keys(self):
ir_params_sudo = self.env['ir.config_parameter'].sudo()
ir_params_sudo.search([('key', 'in', [
'mail.web_push_vapid_private_key',
'mail.web_push_vapid_public_key'
])]).unlink()
new_vapid_public_key = self.env['mail.push.device'].get_web_push_vapid_public_key()
self.assertNotEqual(self.vapid_public_key, new_vapid_public_key)
with self.assertRaises(InvalidVapidError):
self.env['mail.push.device'].register_devices(
endpoint='https://test.odoo.com/webpush/user1',
expiration_time=None,
keys=json.dumps({
'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A',
'auth': 'DJFdtAgZwrT6yYkUMgUqow'
}),
partner_id=self.user_email.partner_id.id,
vapid_public_key=self.vapid_public_key,
)
@patch.object(
odoo.addons.mail.models.mail_thread.Session, 'post', return_value=SimpleNamespace(status_code=201, text='Ok')
)
@patch.object(
odoo.addons.mail.models.mail_thread, 'push_to_end_point',
wraps=odoo.addons.mail.tools.web_push.push_to_end_point,
)
def test_push_notifications_truncate_payload(self, thread_push_mock, session_post_mock):
"""Ensure that when we send large bodies with various character types,
the final encrypted data (post-encryption) never exceeds 4096 bytes.
This test checks the behavior for the current size limits and encryption overhead.
See below test for a more illustrative example.
See MailThread._truncate_payload for a more thorough explanation.
Test scenarios include:
- ASCII characters (X)
- UTF-8 characters (Ø), at various offsets
"""
# compute the size of an empty notification with these parameters
# this could change based on the id of record_simple for example
# but is otherwise constant for any notification sent with the same parameters
self.record_simple.with_user(self.user_email).message_notify(
partner_ids=self.user_inbox.partner_id.ids,
body='',
subject='Test Payload',
)
base_payload_size = len(thread_push_mock.call_args.kwargs['payload'].encode())
effective_payload_size_limit = self.env['mail.thread']._truncate_payload_get_max_payload_length()
# this is just a sanity check that the value makes sense, feel free to update as needed
self.assertEqual(effective_payload_size_limit, 3993, "Payload limit should come out to 3990.")
body_size_limit = effective_payload_size_limit - base_payload_size
encryption_overhead = ENCRYPTION_HEADER_SIZE + ENCRYPTION_BLOCK_OVERHEAD
test_cases = [
# (description, body)
('empty string', '', 0, 0),
('1-byte ASCII characters (below limit)', 'X' * (body_size_limit - 1), body_size_limit - 1, body_size_limit - 1),
('1-byte ASCII characters (at limit)', 'X' * body_size_limit, body_size_limit, body_size_limit),
('1-byte ASCII characters (past limit)', 'X' * (body_size_limit + 1), body_size_limit, body_size_limit),
('1-byte ASCII characters (way past limit)', 'X' * 5000, body_size_limit, body_size_limit),
] + [ # \u00d8 check that it can be cut anywhere by offsetting the string by 1 byte each time
(
f'2-bytes UTF-8 characters (near limit + {offset}-byte offset)',
('+' * offset) + ('Ø' * (body_size_limit // 6)),
offset + ((body_size_limit - offset) // 6), # length truncated to nearest full character (\u00f8)
offset * 1 + ((body_size_limit - offset) // 6) * 6,
)
for offset in range(0, 8)
]
for description, body, expected_body_length, expected_body_size in test_cases:
with self.subTest(description):
self.record_simple.with_user(self.user_email).message_notify(
partner_ids=self.user_inbox.partner_id.ids,
body=body,
subject='Test Payload',
)
encrypted_payload = session_post_mock.call_args.kwargs['data']
payload_before_encryption = thread_push_mock.call_args.kwargs['payload']
self.assertLessEqual(
len(encrypted_payload), 4096, 'Final encrypted payload should not exceed 4096 bytes'
)
self.assertEqual(
len(json.loads(payload_before_encryption)['options']['body']), expected_body_length
)
self.assertEqual(
len(encrypted_payload),
base_payload_size + expected_body_size + encryption_overhead,
'Encrypted size should be exactly the base payload size + body size + encryption overhead.'
)
@patch.object(
odoo.addons.mail.models.mail_thread.Session, 'post', return_value=SimpleNamespace(status_code=201, text='Ok')
)
@patch.object(
odoo.addons.mail.models.mail_thread, 'push_to_end_point',
wraps=odoo.addons.mail.tools.web_push.push_to_end_point,
)
@patch.object(
odoo.addons.mail.tools.web_push, '_encrypt_payload',
wraps=odoo.addons.mail.tools.web_push._encrypt_payload,
)
def test_push_notifications_truncate_payload_mocked_size_limit(self, web_push_encrypt_payload_mock, thread_push_mock, session_post_mock):
"""Illustrative test for text contents truncation.
We want to ensure we truncate utf-8 values properly based on maximum payload size.
Here max payload size is mocked, so that we can test on the same body each time to ease reading.
See MailThread._truncate_payload for a more thorough explanation.
"""
self.record_simple.with_user(self.user_email).message_notify(
partner_ids=self.user_inbox.partner_id.ids,
body="",
subject='Test Payload',
)
base_payload = thread_push_mock.call_args.kwargs['payload'].encode()
base_payload_size = len(base_payload)
encryption_overhead = ENCRYPTION_HEADER_SIZE + ENCRYPTION_BLOCK_OVERHEAD
body = "BØDY"
body_json = json.dumps(body)[1:-1]
for size_limit, expected_body in [
(base_payload_size + len(body_json), "BØDY"),
(base_payload_size + len(body_json) - 1, "BØD"),
(base_payload_size + len(body_json) - 2, ""),
] + [ # truncating anywhere in \u00d8 (Ø) should truncate to the nearest full character (B)
(base_payload_size + len(body_json) - n, "B")
for n in range(3, 9)
] + [
(base_payload_size + len(body_json) - 9, ""),
(base_payload_size + len(body_json) - 10, ""), # should still work even if it would still be too big after truncate
]:
with self.subTest(size_limit=size_limit), patch.object(
odoo.addons.mail.models.mail_thread.MailThread, '_truncate_payload_get_max_payload_length',
return_value=size_limit,
):
self.record_simple.with_user(self.user_email).message_notify(
partner_ids=self.user_inbox.partner_id.ids,
body=body,
subject='Test Payload',
)
payload_at_push = thread_push_mock.call_args.kwargs['payload']
payload_before_encrypt = web_push_encrypt_payload_mock.call_args.args[0]
encrypted_payload = session_post_mock.call_args.kwargs['data']
self.assertEqual(payload_before_encrypt.decode(), payload_at_push, "Payload should not change between encryption and push call.")
self.assertEqual(len(payload_before_encrypt), len(payload_at_push), "Encoded body should be same size as decoded.")
self.assertEqual(
len(encrypted_payload), len(payload_before_encrypt) + encryption_overhead,
'Final encrypted payload should just be the size of the unencrypted payload + the size of encryption overhead.'
)
self.assertEqual(
json.loads(payload_at_push)['options']['body'], expected_body
)
if not expected_body:
self.assertEqual(
payload_before_encrypt, base_payload,
"Only the contents of the body should be truncated, not the rest of the payload."
)

View file

@ -0,0 +1,239 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.base.tests.test_ir_cron import CronMixinCase
from odoo.addons.mail.tests.common import MailCommon
from odoo.addons.test_mail.models.mail_test_lead import MailTestTLead
from odoo.addons.test_mail.tests.common import TestRecipients
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.fields import Datetime as FieldDatetime
from odoo.tests import tagged, users
from odoo.tools import mute_logger
from unittest.mock import patch
@tagged('mail_scheduled_message')
class TestScheduledMessage(MailCommon, TestRecipients):
""" Test Scheduled Message internals """
@classmethod
def setUpClass(cls):
super().setUpClass()
# force 'now' to ease test about schedulers
cls.reference_now = FieldDatetime.to_datetime('2022-12-24 12:00:00')
with cls.mock_datetime_and_now(cls, cls.reference_now):
cls.test_record = cls.env['mail.test.ticket'].with_context(cls._test_context).create([{
'name': 'Test Record',
'customer_id': cls.partner_1.id,
'user_id': cls.user_employee.id,
}])
cls.private_record = cls.env['mail.test.access'].create({
'access': 'admin',
'name': 'Private Record',
})
cls.hidden_scheduled_message, cls.visible_scheduled_message = cls.env['mail.scheduled.message'].create([
{
'author_id': cls.partner_admin.id,
'model': cls.private_record._name,
'res_id': cls.private_record.id,
'body': 'Hidden Scheduled Message',
'scheduled_date': '2022-12-24 15:00:00',
},
{
'author_id': cls.partner_admin.id,
'model': cls.test_record._name,
'res_id': cls.test_record.id,
'body': 'Visible Scheduled Message',
'scheduled_date': '2022-12-24 15:00:00',
},
]).with_user(cls.user_employee)
def schedule_message(self, target_record=None, author_id=None, **kwargs):
with self.mock_datetime_and_now(self.reference_now):
return self.env['mail.scheduled.message'].create({
'author_id': author_id or self.env.user.partner_id.id,
'model': target_record._name if target_record else kwargs.pop('model'),
'res_id': target_record.id if target_record else kwargs.pop('res_id'),
'body': kwargs.pop('body', 'Test Body'),
'scheduled_date': kwargs.pop('scheduled_date', '2022-12-24 15:00:00'),
**kwargs,
})
class TestScheduledMessageAccess(TestScheduledMessage):
@users('employee')
def test_scheduled_message_model_without_post_right(self):
# creation on a record that the user cannot post to
with self.assertRaises(AccessError):
self.schedule_message(self.private_record)
# read a message scheduled on a record the user can't post to
with self.assertRaises(AccessError):
self.hidden_scheduled_message.read()
# search a message scheduled on a record the user can't post to
self.assertFalse(self.env['mail.scheduled.message'].search([['id', '=', self.hidden_scheduled_message.id]]))
# write on a message scheduled on a record the user can't post to
with self.assertRaises(AccessError):
self.hidden_scheduled_message.write({'body': 'boum'})
# post a message scheduled on a record the user can't post to
with self.assertRaises(AccessError):
self.hidden_scheduled_message.post_message()
# unlink a message scheduled on a record the user can't post to
with self.assertRaises(AccessError):
self.hidden_scheduled_message.unlink()
@users('employee')
def test_scheduled_message_model_with_post_right(self):
# read a message scheduled by another user on a record the user can post to
self.visible_scheduled_message.read()
# search a message scheduled by another user on a record the user can post to
self.assertEqual(self.env['mail.scheduled.message'].search([['id', '=', self.visible_scheduled_message.id]]), self.visible_scheduled_message)
# write on a message scheduled by another user on a record the user can post to
with self.assertRaises(AccessError):
self.visible_scheduled_message.write({'body': 'boum'})
# post a message scheduled on a record the user can post to
with self.assertRaises(UserError):
self.visible_scheduled_message.post_message()
# unlink a message scheduled on a record the user can post to
self.visible_scheduled_message.unlink()
@users('employee')
def test_own_scheduled_message(self):
# create a scheduled message on a record the user can post to
scheduled_message = self.schedule_message(self.test_record)
# read own scheduled message
scheduled_message.read()
# search own scheduled message
self.assertEqual(self.env['mail.scheduled.message'].search([['id', '=', scheduled_message.id]]), scheduled_message)
# write on own scheduled message
scheduled_message.write({'body': 'Hello!'})
# unlink own scheduled message
scheduled_message.unlink()
class TestScheduledMessageBusiness(TestScheduledMessage, CronMixinCase):
@users('employee')
def test_scheduled_message_restrictions(self):
# cannot schedule a message in the past
with self.assertRaises(ValidationError):
self.schedule_message(self.test_record, scheduled_date='2022-12-24 10:00:00')
# cannot schedule a message on a model without thread
# with admin as employee does not have write access on res.users)
with self.with_user("admin"), self.assertRaises(ValidationError):
self.schedule_message(self.user_employee)
scheduled_message = self.schedule_message(self.test_record)
# cannot reschedule a message in the past
with self.assertRaises(ValidationError):
scheduled_message.write({'scheduled_date': '2022-12-24 14:00:00'})
# cannot change target record of scheduled message
with self.assertRaises(UserError):
scheduled_message.write({'res_id': 2})
with self.assertRaises(UserError):
scheduled_message.write({'model': 'mail.test.track'})
# unlink the test record should also unlink the test message
self.test_record.sudo().unlink()
self.assertFalse(scheduled_message.exists())
@users('employee')
def test_scheduled_message_posting(self):
schedule_cron_id = self.env.ref('mail.ir_cron_post_scheduled_message').id
test_lead = self.env["mail.test.lead"].create({})
with self.mock_mail_gateway(), \
self.mock_mail_app(), \
self.capture_triggers(schedule_cron_id) as capt:
scheduled_message_id = self.schedule_message(
self.test_record,
scheduled_date='2022-12-24 14:00:00',
partner_ids=self.test_record.customer_id,
body="success",
send_context={"mail_post_autofollow": True},
subject="Test subject",
).id
# cron should be triggered at scheduled date
self.assertEqual(capt.records['call_at'], FieldDatetime.to_datetime('2022-12-24 14:00:00'))
# no message created or mail sent
self.assertFalse(self.test_record.message_ids)
self.assertFalse(self._new_mails)
# add a scheduled message that will fail to check that it won't block the cron
failing_schedueld_message_id = self.schedule_message(
test_lead,
scheduled_date='2022-12-24 14:00:00',
partner_ids=self.test_record.customer_id,
body="fail",
).id
def _message_post_after_hook(self, message, values):
raise Exception("Boum!")
with self.mock_datetime_and_now('2022-12-24 14:00:00'),\
patch.object(MailTestTLead, '_message_post_after_hook', _message_post_after_hook),\
mute_logger('odoo.addons.mail.models.mail_scheduled_message'):
self.env['mail.scheduled.message'].with_user(self.user_root)._post_messages_cron()
# one scheduled message failed, only one mail should be sent
self.assertEqual(len(self._new_mails), 1)
# user should be notified about the failed posting
self.assertMailNotifications(
self._new_msgs.filtered(lambda m: not m.model),
[{
'content': f"<p>The message scheduled on {test_lead._name}({test_lead.id}) with"
" the following content could not be sent:<br>-----<br></p><p>fail</p><br>-----<br>",
'message_type': 'user_notification',
'subtype': 'mail.mt_note',
'message_values': {
'author_id': self.partner_root,
'model': False,
'res_id': False,
'subject': "A scheduled message could not be sent",
},
'notif': [
{'partner': self.partner_employee, 'type': 'inbox'}
]
}])
# other message should be posted and mail should be sent
self.assertMailNotifications(
self._new_msgs.filtered(lambda m: m.model == self.test_record._name),
[{
'content': "<p>success</p>",
'message_type': 'notification',
'message_values': {
'author_id': self.partner_employee,
'model': self.test_record._name,
'res_id': self.test_record.id,
'subject': "Test subject",
},
'notif': [
{'partner': self.test_record.customer_id, 'type': 'email'}
]
}]
)
self.assertEqual(self._new_mails[0].state, 'sent')
# customer should be a follower of the thread (mail_post_autofollow context key)
self.assertIn(self.test_record.customer_id, self.test_record.message_partner_ids)
# scheduled messages shouldn't exist anymore
self.assertFalse(self.env['mail.scheduled.message'].search([['id', 'in', [scheduled_message_id, failing_schedueld_message_id]]]))
@users('employee')
def test_scheduled_message_posting_on_scheduled_time(self):
""" Ensure scheduled message is posted and sent at the scheduled time. """
self.test_record.message_subscribe(partner_ids=[self.partner_1.id])
self.schedule_message(
self.test_record,
scheduled_date=FieldDatetime.to_string(self.reference_now),
)
with self.mock_mail_gateway(), self.mock_datetime_and_now(self.reference_now), self.enter_registry_test_mode():
# Needed to get force_send disabled due to mail_notify_force_send in the context
self.env.ref('mail.ir_cron_post_scheduled_message').with_user(self.user_admin).method_direct_trigger()
# Message is posted and mail is sent on time
self.assertEqual(len(self._new_mails), 1)
self.assertMailMailWRecord(
self.test_record,
[self.partner_1],
'sent',
author=self.env.user.partner_id,
)

View file

@ -1,12 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.test_mail.tests.common import TestMailCommon
from odoo.addons.mail.tests.common import mail_new_test_user, MailCommon
from odoo.exceptions import AccessError
class TestSubtypeAccess(TestMailCommon):
class TestSubtypeAccess(MailCommon):
def test_subtype_access(self):
"""

View file

@ -7,25 +7,22 @@ import datetime
from freezegun import freeze_time
from unittest.mock import patch
from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients
from odoo.tests import tagged, users
from odoo.addons.mail.tests.common import MailCommon
from odoo.addons.test_mail.tests.common import TestRecipients
from odoo.tests import tagged, users, warmup
from odoo.tools import mute_logger, safe_eval
class TestMailTemplateCommon(TestMailCommon, TestRecipients):
class TestMailTemplateCommon(MailCommon, TestRecipients):
@classmethod
def setUpClass(cls):
super(TestMailTemplateCommon, cls).setUpClass()
super().setUpClass()
cls.test_record = cls.env['mail.test.lang'].with_context(cls._test_context).create({
'email_from': 'ignasse@example.com',
'name': 'Test',
})
cls.user_employee.write({
'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)],
})
cls._attachments = [{
'name': 'first.txt',
'datas': base64.b64encode(b'My first attachment'),
@ -51,6 +48,7 @@ class TestMailTemplateCommon(TestMailCommon, TestRecipients):
'email_cc': '%s' % cls.email_3,
'partner_to': '%s,%s' % (cls.partner_2.id, cls.user_admin.partner_id.id),
'subject': 'EnglishSubject for {{ object.name }}',
'use_default_to': False,
})
# activate translations
@ -64,6 +62,17 @@ class TestMailTemplateCommon(TestMailCommon, TestRecipients):
# Force the attachments of the template to be in the natural order.
cls.test_template.invalidate_recordset(['attachment_ids'])
# dynamic reports
cls.test_report = cls.env['ir.actions.report'].create([
{
'name': 'Test Report 3 with variable data on Mail Test Ticket',
'model': 'mail.test.ticket.mc',
'print_report_name': "'TestReport3 for %s' % object.name",
'report_type': 'qweb-pdf',
'report_name': 'test_mail.mail_test_ticket_test_variable_template',
},
])
@tagged('mail_template')
class TestMailTemplate(TestMailTemplateCommon):
@ -79,6 +88,19 @@ class TestMailTemplate(TestMailTemplateCommon):
self.assertEqual(action.name, 'Send Mail (%s)' % self.test_template.name)
self.assertEqual(action.binding_model_id.model, 'mail.test.lang')
def test_template_fields(self):
""" Test computed fields """
# has_dynamic_reports: based on ir.actions.report
test_template_lang = self.test_template.with_user(self.user_employee)
self.assertFalse(test_template_lang.has_dynamic_reports)
test_template_ticket_mc = self.env['mail.template'].with_user(self.user_employee).create({
'model_id': self.env['ir.model']._get_id('mail.test.ticket.mc'),
})
self.assertTrue(test_template_ticket_mc.has_dynamic_reports)
# has_mail_server: based on ir.mail_server available
self.assertTrue(test_template_lang.has_mail_server)
self.assertTrue(test_template_ticket_mc.has_mail_server)
@mute_logger('odoo.addons.mail.models.mail_mail')
@users('employee')
def test_template_schedule_email(self):
@ -119,48 +141,275 @@ class TestMailTemplate(TestMailTemplateCommon):
self.assertFalse(mail.scheduled_date)
self.assertEqual(mail.state, 'outgoing')
@tagged('mail_template', 'multi_lang')
class TestMailTemplateLanguages(TestMailTemplateCommon):
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_template_send_email(self):
def test_template_send_mail_body(self):
""" Test that the body and body_html is set correctly in 'mail.mail'
when sending an email from mail.template """
mail_id = self.test_template.send_mail(self.test_record.id)
mail = self.env['mail.mail'].sudo().browse(mail_id)
body_result = '<p>EnglishBody for %s</p>' % self.test_record.name
self.assertEqual(mail.body_html, body_result)
self.assertEqual(mail.body, body_result)
@tagged('mail_template', 'multi_lang', 'mail_performance', 'post_install', '-at_install')
class TestMailTemplateLanguages(TestMailTemplateCommon):
@classmethod
def setUpClass(cls):
""" Create lang-based records and templates, to test batch and performances
with language involved. """
super().setUpClass()
# use test notification layout
cls.test_template.write({
'email_layout_xmlid': 'mail.test_layout',
})
# double record, one in each lang
cls.test_records = cls.test_record + cls.env['mail.test.lang'].create({
'email_from': 'ignasse.es@example.com',
'lang': 'es_ES',
'name': 'Test Record 2',
})
# pure batch, 100 records
cls.test_records_batch, test_partners = cls._create_records_for_batch(
'mail.test.lang', 100,
)
test_partners[:50].lang = 'es_ES'
# have a template with dynamic templates to check impact
cls.test_template_wreports = cls.test_template.copy({
'email_layout_xmlid': 'mail.test_layout',
})
cls.test_reports = cls.env['ir.actions.report'].create([
{
'name': f'Test Report on {cls.test_record._name}',
'model': cls.test_record._name,
'print_report_name': "f'TestReport for {object.name}'",
'report_type': 'qweb-pdf',
'report_name': 'test_mail.mail_test_ticket_test_template',
}, {
'name': f'Test Report 2 on {cls.test_record._name}',
'model': cls.test_record._name,
'print_report_name': "f'TestReport2 for {object.name}'",
'report_type': 'qweb-pdf',
'report_name': 'test_mail.mail_test_ticket_test_template_2',
}
])
cls.test_template_wreports.report_template_ids = cls.test_reports
cls.env.flush_all()
def setUp(self):
super().setUp()
# warm up group access cache: 5 queries + 1 query per user
self.user_employee.has_group('base.group_user')
# we don't use mock_mail_gateway thus want to mock smtp to test the stack
self._mock_smtplib_connection()
@mute_logger('odoo.addons.mail.models.mail_mail')
@warmup
def test_template_send_email(self):
""" Test 'send_email' on template on a given record, used notably as
contextual action. """
self.env.invalidate_all()
with self.with_user(self.user_employee.login), self.assertQueryCount(13):
mail_id = self.test_template.with_env(self.env).send_mail(self.test_record.id)
mail = self.env['mail.mail'].sudo().browse(mail_id)
self.assertEqual(sorted(mail.attachment_ids.mapped('name')), ['first.txt', 'second.txt'])
self.assertEqual(mail.body_html,
f'<body><p>EnglishBody for {self.test_record.name}</p> English Layout for Lang Chatter Model</body>')
self.assertEqual(mail.email_cc, self.test_template.email_cc)
self.assertEqual(mail.email_to, self.test_template.email_to)
self.assertEqual(mail.recipient_ids, self.partner_2 | self.user_admin.partner_id)
self.assertEqual(mail.subject, 'EnglishSubject for %s' % self.test_record.name)
self.assertEqual(mail.subject, f'EnglishSubject for {self.test_record.name}')
@mute_logger('odoo.addons.mail.models.mail_mail')
@warmup
def test_template_send_email_nolayout(self):
""" Test without layout, just to check impact """
self.test_template.email_layout_xmlid = False
self.env.invalidate_all()
with self.with_user(self.user_employee.login), self.assertQueryCount(12):
mail_id = self.test_template.with_env(self.env).send_mail(self.test_record.id)
mail = self.env['mail.mail'].sudo().browse(mail_id)
self.assertEqual(sorted(mail.attachment_ids.mapped('name')), ['first.txt', 'second.txt'])
self.assertEqual(mail.body_html,
f'<p>EnglishBody for {self.test_record.name}</p>')
self.assertEqual(mail.email_cc, self.test_template.email_cc)
self.assertEqual(mail.email_to, self.test_template.email_to)
self.assertEqual(mail.recipient_ids, self.partner_2 | self.user_admin.partner_id)
self.assertEqual(mail.subject, f'EnglishSubject for {self.test_record.name}')
@mute_logger('odoo.addons.mail.models.mail_mail')
@warmup
def test_template_send_email_batch(self):
""" Test 'send_email' on template in batch """
self.env.invalidate_all()
with self.with_user(self.user_employee.login), self.assertQueryCount(25):
template = self.test_template.with_env(self.env)
mails_sudo = template.send_mail_batch(self.test_records_batch.ids)
self.assertEqual(len(mails_sudo), 100)
for idx, (mail, record) in enumerate(zip(mails_sudo, self.test_records_batch)):
self.assertEqual(sorted(mail.attachment_ids.mapped('name')), ['first.txt', 'second.txt'])
self.assertEqual(mail.attachment_ids.mapped("res_id"), [template.id] * 2)
self.assertEqual(mail.attachment_ids.mapped("res_model"), [template._name] * 2)
self.assertEqual(mail.email_cc, template.email_cc)
self.assertEqual(mail.email_to, template.email_to)
self.assertEqual(mail.recipient_ids, self.partner_2 | self.user_admin.partner_id)
if idx >= 50:
self.assertEqual(mail.subject, f'EnglishSubject for {record.name}')
else:
self.assertEqual(mail.subject, f'SpanishSubject for {record.name}')
@mute_logger('odoo.addons.mail.models.mail_mail')
@warmup
def test_template_send_email_wreport(self):
""" Test 'send_email' on template on a given record, used notably as
contextual action, with dynamic reports involved """
self.env.invalidate_all()
# tm: 22, nightly: +1
with self.with_user(self.user_employee.login), self.assertQueryCount(21):
mail_id = self.test_template_wreports.with_env(self.env).send_mail(self.test_record.id)
mail = self.env['mail.mail'].sudo().browse(mail_id)
self.assertEqual(
sorted(mail.attachment_ids.mapped('name')),
[f'TestReport for {self.test_record.name}.html', f'TestReport2 for {self.test_record.name}.html', 'first.txt', 'second.txt']
)
self.assertEqual(mail.recipient_ids, self.partner_2 | self.user_admin.partner_id)
self.assertEqual(mail.subject, f'EnglishSubject for {self.test_record.name}')
@mute_logger('odoo.addons.mail.models.mail_mail')
@warmup
def test_template_send_email_wreport_batch(self):
""" Test 'send_email' on template in batch with dynamic reports """
self.env.invalidate_all()
# tm: 233, nightly: +1
with self.with_user(self.user_employee.login), self.assertQueryCount(232):
template = self.test_template_wreports.with_env(self.env)
mails_sudo = template.send_mail_batch(self.test_records_batch.ids)
self.assertEqual(len(mails_sudo), 100)
for idx, (mail, record) in enumerate(zip(mails_sudo, self.test_records_batch)):
self.assertEqual(
sorted(mail.attachment_ids.mapped('name')),
[f'TestReport for {record.name}.html', f'TestReport2 for {record.name}.html', 'first.txt', 'second.txt']
)
self.assertEqual(
sorted(mail.attachment_ids.mapped("res_id")),
sorted([self.test_template_wreports.id] * 2 + [mail.mail_message_id.id] * 2),
"Attachments: attachment_ids -> linked to template, attachments -> to mail.message"
)
self.assertEqual(
sorted(mail.attachment_ids.mapped("res_model")),
sorted([template._name] * 2 + ["mail.message"] * 2),
"Attachments: attachment_ids -> linked to template, attachments -> to mail.message"
)
self.assertEqual(mail.email_cc, self.test_template.email_cc)
self.assertEqual(mail.email_to, self.test_template.email_to)
self.assertEqual(mail.recipient_ids, self.partner_2 | self.user_admin.partner_id)
if idx >= 50:
self.assertEqual(mail.subject, f'EnglishSubject for {record.name}')
self.assertEqual(mail.body_html,
f'<body><p>EnglishBody for {record.name}</p> English Layout for Lang Chatter Model</body>')
else:
self.assertEqual(mail.subject, f'SpanishSubject for {record.name}')
self.assertEqual(mail.body_html,
f'<body><p>SpanishBody for {record.name}</p> Spanish Layout para Spanish Model Description</body>')
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_template_send_email_wreport_batch_scalability(self):
""" Test 'send_email' on template in batch, using configuration parameter
for batch rendering. """
for batch_size, exp_mail_create_count in [
(False, 2), # unset, default is 50
(0, 2), # 0: fallbacks on default
(30, 4), # 100 / 30 -> 4 iterations
]:
with self.subTest(batch_size=batch_size):
self.env['ir.config_parameter'].sudo().set_param(
"mail.batch_size", batch_size
)
with self.with_user(self.user_employee.login), \
self.mock_mail_gateway():
template = self.test_template_wreports.with_env(self.env)
mails_sudo = template.send_mail_batch(self.test_records_batch.ids)
self.assertEqual(self.mail_mail_create_mocked.call_count, exp_mail_create_count)
self.assertEqual(len(mails_sudo), 100)
for idx, (mail, record) in enumerate(zip(mails_sudo, self.test_records_batch)):
self.assertEqual(
sorted(mail.attachment_ids.mapped('name')),
[f'TestReport for {record.name}.html', f'TestReport2 for {record.name}.html', 'first.txt', 'second.txt']
)
self.assertEqual(
sorted(mail.attachment_ids.mapped("res_id")),
sorted([self.test_template_wreports.id] * 2 + [mail.mail_message_id.id] * 2),
"Attachments: attachment_ids -> linked to template, attachments -> to mail.message"
)
self.assertEqual(
sorted(mail.attachment_ids.mapped("res_model")),
sorted([template._name] * 2 + ["mail.message"] * 2),
"Attachments: attachment_ids -> linked to template, attachments -> to mail.message"
)
self.assertEqual(mail.email_cc, self.test_template.email_cc)
self.assertEqual(mail.email_to, self.test_template.email_to)
self.assertEqual(mail.recipient_ids, self.partner_2 | self.user_admin.partner_id)
if idx >= 50:
self.assertEqual(mail.subject, f'EnglishSubject for {record.name}')
else:
self.assertEqual(mail.subject, f'SpanishSubject for {record.name}')
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_template_translation_lang(self):
test_record = self.env['mail.test.lang'].browse(self.test_record.ids)
""" Test template rendering using lang defined directly on the record """
test_record = self.test_record.with_env(self.env)
test_record.write({
'lang': 'es_ES',
})
test_template = self.env['mail.template'].browse(self.test_template.ids)
test_template = self.test_template.with_env(self.env)
mail_id = test_template.send_mail(test_record.id, email_layout_xmlid='mail.test_layout')
mail_id = test_template.send_mail(test_record.id)
mail = self.env['mail.mail'].sudo().browse(mail_id)
self.assertEqual(mail.body_html,
'<body><p>SpanishBody for %s</p> Spanish Layout para Spanish Model Description</body>' % self.test_record.name)
self.assertEqual(mail.subject, 'SpanishSubject for %s' % self.test_record.name)
f'<body><p>SpanishBody for {self.test_record.name}</p> Spanish Layout para Spanish Model Description</body>')
self.assertEqual(mail.subject, f'SpanishSubject for {self.test_record.name}')
@mute_logger('odoo.addons.mail.models.mail_mail')
@warmup
def test_template_translation_partner_lang(self):
test_record = self.env['mail.test.lang'].browse(self.test_record.ids)
customer = self.env['res.partner'].create({
'email': 'robert.carlos@test.example.com',
'lang': 'es_ES',
'name': 'Roberto Carlos',
})
test_record.write({
'customer_id': customer.id,
})
test_template = self.env['mail.template'].browse(self.test_template.ids)
""" Test template rendering using lang defined on a sub-record aka
'partner_id.lang' """
test_records = self.env['mail.test.lang'].browse(self.test_records.ids)
customers = self.env['res.partner'].create([
{
'email': 'roberto.carlos@test.example.com',
'lang': 'es_ES',
'name': 'Roberto Carlos',
}, {
'email': 'rob.charly@test.example.com',
'lang': 'en_US',
'name': 'Rob Charly',
}
])
test_records[0].write({'customer_id': customers[0].id})
test_records[1].write({'customer_id': customers[1].id})
mail_id = test_template.send_mail(test_record.id, email_layout_xmlid='mail.test_layout')
mail = self.env['mail.mail'].sudo().browse(mail_id)
self.assertEqual(mail.body_html,
'<body><p>SpanishBody for %s</p> Spanish Layout para Spanish Model Description</body>' % self.test_record.name)
self.assertEqual(mail.subject, 'SpanishSubject for %s' % self.test_record.name)
self.env.invalidate_all()
with self.with_user(self.user_employee.login), self.assertQueryCount(18):
template = self.test_template.with_env(self.env)
mails_sudo = template.send_mail_batch(self.test_records.ids, email_layout_xmlid='mail.test_layout')
self.assertEqual(mails_sudo[0].body_html,
f'<body><p>SpanishBody for {test_records[0].name}</p> Spanish Layout para Spanish Model Description</body>')
self.assertEqual(mails_sudo[0].subject, f'SpanishSubject for {test_records[0].name}')
self.assertEqual(mails_sudo[1].body_html,
f'<body><p>EnglishBody for {test_records[1].name}</p> English Layout for Lang Chatter Model</body>')
self.assertEqual(mails_sudo[1].subject, f'EnglishSubject for {test_records[1].name}')

View file

@ -2,11 +2,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.test_mail.tests.test_mail_template import TestMailTemplateCommon
from odoo.tests import tagged, users
from odoo.tests.common import Form
from odoo.tests import Form, tagged, users
@tagged('mail_template', 'multi_lang')
class TestMailTemplateTools(TestMailTemplateCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
@ -20,6 +21,63 @@ class TestMailTemplateTools(TestMailTemplateCommon):
self.assertEqual(len(self.test_template.partner_to.split(',')), 2)
self.assertTrue(self.test_record.email_from)
@users('employee')
def test_mail_template_preview_fields(self):
test_record = self.test_record.with_user(self.env.user)
test_record_ref = f'{test_record._name},{test_record.id}'
test_template = self.test_template.with_user(self.env.user)
# resource_ref: should not crash if no template (hence no model)
preview = Form(self.env['mail.template.preview'])
self.assertFalse(preview.has_attachments)
self.assertTrue(preview.has_several_languages_installed)
self.assertFalse(preview.resource_ref)
# mail_template_id being invisible, create a new one for template check
preview = Form(self.env['mail.template.preview'].with_context(default_mail_template_id=test_template.id))
self.assertTrue(preview.has_attachments)
self.assertTrue(preview.has_several_languages_installed)
self.assertEqual(preview.resource_ref, test_record_ref, 'Should take first (only) record by default')
def test_mail_template_preview_empty_database(self):
"""Check behaviour of the wizard when there is no record for the target model."""
self.env['mail.test.lang'].search([]).unlink()
test_template = self.env['mail.template'].browse(self.test_template.ids)
preview = self.env['mail.template.preview'].create({
'mail_template_id': test_template.id,
})
self.assertFalse(preview.error_msg)
for field in preview._MAIL_TEMPLATE_FIELDS:
if field in ['partner_to', 'report_template_ids']:
continue
self.assertEqual(test_template[field], preview[field])
def test_mail_template_preview_dynamic_attachment(self):
"""Check behaviour with templates that use reports."""
test_record = self.env['mail.test.lang'].browse(self.test_record.ids)
test_report = self.env['ir.actions.report'].sudo().create({
'name': 'Test Report',
'model': test_record._name,
'print_report_name': "'TestReport for %s' % object.name",
'report_type': 'qweb-pdf',
'report_name': 'test_mail.mail_test_ticket_test_template',
})
self.test_template.write({
'report_template_ids': test_report.ids,
'attachment_ids': False,
})
preview = self.env['mail.template.preview'].with_context({
'force_report_rendering': False, # this also invalidates the test records...
}).create({
'mail_template_id': self.test_template.id,
'resource_ref': test_record,
})
self.assertEqual(preview.body_html, f'<p>EnglishBody for {test_record.name}</p>')
self.assertFalse(preview.attachment_ids, 'Reports should not be listed in attachments')
def test_mail_template_preview_force_lang(self):
test_record = self.env['mail.test.lang'].browse(self.test_record.ids)
test_record.write({

View file

@ -1,14 +1,187 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from odoo import exceptions, tools
from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients
from odoo.tests.common import tagged
from odoo.addons.mail.tests.common import MailCommon
from odoo.addons.mail.tests.common_tracking import MailTrackingDurationMixinCase
from odoo.addons.test_mail.tests.common import TestRecipients
from odoo.tests.common import tagged, users
from odoo.tools import mute_logger
@tagged('mail_thread', 'mail_track', 'is_query_count')
class TestMailTrackingDurationMixin(MailTrackingDurationMixinCase):
@classmethod
def setUpClass(cls):
super().setUpClass('mail.test.track.duration.mixin')
def test_mail_tracking_duration(self):
self._test_record_duration_tracking()
def test_mail_tracking_duration_batch(self):
self._test_record_duration_tracking_batch()
def test_queries_batch_mail_tracking_duration(self):
self._test_queries_batch_duration_tracking()
@tagged('mail_thread', 'mail_track')
class TestMailThreadRottingMixin(MailTrackingDurationMixinCase):
@classmethod
def setUpClass(cls):
super().setUpClass('mail.test.rotting.resource')
[cls.stage_new, cls.stage_qualification, cls.stage_finished] = cls.env['mail.test.rotting.stage'].create([
{
'name': 'stage_new',
'rotting_threshold_days': 3,
}, {
'name': 'stage_qualification',
'rotting_threshold_days': 5,
}, {
'name': 'stage_finished',
'rotting_threshold_days': 1,
'no_rot': True,
},
])
def test_resource_rotting(self):
# create dates for the test
jan1 = datetime(2025, 1, 1)
jan5 = datetime(2025, 1, 5)
jan7 = datetime(2025, 1, 7)
jan12 = datetime(2025, 1, 12)
jan28 = datetime(2025, 1, 28)
# create resources for the test, created on jan 1
with self.mock_datetime_and_now(jan1):
items = [item1, item2, item3, item_done, item_won] = self.env['mail.test.rotting.resource'].create([
{
'name': 'item1',
'stage_id': self.stage_new.id,
}, {
'name': 'item2',
'stage_id': self.stage_qualification.id,
}, {
'name': 'item3',
'stage_id': self.stage_new.id,
}, {
'name': 'item_done',
'stage_id': self.stage_qualification.id,
'done': True,
}, {
'name': 'item_wonStage',
'stage_id': self.stage_finished.id,
},
])
items.flush_recordset(['date_last_stage_update']) # precalculate stage update()
with self.mock_datetime_and_now(jan5):
# need to invalidate on date change to ensure rotting computations
items.invalidate_recordset(['is_rotting'])
for item in [item1, item3]:
self.assertTrue(
item.is_rotting,
'on jan 5: it\'s been four days, so only items in stage_new should be rotting',
)
self.assertEqual(item.rotting_days, 4)
for item in [item2, item_done, item_won]:
self.assertFalse(
item.is_rotting,
'on jan 5: it\'s been four days, so only items in stage_new should be rotting',
)
self.assertEqual(item.rotting_days, 0)
item3.name = 'item3 edited'
self.assertTrue(
item3.is_rotting,
'writing to an item doesn\'t affect its rotting status',
)
with self.mock_datetime_and_now(jan7):
items.invalidate_recordset(['is_rotting'])
self.assertTrue(
item2.is_rotting,
'on jan 7: items belonging to stage_qualification should be rotting, except if their state forbids it',
)
self.assertEqual(item2.rotting_days, 6)
self.assertFalse(
item_done.is_rotting,
'item_done is marked as done, it should not be able to rot',
)
self.assertTrue(item1.is_rotting)
item1.message_post(body='Message received', message_type='email')
self.assertTrue(
item1.is_rotting,
'Receiving an email should not remove rotting',
)
item1.message_post(body='Message sent', message_type='email_outgoing')
self.assertTrue(
item1.is_rotting,
'Nor should sending an email',
)
self.assertFalse(
item_won.is_rotting,
'Items in stage_finished cannot rot',
)
self.stage_finished.no_rot = False
self.assertTrue(
item_won.is_rotting,
'However if the stage no longer disallows rotting, then all items in the stage may once more rot',
)
self.stage_finished.no_rot = True
self.assertFalse(
item_won.is_rotting,
'Disallowing rotting once again should disable rotting once more',
)
with self.mock_datetime_and_now(jan12):
items.invalidate_recordset(['rotting_days', 'is_rotting'])
self.assertTrue(item3.is_rotting)
self.stage_new.rotting_threshold_days = 40
self.assertFalse(
item3.is_rotting,
'Changing the threshold should affect the status immediately)',
)
self.stage_new.rotting_threshold_days = 1
item3.stage_id = self.stage_qualification
self.assertFalse(
item3.is_rotting,
'Changing stages always removes rotting',
)
self.stage_qualification.rotting_threshold_days = 0
self.assertFalse(
item2.is_rotting,
'Setting rotting_threshold_days at 0 on a stage immediately disables rotting for the stage',
)
with self.mock_datetime_and_now(jan28):
items.invalidate_recordset(['rotting_days', 'is_rotting'])
# After a significant amount of time has passed:
self.assertTrue(
item1.is_rotting,
'Items that are not done or won are rotting',
)
for item in [item2, item3, item_done, item_won]:
self.assertFalse(
item.is_rotting,
'Items that are not done, won, or in a disabled rotting stage are not rotting',
)
@tagged('mail_thread', 'mail_blacklist')
class TestMailThread(TestMailCommon, TestRecipients):
class TestMailThread(MailCommon, TestRecipients):
@mute_logger('odoo.models.unlink')
def test_blacklist_mixin_email_normalized(self):
@ -58,3 +231,31 @@ class TestMailThread(TestMailCommon, TestRecipients):
self.assertTrue(new_record.is_blacklisted)
bl_record.unlink()
@tagged('mail_thread', 'mail_thread_cc', 'mail_tools')
class TestMailThreadCC(MailCommon):
@users("employee")
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_suggested_recipients_mail_cc(self):
""" MailThreadCC mixin adds its own suggested recipients management
coming from CC (carbon copy) management. """
record = self.env['mail.test.cc'].create({
'email_cc': 'cc1@example.com, cc2@example.com, cc3 <cc3@example.com>',
})
suggestions = record._message_get_suggested_recipients(no_create=True)
expected_list = [
{
'name': '', 'email': 'cc1@example.com',
'partner_id': False, 'create_values': {},
}, {
'name': '', 'email': 'cc2@example.com',
'partner_id': False, 'create_values': {},
}, {
'name': 'cc3', 'email': 'cc3@example.com',
'partner_id': False, 'create_values': {},
}]
self.assertEqual(len(suggestions), len(expected_list))
for suggestion, expected in zip(suggestions, expected_list):
self.assertDictEqual(suggestion, expected)

View file

@ -1,130 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.test_mail.tests.common import TestMailCommon
from odoo.tests import tagged
from odoo.tools import mute_logger
@tagged('mail_wizards')
class TestMailResend(TestMailCommon):
@classmethod
def setUpClass(cls):
super(TestMailResend, cls).setUpClass()
cls.test_record = cls.env['mail.test.simple'].with_context(cls._test_context).create({'name': 'Test', 'email_from': 'ignasse@example.com'})
#Two users
cls.user1 = mail_new_test_user(cls.env, login='e1', groups='base.group_user', name='Employee 1', notification_type='email', email='e1') # invalid email
cls.user2 = mail_new_test_user(cls.env, login='e2', groups='base.group_portal', name='Employee 2', notification_type='email', email='e2@example.com')
#Two partner
cls.partner1 = cls.env['res.partner'].with_context(cls._test_context).create({
'name': 'Partner 1',
'email': 'p1' # invalid email
})
cls.partner2 = cls.env['res.partner'].with_context(cls._test_context).create({
'name': 'Partner 2',
'email': 'p2@example.com'
})
cls.partners = cls.env['res.partner'].concat(cls.user1.partner_id, cls.user2.partner_id, cls.partner1, cls.partner2)
cls.invalid_email_partners = cls.env['res.partner'].concat(cls.user1.partner_id, cls.partner1)
# @mute_logger('odoo.addons.mail.models.mail_mail')
def test_mail_resend_workflow(self):
with self.assertSinglePostNotifications(
[{'partner': partner, 'type': 'email', 'status': 'exception'} for partner in self.partners],
message_info={'message_type': 'notification'}):
def _connect(*args, **kwargs):
raise Exception("Some exception")
self.connect_mocked.side_effect = _connect
message = self.test_record.with_user(self.user_admin).message_post(partner_ids=self.partners.ids, subtype_xmlid='mail.mt_comment', message_type='notification')
wizard = self.env['mail.resend.message'].with_context({'mail_message_to_resend': message.id}).create({})
self.assertEqual(wizard.notification_ids.mapped('res_partner_id'), self.partners, "wizard should manage notifications for each failed partner")
# three more failure sent on bus, one for each mail in failure and one for resend
self._reset_bus()
expected_bus_notifications = [
(self.cr.dbname, 'res.partner', self.partner_admin.id),
(self.cr.dbname, 'res.partner', self.env.user.partner_id.id),
]
with self.mock_mail_gateway(), self.assertBus(expected_bus_notifications * 3):
wizard.resend_mail_action()
done_msgs, done_notifs = self.assertMailNotifications(message, [
{'content': '', 'message_type': 'notification',
'notif': [{'partner': partner, 'type': 'email', 'status': 'exception' if partner in self.user1.partner_id | self.partner1 else 'sent'} for partner in self.partners]}]
)
self.assertEqual(wizard.notification_ids, done_notifs)
self.assertEqual(done_msgs, message)
self.user1.write({"email": 'u1@example.com'})
# two more failure update sent on bus, one for failed mail and one for resend
self._reset_bus()
with self.mock_mail_gateway(), self.assertBus(expected_bus_notifications * 2):
self.env['mail.resend.message'].with_context({'mail_message_to_resend': message.id}).create({}).resend_mail_action()
done_msgs, done_notifs = self.assertMailNotifications(message, [
{'content': '', 'message_type': 'notification',
'notif': [{'partner': partner, 'type': 'email', 'status': 'exception' if partner == self.partner1 else 'sent', 'check_send': partner == self.partner1} for partner in self.partners]}]
)
self.assertEqual(wizard.notification_ids, done_notifs)
self.assertEqual(done_msgs, message)
self.partner1.write({"email": 'p1@example.com'})
# A success update should be sent on bus once the email has no more failure
self._reset_bus()
with self.mock_mail_gateway(), self.assertBus(expected_bus_notifications):
self.env['mail.resend.message'].with_context({'mail_message_to_resend': message.id}).create({}).resend_mail_action()
self.assertMailNotifications(message, [
{'content': '', 'message_type': 'notification',
'notif': [{'partner': partner, 'type': 'email', 'status': 'sent', 'check_send': partner == self.partner1} for partner in self.partners]}]
)
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_remove_mail_become_canceled(self):
# two failure sent on bus, one for each mail
self._reset_bus()
with self.mock_mail_gateway(), self.assertBus([(self.cr.dbname, 'res.partner', self.partner_admin.id)] * 2):
message = self.test_record.with_user(self.user_admin).message_post(partner_ids=self.partners.ids, subtype_xmlid='mail.mt_comment', message_type='notification')
self.assertMailNotifications(message, [
{'content': '', 'message_type': 'notification',
'notif': [{'partner': partner, 'type': 'email', 'status': 'exception' if partner in self.user1.partner_id | self.partner1 else 'sent'} for partner in self.partners]}]
)
wizard = self.env['mail.resend.message'].with_context({'mail_message_to_resend': message.id}).create({})
partners = wizard.partner_ids.mapped("partner_id")
self.assertEqual(self.invalid_email_partners, partners)
wizard.partner_ids.filtered(lambda p: p.partner_id == self.partner1).write({"resend": False})
wizard.resend_mail_action()
self.assertMailNotifications(message, [
{'content': '', 'message_type': 'notification',
'notif': [{'partner': partner, 'type': 'email',
'status': (partner == self.user1.partner_id and 'exception') or (partner == self.partner1 and 'canceled') or 'sent'} for partner in self.partners]}]
)
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_cancel_all(self):
self._reset_bus()
with self.mock_mail_gateway(), self.assertBus([(self.cr.dbname, 'res.partner', self.partner_admin.id)] * 2):
message = self.test_record.with_user(self.user_admin).message_post(partner_ids=self.partners.ids, subtype_xmlid='mail.mt_comment', message_type='notification')
wizard = self.env['mail.resend.message'].with_context({'mail_message_to_resend': message.id}).create({})
# one update for cancell
self._reset_bus()
expected_bus_notifications = [
(self.cr.dbname, 'res.partner', self.partner_admin.id),
(self.cr.dbname, 'res.partner', self.env.user.partner_id.id),
]
with self.mock_mail_gateway(), self.assertBus(expected_bus_notifications):
wizard.cancel_mail_action()
self.assertMailNotifications(message, [
{'content': '', 'message_type': 'notification',
'notif': [{'partner': partner, 'type': 'email',
'check_send': partner in self.user1.partner_id | self.partner1,
'status': 'canceled' if partner in self.user1.partner_id | self.partner1 else 'sent'} for partner in self.partners]}]
)

View file

@ -1,21 +0,0 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests
from odoo import Command
@odoo.tests.tagged('post_install', '-at_install')
class TestUi(odoo.tests.HttpCase):
def test_01_mail_tour(self):
self.start_tour("/web", 'mail_tour', login="admin")
def test_02_mail_create_channel_no_mail_tour(self):
self.env['res.users'].create({
'email': '', # User should be able to create a channel even if no email is defined
'groups_id': [Command.set([self.ref('base.group_user')])],
'name': 'Test User',
'login': 'testuser',
'password': 'testuser',
})
self.start_tour("/web", 'mail_tour', login='testuser')