19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -1,22 +1,27 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_discuss_controller
from . import test_get_model_definitions
from . import test_discuss_tools
from . import test_fetchmail
from . import test_font_to_img
from . import test_ir_mail_server
from . import test_ir_ui_menu
from . import test_ir_websocket
from . import test_link_preview
from . import test_mail_channel
from . import test_mail_channel_as_guest
from . import test_mail_channel_member
from . import test_mail_activity
from . import test_mail_blacklist
from . import test_mail_composer
from . import test_mail_full_composer
from . import test_mail_mail
from . import test_mail_mail_stable_selection
from . import test_mail_message
from . import test_mail_message_translate
from . import test_mail_presence
from . import test_mail_render
from . import test_mail_template
from . import test_mail_tools
from . import test_res_partner
from . import test_res_role
from . import test_res_users
from . import test_res_users_settings
from . import test_rtc
from . import test_uninstall
from . import test_update_notification
from . import test_user_modify_own_profile
from . import test_websocket_controller
from .discuss import *

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,146 @@
from contextlib import contextmanager
from unittest.mock import patch
from odoo.addons.mail.models.mail_activity import MailActivity
from odoo.addons.mail.tests.common import MailCommon
from odoo.tests import Form
from odoo.tools.misc import format_date
class ActivityScheduleCase(MailCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
# prepare activities
cls.activity_type_todo = cls.env.ref('mail.mail_activity_data_todo')
cls.activity_type_todo.delay_count = 4
cls.activity_type_todo.sequence = 1
cls.activity_type_todo.summary = "TodoSummary"
cls.activity_type_call = cls.env.ref('mail.mail_activity_data_call')
cls.activity_type_call.delay_count = 1
cls.activity_type_call.sequence = 2
cls.activity_type_call.summary = "TodoSumCallSummary"
def reverse_record_set(self, records):
""" Get an equivalent recordset but with elements in reversed order. """
return self.env[records._name].browse([record.id for record in reversed(records)])
def get_last_activities(self, on_record, limit=None):
""" Get the last activities on the record in id asc order. """
return self.reverse_record_set(self.env['mail.activity'].search(
[('res_model', '=', on_record._name), ('res_id', '=', on_record.id)], order='id desc', limit=limit))
# ------------------------------------------------------------
# ACTIVITIES MOCK
# ------------------------------------------------------------
@contextmanager
def _mock_activities(self):
activity_create_origin = MailActivity.create
self._new_activities = self.env['mail.activity'].sudo()
def _activity_create(model, *args, **kwargs):
res = activity_create_origin(model, *args, **kwargs)
self._new_activities += res.sudo()
return res
with patch.object(
MailActivity, 'create', autospec=True, wraps=MailActivity,
side_effect=_activity_create
) as activity_create_mocked:
self.activity_create_mocked = activity_create_mocked
yield
def assertActivityValues(self, activity, activity_values):
self.assertEqual(len(activity), 1)
for fname, fvalue in activity_values.items():
with self.subTest(fname=fname):
self.assertEqual(activity[fname], fvalue)
def assertActivityCreatedOnRecord(self, record, activity_values):
activity = self._new_activities.filtered(
lambda act: act.res_model == record._name and act.res_id == record.id
)
self.assertTrue(activity)
self.assertActivityValues(activity, activity_values)
def assertActivityDoneOnRecord(self, record, activity_type):
last_message = record.message_ids[0]
self.assertEqual(last_message.mail_activity_type_id, activity_type)
self.assertIn(activity_type.name, last_message.body)
self.assertIn('done', last_message.body)
def assertActivitiesFromPlan(self, plan, record, expected_deadlines, expected_responsible=None):
""" Check that the last activities on the record correspond to the one
that the plan must create (number of activities and activities content).
We check the created activities values against the template values because
most of them are just copied when creating activities from templates except
for deadlines and responsible for which we pass the expected values as parameters.
:param <mail.activity.plan> plan: activity plan that has been applied on the record
:param recordset record: record on which the plan has been applied
:param list<date> expected_deadlines: expected deadlines of the record created activities
:param <res.user> expected_responsible: expected responsible for the created activities
if set, otherwise checked against the responsible set on the related templates.
"""
expected_number_of_activity = len(plan.template_ids)
activities = self._new_activities.filtered(
lambda act: act.res_model == record._name and act.res_id == record.id
)
self.assertEqual(len(activities), expected_number_of_activity)
for activity, template, expected_deadline in zip(activities, plan.template_ids, expected_deadlines):
self.assertEqual(activity.activity_type_id, template.activity_type_id)
self.assertEqual(activity.date_deadline, expected_deadline)
self.assertEqual(activity.note, template.note)
self.assertEqual(activity.summary, template.summary)
self.assertFalse(activity.automated)
if expected_responsible:
self.assertEqual(activity.user_id, expected_responsible)
else:
self.assertEqual(activity.user_id, template.responsible_id or self.env.user)
def assertMessagesFromPlan(self, plan, record, expected_deadlines, expected_responsible=None):
""" Check that the last posted message on the record correspond to the one
that the plan must generate (number of activities and activities content).
:param <mail.activity.plan> plan: activity plan that has been applied on the record
:param recordset record: record on which the plan has been applied
:param list<date> expected_deadlines: expected deadlines of the record created activities
:param <res.user> expected_responsible: expected responsible for the created activities
if set, otherwise checked against the responsible set on the related templates.
"""
message = record.message_ids[0]
self.assertIn(f'The plan "{plan.name}" has been started', message.body)
for template, expected_deadline in zip(plan.template_ids, expected_deadlines):
if expected_responsible:
responsible_id = expected_responsible
else:
responsible_id = template.responsible_id or self.env.user
self.assertIn(template.summary, message.body)
self.assertIn(f'{template.summary or template.activity_type_id.name}, '
f'assigned to {responsible_id.name}, due on the '
f'{format_date(self.env, expected_deadline)}', message.body)
def assertPlanExecution(self, plan, records, expected_deadlines, expected_responsible=None):
""" Check that the plan has created the right activities and send the
right message on the records (see assertActivitiesFromPlan and
assertMessagesFromPlan). """
for record in records:
self.assertActivitiesFromPlan(plan, record, expected_deadlines, expected_responsible)
self.assertMessagesFromPlan(plan, record, expected_deadlines, expected_responsible)
def _instantiate_activity_schedule_wizard(self, records, additional_context_value=None):
""" Get a new Form with context default values referring to the records. """
ctx = {
'active_id': records.ids[0],
'active_ids': records.ids,
'active_model': records._name,
} if records else {}
ctx.update(**(additional_context_value or {}))
return Form(self.env['mail.activity.schedule'].with_context(ctx))

View file

@ -0,0 +1,327 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from markupsafe import Markup
from requests.exceptions import HTTPError
from odoo import fields
from odoo.addons.base.tests.common import HttpCase
from odoo.addons.mail.tests.common import mail_new_test_user, MailCommon
from odoo.http import Request
from odoo.tests import JsonRpcException
from odoo.tools import file_open, mute_logger
class MessagePostSubTestData:
def __init__(self, user, allowed, /, *, partners=None, partner_emails=None, add_mention_token=False, route_kw=None,
exp_author=None, exp_partners=None, exp_emails=None):
self.user = user if user._name == "res.users" else user.env.ref("base.public_user")
self.guest = user if user._name == "mail.guest" else user.env["mail.guest"]
self.allowed = allowed
self.route_kw = {
"context": {"mail_post_autofollow_author_skip": True, "mail_post_autofollow": False},
**(route_kw or {}),
}
self.post_data = {
"body": "<p>Hello</p>",
"message_type": "comment",
"subtype_xmlid": "mail.mt_comment",
}
if partner_emails is not None:
self.post_data["partner_emails"] = partner_emails
if partners is not None:
self.post_data["partner_ids"] = partners.ids
if add_mention_token:
self.post_data["partner_ids_mention_token"] = {
partner.id: partner._get_mention_token() for partner in partners
}
self.exp_author = exp_author
self.exp_partners = exp_partners
self.exp_emails = exp_emails
class MailControllerCommon(HttpCase, MailCommon):
# Note that '_get_with_access' is going to call '_get_thread_with_access'
# which relies on classic portal parameter given as kwargs on most routes
# (aka hash, token, pid)
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.maxDiff = None
cls._create_portal_user()
cls.guest = cls.env["mail.guest"].create({"name": "Guest"})
last_message = cls.env["mail.message"].search([], order="id desc", limit=1)
cls.fake_message = cls.env["mail.message"].browse(last_message.id + 1000000)
cls.user_employee_nopartner = mail_new_test_user(
cls.env,
company_id=cls.company_admin.id,
country_id=cls.env.ref('base.be').id,
groups='base.group_user,mail.group_mail_template_editor',
login='employee_nopartner',
name='Elodie EmployeeNoPartner',
notification_type='inbox',
)
# whatever default creation values, we need a "no partner manager" user
cls.user_employee_nopartner.write({'group_ids': [(3, cls.env.ref('base.group_partner_manager').id)]})
def _authenticate_pseudo_user(self, pseudo_user):
user = pseudo_user if pseudo_user._name == "res.users" else self.user_public
guest = pseudo_user if pseudo_user._name == "mail.guest" else self.env["mail.guest"]
if user and user != self.user_public:
self.authenticate(user.login, user.login)
else:
self.authenticate(None, None)
if guest:
self.opener.cookies[guest._cookie_name] = guest._format_auth_cookie()
return user, guest
def _get_sign_token_params(self, record):
if 'access_token' not in record:
raise ValueError("Test should run with portal installed")
access_token = record._portal_ensure_token()
partner = record.env["res.partner"].create({"name": "Sign Partner"})
_hash = record._sign_token(partner.id)
token = {"token": access_token}
bad_token = {"token": "incorrect token"}
sign = {"hash": _hash, "pid": partner.id}
bad_sign = {"hash": "incorrect hash", "pid": partner.id}
return token, bad_token, sign, bad_sign, partner
class MailControllerAttachmentCommon(MailControllerCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.all_users = (
cls.guest,
cls.user_admin,
cls.user_employee,
cls.user_portal,
cls.user_public,
)
def _execute_subtests_upload(self, document, subtests):
for data_user, allowed, *args in subtests:
route_kw = args[0] if args else {}
user, guest = self._authenticate_pseudo_user(data_user)
with self.subTest(document=document, user=user.name, guest=guest.name, route_kw=route_kw):
if allowed:
attachment_id = self._upload_attachment(document, route_kw)
attachment = self.env["ir.attachment"].sudo().search([("id", "=", attachment_id)])
self.assertTrue(attachment)
else:
with self.assertRaises(
HTTPError, msg="upload attachment should raise NotFound"
):
self._upload_attachment(document, route_kw)
def _execute_subtests_delete(self, users, token, allowed, thread=None):
for user_data in users:
user, guest = self._authenticate_pseudo_user(user_data)
with self.subTest(
user=user.name, guest=guest.name, token=token, allowed=allowed, thread=thread,
):
attachment = self.env["ir.attachment"].create({"name": "sample attachment"})
if thread:
attachment.write({"res_model": thread._name, "res_id": thread.id})
if allowed:
self._delete_attachment(attachment, token)
self.assertFalse(attachment.exists())
else:
with self.assertRaises(JsonRpcException, msg="Wrong access token"):
self._delete_attachment(attachment, token)
def _upload_attachment(self, document, route_kw):
with mute_logger("odoo.http"), file_open("addons/web/__init__.py") as file:
res = self.url_open(
url="/mail/attachment/upload",
data={
"csrf_token": Request.csrf_token(self),
"is_pending": True,
"thread_id": document.id,
"thread_model": document._name,
**route_kw,
},
files={"ufile": file},
)
res.raise_for_status()
data = json.loads(res.content.decode("utf-8"))["data"]
self.assertIn(
data["attachment_id"],
[a["id"] for a in data["store_data"]["ir.attachment"]]
)
return data["attachment_id"]
def _delete_attachment(self, attachment, token):
with mute_logger("odoo.http"):
self.make_jsonrpc_request(
route="/mail/attachment/delete",
params={
"attachment_id": attachment.id,
"access_token": attachment._get_ownership_token() if token else None,
},
)
class MailControllerBinaryCommon(MailControllerCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.guest_2 = cls.env["mail.guest"].create({"name": "Guest 2"})
def _execute_subtests(self, record, subtests):
for data_user, allowed in subtests:
user, guest = self._authenticate_pseudo_user(data_user)
with self.subTest(user=user.name, guest=guest.name, record=record):
if allowed:
self.assertEqual(
self._get_avatar_url(record).headers["Content-Disposition"],
f'inline; filename="{record.name}.svg"',
)
else:
self.assertEqual(
self._get_avatar_url(record).headers["Content-Disposition"],
"inline; filename=placeholder.png",
)
def _get_avatar_url(self, record):
url = f"/web/image?field=avatar_128&id={record.id}&model={record._name}&unique={fields.Datetime.to_string(record.write_date)}"
return self.url_open(url)
def _post_message(self, document, auth_pseudo_user):
_user, _guest = self._authenticate_pseudo_user(auth_pseudo_user)
self.make_jsonrpc_request(
route="/mail/message/post",
params={
"thread_model": document._name,
"thread_id": document.id,
"post_data": {
"body": "Test",
"message_type": "comment",
"subtype_xmlid": "mail.mt_comment",
},
},
)
class MailControllerReactionCommon(MailControllerCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.reaction = "😊"
def _execute_subtests(self, message, subtests):
for data_user, allowed, *args in subtests:
route_kw = args[0] if args else {}
kwargs = args[1] if len(args) > 1 else {}
user, guest = self._authenticate_pseudo_user(data_user)
with self.subTest(user=user.name, guest=guest.name, route_kw=route_kw):
if allowed:
self._add_reaction(message, self.reaction, route_kw)
reactions = message.reaction_ids
self.assertEqual(len(reactions), 1)
expected_partner = kwargs.get("partner")
if guest and not expected_partner:
self.assertEqual(reactions.guest_id, guest)
else:
self.assertEqual(reactions.partner_id, expected_partner or user.partner_id)
self._remove_reaction(message, self.reaction, route_kw)
self.assertFalse(message.reaction_ids)
else:
with self.assertRaises(
JsonRpcException, msg="add reaction should raise NotFound"
):
self._add_reaction(message, self.reaction, route_kw)
with self.assertRaises(
JsonRpcException, msg="remove reaction should raise NotFound"
):
self._remove_reaction(message, self.reaction, route_kw)
def _add_reaction(self, message, content, route_kw):
self.make_jsonrpc_request(
route="/mail/message/reaction",
params={"action": "add", "content": content, "message_id": message.id, **route_kw},
)
def _remove_reaction(self, message, content, route_kw):
self.make_jsonrpc_request(
route="/mail/message/reaction",
params={"action": "remove", "content": content, "message_id": message.id, **route_kw},
)
class MailControllerThreadCommon(MailControllerCommon):
def _execute_message_post_subtests(self, record, tests: list[MessagePostSubTestData]):
for test in tests:
self._authenticate_pseudo_user(test.user if (test.user and test.user != self.user_public) else test.guest)
with self.subTest(record=record, user=test.user.name, guest=test.guest.name, route_kw=test.route_kw):
if test.allowed:
message = self._message_post(record, test.post_data, test.route_kw)
if test.guest and not test.exp_author:
self.assertEqual(message.author_guest_id, test.guest)
else:
self.assertEqual(message.author_id, test.exp_author or test.user.partner_id)
if test.exp_partners is not None:
self.assertEqual(message.partner_ids, test.exp_partners)
if test.exp_emails is not None:
self.assertEqual(message.partner_ids.mapped("email"), test.exp_emails)
else:
with self.assertRaises(JsonRpcException, msg="werkzeug.exceptions.NotFound"):
self._message_post(record, test.post_data, test.route_kw)
def _message_post(self, record, post_data, route_kw):
self.make_jsonrpc_request(
route="/mail/message/post",
params={
"thread_model": record._name,
"thread_id": record.id,
"post_data": post_data,
**route_kw,
},
)
return self.env["mail.message"].search(
[("res_id", "=", record.id), ("model", "=", record._name)], order="id desc", limit=1
)
class MailControllerUpdateCommon(MailControllerCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.message_body = "Message body"
cls.alter_message_body = "Altered message body"
def _execute_subtests(self, message, subtests):
for data_user, allowed, *args in subtests:
route_kw = args[0] if args else {}
user, guest = self._authenticate_pseudo_user(data_user)
with self.subTest(user=user.name, guest=guest.name, route_kw=route_kw):
if allowed:
self._update_content(message.id, self.alter_message_body, route_kw)
self.assertEqual(message.body,
Markup('<p>Altered message body<span class="o-mail-Message-edited"></span></p>'))
else:
with self.assertRaises(
JsonRpcException,
msg="update message content should raise NotFound",
):
self._update_content(message.id, self.alter_message_body, route_kw)
def _update_content(self, message_id, body, route_kw):
self.make_jsonrpc_request(
route="/mail/message/update_content",
params={
"message_id": message_id,
"update_data": {
"body": body,
"attachment_ids": [],
},
**route_kw,
},
)

View file

@ -0,0 +1,196 @@
from collections import defaultdict
from datetime import datetime, timedelta
from unittest.mock import patch
from odoo.addons.mail.tests.common import MailCommon
class MailTrackingDurationMixinCase(MailCommon):
@classmethod
def setUpClass(cls, tested_model, model_fields=None):
super().setUpClass()
if model_fields:
for field in model_fields:
if model_fields[field] == 'create':
model_fields[field] = cls.env[tested_model][field].create({'name': 'test'}).id
stage_1_values = {'name': 'Stage 1'}
stage_2_values = {'name': 'Stage 2'}
stage_3_values = {'name': 'Stage 3'}
stage_4_values = {'name': 'Stage 4'}
cls.track_duration_field = cls.env[tested_model]._track_duration_field
stage_model = cls.env[tested_model][cls.track_duration_field]
cls.stage_1 = stage_model.create(stage_1_values)
cls.stage_2 = stage_model.create(stage_2_values)
cls.stage_3 = stage_model.create(stage_3_values)
cls.stage_4 = stage_model.create(stage_4_values)
record_values = {'name': 'test record', cls.track_duration_field: cls.stage_1.id}
if model_fields:
record_values.update(model_fields)
cls.mock_start_time = datetime(2023, 2, 15, 12, 0, 0)
with patch.object(cls.env.cr, 'now', return_value=cls.mock_start_time):
cls.rec_1, cls.rec_2, cls.rec_3, cls.rec_4 = cls.env[tested_model].create(
[record_values for i in range(4)])
cls.flush_tracking(cls)
def _update_duration_tracking(self, record_to_tracking_dic, minutes, new_stage=False):
"""
Updates the mock duration_tracking field for multiple records based on the provided minutes.
If new_stage is defined, the stage of the records is updated as well.
Args:
record_to_tracking_dic (list): A list of tuples mapping records to their respective tracking dictionaries.
minutes (int): The number of minutes to be added to the duration tracking, which will be converted to seconds.
new_stage (int, optional): Indicated the new stage to be set for the records. Defaults to False.
"""
for record, tracking_dic in record_to_tracking_dic:
tracking_dic[str(record[self.track_duration_field].id)] += minutes * 60
if new_stage:
tracking_dic[str(new_stage.id)] += 0
record[self.track_duration_field] = new_stage
self.flush_tracking()
def assertTrackingDuration(self, records, record_to_tracking_dic):
"""
Asserts whether for multiple records their duration_tracking is equal to a dictionary
Args:
records (recordset): all the records that need to be asserted
record_to_tracking_dic (list): A list of tuples mapping records to their respective tracking dictionaries.
"""
records._compute_duration_tracking()
for record, tracking_dic in record_to_tracking_dic:
self.assertDictEqual(dict(tracking_dic), record.duration_tracking)
def _test_record_duration_tracking(self):
"""
Moves a record's many2one field through several values and asserts the duration spent in that value each time
"""
with patch.object(self.env.cr, 'now', return_value=self.mock_start_time) as now:
track_duration_tracking = defaultdict(lambda: 0)
record = self.rec_1
minutes = 5
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking([(record, track_duration_tracking)], minutes, self.stage_2)
self.assertTrackingDuration(record, [(record, track_duration_tracking)])
minutes = 100
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking([(record, track_duration_tracking)], minutes)
self.assertTrackingDuration(record, [(record, track_duration_tracking)])
minutes = 5000
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking([(record, track_duration_tracking)], minutes, self.stage_3)
self.assertTrackingDuration(record, [(record, track_duration_tracking)])
minutes = 5000
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking([(record, track_duration_tracking)], minutes, self.stage_4)
self.assertTrackingDuration(record, [(record, track_duration_tracking)])
minutes = 20
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking([(record, track_duration_tracking)], minutes, self.stage_2)
self.assertTrackingDuration(record, [(record, track_duration_tracking)])
minutes = 55
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking([(record, track_duration_tracking)], minutes, self.stage_4)
self.assertTrackingDuration(record, [(record, track_duration_tracking)])
minutes = 200
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking([(record, track_duration_tracking)], minutes)
self.assertTrackingDuration(record, [(record, track_duration_tracking)])
minutes = 300
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking([(record, track_duration_tracking)], minutes, self.stage_3)
self.assertTrackingDuration(record, [(record, track_duration_tracking)])
def _test_record_duration_tracking_batch(self):
"""
Moves for a batch of records many2one field through several values and asserts the duration
spent in that value each time.
"""
with patch.object(self.env.cr, 'now', return_value=self.mock_start_time) as now:
track_duration_tracking1 = defaultdict(lambda: 0)
track_duration_tracking2 = defaultdict(lambda: 0)
track_duration_tracking3 = defaultdict(lambda: 0)
batch = self.rec_1 | self.rec_2 | self.rec_3
record_to_tracking_dic = [
(self.rec_1, track_duration_tracking1),
(self.rec_2, track_duration_tracking2),
(self.rec_3, track_duration_tracking3)
]
minutes = 5
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking(record_to_tracking_dic, minutes, self.stage_2)
self.assertTrackingDuration(batch, record_to_tracking_dic)
minutes = 100
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking(record_to_tracking_dic, minutes)
self.assertTrackingDuration(batch, record_to_tracking_dic)
minutes = 5000
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking(record_to_tracking_dic, minutes, self.stage_3)
self.assertTrackingDuration(batch, record_to_tracking_dic)
minutes = 5000
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking(record_to_tracking_dic, minutes, self.stage_4)
self.assertTrackingDuration(batch, record_to_tracking_dic)
minutes = 20
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking(record_to_tracking_dic, minutes, self.stage_2)
self.assertTrackingDuration(batch, record_to_tracking_dic)
minutes = 55
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking(record_to_tracking_dic, minutes, self.stage_4)
self.assertTrackingDuration(batch, record_to_tracking_dic)
minutes = 200
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking(record_to_tracking_dic, minutes)
self.assertTrackingDuration(batch, record_to_tracking_dic)
minutes = 300
now.return_value += timedelta(minutes=minutes)
self._update_duration_tracking(record_to_tracking_dic, minutes, self.stage_3)
self.assertTrackingDuration(batch, record_to_tracking_dic)
def _test_queries_batch_duration_tracking(self):
"""
The MailTrackingDuration mixin is only supposed to add 3 queries
"""
batch = self.rec_1 | self.rec_2 | self.rec_3 | self.rec_4
batch[self.track_duration_field] = self.stage_2.id
self.flush_tracking()
batch[self.track_duration_field] = self.stage_4.id
self.flush_tracking()
batch[self.track_duration_field] = self.stage_1.id
self.flush_tracking()
batch[self.track_duration_field] = self.stage_3.id
self.flush_tracking()
batch[self.track_duration_field] = self.stage_2.id
with self.assertQueryCount(2):
batch._compute_duration_tracking()

View file

@ -0,0 +1,25 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_avatar_acl
from . import test_discuss_attachment_controller
from . import test_discuss_binary_controller
from . import test_discuss_channel_invite
from . import test_discuss_action
from . import test_discuss_channel
from . import test_discuss_channel_access
from . import test_discuss_channel_as_guest
from . import test_discuss_channel_member
from . import test_discuss_mail_presence
from . import test_discuss_mention_suggestions
from . import test_discuss_message_update_controller
from . import test_discuss_reaction_controller
from . import test_discuss_res_role
from . import test_discuss_sub_channels
from . import test_discuss_thread_controller
from . import test_guest
from . import test_message_controller
from . import test_guest_feature
from . import test_toggle_upload
from . import test_load_messages
from . import test_rtc
from . import test_ui

View file

@ -0,0 +1,204 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, Command
from odoo.tests import HttpCase
from odoo.tests.common import tagged
@tagged("post_install", "-at_install")
class TestAvatarAcl(HttpCase):
def get_avatar_url(self, record, add_token=False):
access_token = ""
if add_token:
access_token = f"&access_token={record._get_avatar_128_access_token()}"
return f"/web/image?field=avatar_128&id={record.id}&model={record._name}&unique={fields.Datetime.to_string(record.write_date)}{access_token}"
def test_partner_open_guest_avatar(self):
self.env["res.users"].create(
{
"email": "testuser@testuser.com",
"group_ids": [Command.set([self.ref("base.group_user")])],
"name": "Test User",
"login": "testuser",
"password": "testuser",
}
)
self.authenticate("testuser", "testuser")
guest = self.env["mail.guest"].create({"name": "Guest"})
res = self.url_open(url=self.get_avatar_url(guest))
self.assertEqual(res.headers["Content-Disposition"], "inline; filename=Guest.svg")
def test_partner_open_guest_avatar_with_channel(self):
testuser = self.env["res.users"].create(
{
"email": "testuser@testuser.com",
"group_ids": [Command.set([self.ref("base.group_user")])],
"name": "Test User",
"login": "testuser",
"password": "testuser",
}
)
self.authenticate("testuser", "testuser")
guest = self.env["mail.guest"].create({"name": "Guest"})
channel = self.env["discuss.channel"].create(
{
"group_public_id": None,
"name": "Test channel",
}
)
channel._add_members(guests=guest, users=testuser)
res = self.url_open(url=self.get_avatar_url(guest))
self.assertEqual(res.headers["Content-Disposition"], "inline; filename=Guest.svg")
def test_guest_open_partner_avatar(self):
self.authenticate(None, None)
guest = self.env["mail.guest"].create({"name": "Guest"})
self.opener.cookies[guest._cookie_name] = guest._format_auth_cookie()
testuser = self.env["res.users"].create(
{
"email": "testuser@testuser.com",
"group_ids": [Command.set([self.ref("base.group_user")])],
"name": "Test User",
"login": "testuser",
"password": "testuser",
}
)
partner = self.env["res.users"].browse(testuser.id).partner_id
res = self.url_open(url=self.get_avatar_url(partner))
self.assertEqual(res.headers["Content-Disposition"], "inline; filename=placeholder.png")
def test_guest_open_partner_avatar_with_channel(self):
self.authenticate(None, None)
guest = self.env["mail.guest"].create({"name": "Guest"})
self.opener.cookies[guest._cookie_name] = guest._format_auth_cookie()
testuser = self.env["res.users"].create(
{
"email": "testuser@testuser.com",
"group_ids": [Command.set([self.ref("base.group_user")])],
"name": "Test User",
"login": "testuser",
"password": "testuser",
}
)
channel = self.env["discuss.channel"].create(
{
"group_public_id": None,
"name": "Test channel",
}
)
channel._add_members(guests=guest, users=testuser)
res = self.url_open(url=self.get_avatar_url(testuser.partner_id))
self.assertEqual(res.headers["Content-Disposition"], "inline; filename=placeholder.png")
res = self.url_open(url=self.get_avatar_url(testuser.partner_id, add_token=True))
self.assertEqual(res.headers["Content-Disposition"], f'inline; filename="{testuser.partner_id.name}.svg"')
def test_partner_open_partner_avatar(self):
testuser = self.env["res.users"].create(
{
"email": "testuser@testuser.com",
"group_ids": [Command.set([self.ref("base.group_user")])],
"name": "Test User",
"login": "testuser",
"password": "testuser",
}
)
self.authenticate("testuser", "testuser")
testuser2 = self.env["res.users"].create(
{
"email": "testuser2@testuser.com",
"group_ids": [Command.set([self.ref("base.group_user")])],
"name": "Test User 2",
"login": "testuser 2",
"password": "testuser 2",
}
)
channel = self.env["discuss.channel"].create(
{
"group_public_id": None,
"name": "Test channel",
}
)
channel._add_members(users=testuser | testuser2)
res = self.url_open(url=self.get_avatar_url(testuser2.partner_id))
self.assertEqual(res.headers["Content-Disposition"], f'inline; filename="{testuser2.partner_id.name}.svg"')
def test_guest_open_guest_avatar(self):
self.authenticate(None, None)
guest = self.env["mail.guest"].create({"name": "Guest"})
self.opener.cookies[guest._cookie_name] = guest._format_auth_cookie()
guest2 = self.env["mail.guest"].create({"name": "Guest 2"})
res = self.url_open(url=self.get_avatar_url(guest2))
self.assertEqual(res.headers["Content-Disposition"], "inline; filename=placeholder.png")
def test_guest_open_guest_avatar_with_channel(self):
self.authenticate(None, None)
guest = self.env["mail.guest"].create({"name": "Guest"})
self.opener.cookies[guest._cookie_name] = guest._format_auth_cookie()
guest2 = self.env["mail.guest"].create({"name": "Guest 2"})
channel = self.env["discuss.channel"].create(
{
"group_public_id": None,
"name": "Test channel",
}
)
channel._add_members(guests=guest | guest2)
res = self.url_open(url=self.get_avatar_url(guest2))
self.assertEqual(res.headers["Content-Disposition"], "inline; filename=placeholder.png")
res = self.url_open(url=self.get_avatar_url(guest2, add_token=True))
self.assertEqual(res.headers["Content-Disposition"], 'inline; filename="Guest 2.svg"')
def test_portal_open_partner_avatar(self):
self.env["res.users"].create(
{
"email": "testuser@testuser.com",
"group_ids": [Command.set([self.ref("base.group_portal")])],
"name": "Test User",
"login": "testuser",
"password": "testuser",
}
)
self.authenticate("testuser", "testuser")
testuser2 = self.env["res.users"].create(
{
"email": "testuser2@testuser.com",
"group_ids": [Command.set([self.ref("base.group_user")])],
"name": "Test User 2",
"login": "testuser 2",
"password": "testuser 2",
}
)
partner2 = self.env["res.users"].browse(testuser2.id).partner_id
res = self.url_open(url=self.get_avatar_url(partner2))
self.assertEqual(res.headers["Content-Disposition"], "inline; filename=placeholder.png")
def test_portal_open_partner_avatar_with_channel(self):
testuser = self.env["res.users"].create(
{
"email": "testuser@testuser.com",
"group_ids": [Command.set([self.ref("base.group_portal")])],
"name": "Test User",
"login": "testuser",
"password": "testuser",
}
)
self.authenticate("testuser", "testuser")
testuser2 = self.env["res.users"].create(
{
"email": "testuser2@testuser.com",
"group_ids": [Command.set([self.ref("base.group_user")])],
"name": "Test User 2",
"login": "testuser 2",
"password": "testuser 2",
}
)
channel = self.env["discuss.channel"].create(
{
"group_public_id": None,
"name": "Test channel",
}
)
channel._add_members(users=testuser | testuser2)
res = self.url_open(url=self.get_avatar_url(testuser2.partner_id))
self.assertEqual(res.headers["Content-Disposition"], "inline; filename=placeholder.png")
res = self.url_open(url=self.get_avatar_url(testuser2.partner_id, add_token=True))
self.assertEqual(res.headers["Content-Disposition"], f'inline; filename="{testuser2.partner_id.name}.svg"')

View file

@ -1,99 +0,0 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
try:
import websocket as ws
except ImportError:
ws = None
from odoo.tests import tagged, new_test_user
from odoo.addons.bus.tests.common import WebsocketCase
from odoo.addons.mail.tests.common import MailCommon
from odoo.addons.bus.models.bus import channel_with_db, json_dump
@tagged("post_install", "-at_install")
class TestBusPresence(WebsocketCase, MailCommon):
def _receive_presence(self, sender, recipient):
self._reset_bus()
sent_from_user = isinstance(sender, self.env.registry["res.users"])
receive_to_user = isinstance(recipient, self.env.registry["res.users"])
if receive_to_user:
session = self.authenticate(recipient.login, recipient.login)
auth_cookie = f"session_id={session.sid};"
else:
self.authenticate(None, None)
auth_cookie = f"{recipient._cookie_name}={recipient._format_auth_cookie()};"
websocket = self.websocket_connect(cookie=auth_cookie)
sender_bus_target = sender.partner_id if sent_from_user else sender
self.subscribe(
websocket,
[f"odoo-presence-{sender_bus_target._name}_{sender_bus_target.id}"],
self.env["bus.bus"]._bus_last_id(),
)
self.env["bus.presence"].create(
{"user_id" if sent_from_user else "guest_id": sender.id, "status": "online"}
)
self.trigger_notification_dispatching([(sender_bus_target, "presence")])
notifications = json.loads(websocket.recv())
self._close_websockets()
bus_record = self.env["bus.bus"].search([("id", "=", int(notifications[0]["id"]))])
self.assertEqual(
bus_record.channel,
json_dump(channel_with_db(self.env.cr.dbname, (sender_bus_target, "presence"))),
)
self.assertEqual(notifications[0]["message"]["type"], "bus.bus/im_status_updated")
self.assertEqual(notifications[0]["message"]["payload"]["im_status"], "online")
self.assertEqual(notifications[0]["message"]["payload"]["presence_status"], "online")
self.assertEqual(
notifications[0]["message"]["payload"]["partner_id" if sent_from_user else "guest_id"],
sender_bus_target.id,
)
def test_receive_presences_as_guest(self):
guest = self.env["mail.guest"].create({"name": "Guest"})
bob = new_test_user(self.env, login="bob_user", groups="base.group_user")
# Guest should not receive users's presence: no common channel.
with self.assertRaises(ws._exceptions.WebSocketTimeoutException):
self._receive_presence(sender=bob, recipient=guest)
channel = self.env["discuss.channel"].channel_create(group_id=None, name="General")
channel.add_members(guest_ids=[guest.id], partner_ids=[bob.partner_id.id])
# Now that they share a channel, guest should receive users's presence.
self._receive_presence(sender=bob, recipient=guest)
other_guest = self.env["mail.guest"].create({"name": "OtherGuest"})
# Guest should not receive guest's presence: no common channel.
with self.assertRaises(ws._exceptions.WebSocketTimeoutException):
self._receive_presence(sender=other_guest, recipient=guest)
channel.add_members(guest_ids=[other_guest.id])
# Now that they share a channel, guest should receive guest's presence.
self._receive_presence(sender=other_guest, recipient=guest)
def test_receive_presences_as_portal(self):
portal = new_test_user(self.env, login="portal_user", groups="base.group_portal")
bob = new_test_user(self.env, login="bob_user", groups="base.group_user")
# Portal should not receive users's presence: no common channel.
with self.assertRaises(ws._exceptions.WebSocketTimeoutException):
self._receive_presence(sender=bob, recipient=portal)
channel = self.env["discuss.channel"].channel_create(group_id=None, name="General")
channel.add_members(partner_ids=[portal.partner_id.id, bob.partner_id.id])
# Now that they share a channel, portal should receive users's presence.
self._receive_presence(sender=bob, recipient=portal)
guest = self.env["mail.guest"].create({"name": "Guest"})
# Portal should not receive guest's presence: no common channel.
with self.assertRaises(ws._exceptions.WebSocketTimeoutException):
self._receive_presence(sender=guest, recipient=portal)
channel.add_members(guest_ids=[guest.id])
# Now that they share a channel, portal should receive guest's presence.
self._receive_presence(sender=guest, recipient=portal)
def test_receive_presences_as_internal(self):
internal = new_test_user(self.env, login="internal_user", groups="base.group_user")
guest = self.env["mail.guest"].create({"name": "Guest"})
# Internal can access guest's presence regardless of their channels.
self._receive_presence(sender=guest, recipient=internal)
# Internal can access users's presence regardless of their channels.
bob = new_test_user(self.env, login="bob_user", groups="base.group_user")
self._receive_presence(sender=bob, recipient=internal)

View file

@ -0,0 +1,27 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import HttpCase, tagged
from odoo.addons.mail.tests.common import MailCommon
@tagged("post_install", "-at_install", "discuss_action")
class TestDiscussAction(HttpCase, MailCommon):
def test_go_back_to_thread_from_breadcrumbs(self):
self.start_tour(
"/odoo/discuss?active_id=mail.box_inbox",
"discuss_go_back_to_thread_from_breadcrumbs.js",
login="admin",
)
def test_join_call_with_client_action(self):
inviting_user = self.env['res.users'].sudo().create({'name': "Inviting User", 'login': 'inviting'})
invited_user = self.env['res.users'].sudo().create({'name': "Invited User", 'login': 'invited'})
channel = self.env['discuss.channel'].with_user(inviting_user)._get_or_create_chat(partners_to=invited_user.partner_id.ids)
channel_member = channel.sudo().channel_member_ids.filtered(
lambda channel_member: channel_member.partner_id == inviting_user.partner_id)
self._reset_bus()
channel_member._rtc_join_call()
self.start_tour(
f"/odoo/{channel.id}/action-mail.action_discuss?call=accept",
"discuss_channel_call_action.js",
login=invited_user.login,
)

View file

@ -0,0 +1,87 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo
from odoo.addons.mail.tests.common_controllers import MailControllerAttachmentCommon
from odoo.tools.misc import file_open
@odoo.tests.tagged("-at_install", "post_install", "mail_controller")
class TestDiscussAttachmentController(MailControllerAttachmentCommon):
def test_attachment_allowed_upload_public_channel(self):
"""Test access to upload an attachment on an allowed upload public channel"""
channel = self.env["discuss.channel"].create(
{"group_public_id": None, "name": "public channel"}
)
channel._add_members(guests=self.guest)
channel = channel.with_context(guest=self.guest)
self._execute_subtests_upload(
channel,
(
(self.guest, True),
(self.user_admin, True),
(self.user_employee, True),
(self.user_portal, True),
(self.user_public, True),
),
)
def test_attachment_delete_linked_to_public_channel(self):
"""Test access to delete an attachment associated with a public channel
whether or not limited `ownership_token` is sent"""
channel = self.env["discuss.channel"].create(
{"group_public_id": None, "name": "public channel"}
)
self._execute_subtests_delete(self.all_users, token=True, allowed=True, thread=channel)
self._execute_subtests_delete(
(self.user_admin, self.user_employee),
token=False,
allowed=True,
thread=channel,
)
self._execute_subtests_delete(
(self.guest, self.user_portal, self.user_public),
token=False,
allowed=False,
thread=channel,
)
def test_attachment_delete_linked_to_private_channel(self):
"""Test access to delete an attachment associated with a private channel
whether or not limited `ownership_token` is sent"""
channel = self.env["discuss.channel"].create(
{"name": "Private Channel", "channel_type": "group"}
)
self._execute_subtests_delete(self.all_users, token=True, allowed=True, thread=channel)
self._execute_subtests_delete(self.user_admin, token=False, allowed=True, thread=channel)
self._execute_subtests_delete(
(self.guest, self.user_employee, self.user_portal, self.user_public),
token=False,
allowed=False,
thread=channel,
)
def test_first_page_access_of_mail_attachment_pdf(self):
"""Test accessing the first page of a PDF that is encrypted(test_AES.pdf) or has invalid encoding(test_unicode.pdf)."""
attachments = []
for pdf in (
'mail/tests/discuss/files/test_AES.pdf',
'mail/tests/discuss/files/test_unicode.pdf',
):
with file_open(pdf, "rb") as file:
attachments.append({
'name': pdf,
'raw': file.read(),
'mimetype': 'application/pdf',
})
attachments = self.env['ir.attachment'].create(attachments)
self.authenticate("admin", "admin")
for attachment in attachments:
ownership_token = attachment._get_ownership_token()
url = f'/mail/attachment/pdf_first_page/{attachment.id}?access_token={ownership_token}'
response = self.url_open(url)
# Depending on the environment, the response status_code may vary:
# - 200 if PyPDF2 and PyCryptodome are installed (PDF successfully parsed)
# - 415 if those libs are missing (PDF cannot be processed)
self.assertIn(response.status_code, [415, 200])

View file

@ -0,0 +1,390 @@
from odoo.addons.mail.tests.common_controllers import MailControllerBinaryCommon
from odoo.tests import tagged
@tagged("-at_install", "post_install", "mail_controller")
class TestDiscussBinaryController(MailControllerBinaryCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.private_channel = cls.env["discuss.channel"].create(
{"name": "Private Channel", "channel_type": "group"}
)
cls.public_channel = cls.env["discuss.channel"]._create_channel(
name="Public Channel", group_id=None
)
cls.users = (
cls.user_public + cls.user_portal + cls.user_employee + cls.user_admin
)
def test_open_guest_avatar(self):
"""Test access to open the avatar of a guest.
There is no common channel or any interaction from the guest."""
self._execute_subtests(
self.guest_2,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_01_guest_avatar_private_channel(self):
"""Test access to open the avatar:
- target type: guest
- channel type: group
- target joins the channel: True
- other users join the channel: True
- target sends a message: False"""
self.private_channel._add_members(users=self.users, guests=self.guest | self.guest_2)
self._execute_subtests(
self.guest_2,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_01_partner_avatar_private_channel(self):
"""Test access to open the avatar:
- target type: partner
- channel type: group
- target joins the channel: True
- other users join the channel: True
- target sends a message: False"""
self.private_channel._add_members(
users=self.users | self.user_employee_nopartner, guests=self.guest
)
self._execute_subtests(
self.user_employee_nopartner.partner_id,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_02_guest_avatar_private_channel(self):
"""Test access to open the avatar:
- target type: guest
- channel type: group
- target joins the channel: True
- other users join the channel: True
- target sends a message: True"""
self.private_channel._add_members(users=self.users, guests=self.guest | self.guest_2)
self._post_message(self.private_channel, self.guest_2)
self._execute_subtests(
self.guest_2,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_02_partner_avatar_private_channel(self):
"""Test access to open the avatar:
- target type: partner
- channel type: group
- target joins the channel: True
- other users join the channel: True
- target sends a message: True"""
self.private_channel._add_members(
users=self.users | self.user_employee_nopartner, guests=self.guest
)
self._post_message(self.private_channel, 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),
(self.user_admin, True),
),
)
def test_03_guest_avatar_private_channel(self):
"""Test access to open the avatar:
- target type: guest
- channel type: group
- target joins the channel: True
- other users join the channel: True
- target sends a message: False
- target leaves the channel: True"""
self.private_channel._add_members(users=self.users, guests=self.guest | self.guest_2)
self.env["discuss.channel.member"].search(
[("guest_id", "=", self.guest_2.id), ("channel_id", "=", self.private_channel.id)]
).unlink()
self._execute_subtests(
self.guest_2,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_03_partner_avatar_private_channel(self):
"""Test access to open the avatar:
- target type: partner
- channel type: group
- target joins the channel: True
- other users join the channel: True
- target sends a message: False
- target leaves the channel: True"""
self.private_channel._add_members(
users=self.users | self.user_employee_nopartner, guests=self.guest
)
self.env["discuss.channel.member"].search(
[
("partner_id", "=", self.user_employee_nopartner.partner_id.id),
("channel_id", "=", self.private_channel.id),
]
).unlink()
self._execute_subtests(
self.user_employee_nopartner.partner_id,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_04_guest_avatar_private_channel(self):
"""Test access to open the avatar:
- target type: guest
- channel type: group
- target joins the channel: True
- other users join the channel: True
- target sends a message: True
- target leaves the channel: True"""
self.private_channel._add_members(users=self.users, guests=self.guest | self.guest_2)
self._post_message(self.private_channel, self.guest_2)
self.env["discuss.channel.member"].search(
[("guest_id", "=", self.guest_2.id), ("channel_id", "=", self.private_channel.id)]
).unlink()
self._execute_subtests(
self.guest_2,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_04_partner_avatar_private_channel(self):
"""Test access to open the avatar:
- target type: partner
- channel type: group
- target joins the channel: True
- other users join the channel: True
- target sends a message: True
- target leaves the channel: True"""
self.private_channel._add_members(
users=self.users | self.user_employee_nopartner, guests=self.guest
)
self._post_message(self.private_channel, self.user_employee_nopartner)
self.env["discuss.channel.member"].search(
[
("partner_id", "=", self.user_employee_nopartner.partner_id.id),
("channel_id", "=", self.private_channel.id),
]
).unlink()
self._execute_subtests(
self.user_employee_nopartner.partner_id,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_05_guest_avatar_private_channel(self):
"""Test access to open the avatar:
- target type: guest
- channel type: group
- target joins the channel: False
- other users join the channel: False
- target sends a message: True"""
self.private_channel.with_user(self.user_public).with_context(
guest=self.guest_2
).sudo().message_post(body="Test", subtype_xmlid="mail.mt_comment", message_type="comment")
self._execute_subtests(
self.guest_2,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_05_partner_avatar_private_channel(self):
"""Test access to open the avatar:
- target type: partner
- channel type: group
- target joins the channel: False
- other users join the channel: False
- target sends a message: True"""
self.private_channel.message_post(
body="Test",
subtype_xmlid="mail.mt_comment",
message_type="comment",
author_id=self.user_employee_nopartner.partner_id.id,
)
self._execute_subtests(
self.user_employee_nopartner.partner_id,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_01_guest_avatar_public_channel(self):
"""Test access to open the avatar:
- target type: guest
- channel type: public
- target joins the channel: False
- other users join the channel: False
- target sends a message: True"""
self.public_channel.with_user(self.user_public).with_context(
guest=self.guest_2
).sudo().message_post(body="Test", subtype_xmlid="mail.mt_comment", message_type="comment")
self._execute_subtests(
self.guest_2,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_01_partner_avatar_public_channel(self):
"""Test access to open the avatar:
- target type: guest
- channel type: public
- target joins the channel: False
- other users join the channel: False
- target sends a message: True"""
self._post_message(self.public_channel, 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),
(self.user_admin, True),
),
)
def test_02_guest_avatar_public_channel(self):
"""Test access to open the avatar:
- target type: guest
- channel type: public
- target joins the channel: True
- other users join the channel: False
- target sends a message: False
- target leaves the channel: True"""
target_member = self.public_channel._add_members(guests=self.guest_2)
target_member.unlink()
self._execute_subtests(
self.guest_2,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_02_partner_avatar_public_channel(self):
"""Test access to open the avatar:
- target type: partner
- channel type: public
- target joins the channel: True
- other users join the channel: False
- target sends a message: False
- target leaves the channel: True"""
target_member = self.public_channel._add_members(users=self.user_employee_nopartner)
target_member.unlink()
self._execute_subtests(
self.user_employee_nopartner.partner_id,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_03_guest_avatar_public_channel(self):
"""Test access to open the avatar:
- target type: guest
- channel type: public
- target joins the channel: True
- other users join the channel: False
- target sends a message: True
- target leaves the channel: True"""
target_member = self.public_channel._add_members(guests=self.guest_2)
self._post_message(self.public_channel, self.guest_2)
target_member.unlink()
self._execute_subtests(
self.guest_2,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_03_partner_avatar_public_channel(self):
"""Test access to open the avatar:
- target type: partner
- channel type: public
- target joins the channel: True
- other users join the channel: False
- target sends a message: True
- target leaves the channel: True"""
target_member = self.public_channel._add_members(users=self.user_employee_nopartner)
self._post_message(self.public_channel, self.user_employee_nopartner)
target_member.unlink()
self._execute_subtests(
self.user_employee_nopartner.partner_id,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, True),
(self.user_admin, True),
),
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,572 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from psycopg2.errors import UniqueViolation
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.mail.tests.common import MailCommon
from odoo.exceptions import AccessError, UserError
from odoo.tests.common import tagged
from odoo.tools import mute_logger
@tagged("post_install", "-at_install")
class TestDiscussChannelAccess(MailCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._channel_type_channel_access_cases = [
("public", "no_group", "member", "read", True),
("public", "no_group", "member", "write", False),
("public", "no_group", "member", "unlink", False),
("public", "no_group", "outside", "create", False),
("public", "no_group", "outside", "read", True),
("public", "no_group", "outside", "write", False),
("public", "no_group", "outside", "unlink", False),
("public", "group_matching", "member", "read", True),
("public", "group_matching", "member", "write", False),
("public", "group_matching", "member", "unlink", False),
("public", "group_matching", "outside", "create", False),
("public", "group_matching", "outside", "read", True),
("public", "group_matching", "outside", "write", False),
("public", "group_matching", "outside", "unlink", False),
("public", "group_failing", "member", "read", False),
("public", "group_failing", "member", "write", False),
("public", "group_failing", "member", "unlink", False),
("public", "group_failing", "outside", "create", False),
("public", "group_failing", "outside", "read", False),
("public", "group_failing", "outside", "write", False),
("public", "group_failing", "outside", "unlink", False),
("portal", "no_group", "member", "read", True),
("portal", "no_group", "member", "write", False),
("portal", "no_group", "member", "unlink", False),
("portal", "no_group", "outside", "create", False),
("portal", "no_group", "outside", "read", True),
("portal", "no_group", "outside", "write", False),
("portal", "no_group", "outside", "unlink", False),
("portal", "group_matching", "member", "read", True),
("portal", "group_matching", "member", "write", False),
("portal", "group_matching", "member", "unlink", False),
("portal", "group_matching", "outside", "create", False),
("portal", "group_matching", "outside", "read", True),
("portal", "group_matching", "outside", "write", False),
("portal", "group_matching", "outside", "unlink", False),
("portal", "group_failing", "member", "read", False),
("portal", "group_failing", "member", "write", False),
("portal", "group_failing", "member", "unlink", False),
("portal", "group_failing", "outside", "create", False),
("portal", "group_failing", "outside", "read", False),
("portal", "group_failing", "outside", "write", False),
("portal", "group_failing", "outside", "unlink", False),
("user", "no_group", "member", "read", True),
("user", "no_group", "member", "write", True),
("user", "no_group", "member", "unlink", False),
("user", "no_group", "outside", "create", True),
("user", "no_group", "outside", "read", True),
("user", "no_group", "outside", "write", True),
("user", "no_group", "outside", "unlink", False),
("user", "group_matching", "member", "read", True),
("user", "group_matching", "member", "write", True),
("user", "group_matching", "member", "unlink", False),
("user", "group_matching", "outside", "create", True),
("user", "group_matching", "outside", "read", True),
("user", "group_matching", "outside", "write", True),
("user", "group_matching", "outside", "unlink", False),
("user", "group_failing", "member", "read", False),
("user", "group_failing", "member", "write", False),
("user", "group_failing", "member", "unlink", False),
("user", "group_failing", "outside", "create", False),
("user", "group_failing", "outside", "read", False),
("user", "group_failing", "outside", "write", False),
("user", "group_failing", "outside", "unlink", False),
]
cls._channel_type_channel_member_access_cases = [
("public", "no_group", "member", "self", "create", False),
("public", "no_group", "member", "self", "read", True),
("public", "no_group", "member", "self", "write", True),
("public", "no_group", "member", "self", "unlink", True),
("public", "no_group", "member", "other", "create", False),
("public", "no_group", "member", "other", "read", True),
("public", "no_group", "member", "other", "write", False),
("public", "no_group", "member", "other", "unlink", False),
("public", "no_group", "outside", "self", "create", True),
("public", "no_group", "outside", "other", "create", False),
("public", "no_group", "outside", "other", "read", True),
("public", "no_group", "outside", "other", "write", False),
("public", "no_group", "outside", "other", "unlink", False),
("public", "group_matching", "member", "self", "create", False),
("public", "group_matching", "member", "self", "read", True),
("public", "group_matching", "member", "self", "write", True),
("public", "group_matching", "member", "self", "unlink", True),
("public", "group_matching", "member", "other", "create", False),
("public", "group_matching", "member", "other", "read", True),
("public", "group_matching", "member", "other", "write", False),
("public", "group_matching", "member", "other", "unlink", False),
("public", "group_matching", "outside", "self", "create", True),
("public", "group_matching", "outside", "other", "create", False),
("public", "group_matching", "outside", "other", "read", True),
("public", "group_matching", "outside", "other", "write", False),
("public", "group_matching", "outside", "other", "unlink", False),
("public", "group_failing", "member", "self", "create", False),
("public", "group_failing", "member", "self", "read", False),
("public", "group_failing", "member", "self", "write", False),
("public", "group_failing", "member", "self", "unlink", False),
("public", "group_failing", "member", "other", "create", False),
("public", "group_failing", "member", "other", "read", False),
("public", "group_failing", "member", "other", "write", False),
("public", "group_failing", "member", "other", "unlink", False),
("public", "group_failing", "outside", "self", "create", False),
("public", "group_failing", "outside", "other", "create", False),
("public", "group_failing", "outside", "other", "read", False),
("public", "group_failing", "outside", "other", "write", False),
("public", "group_failing", "outside", "other", "unlink", False),
("portal", "no_group", "member", "self", "create", False),
("portal", "no_group", "member", "self", "read", True),
("portal", "no_group", "member", "self", "write", True),
("portal", "no_group", "member", "self", "unlink", True),
("portal", "no_group", "member", "other", "create", False),
("portal", "no_group", "member", "other", "read", True),
("portal", "no_group", "member", "other", "write", False),
("portal", "no_group", "member", "other", "unlink", False),
("portal", "no_group", "outside", "self", "create", True),
("portal", "no_group", "outside", "other", "create", False),
("portal", "no_group", "outside", "other", "read", True),
("portal", "no_group", "outside", "other", "write", False),
("portal", "no_group", "outside", "other", "unlink", False),
("portal", "group_matching", "member", "self", "create", False),
("portal", "group_matching", "member", "self", "read", True),
("portal", "group_matching", "member", "self", "write", True),
("portal", "group_matching", "member", "self", "unlink", True),
("portal", "group_matching", "member", "other", "create", False),
("portal", "group_matching", "member", "other", "read", True),
("portal", "group_matching", "member", "other", "write", False),
("portal", "group_matching", "member", "other", "unlink", False),
("portal", "group_matching", "outside", "self", "create", True),
("portal", "group_matching", "outside", "other", "create", False),
("portal", "group_matching", "outside", "other", "read", True),
("portal", "group_matching", "outside", "other", "write", False),
("portal", "group_matching", "outside", "other", "unlink", False),
("portal", "group_failing", "member", "self", "create", False),
("portal", "group_failing", "member", "self", "read", False),
("portal", "group_failing", "member", "self", "write", False),
("portal", "group_failing", "member", "self", "unlink", False),
("portal", "group_failing", "member", "other", "create", False),
("portal", "group_failing", "member", "other", "read", False),
("portal", "group_failing", "member", "other", "write", False),
("portal", "group_failing", "member", "other", "unlink", False),
("portal", "group_failing", "outside", "self", "create", False),
("portal", "group_failing", "outside", "other", "create", False),
("portal", "group_failing", "outside", "other", "read", False),
("portal", "group_failing", "outside", "other", "write", False),
("portal", "group_failing", "outside", "other", "unlink", False),
("user", "no_group", "member", "self", "create", False),
("user", "no_group", "member", "self", "read", True),
("user", "no_group", "member", "self", "write", True),
("user", "no_group", "member", "self", "unlink", True),
("user", "no_group", "member", "other", "create", True),
("user", "no_group", "member", "other", "read", True),
("user", "no_group", "member", "other", "write", False),
("user", "no_group", "member", "other", "unlink", False),
("user", "no_group", "outside", "self", "create", True),
("user", "no_group", "outside", "other", "create", True),
("user", "no_group", "outside", "other", "read", True),
("user", "no_group", "outside", "other", "write", False),
("user", "no_group", "outside", "other", "unlink", False),
("user", "group_matching", "member", "self", "create", False),
("user", "group_matching", "member", "self", "read", True),
("user", "group_matching", "member", "self", "write", True),
("user", "group_matching", "member", "self", "unlink", True),
("user", "group_matching", "member", "other", "create", True),
("user", "group_matching", "member", "other", "read", True),
("user", "group_matching", "member", "other", "write", False),
("user", "group_matching", "member", "other", "unlink", False),
("user", "group_matching", "outside", "self", "create", True),
("user", "group_matching", "outside", "other", "create", True),
("user", "group_matching", "outside", "other", "read", True),
("user", "group_matching", "outside", "other", "write", False),
("user", "group_matching", "outside", "other", "unlink", False),
("user", "group_failing", "member", "self", "create", False),
("user", "group_failing", "member", "self", "read", False),
("user", "group_failing", "member", "self", "write", False),
("user", "group_failing", "member", "self", "unlink", False),
("user", "group_failing", "member", "other", "create", False),
("user", "group_failing", "member", "other", "read", False),
("user", "group_failing", "member", "other", "write", False),
("user", "group_failing", "member", "other", "unlink", False),
("user", "group_failing", "outside", "self", "create", False),
("user", "group_failing", "outside", "other", "create", False),
("user", "group_failing", "outside", "other", "read", False),
("user", "group_failing", "outside", "other", "write", False),
("user", "group_failing", "outside", "other", "unlink", False),
]
cls._group_type_channel_access_cases = [
("public", "group", "member", "read", True),
("public", "group", "member", "write", False),
("public", "group", "member", "unlink", False),
("public", "group", "outside", "create", False),
("public", "group", "outside", "read", False),
("public", "group", "outside", "write", False),
("public", "group", "outside", "unlink", False),
("portal", "group", "member", "read", True),
("portal", "group", "member", "write", False),
("portal", "group", "member", "unlink", False),
("portal", "group", "outside", "create", False),
("portal", "group", "outside", "read", False),
("portal", "group", "outside", "write", False),
("portal", "group", "outside", "unlink", False),
("user", "group", "member", "read", True),
("user", "group", "member", "write", True),
("user", "group", "member", "unlink", False),
("user", "group", "outside", "create", True),
("user", "group", "outside", "read", False),
("user", "group", "outside", "write", False),
("user", "group", "outside", "unlink", False),
]
cls._group_type_channel_member_access_cases = [
("public", "group", "member", "self", "create", False),
("public", "group", "member", "self", "read", True),
("public", "group", "member", "self", "write", True),
("public", "group", "member", "self", "unlink", True),
("public", "group", "member", "other", "create", False),
("public", "group", "member", "other", "read", True),
("public", "group", "member", "other", "write", False),
("public", "group", "member", "other", "unlink", False),
("public", "group", "outside", "self", "create", False),
("public", "group", "outside", "other", "create", False),
("public", "group", "outside", "other", "read", False),
("public", "group", "outside", "other", "write", False),
("public", "group", "outside", "other", "unlink", False),
("portal", "group", "member", "self", "create", False),
("portal", "group", "member", "self", "read", True),
("portal", "group", "member", "self", "write", True),
("portal", "group", "member", "self", "unlink", True),
("portal", "group", "member", "other", "create", False),
("portal", "group", "member", "other", "read", True),
("portal", "group", "member", "other", "write", False),
("portal", "group", "member", "other", "unlink", False),
("portal", "group", "outside", "self", "create", False),
("portal", "group", "outside", "other", "create", False),
("portal", "group", "outside", "other", "read", False),
("portal", "group", "outside", "other", "write", False),
("portal", "group", "outside", "other", "unlink", False),
("user", "group", "member", "self", "create", False),
("user", "group", "member", "self", "read", True),
("user", "group", "member", "self", "write", True),
("user", "group", "member", "self", "unlink", True),
("user", "group", "member", "other", "create", True),
("user", "group", "member", "other", "read", True),
("user", "group", "member", "other", "write", False),
("user", "group", "member", "other", "unlink", False),
("user", "group", "outside", "self", "create", False),
("user", "group", "outside", "other", "create", False),
("user", "group", "outside", "other", "read", False),
("user", "group", "outside", "other", "write", False),
("user", "group", "outside", "other", "unlink", False),
]
cls.secret_group = cls.env["res.groups"].create({"name": "Secret User Group"})
cls.env["ir.model.data"].create(
{
"name": "secret_group",
"module": "mail",
"model": cls.secret_group._name,
"res_id": cls.secret_group.id,
}
)
cls.guest = cls.env["mail.guest"].create({"name": "A Guest"}).sudo(False)
cls.users = {
"public": mail_new_test_user(
cls.env,
login="public1",
name="A Public User",
groups="base.group_public,mail.secret_group",
),
"portal": mail_new_test_user(
cls.env,
login="portal1",
name="A Portal User",
groups="base.group_portal,mail.secret_group",
),
"user": mail_new_test_user(
cls.env,
login="user1",
name="An Internal User",
groups="base.group_user,mail.secret_group",
),
}
cls.other_user = mail_new_test_user(
cls.env,
login="other1",
name="Another User 1",
groups="base.group_user,mail.secret_group",
)
cls.other_user_2 = mail_new_test_user(
cls.env,
login="other2",
name="Another User 2",
groups="base.group_user,mail.secret_group",
)
def _test_discuss_channel_access(self, cases, for_sub_channel):
"""
Executes a list of operations on channels in various setups and checks whether the outcomes
match the expected results.
:param cases: A list of test cases, where each tuple contains:
- user_key (``"portal"`` | ``"public"`` | ``"user"``): The user performing the operation.
- group_key (``"chat"`` | ``"group"`` | ``"no_group"`` | ``"group_matching"`` |
``"group_failing"``): The group specification to use. ``chat`` and ``group`` define the
channel type, while the others configure group setups for the channels.
- membership (``"member"`` | ``"outside"``): Whether the user is a member of the channel.
- operation (``"create"`` | ``"read"`` | ``"write"`` | ``"unlink"``): The action being tested.
- expected_result (bool): Whether the action is expected to be allowed (``True``) or denied
(``False``).
:type cases: List[Tuple[str, str, str, str, bool]]
:param for_sub_channel: Whether the operation is being tested on a sub-channel. In this case, the
``cases`` parameter is used to configure the parent channel.
"""
for user_key, channel_key, membership, operation, result in cases:
if result:
try:
self._execute_action_channel(
user_key, channel_key, membership, operation, result, for_sub_channel
)
except Exception as e: # noqa: BLE001 - re-raising, just with a more contextual message
raise AssertionError(
f"{user_key, channel_key, membership, operation} should not raise"
) from e
else:
try:
with self.assertRaises(AccessError), mute_logger("odoo.sql_db"), mute_logger(
"odoo.addons.base.models.ir_model"
), mute_logger("odoo.addons.base.models.ir_rule"), mute_logger(
"odoo.models.unlink"
):
self._execute_action_channel(
user_key, channel_key, membership, operation, result, for_sub_channel
)
except AssertionError as e:
raise AssertionError(
f"{user_key, channel_key, membership, operation} should raise"
) from e
def test_01_discuss_channel_access(self):
cases = [
*self._channel_type_channel_access_cases,
*self._group_type_channel_access_cases,
("public", "chat", "outside", "create", False),
("public", "chat", "outside", "read", False),
("public", "chat", "outside", "write", False),
("public", "chat", "outside", "unlink", False),
("portal", "chat", "member", "read", True),
("portal", "chat", "member", "write", False),
("portal", "chat", "member", "unlink", False),
("portal", "chat", "outside", "create", False),
("portal", "chat", "outside", "read", False),
("portal", "chat", "outside", "write", False),
("portal", "chat", "outside", "unlink", False),
("user", "chat", "member", "read", True),
("user", "chat", "member", "write", True),
("user", "chat", "member", "unlink", False),
("user", "chat", "outside", "create", True),
("user", "chat", "outside", "read", False),
("user", "chat", "outside", "write", False),
("user", "chat", "outside", "unlink", False),
]
self._test_discuss_channel_access(cases, for_sub_channel=False)
def test_02_discuss_sub_channel_access(self):
cases = [
*self._channel_type_channel_access_cases,
*self._group_type_channel_access_cases,
]
self._test_discuss_channel_access(cases, for_sub_channel=True)
def _test_discuss_channel_member_access(self, cases, for_sub_channel):
"""
Executes a list of operations on channel members in various setups and checks whether the
outcomes match the expected results.
:param cases: A list of test cases, where each tuple contains:
- user_key (``"portal"`` | ``"public"`` | ``"user"``):
The user performing the operation.
- group_key (``"chat"`` | ``"group"`` | ``"no_group"`` | ``"group_matching"`` |
``"group_failing"``):
The group specification to use. ``chat`` and ``group`` define the channel type, while the
others configure group setups for the channels.
- membership (``"member"`` | ``"outside"``):
Whether the user is a member of the channel.
- target (``"self"`` | ``"other"``):
Whether the operation is executed on the self-member or another member.
- operation (``"create"`` | ``"read"`` | ``"write"`` | ``"unlink"``):
The action being tested.
- expected_result (bool):
Whether the action is expected to be allowed (``True``) or denied (``False``).
:type cases: List[Tuple[str, str, str, str, str, bool]]
:param for_sub_channel: Whether the operation is being tested on a sub-channel. In this case, the
``cases`` parameter is used to configure the parent channel's member.
"""
for user_key, channel_key, membership, target, operation, result in cases:
channel_id = self._get_channel_id(user_key, channel_key, membership, for_sub_channel)
if result:
try:
self._execute_action_member(channel_id, user_key, target, operation, result)
except Exception as e: # noqa: BLE001 - re-raising, just with a more contextual message
raise AssertionError(
f"{user_key, channel_key, membership, target, operation} should not raise"
) from e
else:
try:
with self.assertRaises(AccessError), mute_logger("odoo.sql_db"), mute_logger(
"odoo.addons.base.models.ir_model"
), mute_logger("odoo.addons.base.models.ir_rule"), mute_logger(
"odoo.models.unlink"
):
try:
self._execute_action_member(
channel_id, user_key, target, operation, result
)
except (UniqueViolation, UserError) as e:
raise AccessError("expected errors as access error") from e
except AssertionError as e:
raise AssertionError(
f"{user_key, channel_key, membership, target, operation} should raise access error"
) from e
def test_10_discuss_channel_member_access(self):
cases = [
*self._channel_type_channel_member_access_cases,
*self._group_type_channel_member_access_cases,
("public", "chat", "outside", "self", "create", False),
("public", "chat", "outside", "other", "create", False),
("public", "chat", "outside", "other", "read", False),
("public", "chat", "outside", "other", "write", False),
("public", "chat", "outside", "other", "unlink", False),
("portal", "chat", "member", "self", "create", False),
("portal", "chat", "member", "self", "read", True),
("portal", "chat", "member", "self", "write", True),
("portal", "chat", "member", "self", "unlink", True),
("portal", "chat", "member", "other", "create", False),
("portal", "chat", "member", "other", "read", True),
("portal", "chat", "member", "other", "write", False),
("portal", "chat", "member", "other", "unlink", False),
("portal", "chat", "outside", "self", "create", False),
("portal", "chat", "outside", "other", "create", False),
("portal", "chat", "outside", "other", "read", False),
("portal", "chat", "outside", "other", "write", False),
("portal", "chat", "outside", "other", "unlink", False),
("user", "chat", "member", "self", "create", False),
("user", "chat", "member", "self", "read", True),
("user", "chat", "member", "self", "write", True),
("user", "chat", "member", "self", "unlink", True),
("user", "chat", "member", "other", "create", False),
("user", "chat", "member", "other", "read", True),
("user", "chat", "member", "other", "write", False),
("user", "chat", "member", "other", "unlink", False),
("user", "chat", "outside", "self", "create", False),
("user", "chat", "outside", "other", "create", False),
("user", "chat", "outside", "other", "read", False),
("user", "chat", "outside", "other", "write", False),
("user", "chat", "outside", "other", "unlink", False),
]
self._test_discuss_channel_member_access(cases, for_sub_channel=False)
def test_11_discuss_sub_channel_member_access(self):
cases = [
*self._channel_type_channel_member_access_cases,
*self._group_type_channel_member_access_cases,
]
self._test_discuss_channel_member_access(cases, for_sub_channel=True)
def _get_channel_id(self, user_key, channel_key, membership, sub_channel):
user = self.env["res.users"] if user_key == "public" else self.users[user_key]
partner = user.partner_id
guest = self.guest if user_key == "public" else self.env["mail.guest"]
partners = self.other_user.partner_id
if membership == "member":
partners += partner
DiscussChannel = self.env["discuss.channel"].with_user(self.other_user)
if channel_key == "group":
channel = DiscussChannel._create_group(partners.ids)
if membership == "member":
channel._add_members(users=user, guests=guest)
elif channel_key == "chat":
channel = DiscussChannel._get_or_create_chat(partners.ids)
else:
channel = DiscussChannel._create_channel("Channel", group_id=None)
if membership == "member":
channel._add_members(users=user, guests=guest)
if channel_key == "no_group":
channel.group_public_id = None
elif channel_key == "group_matching":
channel.group_public_id = self.secret_group
elif channel_key == "group_failing":
channel.group_public_id = self.env.ref("base.group_system")
if sub_channel:
channel.sudo()._create_sub_channel()
channel = channel.sub_channel_ids[0]
if membership == "member":
channel.sudo()._add_members(users=user, guests=guest)
return channel.id
def _execute_action_channel(self, user_key, channel_key, membership, operation, result, for_sub_channel):
current_user = self.users[user_key]
guest = self.guest if user_key == "public" else self.env["mail.guest"]
ChannelAsUser = self.env["discuss.channel"].with_user(current_user).with_context(guest=guest)
if operation == "create":
group_public_id = None
if channel_key == "group_matching":
group_public_id = self.secret_group.id
elif channel_key == "group_failing":
group_public_id = self.env.ref("base.group_system").id
data = {
"name": "Test Channel",
"channel_type": channel_key if channel_key in ("group", "chat") else "channel",
"group_public_id": group_public_id,
}
ChannelAsUser.create(data)
else:
channel = ChannelAsUser.browse(
self._get_channel_id(user_key, channel_key, membership, for_sub_channel)
)
self.assertEqual(len(channel), 1, "should find the channel")
if operation == "read":
self.assertEqual(len(ChannelAsUser.search([("id", "=", channel.id)])), 1 if result else 0)
channel.read(["name"])
elif operation == "write":
channel.write({"name": "new name"})
elif operation == "unlink":
channel.unlink()
def _execute_action_member(self, channel_id, user_key, target, operation, result):
current_user = self.users[user_key]
partner = self.env["res.partner"] if user_key == "public" else current_user.partner_id
guest = self.guest if user_key == "public" else self.env["mail.guest"]
ChannelMemberAsUser = self.env["discuss.channel.member"].with_user(current_user).with_context(guest=guest)
if operation == "create":
create_data = {"channel_id": channel_id}
if target == "self":
if guest:
create_data["guest_id"] = guest.id
else:
create_data["partner_id"] = partner.id
else:
create_data["partner_id"] = self.other_user_2.partner_id.id
ChannelMemberAsUser.create(create_data)
else:
domain = [("channel_id", "=", channel_id)]
if target == "self":
if guest:
domain.append(("guest_id", "=", guest.id))
else:
domain.append(("partner_id", "=", partner.id))
else:
domain.append(("partner_id", "=", self.other_user.partner_id.id))
member = ChannelMemberAsUser.sudo().search(domain).sudo(False)
self.assertEqual(len(member), 1, "should find the target member")
if operation == "read":
self.assertEqual(len(ChannelMemberAsUser.search(domain)), 1 if result else 0)
member.read(["custom_channel_name"])
elif operation == "write":
member.write({"custom_channel_name": "new name"})
elif operation == "unlink":
member.unlink()

View file

@ -0,0 +1,112 @@
# 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.tests.common import tagged
from odoo.addons.base.tests.common import HttpCaseWithUserPortal, HttpCaseWithUserDemo
@tagged("post_install", "-at_install", "is_tour")
class TestMailPublicPage(HttpCaseWithUserPortal, HttpCaseWithUserDemo):
"""Checks that the invite page redirects to the channel and that all
modules load correctly on the welcome and channel page when authenticated as various users"""
def setUp(self):
super().setUp()
portal_user = mail_new_test_user(
self.env,
name='Portal Bowser',
login='portal_bowser',
email='portal_bowser@example.com',
groups='base.group_portal',
)
internal_user = mail_new_test_user(
self.env,
name='Internal Luigi',
login='internal_luigi',
email='internal_luigi@example.com',
groups='base.group_user',
)
guest = self.env['mail.guest'].create({'name': 'Guest Mario'})
self.channel = self.env['discuss.channel']._create_channel(group_id=None, name='Test channel')
self.channel._add_members(users=portal_user)
self.channel._add_members(users=internal_user)
self.channel._add_members(guests=guest)
internal_member = self.channel.channel_member_ids.filtered(lambda m: internal_user.partner_id == m.partner_id)
internal_member._rtc_join_call()
self.group = self.env['discuss.channel']._create_group(partners_to=(internal_user + portal_user).partner_id.ids, name="Test group")
self.group._add_members(guests=guest)
self.tour = "discuss_channel_public_tour.js"
def _open_channel_page_as_user(self, login):
self.start_tour(self.channel.invitation_url, self.tour, login=login)
# Update the body to a unique value to ensure the second run does not confuse the 2 messages.
self.channel._get_last_messages().body = "a-very-unique-body-in-channel"
# Second run of the tour as the first call has side effects, like creating user settings or adding members to
# the channel, so we need to run it again to test different parts of the code.
self.start_tour(self.channel.invitation_url, self.tour, login=login)
def _open_group_page_as_user(self, login):
self.start_tour(self.group.invitation_url, self.tour, login=login)
# Update the body to a unique value to ensure the second run does not confuse the 2 messages.
self.channel._get_last_messages().body = "a-very-unique-body-in-group"
# Second run of the tour as the first call has side effects, like creating user settings or adding members to
# the channel, so we need to run it again to test different parts of the code.
self.start_tour(self.group.invitation_url, self.tour, login=login)
def test_discuss_channel_public_page_as_admin(self):
self._open_channel_page_as_user('admin')
def test_mail_group_public_page_as_admin(self):
self._open_group_page_as_user('admin')
def test_discuss_channel_public_page_as_guest(self):
self.start_tour(self.channel.invitation_url, "discuss_channel_as_guest_tour.js")
guest = self.env['mail.guest'].search([('channel_ids', 'in', self.channel.id)], limit=1, order='id desc')
self.start_tour(self.channel.invitation_url, self.tour, cookies={guest._cookie_name: guest._format_auth_cookie()})
def test_discuss_channel_public_page_call_public(self):
self.channel.default_display_mode = 'video_full_screen'
self.start_tour(self.channel.invitation_url, "discuss_channel_call_public_tour.js")
def test_mail_group_public_page_as_guest(self):
self.start_tour(self.group.invitation_url, "discuss_channel_as_guest_tour.js")
guest = self.env['mail.guest'].search([('channel_ids', 'in', self.channel.id)], limit=1, order='id desc')
self.start_tour(self.group.invitation_url, self.tour, cookies={guest._cookie_name: guest._format_auth_cookie()})
def test_discuss_channel_public_page_as_internal(self):
self._open_channel_page_as_user('demo')
def test_mail_group_public_page_as_internal(self):
self._open_group_page_as_user('demo')
def test_discuss_channel_public_page_as_portal(self):
self._open_channel_page_as_user('portal')
def test_mail_group_public_page_as_portal(self):
self._open_group_page_as_user('portal')
def test_chat_from_token_as_guest(self):
self.env['ir.config_parameter'].set_param('mail.chat_from_token', True)
self.url_open('/chat/xyz')
channel = self.env['discuss.channel'].search([('uuid', '=', 'xyz')])
self.assertEqual(len(channel), 1)
def test_channel_invitation_from_token(self):
public_channel = self.env["discuss.channel"]._create_channel(name="Public Channel", group_id=None)
internal_channel = self.env["discuss.channel"]._create_channel(name="Internal Channel", group_id=self.env.ref("base.group_user").id)
public_response = self.url_open(public_channel.invitation_url)
self.assertEqual(public_response.status_code, 200)
internal_response = self.url_open(internal_channel.invitation_url)
self.assertEqual(internal_response.status_code, 404)
def test_sidebar_in_public_page(self):
guest = self.env['mail.guest'].create({'name': 'Guest'})
channel_1 = self.env["discuss.channel"]._create_channel(name="Channel 1", group_id=None)
channel_2 = self.env["discuss.channel"]._create_channel(name="Channel 2", group_id=None)
channel_1._add_members(guests=guest)
channel_2._add_members(guests=guest)
self.start_tour(f"/discuss/channel/{channel_1.id}", "sidebar_in_public_page_tour", cookies={guest._cookie_name: guest._format_auth_cookie()})

View file

@ -0,0 +1,199 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from lxml import html
from itertools import product
from odoo.addons.mail.tests.common import MailCommon
from odoo.exceptions import UserError
from odoo.tests import HttpCase, new_test_user, tagged, users
from odoo.tools.misc import hash_sign
@tagged("-at_install", "post_install")
class TestDiscussChannelInvite(HttpCase, MailCommon):
def test_01_invite_by_email_flow(self):
bob = new_test_user(self.env, "bob", groups="base.group_user", email="bob@test.com")
john = new_test_user(self.env, "john", groups="base.group_user", email="john@test.com")
group_chat = (
self.env["discuss.channel"].with_user(bob)._create_group(partners_to=bob.partner_id.ids)
)
with self.mock_mail_gateway():
self.start_tour(
f"/odoo/discuss?active_id={group_chat.id}", "discuss.invite_by_email", login="bob"
)
self.assertIn(john.partner_id, group_chat.channel_member_ids.partner_id)
self.assertNoMail(self.env["res.partner"], "john@test.com")
self.assertMailMail(
self.env["res.partner"],
status=None,
email_to_all=["unknown_email@test.com"],
author=bob.partner_id,
email_values={
"subject": f"{bob.name} has invited you to a channel",
},
)
mail = self.env["mail.mail"].search(
[("model", "=", "discuss.channel"), ("res_id", "=", group_chat.id)]
)
body_html = html.fromstring(mail.body_html)
join_link = body_html.xpath('//a[normalize-space(text())="Join Channel"]')
self.assertTrue(join_link)
self.assertEqual(
join_link[0].get("href"),
f"{self.env['ir.config_parameter'].get_base_url()}{group_chat.invitation_url}?email_token={hash_sign(self.env, 'mail.invite_email', 'unknown_email@test.com')}",
)
def test_02_invite_by_email_excludes_member_emails(self):
bob = new_test_user(self.env, "bob", groups="base.group_user", email="bob@test.com")
group_chat = (
self.env["discuss.channel"].with_user(bob)._create_group(partners_to=bob.partner_id.ids)
)
alfred_guest = self.env["mail.guest"].create({"email": "alfred@test.com", "name": "Alfred"})
group_chat._add_members(guests=alfred_guest)
with self.mock_mail_gateway():
group_chat.invite_by_email(["alfred@test.com", "bob@test.com", "other@test.com"])
self.assertMailMail(
self.env["res.partner"],
status=None,
email_to_all=["other@test.com"],
author=bob.partner_id,
)
self.assertNoMail(self.env["res.partner"], "bob@test.com")
self.assertNoMail(self.env["res.partner"], "alfred@test.com")
def test_03_only_invite_by_email_on_allowed_channel_types(self):
bob = new_test_user(self.env, "bob", groups="base.group_user")
john = new_test_user(self.env, "john", groups="base.group_user")
chat = (
self.env["discuss.channel"]
.with_user(bob)
._get_or_create_chat(partners_to=john.partner_id.ids)
)
group_chat = (
self.env["discuss.channel"]
.with_user(bob)
._create_group(partners_to=john.partner_id.ids)
)
public_channel = self.env["discuss.channel"].create(
{"name": "public community", "group_public_id": False}
)
private_channel = self.env["discuss.channel"].create(
{
"name": "user restricted channel",
"channel_type": "channel",
"group_public_id": self.env.ref("base.group_user").id,
}
)
for channel in [chat, private_channel]:
with self.assertRaises(UserError) as exc:
channel.invite_by_email(["some@email.com"])
self.assertEqual(
exc.exception.args[0],
f"Inviting by email is not allowed for this channel type ({channel.channel_type}).",
)
with self.mock_mail_gateway():
for channel in [group_chat, public_channel]:
channel.invite_by_email(["some@email.com"])
self.assertMailMail(
self.env["res.partner"],
status=None,
email_to_all=["some@email.com"],
email_values={"model": "discuss.channel", "res_id": channel.id},
)
def test_04_guest_email_updated_when_invited_from_email(self):
bob = new_test_user(self.env, "bob", groups="base.group_user", email="bob@test.com")
group_chat = (
self.env["discuss.channel"].with_user(bob)._create_group(partners_to=bob.partner_id.ids)
)
# Guest email is filled at create
self.url_open(
f"{group_chat.invitation_url}?email_token={hash_sign(self.env, 'mail.invite_email', 'alfred@test.com')}"
)
self.assertEqual(group_chat.channel_member_ids.guest_id.email, "alfred@test.com")
self.assertEqual(group_chat.channel_member_ids.guest_id.name, "alfred@test.com")
# Guest email is updated if empty when invited from email
guest = self.env["mail.guest"].create({"name": "Alice"})
self.assertFalse(guest.email)
self.url_open(
f"{group_chat.invitation_url}?email_token={hash_sign(self.env, 'mail.invite_email', 'alice@test.com')}",
cookies={
guest._cookie_name: f"{guest.id}{guest._cookie_separator}{guest.access_token}",
},
)
self.assertEqual(guest.email, "alice@test.com")
self.assertEqual(guest.name, "Alice")
# Guest email is not overwriten if already filled
guest = self.env["mail.guest"].create({"name": "John", "email": "john@test.com"})
self.url_open(
f"{group_chat.invitation_url}?email_token={hash_sign(self.env, 'mail.invite_email', 'john_other_email@test.com')}",
cookies={
guest._cookie_name: f"{guest.id}{guest._cookie_separator}{guest.access_token}",
},
)
self.assertEqual(guest.email, "john@test.com")
self.assertEqual(guest.name, "John")
def test_05_search_for_channel_invite_selectable_email(self):
bob = new_test_user(self.env, "bob", groups="base.group_user", email="bob@test.com")
john = new_test_user(self.env, "john", groups="base.group_user", email="john@test.com")
alfred_guest = self.env["mail.guest"].create({"email": "alfred@test.com", "name": "Alfred"})
chat = (
self.env["discuss.channel"]
.with_user(bob)
._get_or_create_chat(partners_to=john.partner_id.ids)
)
group_chat = (
self.env["discuss.channel"]
.with_user(bob)
._create_group(partners_to=john.partner_id.ids)
)
group_chat._add_members(guests=alfred_guest)
public_channel = self.env["discuss.channel"].create(
{"name": "public community", "group_public_id": False},
)
public_channel._add_members(guests=alfred_guest)
private_channel = self.env["discuss.channel"].create(
{
"name": "user restricted channel",
"channel_type": "channel",
"group_public_id": self.env.ref("base.group_user").id,
},
)
cases = [
*product(
[chat, private_channel, group_chat, public_channel],
["foo@bar"],
[False],
),
# Channel types that do not allow inviting by email, not selectable.
*product(
[chat, private_channel],
["bob@odoo.com", "alfred@odoo.com", "jane@odoo.com"],
[False],
),
# Channel types that allow inviting by email, valid email, selectable.
*product(
[group_chat, public_channel],
["bob@odoo.com", "alfred@odoo.com", "jane@odoo.com"],
[True],
),
]
for channel, search_term, is_selectable in cases:
with self.subTest(
f"channel={channel.channel_type}_{channel.display_name}, search_term={search_term}, is_selectable={is_selectable}"
):
result = self.env["res.partner"].search_for_channel_invite(
search_term, channel_id=channel.id
)
if is_selectable:
self.assertEqual(result["selectable_email"], search_term)
continue
self.assertFalse(result["selectable_email"])
@users("employee")
def test_06_invite_by_email_posts_user_notification(self):
group_chat = self.env["discuss.channel"]._create_group(partners_to=self.user_employee.partner_id.ids)
with self.mock_mail_gateway():
group_chat.invite_by_email(["alfred@test.com"])
last_message = group_chat._get_last_messages()
self.assertEqual(last_message.message_type, "user_notification")

View file

@ -0,0 +1,274 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mail.tests.common import MailCommon
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.tests.common import new_test_user, tagged
@tagged("post_install", "-at_install")
class TestDiscussChannelMember(MailCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.secret_group = cls.env['res.groups'].create({
'name': 'Secret User Group',
})
cls.env['ir.model.data'].create({
'name': 'secret_group',
'module': 'mail',
'model': cls.secret_group._name,
'res_id': cls.secret_group.id,
})
cls.user_1 = new_test_user(
cls.env, login="user_1", name="User 1", groups="base.group_user,mail.secret_group"
)
cls.user_2 = new_test_user(
cls.env, login="user_2", name="User 2", groups="base.group_user,mail.secret_group"
)
cls.user_3 = new_test_user(
cls.env, login="user_3", name="User 3", groups="base.group_user,mail.secret_group"
)
cls.user_portal = new_test_user(
cls.env, login="user_portal", name="User Portal", groups="base.group_portal"
)
cls.user_public = new_test_user(
cls.env, login="user_public", name="User Public", groups="base.group_public"
)
cls.group = cls.env['discuss.channel'].create({
'name': 'Group',
'channel_type': 'group',
})
cls.group_restricted_channel = cls.env['discuss.channel'].create({
'name': 'Group restricted channel',
'channel_type': 'channel',
'group_public_id': cls.secret_group.id,
})
cls.public_channel = cls.env['discuss.channel']._create_channel(group_id=None, name='Public channel of user 1')
(cls.group | cls.group_restricted_channel | cls.public_channel).channel_member_ids.unlink()
# ------------------------------------------------------------
# GROUP
# ------------------------------------------------------------
def test_group_01(self):
"""Test access on group."""
res = self.env['discuss.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertFalse(res)
# User 1 can join group with SUDO
self.group.with_user(self.user_1).sudo()._add_members(users=self.user_1)
res = self.env['discuss.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertEqual(res.partner_id, self.user_1.partner_id)
# User 2 can not join group
with self.assertRaises(AccessError):
self.group.with_user(self.user_2)._add_members(users=self.user_2)
# User 2 can not create a `discuss.channel.member` to join the group
with self.assertRaises(AccessError):
self.env['discuss.channel.member'].with_user(self.user_2).create({
'partner_id': self.user_2.partner_id.id,
'channel_id': self.group.id,
})
# User 2 can not write on `discuss.channel.member` to join the group
channel_member = self.env['discuss.channel.member'].with_user(self.user_2).search([('is_self', '=', True)])[0]
with self.assertRaises(AccessError):
channel_member.channel_id = self.group.id
with self.assertRaises(AccessError):
channel_member.write({'channel_id': self.group.id})
# Even with SUDO, channel_id of channel.member should not be changed.
with self.assertRaises(AccessError):
channel_member.sudo().channel_id = self.group.id
# User 2 can not write on the `partner_id` of `discuss.channel.member`
# of an other partner to join a group
channel_member_1 = self.env['discuss.channel.member'].search([('channel_id', '=', self.group.id), ('partner_id', '=', self.user_1.partner_id.id)])
with self.assertRaises(AccessError):
channel_member_1.with_user(self.user_2).partner_id = self.user_2.partner_id
self.assertEqual(channel_member_1.partner_id, self.user_1.partner_id)
# Even with SUDO, partner_id of channel.member should not be changed.
with self.assertRaises(AccessError):
channel_member_1.with_user(self.user_2).sudo().partner_id = self.user_2.partner_id
def test_group_members(self):
"""Test invitation in group part 1 (invite using crud methods)."""
self.group.with_user(self.user_1).sudo()._add_members(users=self.user_1)
channel_members = self.env['discuss.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertEqual(len(channel_members), 1)
# User 2 is not in the group, they can not invite user 3
with self.assertRaises(AccessError):
self.env['discuss.channel.member'].with_user(self.user_2).create({
'partner_id': self.user_portal.partner_id.id,
'channel_id': self.group.id,
})
# User 1 is in the group, they can invite other users
self.env['discuss.channel.member'].with_user(self.user_1).create({
'partner_id': self.user_portal.partner_id.id,
'channel_id': self.group.id,
})
channel_members = self.env['discuss.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id | self.user_portal.partner_id)
# But User 3 can not write on the `discuss.channel.member` of other user
channel_member_1 = self.env['discuss.channel.member'].search([('channel_id', '=', self.group.id), ('partner_id', '=', self.user_1.partner_id.id)])
channel_member_3 = self.env['discuss.channel.member'].search([('channel_id', '=', self.group.id), ('partner_id', '=', self.user_portal.partner_id.id)])
channel_member_3.with_user(self.user_portal).custom_channel_name = 'Test'
with self.assertRaises(AccessError):
channel_member_1.with_user(self.user_2).custom_channel_name = 'Blabla'
self.assertNotEqual(channel_member_1.custom_channel_name, 'Blabla')
def test_group_invite(self):
"""Test invitation in group part 2 (use `invite` action)."""
self.group.with_user(self.user_1).sudo()._add_members(users=self.user_1)
channel_members = self.env['discuss.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id)
# User 2 is not in the group, they can not invite user_portal
with self.assertRaises(AccessError):
self.group.with_user(self.user_2)._add_members(users=self.user_portal)
channel_members = self.env['discuss.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id)
# User 1 is in the group, they can invite user_portal
self.group.with_user(self.user_1)._add_members(users=self.user_portal)
channel_members = self.env['discuss.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id | self.user_portal.partner_id)
def test_group_leave(self):
"""Test kick/leave channel."""
self.group.with_user(self.user_1).sudo()._add_members(users=self.user_1)
self.group.with_user(self.user_portal).sudo()._add_members(users=self.user_portal)
channel_members = self.env['discuss.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertEqual(len(channel_members), 2)
# User 2 is not in the group, they can not kick user 1
with self.assertRaises(AccessError):
channel_members.with_user(self.user_2).unlink()
# User 3 is in the group, but not admin/owner, they can not kick user 1
with self.assertRaises(AccessError):
channel_members.with_user(self.user_portal).unlink()
def test_group_subchannel_join(self):
"""Test join subchannel."""
self.group.add_members((self.user_1 | self.user_2).partner_id.ids)
group_subchannel = self.group.with_user(self.user_1)._create_sub_channel()
group_subchannel.with_user(self.user_2).add_members(self.user_2.partner_id.id)
self.assertEqual(group_subchannel.channel_member_ids.partner_id, (self.user_1 | self.user_2).partner_id)
# ------------------------------------------------------------
# GROUP BASED CHANNELS
# ------------------------------------------------------------
def test_group_restricted_channel(self):
"""Test basics on group channel."""
channel_members = self.env['discuss.channel.member'].search([('channel_id', '=', self.group_restricted_channel.id)])
self.assertFalse(channel_members)
# user 1 is in the channel, they can join the channel
self.group_restricted_channel.with_user(self.user_1)._add_members(users=self.user_1)
channel_members = self.env['discuss.channel.member'].search([('channel_id', '=', self.group_restricted_channel.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id)
# user 3 is not in the channel, they can not join
with self.assertRaises(AccessError):
self.group_restricted_channel.with_user(self.user_portal)._add_members(users=self.user_portal)
channel_members = self.env['discuss.channel.member'].search([('channel_id', '=', self.group_restricted_channel.id)])
with self.assertRaises(AccessError):
channel_members.with_user(self.user_portal).partner_id = self.user_portal.partner_id
channel_members = self.env['discuss.channel.member'].search([('channel_id', '=', self.group_restricted_channel.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id)
self.group_restricted_channel.with_user(self.user_1)._add_members(users=self.user_portal)
channel_members = self.env['discuss.channel.member'].search([('channel_id', '=', self.group_restricted_channel.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id | self.user_portal.partner_id)
# but user 2 is in the channel and can be invited by user 1
self.group_restricted_channel.with_user(self.user_1)._add_members(users=self.user_2)
channel_members = self.env['discuss.channel.member'].search([('channel_id', '=', self.group_restricted_channel.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id | self.user_2.partner_id | self.user_portal.partner_id)
# ------------------------------------------------------------
# PUBLIC CHANNELS
# ------------------------------------------------------------
def test_public_channel(self):
""" Test access on public channels """
channel_members = self.env['discuss.channel.member'].search([('channel_id', '=', self.public_channel.id)])
self.assertFalse(channel_members)
self.public_channel.with_user(self.user_1)._add_members(users=self.user_1)
channel_members = self.env['discuss.channel.member'].search([('channel_id', '=', self.public_channel.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id)
self.public_channel.with_user(self.user_2)._add_members(users=self.user_2)
channel_members = self.env['discuss.channel.member'].search([('channel_id', '=', self.public_channel.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id | self.user_2.partner_id)
self.public_channel.with_user(self.user_portal)._add_members(users=self.user_portal)
with self.assertRaises(ValidationError): # public cannot join without having a guest
self.public_channel.with_user(self.user_public)._add_members(users=self.user_public)
def test_channel_member_invite_with_guest(self):
guest = self.env['mail.guest'].create({'name': 'Guest'})
partner = self.env['res.partner'].create({
'name': 'ToInvite',
'active': True,
'type': 'contact',
'user_ids': self.user_1,
})
self.public_channel._add_members(guests=guest)
data = self.env["res.partner"].search_for_channel_invite(
partner.name, channel_id=self.public_channel.id
)["store_data"]
self.assertEqual(len(data["res.partner"]), 1)
self.assertEqual(data["res.partner"][0]["id"], partner.id)
# ------------------------------------------------------------
# UNREAD COUNTER TESTS
# ------------------------------------------------------------
def test_unread_counter_with_message_post(self):
channel_as_user_1 = self.env['discuss.channel'].with_user(self.user_1)._create_channel(group_id=None, name='Public channel')
channel_as_user_1.with_user(self.user_1)._add_members(users=self.user_1)
channel_as_user_1.with_user(self.user_1)._add_members(users=self.user_2)
channel_1_rel_user_2 = self.env['discuss.channel.member'].search([
('channel_id', '=', channel_as_user_1.id),
('partner_id', '=', self.user_2.partner_id.id)
])
self.assertEqual(channel_1_rel_user_2.message_unread_counter, 0, "should not have unread message initially as notification type is ignored")
channel_as_user_1.message_post(body='Test', message_type='comment', subtype_xmlid='mail.mt_comment')
channel_1_rel_user_2 = self.env['discuss.channel.member'].search([
('channel_id', '=', channel_as_user_1.id),
('partner_id', '=', self.user_2.partner_id.id)
])
self.assertEqual(channel_1_rel_user_2.message_unread_counter, 1, "should have 1 unread message after someone else posted a message")
def test_unread_counter_with_message_post_multi_channel(self):
channel_1_as_user_1 = self.env['discuss.channel'].with_user(self.user_1)._create_channel(group_id=None, name='wololo channel')
channel_2_as_user_2 = self.env['discuss.channel'].with_user(self.user_2)._create_channel(group_id=None, name='walala channel')
channel_1_as_user_1._add_members(users=self.user_2)
channel_2_as_user_2._add_members(users=self.user_1)
channel_2_as_user_2._add_members(users=self.user_3)
channel_1_as_user_1.message_post(body='Test', message_type='comment', subtype_xmlid='mail.mt_comment')
channel_1_as_user_1.message_post(body='Test 2', message_type='comment', subtype_xmlid='mail.mt_comment')
channel_2_as_user_2.message_post(body='Test', message_type='comment', subtype_xmlid='mail.mt_comment')
members = self.env['discuss.channel.member'].search([('channel_id', 'in', (channel_1_as_user_1 + channel_2_as_user_2).ids)], order="id")
self.assertEqual(members.mapped('message_unread_counter'), [
0, # channel 1 user 1: posted last message
0, # channel 2 user 2: posted last message
2, # channel 1 user 2: received 2 messages (from message post)
1, # channel 2 user 1: received 1 message (from message post)
1, # channel 2 user 3: received 1 message (from message post)
])

View file

@ -0,0 +1,76 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
try:
import websocket as ws
except ImportError:
ws = None
from itertools import product
from odoo.tests import tagged, new_test_user
from odoo.addons.bus.tests.common import WebsocketCase
from odoo.addons.mail.tests.common import MailCommon, freeze_all_time
from odoo.addons.bus.models.bus import channel_with_db, json_dump
@tagged("post_install", "-at_install")
class TestMailPresence(WebsocketCase, MailCommon):
def _receive_presence(self, requested_by, target, has_token=False):
self.env["mail.presence"].search([]).unlink()
target_user = isinstance(target, self.env.registry["res.users"])
if isinstance(requested_by, self.env.registry["res.users"]):
session = self.authenticate(requested_by.login, requested_by.login)
auth_cookie = f"session_id={session.sid};"
else:
self.authenticate(None, None)
auth_cookie = f"{requested_by._cookie_name}={requested_by._format_auth_cookie()};"
websocket = self.websocket_connect(cookie=auth_cookie)
target_channel = target.partner_id if target_user else target
channel_parts = ["odoo-presence", f"{target_channel._name}_{target_channel.id}"]
if has_token:
channel_parts.append(target_channel._get_im_status_access_token())
self.subscribe(websocket, ["-".join(channel_parts)], self.env["bus.bus"]._bus_last_id())
self.env["mail.presence"]._update_presence(target)
self.trigger_notification_dispatching([(target_channel, "presence")])
notifications = json.loads(websocket.recv())
self._close_websockets()
bus_record = self.env["bus.bus"].search([("id", "=", int(notifications[0]["id"]))])
self.assertEqual(
bus_record.channel,
json_dump(channel_with_db(self.env.cr.dbname, (target_channel, "presence"))),
)
self.assertEqual(notifications[0]["message"]["type"], "bus.bus/im_status_updated")
self.assertEqual(notifications[0]["message"]["payload"]["im_status"], "online")
self.assertEqual(notifications[0]["message"]["payload"]["presence_status"], "online")
self.assertEqual(
notifications[0]["message"]["payload"]["partner_id" if target_user else "guest_id"],
target_channel.id,
)
@freeze_all_time()
def test_presence_access(self):
internal = new_test_user(self.env, login="internal_user", groups="base.group_user")
other_internal = new_test_user(
self.env, login="other_internal_user", groups="base.group_user"
)
portal = new_test_user(self.env, login="portal_user", groups="base.group_portal")
other_portal = new_test_user(
self.env, login="other_portal_user", groups="base.group_portal"
)
guest = self.env["mail.guest"].create({"name": "Guest"})
other_guest = self.env["mail.guest"].create({"name": "Other Guest"})
for requested_by, target, has_token, allowed in [
*product([internal], [guest, other_internal, portal], [True, False], [True]),
*product([guest, portal], [internal, other_guest, other_portal], [False], [False]),
*product([guest, portal], [internal, other_guest, other_portal], [True], [True]),
]:
with self.subTest(
f"test presence access, requested_by={requested_by.name}, target={target.name}, has_token={has_token}, allowed={allowed}"
):
if allowed:
self._receive_presence(requested_by, target, has_token=has_token)
else:
with self.assertRaises(ws._exceptions.WebSocketTimeoutException):
self._receive_presence(requested_by, target, has_token=has_token)

View file

@ -0,0 +1,30 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.tests.common import HttpCase, new_test_user, tagged
@tagged("post_install", "-at_install")
class TestDiscussMentionSuggestions(HttpCase):
def test_mention_suggestions_group_restricted_channel(self):
user_admin = self.env.ref("base.user_admin")
user_group = self.env.ref("base.group_user")
rd_group = self.env["res.groups"].create({"name": "R&D Group"})
new_test_user(self.env, login="dev", name="Dev User", group_ids=[user_group.id, rd_group.id])
# have a user that is not channel member and not in group -> should not be suggested as mention
new_test_user(self.env, login="sales", name="Sales User", groups="base.group_user")
consultant_user = new_test_user(self.env, login="consultant", name="Consultant User", groups="base.group_user")
rd_channel = self.env['discuss.channel'].with_user(user_admin).create({
"name": "R&D Channel",
"channel_type": "channel",
"group_public_id": rd_group.id,
"channel_member_ids": [
Command.create({"partner_id": consultant_user.partner_id.id}),
Command.create({"partner_id": user_admin.partner_id.id}),
],
})
self.start_tour(
f"/odoo/discuss?active_id=discuss.channel_{rd_channel.id}",
"discuss_mention_suggestions_group_restricted_channel.js",
login="admin",
)

View file

@ -0,0 +1,50 @@
from odoo.addons.mail.tests.common_controllers import MailControllerUpdateCommon
from odoo.tests import tagged
@tagged("-at_install", "post_install", "mail_controller")
class TestDiscussMessageUpdateController(MailControllerUpdateCommon):
def test_message_update_guest_as_owner(self):
"""Test only admin user and message author can update the message content in a channel."""
channel = self.env["discuss.channel"].create(
{"group_public_id": None, "name": "public channel"}
)
channel._add_members(guests=self.guest)
# sudo: discuss.channel: posting a message as guest in a test is acceptable
message = (
channel.with_user(self.user_public)
.with_context(guest=self.guest)
.sudo()
.message_post(body=self.message_body, message_type="comment")
)
self._execute_subtests(
message,
(
(self.guest, True),
(self.user_admin, True),
(self.user_employee, False),
(self.user_portal, False),
(self.user_public, False),
),
)
def test_message_update_public_channel(self):
"""Test only admin user can update the message content of other authors in a channel."""
channel = self.env["discuss.channel"].create(
{"group_public_id": None, "name": "public channel"}
)
message = channel.message_post(
body=self.message_body,
message_type="comment",
)
self._execute_subtests(
message,
(
(self.guest, False),
(self.user_admin, True),
(self.user_employee, False),
(self.user_portal, False),
(self.user_public, False),
),
)

View file

@ -0,0 +1,56 @@
from odoo.addons.mail.tests.common_controllers import MailControllerReactionCommon
from odoo.tests import tagged
@tagged("-at_install", "post_install", "mail_controller")
class TestMessageReactionController(MailControllerReactionCommon):
def test_message_reaction_public_channel(self):
"""Test access of message reaction for a public channel."""
channel = self.env["discuss.channel"].create(
{"group_public_id": None, "name": "public channel"}
)
message = channel.message_post(body="public message")
self._execute_subtests(
message,
(
(self.user_public, False),
(self.guest, True),
(self.user_portal, True),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_message_reaction_channel_as_member(self):
"""Test access of message reaction for a channel as member."""
channel = self.env["discuss.channel"]._create_group(
partners_to=(self.user_portal + self.user_employee).partner_id.ids
)
channel._add_members(guests=self.guest)
message = channel.message_post(body="invite message")
self._execute_subtests(
message,
(
(self.user_public, False),
(self.guest, True),
(self.user_portal, True),
(self.user_employee, True),
(self.user_admin, True),
),
)
def test_message_reaction_channel_as_non_member(self):
"""Test access of message reaction for a channel as non-member."""
channel = self.env["discuss.channel"]._create_group(partners_to=[])
message = channel.message_post(body="private message")
self._execute_subtests(
message,
(
(self.user_public, False),
(self.guest, False),
(self.user_portal, False),
(self.user_employee, False),
(self.user_admin, True),
),
)

View file

@ -0,0 +1,58 @@
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.mail.tests.test_res_role import TestResRole
from odoo.tests.common import tagged
@tagged("-at_install", "post_install")
class TestDiscussResRole(TestResRole):
def test_only_mention_by_role_when_channel_is_accessible(self):
self.authenticate("admin", "admin")
role = self.env["res.role"].create({"name": "rd-Discuss"})
for idx, test_case in enumerate([
# channel_type, channel_grp, user_grp, is_member, mentionned
("channel", None, "base.group_user", False, True),
("channel", None, "base.group_user", True, True),
("channel", "base.group_system", "base.group_user", False, False),
("channel", "base.group_system", "base.group_system", True, True),
("group", None, "base.group_user", False, False),
("group", None, "base.group_user", True, True),
]):
channel_type, channel_grp, user_grp, is_member, mentionned = test_case
with self.subTest(
channel_type=channel_type,
channel_grp=channel_grp,
user_grp=user_grp,
is_member=is_member,
notified=mentionned,
):
channel = self.env["discuss.channel"].create(
{
"name": f"channel_{channel_grp}_{user_grp}_{mentionned}",
"channel_type": channel_type,
"group_public_id": self.env.ref(channel_grp).id if channel_grp else None,
}
)
user = mail_new_test_user(
self.env, login=f"user_{user_grp}_{idx}", role_ids=role.ids, groups=user_grp
)
if is_member:
channel.add_members(partner_ids=user.partner_id.ids)
data = self.make_jsonrpc_request(
"/mail/message/post",
{
"thread_model": "discuss.channel",
"thread_id": channel.id,
"post_data": {
"body": "irrelevant",
"message_type": "comment",
"role_ids": role.ids,
"subtype_xmlid": "mail.mt_note",
},
},
)
formatted_partner = user.partner_id.id
message = next(filter(lambda m: m["id"] == data["message_id"], data["store_data"]["mail.message"]))
if mentionned:
self.assertIn(formatted_partner, message["partner_ids"])
else:
self.assertNotIn(formatted_partner, message["partner_ids"])

View file

@ -0,0 +1,231 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from datetime import datetime, timedelta
from freezegun import freeze_time
from unittest.mock import patch
from odoo.tests.common import HttpCase, new_test_user, tagged
from odoo.exceptions import UserError, ValidationError
@tagged("post_install", "-at_install")
class TestDiscussSubChannels(HttpCase):
def test_01_gc_unpin_outdated_sub_channels(self):
bob = new_test_user(self.env, "bob_user", groups="base.group_user")
parent = self.env["discuss.channel"].create({"name": "General"})
parent._create_sub_channel()
sub_channel = parent.sub_channel_ids[0]
sub_channel._add_members(users=self.env.user)
sub_channel.channel_pin(pinned=True)
self_member = sub_channel.channel_member_ids.filtered(lambda m: m.is_self)
self.assertTrue(self_member.is_pinned)
# Last interrest of the member is older than 2 days, no activity on the
# channel: should be unpinned.
two_days_later_dt = datetime.now() + timedelta(days=3)
with freeze_time(two_days_later_dt) as frozen_time:
self.env["discuss.channel.member"]._gc_unpin_outdated_sub_channels()
self.assertFalse(self_member.is_pinned)
unpin_dt = self_member.unpin_dt
# The member isn't unpinned again when GC re-runs.
frozen_time.tick(delta=timedelta(days=1))
self.env["discuss.channel.member"]._gc_unpin_outdated_sub_channels()
self.assertEqual(self_member.unpin_dt, unpin_dt)
sub_channel.channel_pin(pinned=True)
with freeze_time(two_days_later_dt) as frozen_time:
# Last interrest older than 2 days, activity on the channel: should be kept.
message = sub_channel.with_user(bob).message_post(body="Hey!", message_type="comment")
self_member._mark_as_read(message.id)
self.env["discuss.channel.member"]._gc_unpin_outdated_sub_channels()
self.assertTrue(self_member.is_pinned)
# Unread messages: should be kept regardless of last interrest.
message = sub_channel.with_user(bob).message_post(body="Another message!", message_type="comment")
frozen_time.tick(delta=timedelta(days=3))
self.env["discuss.channel.member"]._gc_unpin_outdated_sub_channels()
self.assertTrue(self_member.is_pinned)
self_member._mark_as_read(message.id)
self.env["discuss.channel.member"]._gc_unpin_outdated_sub_channels()
self.assertFalse(self_member.is_pinned)
# Ensure regular channels are not impacted.
channel = self.env["discuss.channel"].create({"name": "General"})
channel.channel_pin(pinned=True)
with freeze_time(two_days_later_dt):
self.env["discuss.channel.member"]._gc_unpin_outdated_sub_channels()
self.assertTrue(channel.channel_member_ids.filtered("is_self").is_pinned)
def test_02_sub_channel_members_sync_with_parent(self):
parent = self.env["discuss.channel"].create({"name": "General"})
parent.action_unfollow()
self.assertFalse(any(m.is_self for m in parent.channel_member_ids))
parent._create_sub_channel()
sub_channel = parent.sub_channel_ids[0]
# Member created for sub channel (_create_sub_channel): should also be
# created for the parent channel.
self.assertTrue(any(m.is_self for m in parent.channel_member_ids))
self.assertTrue(any(m.is_self for m in sub_channel.channel_member_ids))
# Member removed from parent channel: should also be removed from the sub
# channel.
parent.action_unfollow()
self.assertFalse(any(m.is_self for m in parent.channel_member_ids))
self.assertFalse(any(m.is_self for m in sub_channel.channel_member_ids))
# Member created for sub channel (add_members): should also be created
# for parent.
sub_channel._add_members(users=self.env.user)
self.assertTrue(any(m.is_self for m in parent.channel_member_ids))
self.assertTrue(any(m.is_self for m in sub_channel.channel_member_ids))
def test_03_cannot_create_recursive_sub_channel(self):
parent = self.env["discuss.channel"].create({"name": "General"})
parent._create_sub_channel()
sub_channel = parent.sub_channel_ids[0]
with self.assertRaises(ValidationError):
sub_channel._create_sub_channel()
def test_04_sub_channel_panel_search(self):
bob_user = new_test_user(self.env, "bob_user", groups="base.group_user")
self.authenticate("bob_user", "bob_user")
channel = self.env["discuss.channel"]._create_channel(name="General", group_id=None)
channel._add_members(users=bob_user)
for i in range(100):
channel._create_sub_channel(name=f"Sub Channel {i}")
self.start_tour(
f"/odoo/discuss?active_id=discuss.channel_{channel.id}",
"test_discuss_sub_channel_search",
login="bob_user",
)
def test_05_cannot_upate_first_message_nor_parent_channel(self):
parent = self.env["discuss.channel"].create({"name": "General"})
parent.message_post(body="Hello there!")
parent._create_sub_channel(from_message_id=parent.message_ids[0].id)
sub_channel = parent.sub_channel_ids[0]
random_channel = self.env["discuss.channel"].create({"name": "Random"})
parent.message_post(body="Random message")
with self.assertRaises(UserError, msg="Cannot change initial message nor parent channel of: Hello there!."):
sub_channel.parent_channel_id = random_channel
with self.assertRaises(UserError, msg="Cannot change initial message nor parent channel of: Hello there!."):
sub_channel.from_message_id = parent.message_ids[0]
def test_06_initial_message_must_belong_to_parent_channel(self):
parent = self.env["discuss.channel"].create({"name": "General"})
random_channel = self.env["discuss.channel"].create({"name": "Random"})
random_channel.message_post(body="Hello world!")
with self.assertRaises(
ValidationError,
msg="Cannot create Hello world!: initial message should belong to parent channel.",
):
parent._create_sub_channel(from_message_id=random_channel.message_ids[0].id)
def test_07_unlink_sub_channel(self):
bob_user = new_test_user(self.env, "bob_user", groups="base.group_user")
baz_user = new_test_user(self.env, "baz_user", groups="base.group_user")
parent_1 = self.env["discuss.channel"].with_user(bob_user).create({"name": "Parent 1"})
parent_1_baz_member = parent_1._add_members(users=baz_user)
parent_1_sub_channel_1 = parent_1._create_sub_channel(name="Parent 1 Sub 1")
parent_1_sub_channel_1._add_members(users=baz_user)
parent_1_sub_channel_2 = parent_1._create_sub_channel(name="Parent 1 Sub 2")
parent_1_sub_channel_2._add_members(users=baz_user)
parent_2 = self.env["discuss.channel"].with_user(baz_user).create({"name": "Parent 2"})
parent_2_bob_member = parent_2._add_members(users=bob_user)
parent_2_sub_channel = parent_2._create_sub_channel(name="Parent 2 Sub")
parent_2_sub_channel._add_members(users=bob_user)
parent_3 = self.env["discuss.channel"].with_user(bob_user).create({"name": "Parent 3"})
guest = self.env["mail.guest"].create({"name": "Guest"})
parent_3_guest_member = parent_3._add_members(guests=guest)
parent_3_sub_channel = parent_3._create_sub_channel(name="Parent 3 Sub")
parent_3_sub_channel._add_members(guests=guest)
members_to_unlink = parent_1_baz_member + parent_2_bob_member + parent_3_guest_member
members_to_unlink.sudo().unlink()
self.assertNotIn(
baz_user.partner_id,
parent_1.channel_member_ids.partner_id
| parent_1.sub_channel_ids.channel_member_ids.partner_id,
)
self.assertNotIn(
bob_user.partner_id,
parent_2.channel_member_ids.partner_id
| parent_2.sub_channel_ids.channel_member_ids.partner_id,
)
self.assertNotIn(
guest,
parent_3.channel_member_ids.guest_id
| parent_3.sub_channel_ids.channel_member_ids.guest_id,
)
self.assertIn(bob_user.partner_id, parent_1_sub_channel_1.channel_member_ids.partner_id)
self.assertIn(bob_user.partner_id, parent_1_sub_channel_2.channel_member_ids.partner_id)
self.assertIn(baz_user.partner_id, parent_2_sub_channel.channel_member_ids.partner_id)
self.assertIn(bob_user.partner_id, parent_3_sub_channel.channel_member_ids.partner_id)
def test_08_group_public_id_synced_with_parent(self):
parent = self.env["discuss.channel"].create({"name": "General"})
parent._create_sub_channel()
sub_channel = parent.sub_channel_ids[0]
self.assertEqual(parent.group_public_id, self.env.ref("base.group_user"))
self.assertEqual(sub_channel.group_public_id, parent.group_public_id)
parent.group_public_id = self.env.ref("base.group_system")
self.assertEqual(parent.group_public_id, self.env.ref("base.group_system"))
self.assertEqual(sub_channel.group_public_id, parent.group_public_id)
parent.group_public_id = None
self.assertEqual(parent.group_public_id, self.env["res.groups"])
self.assertEqual(sub_channel.group_public_id, parent.group_public_id)
def test_09_cannot_change_group_public_id_of_sub_channel(self):
parent = self.env["discuss.channel"].create({"name": "General"})
parent._create_sub_channel()
sub_channel = parent.sub_channel_ids[0]
with self.assertRaises(UserError):
sub_channel.group_public_id = self.env.ref("base.group_system")
def test_10_sub_channel_message_author_member(self):
bob_user = new_test_user(self.env, "bob_user", groups="base.group_user")
parent = self.env["discuss.channel"].create({
"name": "General",
"channel_member_ids": [Command.create({"partner_id": bob_user.partner_id.id})],
})
message = parent.with_user(bob_user).message_post(body="Hello there!")
sub_channel = parent._create_sub_channel(from_message_id=message.id)
self.assertIn(bob_user.partner_id, sub_channel.channel_member_ids.partner_id)
self.assertEqual(len(sub_channel.channel_member_ids), 2)
def test_11_sub_channel_fallback_name_on_empty_message(self):
parent = self.env["discuss.channel"].create({"name": "General"})
message = parent.message_post(body="Hello there!", message_type="comment")
parent._message_update_content(message, body="")
sub_channel = parent._create_sub_channel(from_message_id=message.id)
self.assertEqual(sub_channel.name, "This message has been removed")
def test_12_unlink_children_members_only_once(self):
parent = self.env["discuss.channel"].create({"name": "General"})
child = parent._create_sub_channel()
og_unlink = self.env.registry["discuss.channel.member"].unlink
unlinked_member_ids = []
expected_unlinked_member_ids = sorted((parent.self_member_id | child.self_member_id).ids)
def _patched_unlink(records):
unlinked_member_ids.extend(records.ids)
og_unlink(records)
with patch.object(self.env.registry["discuss.channel.member"], "unlink", _patched_unlink):
(parent | child).channel_member_ids.unlink()
self.assertEqual(expected_unlinked_member_ids, sorted(unlinked_member_ids))
def test_13_mentioned_user_becomes_sub_channel_member(self):
alice_user = new_test_user(self.env, "alice_user", groups="base.group_user")
bob_user = new_test_user(self.env, "bob_user", groups="base.group_user")
parent = self.env["discuss.channel"].create({
"name": "General",
"channel_member_ids": [
Command.create({"partner_id": alice_user.partner_id.id}),
Command.create({"partner_id": bob_user.partner_id.id}),
],
})
message = parent.with_user(bob_user).message_post(body="Hello there!")
sub_channel = parent._create_sub_channel(from_message_id=message.id)
self.assertNotIn(alice_user.partner_id, sub_channel.channel_member_ids.partner_id)
sub_channel.with_user(bob_user).message_post(
body="Check this out @Alice",
partner_ids=[alice_user.partner_id.id],
)
self.assertIn(alice_user.partner_id, sub_channel.channel_member_ids.partner_id)

View file

@ -0,0 +1,104 @@
from odoo.addons.mail.tests.common_controllers import MailControllerThreadCommon, MessagePostSubTestData
from odoo.tests import tagged
@tagged("-at_install", "post_install", "mail_controller")
class TestDiscussThreadController(MailControllerThreadCommon):
def test_internal_channel_message_post_access(self):
"""Test access of message_post on internal channel."""
channel = self.env["discuss.channel"].create({"name": "Internal Channel"})
def test_access(user, allowed):
return MessagePostSubTestData(user, allowed)
self._execute_message_post_subtests(
channel,
(
test_access(self.user_public, False),
test_access(self.guest, False),
test_access(self.user_portal, False),
test_access(self.user_employee, True),
test_access(self.user_admin, True),
),
)
def test_public_channel_message_post_access(self):
"""Test access of message_post on public channel."""
channel = self.env["discuss.channel"].create(
{"name": "Public Channel", "group_public_id": None}
)
def test_access(user, allowed, exp_author=None):
return MessagePostSubTestData(user, allowed, exp_author=exp_author)
self._execute_message_post_subtests(
channel,
(
test_access(self.user_public, True),
test_access(self.guest, True),
test_access(self.user_portal, True),
test_access(self.user_employee, True),
test_access(self.user_admin, True),
),
)
def test_public_channel_message_post_partner_ids(self):
"""Test partner_ids of message_post on public channel.
Non-internal users cannot use mentions without mention_token."""
channel = self.env["discuss.channel"].create(
{"name": "Public Channel", "group_public_id": None}
)
channel._add_members(users=self.user_employee_nopartner)
partners = (
self.user_portal + self.user_employee + self.user_employee_nopartner + self.user_admin
).partner_id
def test_partners(user, allowed, exp_partners):
return MessagePostSubTestData(
user, allowed, partners=partners, exp_partners=exp_partners
)
self._execute_message_post_subtests(
channel,
(
test_partners(self.user_public, True, self.env["res.partner"]),
test_partners(self.guest, True, self.env["res.partner"]),
test_partners(self.user_portal, True, self.env["res.partner"]),
test_partners(self.user_employee, True, partners),
test_partners(self.user_employee_nopartner, True, partners),
test_partners(self.user_admin, True, partners),
),
)
def test_public_channel_message_post_partner_emails(self):
"""Test partner_emails of message_post on public channel can only be
used by users of base.group_partner_manager."""
channel = self.env["discuss.channel"].create(
{"name": "Public Channel", "group_public_id": None}
)
no_emails = []
existing_emails = [self.user_employee.email]
partner_emails = [self.user_employee.email, "test@example.com"]
def test_emails(user, allowed, exp_emails, exp_author=None):
return MessagePostSubTestData(
user,
allowed,
partner_emails=partner_emails,
exp_author=exp_author,
exp_emails=exp_emails,
)
self._execute_message_post_subtests(
channel,
(
test_emails(self.user_public, True, no_emails),
test_emails(self.guest, True, no_emails),
test_emails(self.user_portal, True, no_emails),
# restricted because not base.group_partner_manager: find existing only
test_emails(self.user_employee_nopartner, True, existing_emails),
test_emails(self.user_employee, True, partner_emails),
test_emails(self.user_admin, True, partner_emails),
),
)

View file

@ -0,0 +1,47 @@
from odoo import fields
from odoo.addons.mail.tests.common import MailCase
from odoo.tests.common import tagged
@tagged("post_install", "-at_install")
class TestGuest(MailCase):
def test_updating_guest_name_linked_to_multiple_channels(self):
"""This test ensures that when a guest is linked to multiple channels,
the guest's name is updated correctly and the appropriate bus notifications are sent.
"""
guest = self.env['mail.guest'].create({'name': 'Guest'})
channel_1 = self.env["discuss.channel"]._create_channel(name="Channel 1", group_id=None)
channel_2 = self.env["discuss.channel"]._create_channel(name="Channel 2", group_id=None)
channel_1._add_members(guests=guest)
channel_2._add_members(guests=guest)
def get_guest_bus_params():
guest_write_date = fields.Datetime.to_string(guest.write_date)
message = {
"type": "mail.record/insert",
"payload": {
"mail.guest": [
{
"avatar_128_access_token": guest._get_avatar_128_access_token(),
"id": guest.id,
"name": "Guest Name Updated",
"write_date": guest_write_date,
},
],
},
}
return (
[
(self.cr.dbname, "discuss.channel", channel_1.id),
(self.cr.dbname, "discuss.channel", channel_2.id),
(self.cr.dbname, "mail.guest", guest.id),
],
[message, message, message],
)
self._reset_bus()
with self.assertBus(get_params=get_guest_bus_params):
guest._update_name("Guest Name Updated")
self.assertEqual(guest.name, "Guest Name Updated")

View file

@ -0,0 +1,61 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from odoo.tests import tagged
from odoo.addons.bus.tests.common import WebsocketCase
from odoo.addons.mail.tests.common import MailCommon
@tagged("post_install", "-at_install")
class TestGuestFeature(WebsocketCase, MailCommon):
def test_mark_as_read_as_guest(self):
guest = self.env["mail.guest"].create({"name": "Guest"})
partner = self.env["res.partner"].create({"name": "John"})
channel = self.env["discuss.channel"]._create_channel(
group_id=None, name="General"
)
channel._add_members(guests=guest, partners=partner)
channel.message_post(
body="Hello World!", message_type="comment", subtype_xmlid="mail.mt_comment"
)
guest_member = channel.channel_member_ids.filtered(
lambda m: m.guest_id == guest
)
self.assertEqual(guest_member.seen_message_id, self.env["mail.message"])
self.make_jsonrpc_request(
"/discuss/channel/mark_as_read",
{
"channel_id": channel.id,
"last_message_id": channel.message_ids[0].id,
},
cookies={guest._cookie_name: guest._format_auth_cookie()}
)
self.assertEqual(guest_member.seen_message_id, channel.message_ids[0])
def test_subscribe_to_guest_channel(self):
self._reset_bus()
guest = self.env["mail.guest"].create({"name": "Guest"})
guest_websocket = self.websocket_connect()
self.subscribe(guest_websocket, [f"mail.guest_{guest._format_auth_cookie()}"], guest.id)
guest._bus_send("lambda", {"foo": "bar"})
self.trigger_notification_dispatching([guest])
notifications = json.loads(guest_websocket.recv())
self.assertEqual(1, len(notifications))
self.assertEqual(notifications[0]["message"]["type"], "lambda")
self.assertEqual(notifications[0]["message"]["payload"], {"foo": "bar"})
def test_subscribe_to_discuss_channel(self):
guest = self.env["mail.guest"].create({"name": "Guest"})
channel = self.env["discuss.channel"]._create_channel(
group_id=None, name="General"
)
channel._add_members(guests=guest)
self._reset_bus()
guest_websocket = self.websocket_connect()
self.subscribe(guest_websocket, [f"mail.guest_{guest._format_auth_cookie()}"], guest.id)
channel._bus_send("lambda", {"foo": "bar"})
self.trigger_notification_dispatching([channel])
notifications = json.loads(guest_websocket.recv())
self.assertEqual(1, len(notifications))
self.assertEqual(notifications[0]["message"]["type"], "lambda")
self.assertEqual(notifications[0]["message"]["payload"], {"foo": "bar"})

View file

@ -0,0 +1,26 @@
import odoo.tests
from odoo import Command
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
@odoo.tests.tagged('post_install', '-at_install')
class TestLoadMessages(HttpCaseWithUserDemo):
def test_01_mail_message_load_order_tour(self):
partner_admin = self.env.ref('base.partner_admin')
channel_id = self.env["discuss.channel"].create({
"name": "MyTestChannel",
"channel_member_ids": [Command.create({"partner_id": partner_admin.id})],
})
self.env["mail.message"].create([{
"body": str(n),
"model": "discuss.channel",
"pinned_at": odoo.fields.Datetime.now() if n == 1 else None,
"res_id": channel_id.id,
"author_id": partner_admin.id,
"message_type": "comment",
} for n in range(1, 61)])
channel_id.channel_member_ids.filtered(
lambda m: m.partner_id == partner_admin
)._mark_as_read(channel_id.message_ids[0].id)
self.start_tour("/odoo/action-mail.action_discuss", "mail_message_load_order_tour", login="admin")

View file

@ -0,0 +1,351 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import odoo
from odoo.tests import tagged, users
from odoo.tools import mute_logger
from odoo.addons.base.tests.common import HttpCase, HttpCaseWithUserDemo
from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user
from odoo.http import STATIC_CACHE_LONG
from odoo import Command, fields
@odoo.tests.tagged("-at_install", "post_install", "mail_controller")
class TestMessageController(HttpCaseWithUserDemo):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.channel = cls.env["discuss.channel"].create(
{
"group_public_id": None,
"name": "Test channel",
}
)
cls.public_user = cls.env.ref("base.public_user")
cls.attachments = (
cls.env["ir.attachment"]
.with_user(cls.public_user)
.sudo()
.create(
[
{
"name": "File 1",
"res_id": 0,
"res_model": "mail.compose.message",
},
{
"name": "File 2",
"res_id": 0,
"res_model": "mail.compose.message",
},
]
)
)
cls.guest = cls.env["mail.guest"].create({"name": "Guest"})
cls.channel._add_members(guests=cls.guest)
@mute_logger("odoo.addons.http_routing.models.ir_http", "odoo.http")
def test_channel_message_attachments(self):
self.authenticate(None, None)
self.opener.cookies[self.guest._cookie_name] = self.guest._format_auth_cookie()
# test message post: token error
res1 = self.url_open(
url="/mail/message/post",
data=json.dumps(
{
"params": {
"thread_model": "discuss.channel",
"thread_id": self.channel.id,
"post_data": {
"body": "test",
"attachment_ids": [self.attachments[0].id],
"attachment_tokens": ["wrong token"],
},
},
},
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res1.status_code, 200)
self.assertIn(
"One or more attachments do not exist, or you do not have the rights to access them.",
res1.text,
"guest should not be allowed to add attachment without token when posting message",
)
# test message post: token ok
res2 = self.url_open(
url="/mail/message/post",
data=json.dumps(
{
"params": {
"thread_model": "discuss.channel",
"thread_id": self.channel.id,
"post_data": {
"body": "test",
"attachment_ids": [self.attachments[0].id],
"attachment_tokens": [self.attachments[0]._get_ownership_token()],
"message_type": "comment",
},
},
},
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res2.status_code, 200)
data1 = res2.json()["result"]
self.assertEqual(
data1["store_data"]["ir.attachment"],
[
{
"checksum": False,
"create_date": fields.Datetime.to_string(self.attachments[0].create_date),
"file_size": 0,
"has_thumbnail": False,
"id": self.attachments[0].id,
"mimetype": "application/octet-stream",
"name": "File 1",
"ownership_token": self.attachments[0]._get_ownership_token(),
"raw_access_token": self.attachments[0]._get_raw_access_token(),
"res_name": "Test channel",
"res_model": self.attachments[0].res_model,
"thread": {"id": self.channel.id, "model": "discuss.channel"},
"thumbnail_access_token": self.attachments[0]._get_thumbnail_token(),
"voice_ids": [],
'type': 'binary',
'url': False,
},
],
"guest should be allowed to add attachment with token when posting message",
)
# test message update: token error
res3 = self.url_open(
url="/mail/message/update_content",
data=json.dumps(
{
"params": {
"message_id": data1["message_id"],
"update_data": {
"body": "test",
"attachment_ids": [self.attachments[1].id],
"attachment_tokens": ["wrong token"],
},
},
},
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res3.status_code, 200)
self.assertIn(
"One or more attachments do not exist, or you do not have the rights to access them.",
res3.text,
"guest should not be allowed to add attachment without token when updating message",
)
# test message update: token ok
res4 = self.url_open(
url="/mail/message/update_content",
data=json.dumps(
{
"params": {
"message_id": data1["message_id"],
"update_data": {
"body": "test",
"attachment_ids": [self.attachments[1].id],
"attachment_tokens": [self.attachments[1]._get_ownership_token()],
},
},
},
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res4.status_code, 200)
data2 = res4.json()["result"]
self.assertEqual(
data2["ir.attachment"],
[
{
"checksum": False,
"create_date": fields.Datetime.to_string(self.attachments[0].create_date),
"file_size": 0,
"has_thumbnail": False,
"id": self.attachments[0].id,
"mimetype": "application/octet-stream",
"name": "File 1",
"ownership_token": self.attachments[0]._get_ownership_token(),
"raw_access_token": self.attachments[0]._get_raw_access_token(),
"res_name": "Test channel",
"res_model": self.attachments[0].res_model,
"thread": {"id": self.channel.id, "model": "discuss.channel"},
"thumbnail_access_token": self.attachments[0]._get_thumbnail_token(),
"voice_ids": [],
'type': 'binary',
'url': False,
},
{
"checksum": False,
"create_date": fields.Datetime.to_string(self.attachments[1].create_date),
"file_size": 0,
"has_thumbnail": False,
"id": self.attachments[1].id,
"mimetype": "application/octet-stream",
"name": "File 2",
"ownership_token": self.attachments[1]._get_ownership_token(),
"raw_access_token": self.attachments[1]._get_raw_access_token(),
"res_name": "Test channel",
"res_model": self.attachments[1].res_model,
"thread": {"id": self.channel.id, "model": "discuss.channel"},
"thumbnail_access_token": self.attachments[1]._get_thumbnail_token(),
"voice_ids": [],
'type': 'binary',
'url': False,
},
],
"guest should be allowed to add attachment with token when updating message",
)
@mute_logger("odoo.addons.http_routing.models.ir_http", "odoo.http")
def test_mail_partner_from_email_unauthenticated(self):
self.authenticate(None, None)
self.opener.cookies[self.guest._cookie_name] = self.guest._format_auth_cookie()
res1 = self.url_open(
url="/mail/partner/from_email",
data=json.dumps(
{
"params": {
"thread_model": "discuss.channel",
"thread_id": self.channel.id,
"emails": ["john@test.be"],
},
}
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res1.status_code, 200)
self.assertEqual(
0,
self.env["res.partner"].search_count([('email', '=', "john@test.be")]),
"guest should not be allowed to create a partner from an email",
)
self.authenticate(None, None)
self.opener.cookies[self.guest._cookie_name] = self.guest._format_auth_cookie()
res1 = self.url_open(
url="/mail/partner/from_email",
data=json.dumps(
{
"params": {
"thread_model": "discuss.channel",
"thread_id": self.channel.id,
"emails": ["john@test.be"],
},
}
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res1.status_code, 200)
self.assertEqual(
0,
self.env["res.partner"].search_count([('email', '=', "john@test.be")]),
"guest should not be allowed to create a partner from an email",
)
res2 = self.url_open(
url="/mail/message/post",
data=json.dumps(
{
"params": {
"thread_model": "discuss.channel",
"thread_id": self.channel.id,
"post_data": {
"body": "test",
"partner_emails": ["john@test.be"],
},
},
},
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res2.status_code, 200)
self.assertEqual(
0,
self.env["res.partner"].search_count([('email', '=', "john@test.be")]),
"guest should not be allowed to create a partner from an email from message_post",
)
def test_mail_cache_control_header(self):
testuser = self.env['res.users'].create({
'email': 'testuser@testuser.com',
'group_ids': [Command.set([self.ref('base.group_portal')])],
'name': 'Test User',
'login': 'testuser',
'password': 'testuser',
})
test_user = self.authenticate("testuser", "testuser")
partner = self.env["res.users"].browse(test_user.uid).partner_id
self.channel._add_members(users=testuser)
res = self.url_open(
url=f"/web/image/?field=avatar_128&id={self.channel.id}&model=discuss.channel&unique={self.channel.avatar_cache_key}"
)
self.assertIn(f"max-age={STATIC_CACHE_LONG}", res.headers["Cache-Control"])
res = self.url_open(
url=f"/web/image/?field=avatar_128&id={self.channel.id}&model=discuss.channel"
)
self.assertIn("no-cache", res.headers["Cache-Control"])
res = self.url_open(
url=f"/web/image?field=avatar_128&id={partner.id}&model=res.partner&unique={fields.Datetime.to_string(partner.write_date)}"
)
self.assertIn(f"max-age={STATIC_CACHE_LONG}", res.headers["Cache-Control"])
res = self.url_open(
url=f"/web/image?field=avatar_128&id={partner.id}&model=res.partner"
)
self.assertIn("no-cache", res.headers["Cache-Control"])
res = self.url_open(
url=f"/web/image?field=avatar_128&id={self.guest.id}&model=mail.guest&unique={fields.Datetime.to_string(partner.write_date)}"
)
self.assertIn(f"max-age={STATIC_CACHE_LONG}", res.headers["Cache-Control"])
res = self.url_open(
url=f"/web/image?field=avatar_128&id={self.guest.id}&model=mail.guest"
)
self.assertIn("no-cache", res.headers["Cache-Control"])
@tagged("mail_message")
class TestMessageLinks(MailCommon, HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_employee_1 = mail_new_test_user(cls.env, login='tao1', groups='base.group_user', name='Tao Lee')
cls.public_channel = cls.env['discuss.channel']._create_channel(name='Public Channel1', group_id=None)
cls.private_group = cls.env['discuss.channel']._create_group(partners_to=cls.user_employee_1.partner_id.ids, name="Group")
@users('employee')
def test_message_link_by_employee(self):
channel_message = self.public_channel.message_post(body='Public Channel Message', message_type='comment')
private_message_id = self.private_group.with_user(self.user_employee_1).message_post(
body='Private Message',
message_type='comment',
).id
self.authenticate('employee', 'employee')
with self.subTest(channel_message=channel_message):
expected_url = self.base_url() + f'/odoo/action-mail.action_discuss?active_id={channel_message.res_id}&highlight_message_id={channel_message.id}'
res = self.url_open(f'/mail/message/{channel_message.id}')
self.assertEqual(res.url, expected_url)
with self.subTest(private_message_id=private_message_id):
res = self.url_open(f'/mail/message/{private_message_id}')
self.assertEqual(res.status_code, 404)
@users('employee')
def test_message_link_by_public(self):
message = self.public_channel.message_post(
body='Public Channel Message',
message_type='comment',
subtype_xmlid='mail.mt_comment'
)
res = self.url_open(f'/mail/message/{message.id}')
self.assertEqual(res.status_code, 200)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from requests.exceptions import HTTPError
from odoo import Command, http
from odoo.tests.common import tagged, HttpCase
from odoo.tools import file_open, mute_logger
@tagged("post_install", "-at_install")
class TestToggleUpload(HttpCase):
def test_upload_allowed(self):
self.authenticate(None, None)
channel = self.env["discuss.channel"].create({"name": "General", "group_public_id": None})
guest = self.env["mail.guest"].create({"name": "Guest"})
channel.write({"channel_member_ids": [Command.create({"guest_id": guest.id})]})
with file_open("addons/web/__init__.py") as file:
response = self.url_open(
"/mail/attachment/upload",
{
"csrf_token": http.Request.csrf_token(self),
"thread_id": channel.id,
"thread_model": "discuss.channel",
},
files={"ufile": file},
cookies={guest._cookie_name: guest._format_auth_cookie()}
)
self.assertEqual(response.status_code, 200)

View file

@ -0,0 +1,48 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests
from odoo import Command
from odoo.addons.base.tests.common import HttpCaseWithUserDemo, new_test_user
@odoo.tests.tagged('post_install', '-at_install')
class TestUi(HttpCaseWithUserDemo):
def test_01_mail_tour(self):
self.start_tour("/odoo", 'discuss_channel_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
'group_ids': [Command.set([self.ref('base.group_user')])],
'name': 'Test User',
'login': 'testuser',
'password': 'testuser',
})
self.start_tour("/odoo", 'discuss_channel_tour', login='testuser')
# basic rendering test of the configuration menu in Discuss
def test_03_mail_discuss_configuration_tour(self):
self.start_tour("/odoo", "discuss_configuration_tour", login="admin")
def test_04_meeting_view_tour(self):
bob = new_test_user(self.env, "bob", groups="base.group_user", email="bob@test.com")
john = new_test_user(self.env, "john", groups="base.group_user", email="john@test.com")
group_chat = (
self.env["discuss.channel"]
.with_user(bob)
._create_group(
partners_to=john.partner_id.ids, default_display_mode="video_full_screen"
)
)
self.authenticate("bob", "bob")
self.make_jsonrpc_request("/mail/rtc/channel/join_call", {"channel_id": group_chat.id})
self.start_tour(
f"/odoo/discuss?active_id=discuss.channel_{group_chat.id}&fullscreen=1",
"discuss.meeting_view_tour",
login="john",
)
self.start_tour(group_chat.invitation_url, "discuss.meeting_view_public_tour", login="john")
def test_05_can_create_channel_tour(self):
self.start_tour("odoo/discuss", "can_create_channel_from_form_view", login="demo")

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

@ -1,209 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import odoo
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
from odoo.tools import mute_logger
@odoo.tests.tagged("-at_install", "post_install")
class TestDiscussController(HttpCaseWithUserDemo):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.channel = cls.env["mail.channel"].create(
{
"group_public_id": None,
"name": "Test channel",
}
)
cls.public_user = cls.env.ref("base.public_user")
cls.attachments = (
cls.env["ir.attachment"]
.with_user(cls.public_user)
.sudo()
.create(
[
{
"access_token": cls.env["ir.attachment"]._generate_access_token(),
"name": "File 1",
"res_id": 0,
"res_model": "mail.compose.message",
},
{
"access_token": cls.env["ir.attachment"]._generate_access_token(),
"name": "File 2",
"res_id": 0,
"res_model": "mail.compose.message",
},
]
)
)
cls.guest = cls.env["mail.guest"].create({"name": "Guest"})
cls.channel.add_members(guest_ids=cls.guest.ids)
@mute_logger("odoo.addons.http_routing.models.ir_http", "odoo.http")
def test_channel_message_attachments(self):
self.authenticate(None, None)
self.opener.cookies[
self.guest._cookie_name
] = f"{self.guest.id}{self.guest._cookie_separator}{self.guest.access_token}"
# test message post: token error
res1 = self.url_open(
url="/mail/message/post",
data=json.dumps(
{
"params": {
"thread_model": self.channel._name,
"thread_id": self.channel.id,
"post_data": {
"body": "test",
"attachment_ids": [self.attachments[0].id],
"attachment_tokens": ["wrong token"],
},
},
}
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res1.status_code, 200)
self.assertIn(
f"The attachment {self.attachments[0].id} does not exist or you do not have the rights to access it",
res1.text,
"guest should not be allowed to add attachment without token when posting message",
)
# test message post: token ok
res2 = self.url_open(
url="/mail/message/post",
data=json.dumps(
{
"params": {
"thread_model": self.channel._name,
"thread_id": self.channel.id,
"post_data": {
"body": "test",
"attachment_ids": [self.attachments[0].id],
"attachment_tokens": [self.attachments[0].access_token],
"message_type": "comment",
},
},
}
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res2.status_code, 200)
message_format1 = res2.json()["result"]
self.assertEqual(
message_format1["attachment_ids"],
json.loads(json.dumps(self.attachments[0]._attachment_format())),
"guest should be allowed to add attachment with token when posting message",
)
# test message update: token error
res3 = self.url_open(
url="/mail/message/update_content",
data=json.dumps(
{
"params": {
"message_id": message_format1["id"],
"body": "test",
"attachment_ids": [self.attachments[1].id],
"attachment_tokens": ["wrong token"],
},
}
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res3.status_code, 200)
self.assertIn(
f"The attachment {self.attachments[1].id} does not exist or you do not have the rights to access it",
res3.text,
"guest should not be allowed to add attachment without token when updating message",
)
# test message update: token ok
res4 = self.url_open(
url="/mail/message/update_content",
data=json.dumps(
{
"params": {
"message_id": message_format1["id"],
"body": "test",
"attachment_ids": [self.attachments[1].id],
"attachment_tokens": [self.attachments[1].access_token],
},
}
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res4.status_code, 200)
message_format2 = res4.json()["result"]
self.assertEqual(
message_format2["attachments"],
json.loads(json.dumps(self.attachments.sorted()._attachment_format())),
"guest should be allowed to add attachment with token when updating message",
)
# test message update: own attachment ok
res5 = self.url_open(
url="/mail/message/update_content",
data=json.dumps(
{
"params": {
"message_id": message_format2["id"],
"body": "test",
"attachment_ids": [self.attachments[1].id],
},
}
),
headers={"Content-Type": "application/json"},
)
self.assertEqual(res5.status_code, 200)
message_format3 = res5.json()["result"]
self.assertEqual(
message_format3["attachments"],
json.loads(json.dumps(self.attachments.sorted()._attachment_format())),
"guest should be allowed to add own attachment without token when updating message",
)
@mute_logger("odoo.addons.http_routing.models.ir_http", "odoo.http")
def test_attachment_hijack(self):
att = self.env["ir.attachment"].create(
[
{
"name": "arguments_for_firing_marc_demo",
"res_id": 0,
"res_model": "mail.compose.message",
},
]
)
demo = self.authenticate("demo", "demo")
channel = self.env["mail.channel"].create({"group_public_id": None, "name": "public_channel"})
channel.add_members(
self.env["res.users"].browse(demo.uid).partner_id.ids
) # don't care, we just need a channel where demo is follower
no_access_request = self.url_open("/web/content/" + str(att.id))
self.assertFalse(
no_access_request.ok
) # 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.
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": channel.id,
"thread_model": "mail.channel",
}
},
),
)
self.assertNotIn(
"arguments_for_firing_marc_demo", response.text
) # demo should not be able to see the name of the document

View file

@ -0,0 +1,243 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mail.tools.discuss import Store
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.tests import tagged, TransactionCase
from odoo.tests.common import new_test_user
@tagged("post_install", "-at_install")
class TestDiscussTools(TransactionCase):
"""Test class for discuss tools."""
# 0xx generic tests (key not in ids_by_model)
def test_010_store_dict(self):
"""Test dict is present in result."""
store = Store()
store.add_model_values("key1", {"id": 1, "test": True})
self.assertEqual(store.get_result(), {"key1": [{"id": 1, "test": True}]})
def test_011_store_dict_update_same_id(self):
"""Test dict update same id."""
store = Store()
store.add_model_values("key1", {"id": 1, "test": True})
store.add_model_values("key1", {"id": 1, "test": False, "abc": 1})
self.assertEqual(store.get_result(), {"key1": [{"id": 1, "test": False, "abc": 1}]})
def test_012_store_dict_update_multiple_ids(self):
"""Test dict update multiple ids."""
store = Store()
store.add_model_values("key1", {"id": 1, "test": True})
store.add_model_values("key1", {"id": 2, "test": True})
store.add_model_values("key1", {"id": 2, "test": False, "abc": 1})
self.assertEqual(
store.get_result(),
{"key1": [{"id": 1, "test": True}, {"id": 2, "test": False, "abc": 1}]},
)
def test_040_store_invalid(self):
"""Test adding invalid value."""
store = Store()
with self.assertRaises(AttributeError):
store.add_model_values("key1", True)
def test_042_store_invalid_missing_id(self):
"""Test Thread adding invalid list value."""
store = Store()
with self.assertRaises(AssertionError):
store.add_model_values("key1", {"test": True})
def test_060_store_data_empty_val(self):
"""Test empty values are not present in result."""
store = Store()
store.add_model_values("key1", {})
self.assertEqual(store.get_result(), {})
def test_061_store_data_empty_not_empty(self):
"""Test mixing empty and non-empty values."""
store = Store()
store.add_model_values("key1", {})
store.add_model_values("key2", {"id": 1})
self.assertEqual(store.get_result(), {"key2": [{"id": 1}]})
def test_075_store_same_related_field_twice(self):
"""Test adding the same related field twice combines their data"""
user = mail_new_test_user(self.env, login="test_user", name="Test User")
self.assertEqual(
Store()
.add(
user,
[Store.One("partner_id", "name"), Store.One("partner_id", "country_id")],
)
.get_result(),
{
"res.partner": [
{
"id": user.partner_id.id,
"name": "Test User",
"country_id": False,
},
],
"res.users": [
{"id": user.id, "partner_id": user.partner_id.id},
],
},
)
# 1xx Store specific tests (singleton in ids_by_model)
def test_110_store_store_singleton(self):
"""Test Store dict is present in result as a singleton."""
store = Store()
store.add_global_values(test=True)
self.assertEqual(store.get_result(), {"Store": {"test": True}})
def test_111_store_store_dict_update(self):
"""Test Store dict update."""
store = Store()
store.add_global_values(test=True)
store.add_global_values(test=False, abc=1)
self.assertEqual(store.get_result(), {"Store": {"test": False, "abc": 1}})
def test_140_store_store_invalid_bool(self):
"""Test Store adding invalid value."""
store = Store()
with self.assertRaises(AttributeError):
store.add_model_values("key1", True)
def test_141_store_store_invalid_list(self):
"""Test adding invalid value."""
store = Store()
with self.assertRaises(AttributeError):
store.add_model_values("key1", [{"test": True}])
def test_160_store_store_data_empty_val(self):
"""Test Store empty values are not present in result."""
store = Store()
store.add_global_values()
self.assertEqual(store.get_result(), {})
def test_161_store_store_data_empty_not_empty(self):
"""Test Store mixing empty and non-empty values."""
store = Store()
store.add_global_values()
store.add_model_values("key2", {"id": 1})
self.assertEqual(store.get_result(), {"key2": [{"id": 1}]})
# 2xx Thread specific tests (dual id in ids_by_model)
def test_210_store_thread_dict(self):
"""Test Thread dict is present in result."""
store = Store()
store.add_model_values("mail.thread", {"id": 1, "model": "res.partner", "test": True})
self.assertEqual(
store.get_result(), {"mail.thread": [{"id": 1, "model": "res.partner", "test": True}]}
)
def test_211_store_thread_dict_update_same_id(self):
"""Test Thread dict update same id."""
store = Store()
store.add_model_values("mail.thread", {"id": 1, "model": "res.partner", "test": True})
store.add_model_values("mail.thread", {"id": 1, "model": "res.partner", "test": False, "abc": 1})
self.assertEqual(
store.get_result(),
{"mail.thread": [{"id": 1, "model": "res.partner", "test": False, "abc": 1}]},
)
def test_212_store_thread_dict_update_multiple_ids(self):
"""Test Thread dict update multiple ids."""
store = Store()
store.add_model_values("mail.thread", {"id": 1, "model": "res.partner", "test": True})
store.add_model_values("mail.thread", {"id": 2, "model": "res.partner", "test": True})
store.add_model_values("mail.thread", {"id": 2, "model": "res.partner", "test": False, "abc": 1})
self.assertEqual(
store.get_result(),
{
"mail.thread": [
{"id": 1, "model": "res.partner", "test": True},
{"id": 2, "model": "res.partner", "test": False, "abc": 1},
]
},
)
def test_213_store_thread_dict_update_multiple_models(self):
"""Test Thread dict update multiple models."""
store = Store()
store.add_model_values("mail.thread", {"id": 1, "model": "res.partner", "test": True})
store.add_model_values("mail.thread", {"id": 2, "model": "res.partner", "test": True})
store.add_model_values("mail.thread", {"id": 2, "model": "discuss.channel", "test": True, "abc": 1})
store.add_model_values("mail.thread", {"id": 2, "model": "discuss.channel", "test": False, "abc": 2})
store.add_model_values("mail.thread", {"id": 1, "model": "res.partner", "test": False})
self.assertEqual(
store.get_result(),
{
"mail.thread": [
{"id": 1, "model": "res.partner", "test": False},
{"id": 2, "model": "res.partner", "test": True},
{"id": 2, "model": "discuss.channel", "test": False, "abc": 2},
]
},
)
def test_240_store_thread_invalid_bool(self):
"""Test Thread adding invalid bool value."""
store = Store()
with self.assertRaises(AttributeError):
store.add_model_values("mail.thread", True)
def test_241_store_thread_invalid_list(self):
"""Test Thread adding invalid list value."""
store = Store()
with self.assertRaises(AttributeError):
store.add_model_values("mail.thread", [True])
def test_242_store_thread_invalid_missing_id(self):
"""Test Thread adding invalid missing id."""
store = Store()
with self.assertRaises(AssertionError):
store.add_model_values("mail.thread", {"model": "res.partner"})
def test_243_store_thread_invalid_missing_model(self):
"""Test Thread adding invalid list value."""
store = Store()
with self.assertRaises(AssertionError):
store.add_model_values("mail.thread", {"id": 1})
def test_260_store_thread_data_empty_val(self):
"""Test Thread empty values are not present in result."""
store = Store()
store.add_model_values("mail.thread", {})
self.assertEqual(store.get_result(), {})
def test_261_store_thread_data_empty_not_empty(self):
"""Test Thread mixing empty and non-empty values."""
store = Store()
store.add_model_values("key1", {})
store.add_model_values("mail.thread", {"id": 1, "model": "res.partner"})
self.assertEqual(store.get_result(), {"mail.thread": [{"id": 1, "model": "res.partner"}]})
# 3xx Tests with real models
def test_350_non_list_extra_fields_copy_when_following_relations(self):
"""Test that non-list extra_fields are properly copied when following relations."""
user = new_test_user(self.env, "test_user_350@example.com")
store = Store()
store.add(user, Store.One("partner_id", extra_fields="email"))
self.assertEqual(store.get_result()["res.partner"][0]["email"], "test_user_350@example.com")
def test_355_single_extra_fields_copy_with_records(self):
"""Test that dynamic_fields apply individually to each record even when list extra_fields are present."""
user_a = new_test_user(self.env, "test_user_355_a@example.com")
user_b = new_test_user(self.env, "test_user_355_b@example.com")
store = Store()
store.add(
user_a + user_b,
Store.One(
"partner_id",
[],
dynamic_fields=lambda user: ["email"] if user == user_a else [],
extra_fields=["name"],
),
)
self.assertEqual(store.get_result()["res.partner"][0]["email"], "test_user_355_a@example.com")
self.assertNotIn("email", store.get_result()["res.partner"][1])

View file

@ -0,0 +1,106 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import timedelta
from unittest.mock import patch
from odoo.tests import TransactionCase, mute_logger
class MockedConnection:
def __init__(self):
self.mock_messages = {}
def check_unread_messages(self):
return len(self.mock_messages)
def retrieve_unread_messages(self):
yield from list(self.mock_messages.items())
def handled_message(self, num):
self.mock_messages.pop(num)
def disconnect(self):
pass
class TestFetchmail(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# mock connection
cls.connection = MockedConnection()
def _connect__(self, allow_archived=False):
self.ensure_one()
return cls.connection
patcher = patch.object(cls.registry['fetchmail.server'], '_connect__', _connect__)
patcher.start()
cls.addClassCleanup(patcher.stop)
def setUp(self):
self.connection.mock_messages.clear()
return super().setUp()
def test_fetchmail(self):
mail_server = self.env['fetchmail.server'].create({
'name': 'test server',
})
mail_server.search([('id', '!=', mail_server.id)]).action_archive()
# confirm the server
mail_server.button_confirm_login()
self.assertEqual(mail_server.state, 'done')
# fetch mail
partner = self.env['res.partner'].create({'name': 'fetch test'})
def message_process(obj, model, message, **kw):
self.assertEqual(message, "test msg")
partner.with_env(obj.env).name = 'processed'
with (
self.enter_registry_test_mode(),
self.registry.cursor() as cr,
patch.object(self.registry['mail.thread'], 'message_process', side_effect=message_process, autospec=True) as process,
):
self.connection.mock_messages[1] = "test msg"
mail_server.with_env(mail_server.env(cr=cr)).fetch_mail()
process.assert_called_once()
self.assertFalse(self.connection.mock_messages, "message not handled")
self.assertEqual(partner.name, 'processed', "message_process side effect should be saved")
def test_fetchmail_deactivation(self):
mail_server = self.env['fetchmail.server'].create({
'name': 'test deactivation server',
})
mail_server.search([('id', '!=', mail_server.id)]).action_archive()
mail_server.button_confirm_login()
self.assertEqual(mail_server.state, 'done')
# fetch mail
connection_failed_exception = Exception("mocked connection that fails")
def _connect__(obj, **kw):
raise connection_failed_exception
with (
self.enter_registry_test_mode(),
self.registry.cursor() as cr,
patch.object(self.registry['fetchmail.server'], '_connect__', side_effect=_connect__, autospec=True),
mute_logger('odoo.addons.mail.models'),
self.assertLogs('odoo.addons.base.models.ir_cron') as cron_log_catcher,
):
server = mail_server.with_env(mail_server.env(cr=cr))
self.assertFalse(server.error_date)
self.assertFalse(server.error_message)
exc = server._fetch_mail()
self.assertIs(exc, connection_failed_exception)
self.assertTrue(server.error_date)
self.assertIn("mocked connection", server.error_message)
# set the date in past for deactivation
server.error_date = server.error_date - timedelta(days=7, minutes=1)
exc = server._fetch_mail()
self.assertIs(exc, connection_failed_exception)
self.assertEqual(server.state, 'draft')
self.assertIn('WARNING:odoo.addons.base.models.ir_cron:Deactivating fetchmail imap server test deactivation server (too many failures)', cron_log_catcher.output)

View file

@ -0,0 +1,31 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from io import BytesIO
from PIL import Image
from odoo.tests.common import HttpCase, tagged
from odoo.tools.misc import file_open
@tagged("-at_install", "post_install")
class TestFontToImg(HttpCase):
def test_font_to_img(self):
# This test was introduced because the play button was cropped in noble following some adaptation.
# This test is able to reproduce the issue and ensure that the expected result is the right one
# comparing image is not ideal, but this should work in most case, maybe adapted if the font is changed.
response = self.url_open(
"/mail/font_to_img/61802/rgb(0,143,140)/rgb(255,255,255)/190x200"
)
img = Image.open(BytesIO(response.content))
self.assertEqual(
img.size,
(201, 200),
"Looks strange regarding request but this is the current result",
)
# Image is a play button
img_reference = Image.open(file_open("mail/tests/play.png", "rb"))
self.assertEqual(img, img_reference, "Result image should be the play button")

View file

@ -1,50 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo
from odoo.tests import HttpCase
@odoo.tests.tagged('-at_install', 'post_install')
class TestGetModelDefinitions(HttpCase):
def test_access_cr(self):
""" Checks that get_model_definitions does not return anything else than models """
with self.assertRaises(KeyError):
self.env['ir.model']._get_model_definitions(['res.users', 'cr'])
def test_access_all_model_fields(self):
"""
Check that get_model_definitions return all the models
and their fields
"""
model_definitions = self.env['ir.model']._get_model_definitions([
'res.users', 'res.partner'
])
# models are retrieved
self.assertIn('res.users', model_definitions)
self.assertIn('res.partner', model_definitions)
# check that model fields are retrieved
self.assertTrue(
all(fname in model_definitions['res.users'].keys() for fname in ['email', 'name', 'partner_id'])
)
self.assertTrue(
all(fname in model_definitions['res.partner'].keys() for fname in ['active', 'date', 'name'])
)
def test_relational_fields_with_missing_model(self):
"""
Check that get_model_definitions only returns relational fields
if the model is requested
"""
model_definitions = self.env['ir.model']._get_model_definitions([
'res.partner'
])
# since res.country is not requested, country_id shouldn't be in
# the model definition fields
self.assertNotIn('country_id', model_definitions['res.partner'])
model_definitions = self.env['ir.model']._get_model_definitions([
'res.partner', 'res.country',
])
# res.country is requested, country_id should be present on res.partner
self.assertIn('country_id', model_definitions['res.partner'])

View file

@ -0,0 +1,745 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from contextlib import contextmanager
from datetime import datetime, timedelta
from unittest.mock import patch
from odoo.addons.mail.tests.common import MailCommon
from odoo.exceptions import UserError
from odoo.tests import tagged, users
from odoo.tools import config, mute_logger, split_every
@tagged('mail_server')
class TestIrMailServer(MailCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.default_bounce_address = f'{cls.alias_bounce}@{cls.alias_domain}'
cls.default_from_address = f'{cls.default_from}@{cls.alias_domain}'
def test_alter_smtp_to_list(self):
""" Check smtp_to_list alteration. Reminder: Message is the envelope,
SMTP is the actual sending. """
IrMailServer = self.env['ir.mail_server']
mail_from = 'specific_user@test.mycompany.com'
for mail_server, mail_values, smtp_to_lst, msg_to_lst, msg_cc_lst in [
(
IrMailServer,
{'email_to': '"Customer" <customer@test.example.com>'},
['customer@test.example.com'],
['"Customer" <customer@test.example.com>'],
[],
),
# 'send_validated_to' context key: restrict SMTP To actual recipients
# but do not rewrite Msg['To'], aka envelope (main usage is to cleanup
# addresses found by extract_rfc2822_addresses anyway)
(
IrMailServer.with_context(send_validated_to=['another@test.example.com', 'customer@test.example.com']),
{'email_to': ['"Customer" <customer@test.example.com>', 'user2@test.mycompany.com']},
['customer@test.example.com'],
['"Customer" <customer@test.example.com>', 'user2@test.mycompany.com'],
[],
),
# 'send_smtp_skip_to' context key: block list of SMTP recipients
(
IrMailServer.with_context(send_smtp_skip_to=['skip@test.example.com', 'other@test.example.com', 'wrong', 'skip.2@test.example.com']),
{
'email_to': ['"Customer" <customer@test.example.com>', '"Skip Me" <skip@test.example.com>',
'"User" <user@test.mycompany.com>', 'user2@test.mycompany.com', '"Skip Me 2" <skip.2@test.example.com>'],
},
['customer@test.example.com', 'user@test.mycompany.com', 'user2@test.mycompany.com'],
['"Customer" <customer@test.example.com>', '"Skip Me" <skip@test.example.com>',
'"User" <user@test.mycompany.com>', 'user2@test.mycompany.com', '"Skip Me 2" <skip.2@test.example.com>'],
{},
),
# 'X-Forge-To' header: force envelope Msg['To'] (not SMTP recipients)
# used notably for mailing lists
(
IrMailServer,
{
'email_to': ['"Customer" <customer@test.example.com>', 'user2@test.mycompany.com'],
'headers': {'X-Forge-To': 'mailing@some.domain'}
},
['customer@test.example.com', 'user2@test.mycompany.com'],
['mailing@some.domain'],
[],
),
# 'X-Msg-To-Add' header: add in Msg['To'] without impacting SMTP To, e.g.
# displaying more recipients than actually mailed
(
IrMailServer,
{
'email_to': ['"Customer" <customer@test.example.com>', 'user2@test.mycompany.com'],
'headers': {'X-Msg-To-Add': '"Other" <other.customer@test.example.com>'}
},
['customer@test.example.com', 'user2@test.mycompany.com'],
['"Customer" <customer@test.example.com>', 'user2@test.mycompany.com', '"Other" <other.customer@test.example.com>'],
{},
),
]:
with self.subTest(mail_values=mail_values, smtp_to_lst=smtp_to_lst):
with self.mock_smtplib_connection():
smtp_session = mail_server._connect__(smtp_from=mail_from)
message = self._build_email(mail_from=mail_from, **mail_values)
mail_server.send_email(message, smtp_session=smtp_session)
self.assertEqual(len(self.emails), 1)
self.assertSMTPEmailsSent(
message_from=mail_from,
smtp_from=mail_from,
smtp_to_list=smtp_to_lst,
msg_cc_lst=msg_cc_lst,
msg_to_lst=msg_to_lst,
)
def test_assert_base_values(self):
self.assertEqual(
self.env['ir.mail_server']._get_default_bounce_address(),
self.default_bounce_address)
self.assertEqual(
self.env['ir.mail_server']._get_default_from_address(),
self.default_from_address)
@patch.dict(config.options, {"email_from": "settings@example.com"})
def test_default_email_from(self, *args):
""" Check that default_from parameter of alias domain respected. """
for (default_from, domain_name), expected_from in zip(
[
('icp', 'test.mycompany.com'),
(False, 'test.mycompany.com'),
(False, False),
], [
"icp@test.mycompany.com",
"settings@example.com",
"settings@example.com",
],
):
with self.subTest(default_from=default_from, domain_name=domain_name):
if domain_name:
self.mail_alias_domain.name = domain_name
self.mail_alias_domain.default_from = default_from
self.env.company.alias_domain_id = self.mail_alias_domain
else:
self.env.company.alias_domain_id = False
message = self.env["ir.mail_server"]._build_email__(
False, "recipient@example.com", "Subject",
"The body of an email",
)
self.assertEqual(message["From"], expected_from)
@mute_logger('odoo.models.unlink')
@patch.dict(config.options, {
"from_filter": "dummy@example.com, test.mycompany.com, dummy2@example.com",
"smtp_server": "example.com",
})
def test_mail_server_config_bin(self):
""" Test the configuration provided in the odoo-bin arguments. This config
is used when no mail server exists. Test with and without giving a
pre-configured SMTP session, should not impact results.
Also check "mail.default.from_filter" parameter usage that should overwrite
odoo-bin argument "--from-filter".
"""
IrMailServer = self.env['ir.mail_server']
# Remove all mail server so we will use the odoo-bin arguments
IrMailServer.search([]).unlink()
self.assertFalse(IrMailServer.search([]))
for mail_from, (expected_smtp_from, expected_msg_from) in zip(
[
# inside "from_filter" domain
'specific_user@test.mycompany.com',
'"Formatted Name" <specific_user@test.mycompany.com>',
'"Formatted Name" <specific_user@test.MYCOMPANY.com>',
'"Formatted Name" <SPECIFIC_USER@test.mycompany.com>',
# outside "from_filter" domain
'test@unknown_domain.com',
'"Formatted Name" <test@unknown_domain.com>',
], [
# inside "from_filter" domain: no rewriting
(self.default_bounce_address, 'specific_user@test.mycompany.com'),
(self.default_bounce_address, '"Formatted Name" <specific_user@test.mycompany.com>'),
(self.default_bounce_address, '"Formatted Name" <specific_user@test.MYCOMPANY.com>'),
(self.default_bounce_address, '"Formatted Name" <SPECIFIC_USER@test.mycompany.com>'),
# outside "from_filter" domain: we will use notifications emails in the
# headers, and bounce address in the envelope because the "from_filter"
# allows to use the entire domain
(self.default_bounce_address, f'"test" <{self.default_from}@{self.alias_domain}>'),
(self.default_bounce_address, f'"Formatted Name" <{self.default_from}@{self.alias_domain}>'),
]
):
for provide_smtp in [False, True]: # providing smtp session should ont impact test
with self.subTest(mail_from=mail_from, provide_smtp=provide_smtp):
with self.mock_smtplib_connection():
if provide_smtp:
smtp_session = IrMailServer._connect__(smtp_from=mail_from)
message = self._build_email(mail_from=mail_from)
IrMailServer.send_email(message, smtp_session=smtp_session)
else:
message = self._build_email(mail_from=mail_from)
IrMailServer.send_email(message)
self.connect_mocked.assert_called_once()
self.assertEqual(len(self.emails), 1)
self.assertSMTPEmailsSent(
smtp_from=expected_smtp_from,
message_from=expected_msg_from,
from_filter="dummy@example.com, test.mycompany.com, dummy2@example.com",
)
# for from_filter in ICP, overwrite the one from odoo-bin
self.env['ir.config_parameter'].sudo().set_param('mail.default.from_filter', 'icp.example.com')
# Use an email in the domain of the config parameter "mail.default.from_filter"
with self.mock_smtplib_connection():
message = self._build_email(mail_from='specific_user@icp.example.com')
IrMailServer.send_email(message)
self.assertSMTPEmailsSent(
smtp_from='specific_user@icp.example.com',
message_from='specific_user@icp.example.com',
from_filter='icp.example.com',
)
@users('admin')
def test_mail_server_get_test_email_from(self):
""" Test the email used to test the mail server connection. Check
from_filter parsing / alias_domain.default_from support. """
test_server = self.env['ir.mail_server'].create({
'from_filter': 'example_2.com, example_3.com',
'name': 'Test Server',
'smtp_host': 'smtp_host',
'smtp_encryption': 'none',
})
# check default.from / filter matching
for (default_from, from_filter), expected_test_email in zip(
[
('notifications', 'dummy.com, full_email@example_2.com, dummy2.com'),
('notifications', self.mail_alias_domain.name),
('notifications', f'{self.mail_alias_domain.name}, example_2.com'),
# default relies on "odoo"
(False, self.mail_alias_domain.name),
# fallback on user email if no from_filter
('notifications', ' '),
('notifications', ','),
('notifications', False),
(False, False),
], [
'full_email@example_2.com',
f'notifications@{self.mail_alias_domain.name}',
f'notifications@{self.mail_alias_domain.name}',
f'odoo@{self.mail_alias_domain.name}',
self.env.user.email,
self.env.user.email,
self.env.user.email,
self.env.user.email,
],
):
with self.subTest(default_from=default_from, from_filter=from_filter):
self.mail_alias_domain.default_from = default_from
test_server.from_filter = from_filter
email_from = test_server._get_test_email_from()
self.assertEqual(email_from, expected_test_email)
@mute_logger('odoo.models.unlink')
def test_mail_server_priorities(self):
""" Test if we choose the right mail server to send an email.
Priorities are
1. Forced mail server (e.g.: in mass mailing)
- If the "from_filter" of the mail server match the notification email
use the notifications email in the "From header"
- Otherwise spoof the "From" (because we force the mail server but we don't
know which email use to send it)
2. A mail server for which the "from_filter" match the "From" header
3. A mail server for which the "from_filter" match the domain of the "From" header
4. The mail server used for notifications
5. A mail server without "from_filter" (and so spoof the "From" header because we
do not know for which email address it can be used)
"""
# this mail server can now be used for a specific email address and 2 domain names
self.mail_server_user.from_filter = "domain1.com, specific_user@test.mycompany.com, domain2.com"
for email_from, (expected_mail_server, expected_email_from) in zip(
[
# matches user-specific server
'specific_user@test.mycompany.com',
# matches user-specific server (with formatting') -> should extract
# email from full name, must keep the given email_from
'"Name name@strange.name" <specific_user@test.mycompany.com>',
# case check
'SPECIFIC_USER@test.mycompany.com',
'specific_user@test.MYCOMPANY.com',
# matches domain-based server: domain is case insensitive
'unknown_email@test.mycompany.com',
'unknown_email@TEST.MYCOMPANY.COM',
'"Unknown" <unknown_email@test.mycompany.com>',
# fallback on notification email
'"Test" <test@unknown_domain.com>',
# fallback when email_from is False, should default to notification email
False,
# mail_server_user multiple from_filter check: can be used for a
# specific email and 2 domain names -> check other domains in filter
'"Example" <test@domain2.com>',
'"Example" <test@domain1.com>',
], [
(self.mail_server_user, 'specific_user@test.mycompany.com'),
(self.mail_server_user, '"Name name@strange.name" <specific_user@test.mycompany.com>'),
(self.mail_server_user, 'SPECIFIC_USER@test.mycompany.com'),
(self.mail_server_user, 'specific_user@test.MYCOMPANY.com'),
(self.mail_server_domain, 'unknown_email@test.mycompany.com'),
(self.mail_server_domain, 'unknown_email@TEST.MYCOMPANY.COM'),
(self.mail_server_domain, '"Unknown" <unknown_email@test.mycompany.com>'),
(self.mail_server_notification, f'{self.default_from}@test.mycompany.com'),
(self.mail_server_notification, f'{self.default_from}@test.mycompany.com'),
# mail_server_user multiple from_filter check
(self.mail_server_user, '"Example" <test@domain2.com>'),
(self.mail_server_user, '"Example" <test@domain1.com>'),
],
):
with self.subTest(email_from=email_from):
mail_server, mail_from = self.env['ir.mail_server']._find_mail_server(email_from=email_from)
self.assertEqual(mail_server, expected_mail_server)
self.assertEqual(mail_from, expected_email_from)
@mute_logger('odoo.models.unlink', 'odoo.addons.base.models.ir_mail_server')
def test_mail_server_send_email(self):
""" Test main 'send_email' usage: check mail_server choice based on from
filters, encapsulation, spoofing. """
IrMailServer = self.env['ir.mail_server']
for mail_from, (expected_smtp_from, expected_msg_from, expected_mail_server) in zip(
[
'specific_user@test.mycompany.com',
'"Name" <test@unknown_domain.com>',
'test@unknown_domain.com',
'"Name" <unknown_name@test.mycompany.com>'
], [
# A mail server is configured for the email
('specific_user@test.mycompany.com', 'specific_user@test.mycompany.com', self.mail_server_user),
# No mail server is configured for the email address, so it will use the
# notifications email instead and encapsulate the old email
(f'{self.default_from}@{self.alias_domain}', f'"Name" <{self.default_from}@{self.alias_domain}>', self.mail_server_notification),
# same situation, but the original email has no name part
(f'{self.default_from}@{self.alias_domain}', f'"test" <{self.default_from}@{self.alias_domain}>', self.mail_server_notification),
# A mail server is configured for the entire domain name, so we can use the bounce
# email address because the mail server supports it
(self.default_bounce_address, '"Name" <unknown_name@test.mycompany.com>', self.mail_server_domain),
],
):
# test with and without providing an SMTP session, which should not impact test
for provide_smtp in [True, False]:
with self.subTest(mail_from=mail_from):
with self.mock_smtplib_connection():
if provide_smtp:
smtp_session = IrMailServer._connect__(smtp_from=mail_from)
message = self._build_email(mail_from=mail_from)
IrMailServer.send_email(message, smtp_session=smtp_session)
else:
message = self._build_email(mail_from=mail_from)
IrMailServer.send_email(message)
self.connect_mocked.assert_called_once()
self.assertEqual(len(self.emails), 1)
self.assertSMTPEmailsSent(
smtp_from=expected_smtp_from,
message_from=expected_msg_from,
mail_server=expected_mail_server,
)
# remove the notification server
# so <notifications.test@test.mycompany.com> will use the <test.mycompany.com> mail server
# The mail server configured for the notifications email has been removed
# but we can still use the mail server configured for test.mycompany.com
# and so we will be able to use the bounce address
# because we use the mail server for "test.mycompany.com"
self.mail_server_notification.unlink()
for provide_smtp in [False, True]:
with self.mock_smtplib_connection():
if provide_smtp:
smtp_session = IrMailServer._connect__(smtp_from='"Name" <test@unknown_domain.com>')
message = self._build_email(mail_from='"Name" <test@unknown_domain.com>')
IrMailServer.send_email(message, smtp_session=smtp_session)
else:
message = self._build_email(mail_from='"Name" <test@unknown_domain.com>')
IrMailServer.send_email(message)
self.connect_mocked.assert_called_once()
self.assertEqual(len(self.emails), 1)
self.assertSMTPEmailsSent(
smtp_from=self.default_bounce_address,
message_from=f'"Name" <{self.default_from}@{self.alias_domain}>',
mail_server=self.mail_server_domain,
)
# miss-configured database, no mail servers from filter
# match the user / notification email
self.env['ir.mail_server'].search([]).from_filter = "random.domain"
self.mail_alias_domain.default_from = 'test'
self.mail_alias_domain.name = 'custom_domain.com'
with self.mock_smtplib_connection():
message = self._build_email(mail_from='specific_user@test.com')
IrMailServer.send_email(message)
self.connect_mocked.assert_called_once()
self.assertSMTPEmailsSent(
smtp_from='test@custom_domain.com',
message_from='"specific_user" <test@custom_domain.com>',
from_filter='random.domain',
)
@tagged('mail_server')
class TestPersonalServer(MailCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_1, cls.user_2 = cls.user_employee, cls.user_employee_c2
cls.mail_server_1, cls.mail_server_2 = cls.env["ir.mail_server"].create([{
'name': 'test',
'owner_user_id': user.id,
'from_filter': user.email,
'smtp_user': user.email,
'smtp_host': f'test_{i}@example.com',
} for i, user in enumerate((cls.user_1, cls.user_2))])
@contextmanager
def assert_mail_sent_then_scheduled(self, mails, to_process_count, sent_count, send_datetime):
"""Assert that X emails has been sent, and the other have been scheduled."""
TEST_LIMIT = 5
outgoing = mails.filtered(
lambda m: m.state == 'outgoing'
and (not m.scheduled_date or m.scheduled_date <= send_datetime)
).sorted(lambda m: (m.create_date, m.id))
self.assertEqual(len(outgoing), to_process_count)
yield
sent = outgoing.filtered(lambda m: m.state == 'sent')
self.assertEqual(sent, outgoing[:len(sent)], "Should send in priority old mails")
unsent = outgoing - sent
self.assertEqual(len(sent), sent_count)
scheduled_dates = sorted(unsent.mapped('scheduled_date'))
scheduled_dates = list(split_every(TEST_LIMIT, scheduled_dates))
for i, to_check in enumerate(scheduled_dates, start=1):
self.assertEqual(set(to_check), {send_datetime.replace(second=0) + timedelta(minutes=i)})
@mute_logger('odoo.models.unlink', 'odoo.addons.base.models.ir_mail_server')
@patch.dict(config.options, {
"from_filter": "cli@example.com",
"smtp_server": "example.com",
})
def test_personal_mail_server(self):
"""Test that the personal mail servers can not be used as fallback."""
IrMailServer = self.env['ir.mail_server']
self.env['ir.mail_server'].search([]).from_filter = "random.domain"
# Sanity check, no owner so it can be used as fallback
(self.env['ir.mail_server'].search([]) - self.mail_server_user).unlink()
self.mail_server_user.write({
'from_filter': 'user@test.mycompany.com',
'owner_user_id': False,
})
with self.mock_smtplib_connection():
message = self._build_email(mail_from='notifications.test@test.mycompany.com')
IrMailServer.send_email(message)
self.connect_mocked.assert_called_once()
self.assertSMTPEmailsSent(
smtp_from='notifications.test@test.mycompany.com',
message_from='notifications.test@test.mycompany.com',
from_filter='user@test.mycompany.com',
)
# Check that even if there is no other mail server,
# we don't use the mail server having an owner as fallback
# (to avoid leaking outgoing emails)
self.mail_server_user.write({
'from_filter': 'user@test.mycompany.com',
'owner_user_id': self.env.user.id,
})
with self.mock_smtplib_connection():
message = self._build_email(mail_from='notifications.test@test.mycompany.com')
IrMailServer.send_email(message)
self.connect_mocked.assert_called_once()
self.assertSMTPEmailsSent(
smtp_from='notifications.test@test.mycompany.com',
message_from='notifications.test@test.mycompany.com',
from_filter='cli@example.com',
)
with self.mock_smtplib_connection(), self.assertRaises(UserError):
# We can't even force it
message = self._build_email(mail_from='test@test.mycompany.com')
IrMailServer.send_email(message, mail_server_id=self.mail_server_user.id)
self.assertFalse(self.emails)
@mute_logger('odoo.models.unlink')
def test_personal_mail_server_limit(self):
# Test the limit per personal mail servers
TEST_LIMIT = 5
self.env['ir.config_parameter'].set_param('mail.server.personal.limit.minutes', str(TEST_LIMIT))
user_1, user_2 = self.user_1, self.user_2
mail_server_1, mail_server_2 = self.mail_server_1, self.mail_server_2
with self.mock_datetime_and_now("2025-01-01 20:02:23"):
mails_user_1 = self.env["mail.mail"].with_user(user_1).sudo().create([
{'state': 'outgoing', 'email_to': 'target@test.com', 'email_from': user_1.email}
for i in range(22)
])
mails_user_2 = self.env["mail.mail"].with_user(user_2).sudo().create([
{'state': 'outgoing', 'email_to': 'target@test.com', 'email_from': user_2.email}
for i in range(17)
])
mails_other = self.env["mail.mail"].create([
{'state': 'outgoing', 'email_to': 'target@test.com', 'email_from': user_1.email}
for i in range(25)
])
mails = mails_other + mails_user_1 + mails_user_2
self.assertEqual(mail_server_1.owner_limit_count, 0)
self.assertFalse(mail_server_1.owner_limit_time)
DATE_SEND_1 = datetime(2025, 1, 1, 20, 5, 23)
with (
self.mock_smtplib_connection(),
self.mock_datetime_and_now(DATE_SEND_1),
self.assert_mail_sent_then_scheduled(mails_user_1, len(mails_user_1), 5, DATE_SEND_1),
self.assert_mail_sent_then_scheduled(mails_user_2, len(mails_user_2), 5, DATE_SEND_1),
):
mails.send()
for personal_server in (mail_server_1, mail_server_2):
self.assertEqual(personal_server.owner_limit_count, TEST_LIMIT)
self.assertEqual(personal_server.owner_limit_time, DATE_SEND_1.replace(second=0))
self.assertEqual(self.connect_mocked.call_count, 3, "Called once for each mail server")
# Check that the email not related to personal mail server are all sent
self.assertEqual(set(mails_other.mapped('state')), {'sent'})
# User 1 continues sending emails
# Because emails are still in the queue, we delay all of them
with self.mock_datetime_and_now("2025-01-01 20:04:23"):
new_mails_user_1 = self.env["mail.mail"].with_user(user_1).sudo().create([
{'state': 'outgoing', 'email_to': 'target@test.com', 'email_from': user_1.email}
for i in range(12)
])
mails_user_1 |= new_mails_user_1
DATE_SEND_2 = datetime(2025, 1, 1, 20, 5, 23)
with (
self.mock_smtplib_connection(),
self.mock_datetime_and_now(DATE_SEND_2),
self.assert_mail_sent_then_scheduled(new_mails_user_1, 12, 0, DATE_SEND_2),
):
new_mails_user_1.send()
self.assertEqual(mail_server_1.owner_limit_count, TEST_LIMIT)
self.assertEqual(mail_server_1.owner_limit_time, DATE_SEND_2.replace(second=0))
# One minute later, we can send again
DATE_SEND_3 = datetime(2025, 1, 1, 20, 6, 23)
processed = (mails_user_1 | new_mails_user_1).filtered(
lambda m: not m.scheduled_date or m.scheduled_date <= DATE_SEND_3)
with (
self.mock_smtplib_connection(),
self.mock_datetime_and_now(DATE_SEND_3),
self.assert_mail_sent_then_scheduled(processed, 10, 5, DATE_SEND_3),
):
self.env['mail.mail'].process_email_queue()
self.assertEqual(mail_server_1.owner_limit_count, TEST_LIMIT)
self.assertEqual(mail_server_1.owner_limit_time, DATE_SEND_3.replace(second=0))
# The CRON run in one minute later, we can 5 more emails
DATE_SEND_5 = datetime(2025, 1, 1, 20, 7, 23)
with (
self.mock_smtplib_connection(),
self.mock_datetime_and_now(DATE_SEND_5),
self.assert_mail_sent_then_scheduled(mails_user_1, 15, 5, DATE_SEND_5),
):
self.env['mail.mail'].process_email_queue()
self.assertEqual(mail_server_1.owner_limit_count, TEST_LIMIT)
self.assertEqual(mail_server_1.owner_limit_time, DATE_SEND_5.replace(second=0))
# The CRON is late compared to the scheduled mails,
# it should re-schedule the mails, starting from the current time
# Should send in priority the old mails
DATE_SEND_6 = datetime(2025, 1, 1, 20, 25, 23)
with (
self.mock_smtplib_connection(),
self.mock_datetime_and_now(DATE_SEND_6),
self.assert_mail_sent_then_scheduled(mails_user_1, 19, 5, DATE_SEND_6),
):
self.env['mail.mail'].process_email_queue()
self.assertEqual(mail_server_1.owner_limit_count, TEST_LIMIT)
self.assertEqual(mail_server_1.owner_limit_time, DATE_SEND_6.replace(second=0))
# Finish sending the email
for i in range(2):
DATE_SEND_7 = datetime(2025, 1, 1, 20, 26 + i, 23)
with self.mock_smtplib_connection(), self.mock_datetime_and_now(DATE_SEND_7):
self.env['mail.mail'].process_email_queue()
self.assertEqual(mail_server_1.owner_limit_count, TEST_LIMIT)
self.assertEqual(mail_server_1.owner_limit_time, DATE_SEND_7.replace(second=0))
DATE_SEND_8 = datetime(2025, 1, 1, 20, 28, 23)
with self.mock_smtplib_connection(), self.mock_datetime_and_now(DATE_SEND_8):
self.env['mail.mail'].process_email_queue()
self.assertEqual(mail_server_1.owner_limit_count, 4)
self.assertEqual(mail_server_1.owner_limit_time, DATE_SEND_8.replace(second=0))
# We send 4 emails this minute, check that will send 1 and schedule the remaining
new_mails_user_1 = self.env["mail.mail"].with_user(user_1).sudo().create([
{'state': 'outgoing', 'email_to': 'target@test.com', 'email_from': user_1.email}
for i in range(TEST_LIMIT)
])
DATE_SEND_9 = datetime(2025, 1, 1, 20, 28, 23)
with (
self.mock_smtplib_connection(),
self.mock_datetime_and_now(DATE_SEND_9),
self.assert_mail_sent_then_scheduled(new_mails_user_1, len(new_mails_user_1), 1, DATE_SEND_9),
):
self.env['mail.mail'].process_email_queue()
self.assertEqual(mail_server_1.owner_limit_count, TEST_LIMIT)
self.assertEqual(mail_server_1.owner_limit_time, DATE_SEND_9.replace(second=0))
@mute_logger('odoo.models.unlink')
def test_personal_mail_server_limit_many_recipient(self):
# Test with many recipients (should split the mail)
TEST_LIMIT = 5
self.env['ir.config_parameter'].set_param('mail.server.personal.limit.minutes', str(TEST_LIMIT))
partners = self.env['res.partner'].create([
{'name': f'Partner {i}', 'email': f'partner_{i}@test.com'}
for i in range(16)
])
with self.mock_datetime_and_now("2025-01-01 20:30:23"):
email_to = '"Named To1" <to.1@test.com>, "Named To2" <to.1@test.com>'
mail = self.env["mail.mail"].with_user(self.user_employee).sudo().create({
'email_from': self.user_employee.email,
'email_cc': '"Named Cc1" <cc.1@test.com>, "Named Cc2" <cc.2@test.com>',
'email_to': email_to,
'headers': '{"test": "test header"}',
'recipient_ids': partners.ids,
'state': 'outgoing',
})
with self.mock_smtplib_connection(), self.mock_datetime_and_now("2025-01-01 20:31:23"):
self.env['mail.mail'].process_email_queue()
self.assertEqual(self.mail_server_1.owner_limit_count, TEST_LIMIT)
mails = self.env["mail.mail"].search(
[('mail_message_id', '=', mail.mail_message_id.id)],
order='create_date DESC, id DESC',
)
self.assertEqual(len(mails), 2)
# Only one mail preserved the email_to
self.assertEqual(mails.mapped('email_to'), [email_to, False])
# Should preserve the header
self.assertEqual(len(set(mails.mapped("headers"))), 1)
self.assertEqual({"test": "test header"}, json.loads(mails[0].headers))
self.assertEqual(mails.mapped('state'), ['sent', 'outgoing'])
outgoing = mails.filtered(lambda m: m.state == 'outgoing')
self.assertEqual(len(outgoing), 1)
self.assertFalse(outgoing.email_to)
self.assertEqual(outgoing.state, 'outgoing')
self.assertEqual(outgoing.create_uid, self.user_employee)
self.assertEqual(len(outgoing.recipient_ids), 16 - TEST_LIMIT)
# Re-send the same minute, nothing change
with self.mock_smtplib_connection(), self.mock_datetime_and_now("2025-01-01 20:31:33"):
self.env['mail.mail'].process_email_queue()
self.assertEqual(self.mail_server_1.owner_limit_count, TEST_LIMIT)
mails = self.env["mail.mail"].search([('mail_message_id', '=', mail.mail_message_id.id)])
self.assertEqual(len(mails), 2)
self.assertEqual(outgoing.state, 'outgoing')
self.assertEqual(outgoing.create_uid, self.user_employee)
self.assertEqual(len(outgoing.recipient_ids), 16 - TEST_LIMIT)
# Re-send one minute later
with self.mock_smtplib_connection(), self.mock_datetime_and_now("2025-01-01 20:32:27"):
self.env['mail.mail'].process_email_queue()
self.assertEqual(self.mail_server_1.owner_limit_count, TEST_LIMIT)
mails = self.env["mail.mail"].search([('mail_message_id', '=', mail.mail_message_id.id)])
self.assertEqual(sorted(mails.mapped('state')), ['outgoing', 'sent', 'sent'])
outgoing = mails.filtered(lambda m: m.state == 'outgoing')
self.assertEqual(len(outgoing), 1)
self.assertFalse(outgoing.email_to)
self.assertEqual(outgoing.state, 'outgoing')
self.assertEqual(outgoing.create_uid, self.user_employee)
self.assertEqual(len(outgoing.recipient_ids), 16 - 2 * TEST_LIMIT)
# The user re-send emails, while some emails are still in the queue
# We have now 2 mails with many recipients to process in the queue
with self.mock_datetime_and_now("2025-01-01 20:32:29"):
other_mail = self.env["mail.mail"].with_user(self.user_employee).sudo().create({
'email_from': self.user_employee.email,
'email_to': 'target@test.com',
'headers': '{"test": "test header"}',
'recipient_ids': partners[:7].ids,
'state': 'outgoing',
})
with self.mock_smtplib_connection(), self.mock_datetime_and_now("2025-01-01 20:37:29"):
self.env['mail.mail'].process_email_queue()
self.assertEqual(self.mail_server_1.owner_limit_count, TEST_LIMIT)
self.assertEqual(other_mail.state, 'outgoing')
mails = self.env["mail.mail"].search(
[('mail_message_id', 'in', (mail.mail_message_id.id, other_mail.mail_message_id.id))],
order='create_date DESC, id DESC',
)
self.assertEqual(sorted(mails.mapped('state')), ['outgoing', 'outgoing', 'sent', 'sent', 'sent'])
outgoing_1 = mails[-1]
self.assertFalse(outgoing_1.email_to)
self.assertEqual(outgoing_1.state, 'outgoing')
self.assertEqual(outgoing_1.create_uid, self.user_employee)
self.assertEqual(len(outgoing_1.recipient_ids), 16 - 3 * TEST_LIMIT) # 1 recipient left
outgoing_2 = mails[1]
self.assertEqual(outgoing_2.email_to, 'target@test.com')
self.assertEqual(outgoing_2.state, 'outgoing')
self.assertEqual(outgoing_2.create_uid, self.user_employee)
self.assertEqual(len(outgoing_2.recipient_ids), 7)
# The next CRON will send all remaining emails of the first mail (1), and 4 mails for the second one
# and schedule the 3 last (7 recipients - 3 sent)
with self.mock_smtplib_connection(), self.mock_datetime_and_now("2025-01-01 20:39:29"):
self.env['mail.mail'].process_email_queue()
self.assertEqual(self.mail_server_1.owner_limit_count, TEST_LIMIT)
mails = self.env["mail.mail"].search([('mail_message_id', 'in', (mail.mail_message_id.id, other_mail.mail_message_id.id))])
self.assertEqual(
sorted(mails.mapped('state')),
['outgoing', 'sent', 'sent', 'sent', 'sent', 'sent'],
)
outgoing = mails.filtered(lambda m: m.state == 'outgoing')
self.assertEqual(len(outgoing), 1)
self.assertFalse(outgoing.email_to)
self.assertEqual(outgoing.state, 'outgoing')
self.assertEqual(outgoing.create_uid, self.user_employee)
self.assertEqual(len(outgoing.recipient_ids), 3)
# Send the last email
with self.mock_smtplib_connection(), self.mock_datetime_and_now("2025-01-01 20:42:29"):
self.env['mail.mail'].process_email_queue()
self.assertEqual(self.mail_server_1.owner_limit_count, 3)
mails = self.env["mail.mail"].search([('mail_message_id', 'in', (mail.mail_message_id.id, other_mail.mail_message_id.id))])
self.assertEqual(set(mails.mapped('state')), {'sent'})

View file

@ -0,0 +1,162 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from unittest.mock import Mock, patch
from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user
from odoo.tests import tagged, users
from odoo.tests.common import warmup
@tagged('mail_thread')
class TestMenuRootLookupByModel(MailCommon):
""" Test the determination of the best menu root for a given model.
When sharing a record through a link, it doesn't contain the menu context
(menu root). To help the user, we try to restore a root menu related to
the record when redirecting to the record from the link. That's what is tested
here. For more details see IrUiMenu._get_best_backend_root_menu_id_for_model.
"""
@classmethod
def setUpClass(cls):
""" Setup data for the tests, especially this menu hierarchy:
- Contacts
- Contacts (res.partner)
- Invoicing
- Customers
- Customers (res.partner)
- Companies (res.company)
- Sales
- Orders
- Customers (res.partner)
- Settings
- Users & Companies
- Companies (res.company)
"""
super().setUpClass()
Menu = cls.env['ir.ui.menu']
Action = cls.env['ir.actions.act_window']
def _new_menu(name, parent_id=None, action=None):
menu = Menu.create({'name': name, 'parent_id': parent_id})
if action:
menu.action = action
return menu
new_menu = Mock(side_effect=_new_menu)
def new_action(name, res_model, path=None, view_mode=None, domain=None, context=None):
return Action.create({
'name': name,
'res_model': res_model,
'path': path,
'view_mode': view_mode,
'domain': domain,
'context': str(context) if context else {},
'type': 'ir.actions.act_window',
})
# Remove all menus and setup test menu with known results
Menu.search([]).unlink()
menu_root_contact = new_menu('Contacts')
new_menu('Contacts', parent_id=menu_root_contact.id,
action=new_action(
'Contacts', res_model='res.partner',
path='all-contacts',
view_mode='kanban,list,form,activity',
context={'default_is_company': True},
domain='[]'))
menu_root_invoicing = new_menu('Invoicing')
new_menu('Companies', parent_id=menu_root_invoicing.id,
action=new_action(
'Companies', res_model='res.company',
view_mode='list,kanban,form'))
menu_invoicing_customer = new_menu('Customers', parent_id=menu_root_invoicing.id)
new_menu('Customers', parent_id=menu_invoicing_customer.id,
action=new_action(
'Customers', res_model='res.partner',
path='all-customers',
view_mode='kanban,list,form',
context={
'search_default_customer': 1,
'res_partner_search_mode': 'customer',
'default_is_company': True,
'default_customer_rank': 1,
}))
menu_root_sales = new_menu('Sales')
menu_sales_orders = new_menu('Orders', parent_id=menu_root_sales.id)
new_menu('Customers', parent_id=menu_sales_orders.id,
action=new_action(
'Customers', res_model='res.partner',
view_mode='kanban,list,form',
context={
'search_default_customer': 1,
'res_partner_search_mode': 'customer',
'default_is_company': True,
'default_customer_rank': 1,
}))
menu_root_settings = new_menu('Settings')
menu_settings_user_and_companies = new_menu(
'Users & Companies', parent_id=menu_root_settings.id)
new_menu('Companies', parent_id=menu_settings_user_and_companies.id,
action=new_action(
'Companies', res_model='res.company',
path='all-companies',
view_mode='list,kanban,form'))
cls.menu_count = new_menu.call_count
cls.menu_root_contact = menu_root_contact
cls.menu_root_sales = menu_root_sales
cls.menu_root_settings = menu_root_settings
cls.user_public = mail_new_test_user(
cls.env, login='user_public', groups='base.group_public', name='Bert Tartignole')
cls.user_portal = mail_new_test_user(
cls.env, login='user_portal', groups='base.group_portal', name='Chell Gladys')
def patch_get_backend_root_menu_ids(self, model, return_values):
return patch.object(model.__class__, '_get_backend_root_menu_ids', return_value=return_values)
def test_initial_data(self):
self.assertEqual(len(self.env['ir.ui.menu']._visible_menu_ids()), self.menu_count)
@warmup
@users('employee')
def test_look_for_existing_menu_root_user_with_access(self):
Menu = self.env['ir.ui.menu']
with (self.patch_get_backend_root_menu_ids(self.env['res.company'], []),
self.assertQueryCount(employee=2)):
# Auto-detection: the menu root with a sub-menu having an action with a path is selected
self.assertEqual(Menu._get_best_backend_root_menu_id_for_model('res.company'),
self.menu_root_settings.id)
# Second time cache is used, so we got 2 queries less
for idx, (return_values, expected_menu_root_id) in enumerate((
# Auto-detection
([], self.menu_root_contact.id),
# Menu root defined by the model (and inheritance of), take the first one i.e. the least specific
([self.menu_root_sales.id], self.menu_root_sales.id),
([self.menu_root_sales.id, self.menu_root_contact.id], self.menu_root_sales.id),
([self.menu_root_contact.id, self.menu_root_sales.id], self.menu_root_contact.id),
)):
with (self.patch_get_backend_root_menu_ids(self.env['res.partner'], return_values),
self.assertQueryCount(employee=0)):
self.assertEqual(Menu._get_best_backend_root_menu_id_for_model('res.partner'), expected_menu_root_id)
@warmup
@users('user_portal', 'user_public')
def test_look_for_existing_menu_root_user_no_access(self):
Menu = self.env['ir.ui.menu']
with self.assertQueryCount(user_portal=2, user_public=2):
self.assertEqual(Menu._get_best_backend_root_menu_id_for_model('res.partner'), None)
with self.assertQueryCount(user_portal=1, user_public=1):
self.assertEqual(Menu._get_best_backend_root_menu_id_for_model('res.company'), None)
with (self.patch_get_backend_root_menu_ids(
self.env['res.partner'], [self.menu_root_sales.id, self.menu_root_contact.id]),
self.assertQueryCount(user_portal=1, user_public=1)):
self.assertEqual(Menu._get_best_backend_root_menu_id_for_model('res.partner'), None)
@warmup
def test_look_for_non_existing_menu_root(self):
with (self.patch_get_backend_root_menu_ids(self.env['res.bank'], []),
self.assertQueryCount(__system__=2)):
self.assertEqual(self.env['ir.ui.menu']._get_best_backend_root_menu_id_for_model('res.bank'), None)

View file

@ -0,0 +1,92 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from datetime import datetime, timedelta
from freezegun import freeze_time
try:
import websocket as ws
except ImportError:
ws = None
from odoo.tests import new_test_user, tagged
from odoo.addons.bus.tests.common import WebsocketCase
from odoo.addons.bus.models.bus import channel_with_db, json_dump
from odoo.addons.mail.models.mail_presence import AWAY_TIMER
@tagged("-at_install", "post_install")
class TestIrWebsocket(WebsocketCase):
def test_notify_on_status_change(self):
bob = new_test_user(self.env, login="bob_user", groups="base.group_user")
session = self.authenticate("bob_user", "bob_user")
websocket = self.websocket_connect(cookie=f"session_id={session.sid};")
self.subscribe(
websocket,
[f"odoo-presence-res.partner_{bob.partner_id.id}"],
self.env["bus.bus"]._bus_last_id(),
)
# offline => online
self.env["mail.presence"]._update_presence(bob)
self.trigger_notification_dispatching([(bob.partner_id, "presence")])
message = json.loads(websocket.recv())[0]["message"]
self.assertEqual(message["type"], "bus.bus/im_status_updated")
self.assertEqual(message["payload"]["im_status"], "online")
self.assertEqual(message["payload"]["presence_status"], "online")
self.assertEqual(message["payload"]["partner_id"], bob.partner_id.id)
# online => away
away_timer_later = datetime.now() + timedelta(seconds=AWAY_TIMER + 1)
with freeze_time(away_timer_later):
self.env["mail.presence"]._update_presence(bob, (AWAY_TIMER + 1) * 1000)
self.trigger_notification_dispatching([(bob.partner_id, "presence")])
message = json.loads(websocket.recv())[0]["message"]
self.assertEqual(message["type"], "bus.bus/im_status_updated")
self.assertEqual(message["payload"]["im_status"], "away")
self.assertEqual(message["payload"]["presence_status"], "away")
self.assertEqual(message["payload"]["partner_id"], bob.partner_id.id)
# away => online
ten_minutes_later = datetime.now() + timedelta(minutes=10)
with freeze_time(ten_minutes_later):
self.env["mail.presence"]._update_presence(bob)
self.trigger_notification_dispatching([(bob.partner_id, "presence")])
message = json.loads(websocket.recv())[0]["message"]
self.assertEqual(message["type"], "bus.bus/im_status_updated")
self.assertEqual(message["payload"]["im_status"], "online")
self.assertEqual(message["payload"]["presence_status"], "online")
self.assertEqual(message["payload"]["partner_id"], bob.partner_id.id)
# online => online, nothing happens
ten_minutes_later = datetime.now() + timedelta(minutes=10)
with freeze_time(ten_minutes_later):
self.env["mail.presence"]._update_presence(bob)
self.trigger_notification_dispatching([(bob.partner_id, "presence")])
timeout_occurred = False
# Save point rollback of `assertRaises` can compete with `on_websocket_close`
# leading to `InvalidSavepoint` errors. We need to avoid it.
try:
websocket.recv()
except ws._exceptions.WebSocketTimeoutException:
timeout_occurred = True
self.assertTrue(timeout_occurred)
def test_receive_missed_presences_on_subscribe(self):
bob = new_test_user(self.env, login="bob_user", groups="base.group_user")
session = self.authenticate("bob_user", "bob_user")
websocket = self.websocket_connect(cookie=f"session_id={session.sid};")
self.env["mail.presence"]._update_presence(bob)
self.env.cr.precommit.run() # trigger the creation of bus.bus records
self.subscribe(
websocket,
[f"odoo-presence-res.partner_{bob.partner_id.id}"],
self.env["bus.bus"]._bus_last_id(),
)
self.trigger_notification_dispatching([(bob.partner_id, "presence")])
notification = json.loads(websocket.recv())[0]
self._close_websockets()
bus_record = self.env["bus.bus"].search([("id", "=", int(notification["id"]))])
self.assertEqual(
bus_record.channel,
json_dump(channel_with_db(self.env.cr.dbname, bob.partner_id)),
)
self.assertEqual(notification["message"]["type"], "bus.bus/im_status_updated")
self.assertEqual(notification["message"]["payload"]["im_status"], "online")
self.assertEqual(notification["message"]["payload"]["presence_status"], "online")
self.assertEqual(notification["message"]["payload"]["partner_id"], bob.partner_id.id)

View file

@ -1,107 +1,295 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from functools import partial
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.mail.tests.common import MailCommon
from markupsafe import Markup
from unittest.mock import patch
import io
import requests
mail_channel_new_test_user = partial(mail_new_test_user, context={'mail_channel_nosubscribe': False})
def _patched_get_html(*args, **kwargs):
response = requests.Response()
response.status_code = 200
response._content = b"""
<html>
<head>
<meta property="og:title" content="Test title">
<meta property="og:description" content="Test description">
</head>
</html>
"""
response.headers["Content-Type"] = 'text/html'
return response
def _patch_head_html(*args, **kwargs):
response = requests.Response()
response.status_code = 200
response.headers["Content-Type"] = 'text/html'
return response
def _patch_no_content_type(*args, **kwargs):
response = requests.Response()
response.status_code = 200
return response
from odoo.addons.mail.tests.common import MailCommon
from odoo.addons.mail.tools import link_preview
from odoo.tests.common import tagged
@tagged("mail_link_preview", "mail_message", "post_install", "-at_install")
class TestLinkPreview(MailCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_1 = mail_channel_new_test_user(
cls.env, login='user_1',
name='User 1',
groups='base.group_user')
cls.maxDiff = None
cls.test_partner = cls.env['res.partner'].create({'name': 'a partner'})
cls.existing_message = cls.test_partner.message_post(body='Test')
cls.title = 'Test title'
cls.og_title = 'Le carousel ne démarre pas.webm'
cls.og_description = 'Test OG description'
cls.og_image = 'https://dummy-image-url.nothing'
cls.source_url = 'https://thisdomainedoentexist.nothing'
cls.public_channel = cls.env['mail.channel'].create({
'name': 'Public channel of user 1',
'channel_type': 'channel',
})
cls.public_channel.channel_member_ids.unlink()
def _patch_head_html(self, *args, **kwargs):
response = requests.Response()
response.status_code = 200
response.headers["Content-Type"] = 'text/html'
return response
def test_01_link_preview_throttle(self):
with patch.object(requests.Session, 'get', _patched_get_html), patch.object(requests.Session, 'head', _patch_head_html):
throttle = int(self.env['ir.config_parameter'].sudo().get_param('mail.link_preview_throttle', 99))
link_previews = []
for _ in range(throttle):
link_previews.append({'source_url': 'https://thisdomainedoentexist.nothing', 'message_id': 1})
self.env['mail.link.preview'].create(link_previews)
message = self.env['mail.message'].create({
'model': 'mail.channel',
'res_id': self.public_channel.id,
'body': '<a href="https://thisdomainedoentexist.nothing">Nothing link</a>',
})
self.env['mail.link.preview']._create_link_previews(message)
link_preview_count = self.env['mail.link.preview'].search_count([('source_url', '=', 'https://thisdomainedoentexist.nothing')])
self.assertEqual(link_preview_count, throttle + 1)
def _patched_get_html(self, content_type, content):
response = requests.Response()
response.status_code = 200
response._content = content
response.encoding = 'utf-8'
# To handle chunks read on stream requests
response.raw = io.BytesIO(response._content)
response.headers["Content-Type"] = content_type
return response
def test_02_link_preview_create(self):
with patch.object(requests.Session, 'get', _patched_get_html), patch.object(requests.Session, 'head', _patch_head_html):
message = self.env['mail.message'].create({
'model': 'mail.channel',
'res_id': self.public_channel.id,
'body': '<a href="https://thisdomainedoentexist.nothing">Nothing link</a>',
})
self.env['mail.link.preview']._create_link_previews(message)
self.assertBusNotifications(
[(self.cr.dbname, 'mail.channel', self.public_channel.id)],
message_items=[{
'type': 'mail.link.preview/insert',
'payload': [{
'id': link_preview.id,
'message': {'id': message.id},
'image_mimetype': False,
'og_description': 'Test description',
'og_image': False,
'og_mimetype': False,
'og_title': 'Test title',
'og_type': False,
'source_url': 'https://thisdomainedoentexist.nothing',
} for link_preview in message.link_preview_ids]
}]
def _patch_with_og_properties(self, *args, **kwargs):
content = b"""
<html>
<head>
<title>Test title</title>
<meta property="og:title" content="Le carousel ne d\xc3\xa9marre pas.webm">
<meta property="og:description" content="Test OG description">
<meta property="og:image" content="https://dummy-image-url.nothing">
</head>
</html>
"""
return self._patched_get_html('text/html', content)
def _patch_without_og_properties(self, *args, **kwargs):
content = b"""
<html>
<head>
<title>Test title</title>
</head>
</html>
"""
return self._patched_get_html('text/html', content)
def _patch_with_image_mimetype(self, *args, **kwargs):
content = b"""
<html>
<body>
<img src='https://dummy-image-url.nothing'/>
</body>
</html>
"""
return self._patched_get_html('image/png', content)
def _patch_with_no_content_type(self, *args, **kwargs):
content = b""""""
return self._patched_get_html(None, content)
def _patch_with_xml_declaration(self, *args, **kwargs):
content = b"""<?xml version="1.0" encoding="UTF-8"?>
<html>
<head>
<title>Test title</title>
</head>
</html>
"""
return self._patched_get_html("text/html", content)
def test_get_link_preview_from_url(self):
test_cases = [
(self._patch_with_og_properties, self.source_url),
(self._patch_without_og_properties, self.source_url),
(self._patch_with_image_mimetype, self.og_image),
(self._patch_with_xml_declaration, self.source_url)
]
expected_values = [
{
'og_description': self.og_description,
'og_image': self.og_image,
'og_mimetype': None,
'og_title': self.og_title,
'og_type': None,
'og_site_name': None,
'source_url': self.source_url,
},
{
'og_description': None,
'og_image': None,
'og_mimetype': None,
'og_title': self.title,
'og_type': None,
'og_site_name': None,
'source_url': self.source_url,
},
{
'image_mimetype': 'image/png',
'og_image': self.og_image,
'source_url': self.og_image,
},
{
'og_description': None,
'og_image': None,
'og_mimetype': None,
'og_title': self.title,
'og_type': None,
'og_site_name': None,
'source_url': self.source_url,
}
]
session = requests.Session()
for (get_patch, url), expected in zip(test_cases, expected_values):
with self.subTest(get_patch=get_patch, url=url, expected=expected), patch.object(requests.Session, 'get', get_patch):
preview = link_preview.get_link_preview_from_url(url, session)
self.assertEqual(preview, expected)
def test_link_preview(self):
with patch.object(requests.Session, 'get', self._patch_with_og_properties), patch.object(requests.Session, 'head', self._patch_head_html):
message = self.test_partner.message_post(
body=Markup(f'<a href={self.source_url}>Nothing link</a>'),
)
def test_03_link_preview_create_no_content_type(self):
with patch.object(requests.Session, 'request', _patch_no_content_type):
message = self.env['mail.message'].create({
'model': 'mail.channel',
'res_id': self.public_channel.id,
'body': '<a href="https://thisdomainedoentexist.nothing">Nothing link</a>',
})
self.env['mail.link.preview']._create_link_previews(message)
link_preview_count = self.env['mail.link.preview'].search_count([('source_url', '=', 'https://thisdomainedoentexist.nothing')])
self.assertEqual(link_preview_count, 0)
def get_bus_params():
return (
[(self.cr.dbname, "res.partner", self.env.user.partner_id.id)],
[
{
"type": "mail.record/insert",
"payload": {
"mail.link.preview": [
{
"id": message.message_link_preview_ids.link_preview_id.id,
"image_mimetype": False,
"og_description": self.og_description,
"og_image": self.og_image,
"og_mimetype": False,
"og_site_name": False,
"og_title": self.og_title,
"og_type": False,
"source_url": self.source_url,
},
],
"mail.message": self._filter_messages_fields(
{
"id": message.id,
"message_link_preview_ids": message.message_link_preview_ids.ids,
},
),
"mail.message.link.preview": [
{
"id": message.message_link_preview_ids.id,
"link_preview_id": message.message_link_preview_ids.link_preview_id.id,
"message_id": message.id,
}
],
},
}
],
)
with self.assertBus(get_params=get_bus_params):
self.env["mail.link.preview"]._create_from_message_and_notify(message)
def test_link_preview_no_content_type(self):
with patch.object(requests.Session, 'request', self._patch_with_no_content_type):
url = self.source_url
session = requests.Session()
link_preview.get_link_preview_from_url(url, session)
def test_link_preview_ignore_internal_link(self):
"""Test internal links are properly ignored from link preview."""
with patch.object(requests.Session, "get", self._patch_with_og_properties), patch.object(
requests.Session, "head", self._patch_head_html
):
urls = [
("http://localhost:8069/", "http://localhost:8069/odoo", 0),
("http://localhost:8069/", "http://localhost:8069/odoo/test", 0),
("http://localhost:8069/", "http://localhost:8069/web/test", 0),
("http://localhost:8069/", "http://localhost:8069/", 1),
("http://localhost:8069/", "http://localhost:8069/odoo-experience", 1),
("http://localhost:8069/", "http://localhost:8069/chat/5/bFtIfYHRco", 0),
("https://www.odoo.com/", "https://www.odoo.com/web", 0),
("https://www.odoo.com/", "https://www.odoo.com/odoo", 0),
("https://www.odoo.com/", "https://www.odoo.com/odoo/", 0),
("https://www.odoo.com/", "https://www.odoo.com/odoo?debug=assets", 0),
("https://www.odoo.com/", "https://www.odoo.com/odoo#anchor", 0),
("https://www.odoo.com/", "https://www.odoo.com/odoo-experience", 1),
("https://www.odoo.com/", "https://www.odoo.com/odoo/1519/tasks/4102866", 0),
("http://www.odoo.com/", "https://www.odoo.com/odoo/1519/tasks/4102866", 1),
("https://www.odoo.com/", "https://wwwaodoo.com/odoo/", 1),
("https://www.odoo.com/", "https://www.odoo.com/chat/", 0),
("https://www.odoo.com/", "https://www.odoo.com/chat/5/bFtIfYHRco", 0),
("http://www.odoo.com/", "https://www.odoo.com/chat/5/bFtIfYHRco", 1),
("https://clients.odoo.com/", "https://www.odoo.com/odoo/1519/tasks/4102866", 1),
("https://clients.odoo.com/", "https://www.odoo.com/chat/5/bFtIfYHRco", 1),
]
for request_url, url, counter in urls:
with self.subTest(request_url=request_url, url=url, counter=counter):
message = self.test_partner.message_post(
body=Markup(f'<a href="{url}">Nothing link</a>'),
)
self.env["mail.link.preview"]._create_from_message_and_notify(
message, request_url
)
link_preview_count = self.env["mail.message.link.preview"].search_count(
[("message_id", "=", message.id)]
)
self.assertEqual(link_preview_count, counter)
def test_remove_unused_link_preview(self):
with (
patch.object(requests.Session, "get", self._patch_with_og_properties),
patch.object(requests.Session, "head", self._patch_head_html),
):
message = self.test_partner.message_post(
body=Markup(
'<a href="https://www.odoo.com/odoo-experience">Nothing link</a> <a href="https://www.odoo.com/odoo-experience-2025">Other Nothing link</a>'
),
message_type="comment",
)
self.env["mail.link.preview"]._create_from_message_and_notify(message)
link_preview_count = self.env["mail.message.link.preview"].search_count(
[("message_id", "=", message.id)]
)
self.assertEqual(link_preview_count, 2)
self.test_partner._message_update_content(
message,
body=Markup('<a href="https://www.odoo.com/odoo-experience">Nothing link</a>'),
)
self.env["mail.link.preview"]._create_from_message_and_notify(message)
link_preview_count = self.env["mail.message.link.preview"].search_count(
[("message_id", "=", message.id)]
)
self.assertEqual(link_preview_count, 1)
def test_link_preview_throttle(self):
self.env["ir.config_parameter"].sudo().set_param("mail.link_preview_throttle", 1)
with (
patch.object(requests.Session, "get", self._patch_with_og_properties),
patch.object(requests.Session, "head", self._patch_head_html),
):
message = self.test_partner.message_post(
body=Markup('<a href="%s">Nothing link</a>') % self.source_url,
)
self.env["mail.link.preview"]._create_from_message_and_notify(message)
link_preview = (
self.env["mail.message.link.preview"]
.search([("message_id", "=", message.id)])
.link_preview_id
)
message = self.test_partner.message_post(
body=Markup(f'<a href="%s/test">Nothing link</a>') % self.source_url
)
self.env["mail.link.preview"]._create_from_message_and_notify(message)
link_preview_count = self.env["mail.message.link.preview"].search_count(
[("link_preview_id", "=", link_preview.id)]
)
self.assertEqual(link_preview_count, 1)
def test_link_preview_delete_with_message(self):
with patch.object(requests.Session, "get", self._patch_with_og_properties), patch.object(
requests.Session, "head", self._patch_head_html
):
message = self.test_partner.message_post(
body=Markup('<a href="%s">Test link</a>') % self.source_url,
message_type="comment"
)
self.env["mail.link.preview"]._create_from_message_and_notify(message)
preview = message.message_link_preview_ids
self.assertTrue(preview)
self.assertEqual(preview.link_preview_id.source_url, self.source_url)
self.test_partner._message_update_content(message, body="")
self.assertFalse(message.message_link_preview_ids)

View file

@ -0,0 +1,71 @@
from freezegun import freeze_time
from odoo import exceptions
from odoo.addons.mail.tests.common_activity import ActivityScheduleCase
from odoo.tests import tagged, HttpCase
@tagged("mail_activity", "-at_install", "post_install")
class TestMailActivityChatter(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.test_partner = cls.env['res.partner'].create({
'email': 'test.partner@example.com',
'name': 'Test User',
})
def test_mail_activity_date_format(self):
with freeze_time("2024-01-01 09:00:00 AM"):
LANG_CODE = "en_US"
self.env = self.env(context={"lang": LANG_CODE})
lang = self.env["res.lang"].search([('code', '=', LANG_CODE)])
lang.date_format = "%d/%b/%y"
lang.time_format = "%I:%M:%S %p"
self.start_tour(
f"/web#id={self.test_partner.id}&model=res.partner",
"mail_activity_date_format",
login="admin",
)
def test_mail_activity_schedule_from_chatter(self):
self.start_tour(
f"/odoo/res.partner/{self.test_partner.id}",
"mail_activity_schedule_from_chatter",
login="admin",
)
@tagged("-at_install", "post_install", "mail_activity")
class TestMailActivityIntegrity(ActivityScheduleCase):
def test_mail_activity_type_master_data(self):
""" Test master data integrity
* 'call', 'meeting', 'todo', 'upload document' and 'warning' should always be cross model;
* 'call', 'meeting' and 'todo' cannot be removed
"""
call = self.env.ref('mail.mail_activity_data_call')
meeting = self.env.ref('mail.mail_activity_data_meeting')
todo = self.env.ref('mail.mail_activity_data_todo')
upload = self.env.ref('mail.mail_activity_data_upload_document')
warning = self.env.ref('mail.mail_activity_data_warning')
with self.assertRaises(exceptions.UserError):
call.write({'res_model': 'res.partner'})
with self.assertRaises(exceptions.UserError):
meeting.write({'res_model': 'res.partner'})
with self.assertRaises(exceptions.UserError):
todo.write({'res_model': 'res.partner'})
with self.assertRaises(exceptions.UserError):
upload.write({'res_model': 'res.partner'})
with self.assertRaises(exceptions.UserError):
warning.write({'res_model': 'res.partner'})
with self.assertRaises(exceptions.UserError):
call.unlink()
with self.assertRaises(exceptions.UserError):
meeting.unlink()
with self.assertRaises(exceptions.UserError):
todo.unlink()

View file

@ -0,0 +1,41 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import TransactionCase
class TestMailBlacklist(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.email = "archived@test.example.com"
cls.partner = cls.env["res.partner"].create(
{
"name": "Archived Blacklist Test",
"email": cls.email,
}
)
cls.blacklist_entry = cls.env["mail.blacklist"].create(
{
"email": cls.email,
"active": False,
}
)
def test_is_blacklisted_ignores_archived(self):
self.assertFalse(
self.partner.is_blacklisted,
"Archived blacklist entries should not mark partner as blacklisted",
)
def test_is_blacklisted_active_entry(self):
self.blacklist_entry.action_unarchive()
self.assertTrue(
self.partner.is_blacklisted,
"Active blacklist entries should mark partner as blacklisted",
)
def test_active_test_false_context_works(self):
self.assertFalse(
self.partner.with_context(active_test=False).is_blacklisted,
"Always blacklisted, even if context searches for archived records",
)

View file

@ -1,578 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from datetime import datetime
from unittest.mock import patch
from odoo import Command, fields
from odoo.addons.mail.models.mail_channel import channel_avatar, group_avatar
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.mail.tests.common import MailCommon
from odoo.exceptions import AccessError, UserError
from odoo.tests import tagged, Form
from odoo.tests.common import users
from odoo.tools import html_escape, mute_logger
from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
@tagged('mail_channel')
class TestChannelAccessRights(MailCommon):
@classmethod
def setUpClass(cls):
super(TestChannelAccessRights, cls).setUpClass()
cls.user_employee_1 = mail_new_test_user(cls.env, login='user_employee_1', groups='base.group_user', name='Tao Lee')
cls.user_public = mail_new_test_user(cls.env, login='user_public', groups='base.group_public', name='Bert Tartignole')
cls.user_portal = mail_new_test_user(cls.env, login='user_portal', groups='base.group_portal', name='Chell Gladys')
# Channel for certain group
cls.group_restricted_channel = cls.env['mail.channel'].browse(cls.env['mail.channel'].channel_create(name='Channel for Groups', group_id=cls.env.ref('base.group_user').id)['id'])
# Public Channel
cls.public_channel = cls.env['mail.channel'].browse(cls.env['mail.channel'].channel_create(name='Public Channel', group_id=None)['id'])
# Group
cls.private_group = cls.env['mail.channel'].browse(cls.env['mail.channel'].create_group(partners_to=cls.user_employee.partner_id.ids, name="Group")['id'])
# Chat
cls.chat_user_employee = cls.env['mail.channel'].browse(cls.env['mail.channel'].channel_get(cls.user_employee.partner_id.ids)['id'])
cls.chat_user_employee_1 = cls.env['mail.channel'].browse(cls.env['mail.channel'].channel_get(cls.user_employee_1.partner_id.ids)['id'])
cls.chat_user_portal = cls.env['mail.channel'].browse(cls.env['mail.channel'].channel_get(cls.user_portal.partner_id.ids)['id'])
cls.chat_user_public = cls.env['mail.channel'].browse(cls.env['mail.channel'].channel_get(cls.user_public.partner_id.ids)['id'])
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.base.models.ir_model', 'odoo.models')
@users('user_public')
def test_access_public(self):
# Read public channel -> ok
self.env['mail.channel'].browse(self.public_channel.id).read()
# Read group restricted channel -> ko, no access rights
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.group_restricted_channel.id).read()
# Read group -> ko, no access rights
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.private_group.id).read()
# Being a member of public channel: -> ok
self.public_channel.add_members(self.user_public.partner_id.id)
# Being a member of group restricted channel: -> ko, no access rights
with self.assertRaises(UserError):
self.group_restricted_channel.add_members(self.user_public.partner_id.id)
# Being a group member: -> ok
self.private_group.add_members(self.user_public.partner_id.id)
# Read a group when being a member: -> ko, no access rights
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.private_group.id).read()
# Read a chat when being a member: -> ko, no access rights
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.chat_user_public.id).read()
# Create channel/group/chat: ko, no access rights
with self.assertRaises(AccessError):
self.env['mail.channel'].create({'name': 'Test', 'channel_type': 'channel'})
with self.assertRaises(AccessError):
self.env['mail.channel'].create({'name': 'Test', 'channel_type': 'group'})
with self.assertRaises(AccessError):
self.env['mail.channel'].create({'name': 'Test', 'channel_type': 'chat'})
# Update channel/group/chat: ko, no access rights
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.public_channel.id).write({'name': 'modified'})
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.group_restricted_channel.id).write({'name': 'modified'})
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.private_group.id).write({'name': 'modified'})
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.chat_user_public.id).write({'name': 'modified'})
# Unlink channel/group/chat: ko, no access rights
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.public_channel.id).unlink()
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.group_restricted_channel.id).unlink()
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.private_group.id).unlink()
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.chat_user_public.id).unlink()
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.base.models.ir_model', 'odoo.models')
@users('employee')
def test_access_employee(self):
# Read public channel -> ok
self.env['mail.channel'].browse(self.public_channel.id).read()
# Read group restricted channel -> ok
self.env['mail.channel'].browse(self.group_restricted_channel.id).read()
# Read chat when not being a member: ko, no access rights
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.chat_user_employee_1.id).read()
# Update chat when not being a member: ko, no access rights
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.chat_user_employee_1.id).write({'name': 'modified'})
# Being a channel/group member: -> ok
self.public_channel.add_members(self.user_public.partner_id.id)
self.group_restricted_channel.add_members(self.env.user.partner_id.id)
self.private_group.add_members(self.env.user.partner_id.id)
# Read a group when being a member: ok
self.env['mail.channel'].browse(self.private_group.id).read()
# Read a chat when being a member: ok
self.env['mail.channel'].browse(self.chat_user_employee.id).read()
# Update channel/group/chat when being a member: ok
self.env['mail.channel'].browse(self.public_channel.id).write({'name': 'modified again'})
self.env['mail.channel'].browse(self.group_restricted_channel.id).write({'name': 'modified again'})
self.env['mail.channel'].browse(self.private_group.id).write({'name': 'modified again'})
self.env['mail.channel'].browse(self.chat_user_employee.id).write({'name': 'modified again'})
# Create channel/group/chat: ok
new_channel = self.env['mail.channel'].create(
{'name': 'Test', 'channel_type': 'channel'})
new_group = self.env['mail.channel'].create(
{'name': 'Test', 'channel_type': 'group'})
new_chat = self.env['mail.channel'].create(
{'name': 'Test', 'channel_type': 'chat'})
# Employee should be inside the created chat/group/chat
self.assertIn(new_channel.channel_partner_ids, self.partner_employee)
self.assertIn(new_group.channel_partner_ids, self.partner_employee)
self.assertIn(new_chat.channel_partner_ids, self.partner_employee)
# Unlink channel/group/chat: ko, no access rights
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.public_channel.id).unlink()
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.group_restricted_channel.id).unlink()
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.private_group.id).unlink()
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.chat_user_employee.id).unlink()
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.base.models.ir_model', 'odoo.models')
@users('user_portal')
def test_access_portal(self):
# Read public channel -> ok
self.env['mail.channel'].browse(self.public_channel.id).read()
# Read group restricted channel/group -> ko, no access rights
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.group_restricted_channel.id).read()
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.private_group.id).read()
# Being a group member: -> ok
self.private_group.add_members(self.user_portal.partner_id.id)
# Read a group/chat when being a member: ok
self.env['mail.channel'].browse(self.private_group.id).read()
self.env['mail.channel'].browse(self.chat_user_portal.id).read()
# Update group/chat when being a member: ko, no access rights
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.private_group.id).write({'name': 'modified'})
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.chat_user_portal.id).write({'name': 'modified'})
# Create group/chat: ko, no access rights
with self.assertRaises(AccessError):
self.env['mail.channel'].create({'name': 'Test', 'channel_type': 'group'})
with self.assertRaises(AccessError):
self.env['mail.channel'].create({'name': 'Test', 'channel_type': 'chat'})
# Unlink group/chat: ko, no access rights
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.private_group.id).unlink()
with self.assertRaises(AccessError):
self.env['mail.channel'].browse(self.chat_user_portal.id).unlink()
# Read message from group/chat: ok
group_portal = self.env['mail.channel'].browse(self.private_group.id)
for message in group_portal.message_ids:
message.read(['subject'])
chat_portal = self.env['mail.channel'].browse(self.chat_user_portal.id)
for message in chat_portal.message_ids:
message.read(['subject'])
# Read partner list from group: ko, no access rights
with self.assertRaises(AccessError):
group_portal.message_partner_ids
for partner in self.private_group.message_partner_ids:
if partner.id == self.user_portal.partner_id.id:
# Portal user can read their own partner record
continue
with self.assertRaises(AccessError):
partner.with_user(self.user_portal).name
# Read partner list from chat: ko, no access rights
with self.assertRaises(AccessError):
chat_portal.message_partner_ids
for partner in self.chat_user_portal.message_partner_ids:
if partner.id == self.user_portal.partner_id.id:
# Portal user can read their own partner record
continue
with self.assertRaises(AccessError):
partner.with_user(self.user_portal).name
@tagged('mail_channel')
class TestChannelInternals(MailCommon):
@classmethod
def setUpClass(cls):
super(TestChannelInternals, cls).setUpClass()
cls.test_channel = cls.env['mail.channel'].browse(cls.env['mail.channel'].with_context(cls._test_context).channel_create(name='Channel', group_id=None)['id'])
cls.test_partner = cls.env['res.partner'].with_context(cls._test_context).create({
'name': 'Test Partner',
'email': 'test_customer@example.com',
})
cls.user_employee_nomail = mail_new_test_user(
cls.env, login='employee_nomail',
email=False,
groups='base.group_user',
company_id=cls.company_admin.id,
name='Evita Employee NoEmail',
notification_type='email',
signature='--\nEvite'
)
cls.partner_employee_nomail = cls.user_employee_nomail.partner_id
@users('employee')
def test_channel_members(self):
channel = self.env['mail.channel'].browse(self.test_channel.ids)
self.assertEqual(channel.message_partner_ids, self.env['res.partner'])
self.assertEqual(channel.channel_partner_ids, self.env['res.partner'])
channel.add_members(self.test_partner.ids)
self.assertEqual(channel.message_partner_ids, self.env['res.partner'])
self.assertEqual(channel.channel_partner_ids, self.test_partner)
self.env['mail.channel.member'].sudo().search([
('partner_id', 'in', self.test_partner.ids),
('channel_id', 'in', channel.ids)
]).unlink()
self.assertEqual(channel.message_partner_ids, self.env['res.partner'])
self.assertEqual(channel.channel_partner_ids, self.env['res.partner'])
channel.message_post(body='Test', message_type='comment', subtype_xmlid='mail.mt_comment')
self.assertEqual(channel.message_partner_ids, self.env['res.partner'])
self.assertEqual(channel.channel_partner_ids, self.env['res.partner'])
@users('employee')
@mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink')
def test_channel_chat_message_post_should_update_last_interest_dt(self):
channel_info = self.env['mail.channel'].with_user(self.user_admin).channel_get((self.partner_employee | self.user_admin.partner_id).ids)
chat = self.env['mail.channel'].with_user(self.user_admin).browse(channel_info['id'])
post_time = fields.Datetime.now()
# Mocks the return value of field.Datetime.now(),
# so we can see if the `last_interest_dt` is updated correctly
with patch.object(fields.Datetime, 'now', lambda: post_time):
chat.message_post(body="Test", message_type='comment', subtype_xmlid='mail.mt_comment')
channel_member_employee = self.env['mail.channel.member'].search([
('partner_id', '=', self.partner_employee.id),
('channel_id', '=', chat.id),
])
channel_member_admin = self.env['mail.channel.member'].search([
('partner_id', '=', self.partner_admin.id),
('channel_id', '=', chat.id),
])
self.assertEqual(channel_member_employee.last_interest_dt, post_time)
self.assertEqual(channel_member_admin.last_interest_dt, post_time)
@users('employee')
@mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink')
def test_channel_recipients_channel(self):
""" Posting a message on a channel should not send emails """
channel = self.env['mail.channel'].browse(self.test_channel.ids)
channel.add_members((self.partner_employee | self.partner_admin | self.test_partner).ids)
with self.mock_mail_gateway():
new_msg = channel.message_post(body="Test", message_type='comment', subtype_xmlid='mail.mt_comment')
self.assertNotSentEmail()
self.assertEqual(new_msg.model, self.test_channel._name)
self.assertEqual(new_msg.res_id, self.test_channel.id)
self.assertEqual(new_msg.partner_ids, self.env['res.partner'])
self.assertEqual(new_msg.notified_partner_ids, self.env['res.partner'])
@users('employee')
@mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink')
def test_channel_recipients_chat(self):
""" Posting a message on a chat should not send emails """
channel_info = self.env['mail.channel'].with_user(self.user_admin).channel_get((self.partner_employee | self.user_admin.partner_id).ids)
chat = self.env['mail.channel'].with_user(self.user_admin).browse(channel_info['id'])
with self.mock_mail_gateway():
with self.with_user('employee'):
new_msg = chat.message_post(body="Test", message_type='comment', subtype_xmlid='mail.mt_comment')
self.assertNotSentEmail()
self.assertEqual(new_msg.model, chat._name)
self.assertEqual(new_msg.res_id, chat.id)
self.assertEqual(new_msg.partner_ids, self.env['res.partner'])
self.assertEqual(new_msg.notified_partner_ids, self.env['res.partner'])
@mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink')
def test_channel_recipients_mention(self):
""" Posting a message on a classic channel should support mentioning somebody """
with self.mock_mail_gateway():
self.test_channel.message_post(
body="Test", partner_ids=self.test_partner.ids,
message_type='comment', subtype_xmlid='mail.mt_comment')
self.assertSentEmail(self.test_channel.env.user.partner_id, [self.test_partner])
@mute_logger('odoo.models.unlink')
def test_channel_user_synchronize(self):
"""Archiving / deleting a user should automatically unsubscribe related partner from group restricted channels"""
group_restricted_channel = self.env['mail.channel'].browse(self.env['mail.channel'].channel_create(name='Sic Mundus', group_id=self.env.ref('base.group_user').id)['id'])
self.test_channel.add_members((self.partner_employee | self.partner_employee_nomail).ids)
group_restricted_channel.add_members((self.partner_employee | self.partner_employee_nomail).ids)
# Unsubscribe archived user from the private channels, but not from public channels
self.user_employee.active = False
self.assertEqual(group_restricted_channel.channel_partner_ids, self.partner_employee_nomail)
self.assertEqual(self.test_channel.channel_partner_ids, self.user_employee.partner_id | self.partner_employee_nomail)
# Unsubscribe deleted user from the private channels, but not from public channels
self.user_employee_nomail.unlink()
self.assertEqual(group_restricted_channel.channel_partner_ids, self.env['res.partner'])
self.assertEqual(self.test_channel.channel_partner_ids, self.user_employee.partner_id | self.partner_employee_nomail)
@users('employee_nomail')
def test_channel_info_get(self):
# `channel_get` should return a new channel the first time a partner is given
initial_channel_info = self.env['mail.channel'].channel_get(partners_to=self.test_partner.ids)
# shape of channelMembers is [('insert', data...)], [0][1] accesses the data
self.assertEqual(set(m['persona']['partner']['id'] for m in initial_channel_info['channel']['channelMembers'][0][1]), {self.partner_employee_nomail.id, self.test_partner.id})
# `channel_get` should return the existing channel every time the same partner is given
same_channel_info = self.env['mail.channel'].channel_get(partners_to=self.test_partner.ids)
self.assertEqual(same_channel_info['id'], initial_channel_info['id'])
# `channel_get` should return the existing channel when the current partner is given together with the other partner
together_channel_info = self.env['mail.channel'].channel_get(partners_to=(self.partner_employee_nomail + self.test_partner).ids)
self.assertEqual(together_channel_info['id'], initial_channel_info['id'])
# `channel_get` should return a new channel the first time just the current partner is given,
# even if a channel containing the current partner together with other partners already exists
solo_channel_info = self.env['mail.channel'].channel_get(partners_to=self.partner_employee_nomail.ids)
self.assertNotEqual(solo_channel_info['id'], initial_channel_info['id'])
# shape of channelMembers is [('insert', data...)], [0][1] accesses the data
self.assertEqual(set(m['persona']['partner']['id'] for m in solo_channel_info['channel']['channelMembers'][0][1]), {self.partner_employee_nomail.id})
# `channel_get` should return the existing channel every time the current partner is given
same_solo_channel_info = self.env['mail.channel'].channel_get(partners_to=self.partner_employee_nomail.ids)
self.assertEqual(same_solo_channel_info['id'], solo_channel_info['id'])
# `channel_get` will pin the channel by default and thus last interest will be updated.
@users('employee')
def test_channel_info_get_should_update_last_interest_dt(self):
# create the channel via `channel_get`
self.env['mail.channel'].channel_get(partners_to=self.partner_admin.ids)
retrieve_time = datetime(2021, 1, 1, 0, 0)
with patch.object(fields.Datetime, 'now', lambda: retrieve_time):
# `last_interest_dt` should be updated again when `channel_get` is called
# because `channel_pin` is called.
channel_info = self.env['mail.channel'].channel_get(partners_to=self.partner_admin.ids)
self.assertEqual(channel_info['last_interest_dt'], retrieve_time.strftime(DEFAULT_SERVER_DATETIME_FORMAT))
@users('employee')
def test_channel_info_seen(self):
""" In case of concurrent channel_seen RPC, ensure the oldest call has no effect. """
channel_info = self.env['mail.channel'].with_user(self.user_admin).channel_get((self.partner_employee | self.user_admin.partner_id).ids)
chat = self.env['mail.channel'].with_user(self.user_admin).browse(channel_info['id'])
msg_1 = self._add_messages(chat, 'Body1', author=self.user_employee.partner_id)
msg_2 = self._add_messages(chat, 'Body2', author=self.user_employee.partner_id)
chat._channel_seen(msg_2.id)
self.assertEqual(
chat.channel_info()[0]['seen_partners_info'][0]['seen_message_id'],
msg_2.id,
"Last message id should have been updated"
)
chat._channel_seen(msg_1.id)
self.assertEqual(
chat.channel_info()[0]['seen_partners_info'][0]['seen_message_id'],
msg_2.id,
"Last message id should stay the same after mark channel as seen with an older message"
)
def test_channel_message_post_should_not_allow_adding_wrong_parent(self):
channels = self.env['mail.channel'].create([{'name': '1'}, {'name': '2'}])
message = self._add_messages(channels[0], 'Body1')
message_format2 = channels[1].message_post(body='Body2', parent_id=message.id)
self.assertFalse(message_format2['parent_id'], "should not allow parent from wrong thread")
message_format3 = channels[1].message_post(body='Body3', parent_id=message.id + 100)
self.assertFalse(message_format3['parent_id'], "should not allow non-existing parent")
@mute_logger('odoo.models.unlink')
def test_channel_unsubscribe_auto(self):
""" Archiving / deleting a user should automatically unsubscribe related
partner from private channels """
test_user = self.env['res.users'].create({
"login": "adam",
"name": "Jonas",
})
test_partner = test_user.partner_id
group_restricted_channel = self.env['mail.channel'].with_context(self._test_context).create({
'name': 'Sic Mundus',
'group_public_id': self.env.ref('base.group_user').id,
'channel_partner_ids': [Command.link(self.user_employee.partner_id.id), Command.link(test_partner.id)],
})
self.test_channel.with_context(self._test_context).write({
'channel_partner_ids': [Command.link(self.user_employee.partner_id.id), Command.link(test_partner.id)],
})
private_group = self.env['mail.channel'].with_user(self.user_employee).with_context(self._test_context).create({
'name': 'test',
'channel_type': 'group',
'channel_partner_ids': [Command.link(self.user_employee.partner_id.id), Command.link(test_partner.id)],
})
# Unsubscribe archived user from the private channels, but not from public channels and not from group
self.user_employee.active = False
(private_group | self.test_channel).invalidate_recordset(['channel_partner_ids'])
self.assertEqual(group_restricted_channel.channel_partner_ids, test_partner)
self.assertEqual(self.test_channel.channel_partner_ids, self.user_employee.partner_id | test_partner)
self.assertEqual(private_group.channel_partner_ids, self.user_employee.partner_id | test_partner)
# Unsubscribe deleted user from the private channels, but not from public channels and not from group
test_user.unlink()
self.assertEqual(group_restricted_channel.channel_partner_ids, self.env['res.partner'])
self.assertEqual(self.test_channel.channel_partner_ids, self.user_employee.partner_id | test_partner)
self.assertEqual(private_group.channel_partner_ids, self.user_employee.partner_id | test_partner)
@users('employee')
@mute_logger('odoo.models.unlink')
def test_channel_private_unfollow(self):
""" Test that a partner can leave (unfollow) a channel/group/chat. """
group_restricted_channel = self.env['mail.channel'].browse(self.env['mail.channel'].channel_create(name='Channel for Groups', group_id=self.env.ref('base.group_user').id)['id'])
public_channel = self.env['mail.channel'].browse(self.env['mail.channel'].channel_create(name='Channel for Everyone', group_id=None)['id'])
private_group = self.env['mail.channel'].browse(self.env['mail.channel'].create_group(partners_to=self.user_employee.partner_id.ids, name="Group")['id'])
chat_user_current = self.env['mail.channel'].browse(self.env['mail.channel'].channel_get(self.env.user.partner_id.ids)['id'])
group_restricted_channel.add_members(self.env.user.partner_id.id)
public_channel.add_members(self.env.user.partner_id.id)
group_restricted_channel.action_unfollow()
public_channel.action_unfollow()
private_group.action_unfollow()
chat_user_current.action_unfollow()
self.assertEqual(group_restricted_channel.channel_partner_ids, self.env['res.partner'])
self.assertEqual(public_channel.channel_partner_ids, self.env['res.partner'])
self.assertEqual(private_group.channel_partner_ids, self.env['res.partner'])
self.assertEqual(chat_user_current.channel_partner_ids, self.env['res.partner'])
def test_channel_unfollow_should_not_post_message_if_the_partner_has_been_removed(self):
'''
When a partner leaves a channel, the system will help post a message under
that partner's name in the channel to notify others if `email_sent` is set `False`.
The message should only be posted when the partner is still a member of the channel
before method `_action_unfollow()` is called.
If the partner has been removed earlier, no more messages will be posted
even if `_action_unfollow()` is called again.
'''
channel = self.env['mail.channel'].browse(self.test_channel.id)
channel.add_members(self.test_partner.ids)
# no message should be posted under test_partner's name
messages_0 = self.env['mail.message'].search([
('model', '=', 'mail.channel'),
('res_id', '=', channel.id),
('author_id', '=', self.test_partner.id)
])
self.assertEqual(len(messages_0), 0)
# a message should be posted to notify others when a partner is about to leave
channel._action_unfollow(self.test_partner)
messages_1 = self.env['mail.message'].search([
('model', '=', 'mail.channel'),
('res_id', '=', channel.id),
('author_id', '=', self.test_partner.id)
])
self.assertEqual(len(messages_1), 1)
# no more messages should be posted if the partner has been removed before.
channel._action_unfollow(self.test_partner)
messages_2 = self.env['mail.message'].search([
('model', '=', 'mail.channel'),
('res_id', '=', channel.id),
('author_id', '=', self.test_partner.id)
])
self.assertEqual(len(messages_2), 1)
self.assertEqual(messages_1, messages_2)
def test_channel_should_generate_correct_default_avatar(self):
test_channel = self.env['mail.channel'].browse(self.env['mail.channel'].channel_create(name='Channel', group_id=self.env.ref('base.group_user').id)['id'])
test_channel.uuid = 'channel-uuid'
private_group = self.env['mail.channel'].browse(self.env['mail.channel'].create_group(partners_to=self.user_employee.partner_id.ids)['id'])
private_group.uuid = 'group-uuid'
bgcolor_channel = html_escape('hsl(316, 61%, 45%)') # depends on uuid
bgcolor_group = html_escape('hsl(17, 60%, 45%)') # depends on uuid
expceted_avatar_channel = (channel_avatar.replace('fill="#875a7b"', f'fill="{bgcolor_channel}"')).encode()
expected_avatar_group = (group_avatar.replace('fill="#875a7b"', f'fill="{bgcolor_group}"')).encode()
self.assertEqual(base64.b64decode(test_channel.avatar_128), expceted_avatar_channel)
self.assertEqual(base64.b64decode(private_group.avatar_128), expected_avatar_group)
test_channel.image_128 = base64.b64encode(("<svg/>").encode())
self.assertEqual(test_channel.avatar_128, test_channel.image_128)
def test_channel_write_should_send_notification_if_image_128_changed(self):
channel = self.env['mail.channel'].create({'name': '', 'uuid': 'test-uuid'})
# do the operation once before the assert to grab the value to expect
channel.image_128 = base64.b64encode(("<svg/>").encode())
avatar_cache_key = channel._get_avatar_cache_key()
channel.image_128 = False
self.env['bus.bus'].search([]).unlink()
with self.assertBus(
[(self.cr.dbname, 'mail.channel', channel.id)],
[{
"type": "mail.channel/insert",
"payload": {
"avatarCacheKey": avatar_cache_key,
"id": channel.id,
},
}]
):
channel.image_128 = base64.b64encode(("<svg/>").encode())
def test_mail_message_starred_group(self):
""" Test starred message computation for a group. A starred
message in a group should be considered only if:
- It's our message
- OR we have access to the channel
"""
self.assertEqual(self.user_employee._init_messaging()['starred_counter'], 0)
test_group = self.env['mail.channel'].create({
'name': 'Private Channel',
'channel_type': 'group',
'channel_partner_ids': [(6, 0, self.partner_employee.id)]
})
test_group_own_message = test_group.with_user(self.user_employee.id).message_post(body='TestingMessage')
test_group_own_message.write({'starred_partner_ids': [(6, 0, self.partner_employee.ids)]})
self.assertEqual(self.user_employee.with_user(self.user_employee)._init_messaging()['starred_counter'], 1)
test_group_message = test_group.message_post(body='TestingMessage')
test_group_message.write({'starred_partner_ids': [(6, 0, self.partner_employee.ids)]})
self.assertEqual(self.user_employee.with_user(self.user_employee)._init_messaging()['starred_counter'], 2)
test_group.write({'channel_partner_ids': False})
self.assertEqual(self.user_employee.with_user(self.user_employee)._init_messaging()['starred_counter'], 1)
def test_multi_company_chat(self):
self._activate_multi_company()
self.assertEqual(self.env.user.company_id, self.company_admin)
with self.with_user('employee'):
initial_channel_info = self.env['mail.channel'].with_context(
allowed_company_ids=self.company_admin.ids
).channel_get(self.partner_employee_c2.ids)
self.assertTrue(initial_channel_info, 'should be able to chat with multi company user')
@users('employee')
def test_create_chat_channel_should_only_pin_the_channel_for_the_current_user(self):
chat = self.env['mail.channel'].channel_get(partners_to=self.test_partner.ids)
member_of_current_user = self.env['mail.channel.member'].search([('channel_id', '=', chat['id']), ('partner_id', '=', self.env.user.partner_id.id)])
member_of_correspondent = self.env['mail.channel.member'].search([('channel_id', '=', chat['id']), ('partner_id', '=', self.test_partner.id)])
self.assertTrue(member_of_current_user.is_pinned)
self.assertFalse(member_of_correspondent.is_pinned)

View file

@ -1,86 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo
from odoo.addons.base.tests.common import HttpCaseWithUserPortal, HttpCaseWithUserDemo
from odoo.addons.mail.tests.common import mail_new_test_user
@odoo.tests.tagged('-at_install', 'post_install', 'is_tour')
class TestMailPublicPage(HttpCaseWithUserDemo, HttpCaseWithUserPortal):
"""Checks that the invite page redirects to the channel and that all
modules load correctly on the welcome and channel page when authenticated as various users"""
def setUp(self):
super().setUp()
portal_user = mail_new_test_user(
self.env,
name='Portal Bowser',
login='portal_bowser',
email='portal_bowser@example.com',
groups='base.group_portal',
)
internal_user = mail_new_test_user(
self.env,
name='Internal Luigi',
login='internal_luigi',
email='internal_luigi@example.com',
groups='base.group_user',
)
guest = self.env['mail.guest'].create({'name': 'Guest Mario'})
self.channel = self.env['mail.channel'].browse(self.env['mail.channel'].channel_create(group_id=None, name='Test channel')['id'])
self.channel.add_members(portal_user.partner_id.ids)
self.channel.add_members(internal_user.partner_id.ids)
self.channel.add_members(guest_ids=[guest.id])
self.group = self.env['mail.channel'].browse(self.env['mail.channel'].create_group(partners_to=(internal_user + portal_user).partner_id.ids, name="Test group")['id'])
self.group.add_members(guest_ids=[guest.id])
self.tour = "mail/static/tests/tours/discuss_public_tour.js"
def _open_channel_page_as_user(self, login):
self.start_tour(self.channel.invitation_url, self.tour, login=login)
# Second run of the tour as the first call has side effects, like creating user settings or adding members to
# the channel, so we need to run it again to test different parts of the code.
self.start_tour(self.channel.invitation_url, self.tour, login=login)
def _open_group_page_as_user(self, login):
self.start_tour(self.group.invitation_url, self.tour, login=login)
# Second run of the tour as the first call has side effects, like creating user settings or adding members to
# the channel, so we need to run it again to test different parts of the code.
self.start_tour(self.group.invitation_url, self.tour, login=login)
def test_mail_channel_public_page_as_admin(self):
self._open_channel_page_as_user('admin')
def test_mail_group_public_page_as_admin(self):
self._open_group_page_as_user('admin')
def test_mail_channel_public_page_as_guest(self):
self.start_tour(self.channel.invitation_url, "mail/static/tests/tours/mail_channel_as_guest_tour.js")
guest = self.env['mail.guest'].search([('channel_ids', 'in', self.channel.id)], limit=1, order='id desc')
self.start_tour(self.channel.invitation_url, self.tour, cookies={guest._cookie_name: f"{guest.id}{guest._cookie_separator}{guest.access_token}"})
def test_mail_group_public_page_as_guest(self):
self.start_tour(self.group.invitation_url, "mail/static/tests/tours/mail_channel_as_guest_tour.js")
guest = self.env['mail.guest'].search([('channel_ids', 'in', self.channel.id)], limit=1, order='id desc')
self.start_tour(self.group.invitation_url, self.tour, cookies={guest._cookie_name: f"{guest.id}{guest._cookie_separator}{guest.access_token}"})
def test_mail_channel_public_page_as_internal(self):
self._open_channel_page_as_user('demo')
def test_mail_group_public_page_as_internal(self):
self._open_group_page_as_user('demo')
def test_mail_channel_public_page_as_portal(self):
self._open_channel_page_as_user('portal')
def test_mail_group_public_page_as_portal(self):
self._open_group_page_as_user('portal')
def test_chat_from_token_as_guest(self):
self.env['ir.config_parameter'].set_param('mail.chat_from_token', True)
self.url_open('/chat/xyz')
channel = self.env['mail.channel'].search([('uuid', '=', 'xyz')])
self.assertEqual(len(channel), 1)

View file

@ -1,278 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from functools import partial
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.mail.tests.common import MailCommon
from odoo.exceptions import AccessError, UserError
mail_channel_new_test_user = partial(mail_new_test_user, context={'mail_channel_nosubscribe': False})
class TestMailChannelMembers(MailCommon):
@classmethod
def setUpClass(cls):
super(TestMailChannelMembers, cls).setUpClass()
cls.secret_group = cls.env['res.groups'].create({
'name': 'Secret User Group',
})
cls.env['ir.model.data'].create({
'name': 'secret_group',
'module': 'mail',
'model': cls.secret_group._name,
'res_id': cls.secret_group.id,
})
cls.user_1 = mail_channel_new_test_user(
cls.env, login='user_1',
name='User 1',
groups='base.group_user,mail.secret_group')
cls.user_2 = mail_channel_new_test_user(
cls.env, login='user_2',
name='User 2',
groups='base.group_user,mail.secret_group')
cls.user_3 = mail_channel_new_test_user(
cls.env, login='user_3',
name='User 3',
groups='base.group_user,mail.secret_group')
cls.user_portal = mail_channel_new_test_user(
cls.env, login='user_portal',
name='User Portal',
groups='base.group_portal')
cls.user_public = mail_channel_new_test_user(
cls.env, login='user_ublic',
name='User Public',
groups='base.group_public')
cls.group = cls.env['mail.channel'].create({
'name': 'Group',
'channel_type': 'group',
})
cls.group_restricted_channel = cls.env['mail.channel'].create({
'name': 'Group restricted channel',
'channel_type': 'channel',
'group_public_id': cls.secret_group.id,
})
cls.public_channel = cls.env['mail.channel'].browse(cls.env['mail.channel'].channel_create(group_id=None, name='Public channel of user 1')['id'])
(cls.group | cls.group_restricted_channel | cls.public_channel).channel_member_ids.unlink()
# ------------------------------------------------------------
# GROUP
# ------------------------------------------------------------
def test_group_01(self):
"""Test access on group."""
res = self.env['mail.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertFalse(res)
# User 1 can join group with SUDO
self.group.with_user(self.user_1).sudo().add_members(self.user_1.partner_id.ids)
res = self.env['mail.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertEqual(res.partner_id, self.user_1.partner_id)
# User 2 can not join group
with self.assertRaises(AccessError):
self.group.with_user(self.user_2).add_members(self.user_2.partner_id.ids)
# User 2 can not create a `mail.channel.member` to join the group
with self.assertRaises(AccessError):
self.env['mail.channel.member'].with_user(self.user_2).create({
'partner_id': self.user_2.partner_id.id,
'channel_id': self.group.id,
})
# User 2 can not write on `mail.channel.member` to join the group
channel_member = self.env['mail.channel.member'].with_user(self.user_2).search([('partner_id', '=', self.user_2.partner_id.id)])[0]
with self.assertRaises(AccessError):
channel_member.channel_id = self.group.id
with self.assertRaises(AccessError):
channel_member.write({'channel_id': self.group.id})
# Even with SUDO, channel_id of channel.member should not be changed.
with self.assertRaises(AccessError):
channel_member.sudo().channel_id = self.group.id
# User 2 can not write on the `partner_id` of `mail.channel.member`
# of an other partner to join a group
channel_member_1 = self.env['mail.channel.member'].search([('channel_id', '=', self.group.id), ('partner_id', '=', self.user_1.partner_id.id)])
with self.assertRaises(AccessError):
channel_member_1.with_user(self.user_2).partner_id = self.user_2.partner_id
self.assertEqual(channel_member_1.partner_id, self.user_1.partner_id)
# Even with SUDO, partner_id of channel.member should not be changed.
with self.assertRaises(AccessError):
channel_member_1.with_user(self.user_2).sudo().partner_id = self.user_2.partner_id
def test_group_members(self):
"""Test invitation in group part 1 (invite using crud methods)."""
self.group.with_user(self.user_1).sudo().add_members(self.user_1.partner_id.ids)
channel_members = self.env['mail.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertEqual(len(channel_members), 1)
# User 2 is not in the group, they can not invite user 3
with self.assertRaises(AccessError):
self.env['mail.channel.member'].with_user(self.user_2).create({
'partner_id': self.user_portal.partner_id.id,
'channel_id': self.group.id,
})
# User 1 is in the group, they can invite other users
self.env['mail.channel.member'].with_user(self.user_1).create({
'partner_id': self.user_portal.partner_id.id,
'channel_id': self.group.id,
})
channel_members = self.env['mail.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id | self.user_portal.partner_id)
# But User 3 can not write on the `mail.channel.member` of other user
channel_member_1 = self.env['mail.channel.member'].search([('channel_id', '=', self.group.id), ('partner_id', '=', self.user_1.partner_id.id)])
channel_member_3 = self.env['mail.channel.member'].search([('channel_id', '=', self.group.id), ('partner_id', '=', self.user_portal.partner_id.id)])
channel_member_3.with_user(self.user_portal).custom_channel_name = 'Test'
with self.assertRaises(AccessError):
channel_member_1.with_user(self.user_2).custom_channel_name = 'Blabla'
self.assertNotEqual(channel_member_1.custom_channel_name, 'Blabla')
def test_group_invite(self):
"""Test invitation in group part 2 (use `invite` action)."""
self.group.with_user(self.user_1).sudo().add_members(self.user_1.partner_id.ids)
channel_members = self.env['mail.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id)
# User 2 is not in the group, they can not invite user_portal
with self.assertRaises(AccessError):
self.group.with_user(self.user_2).add_members(self.user_portal.partner_id.ids)
channel_members = self.env['mail.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id)
# User 1 is in the group, they can invite user_portal
self.group.with_user(self.user_1).add_members(self.user_portal.partner_id.ids)
channel_members = self.env['mail.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id | self.user_portal.partner_id)
def test_group_leave(self):
"""Test kick/leave channel."""
self.group.with_user(self.user_1).sudo().add_members(self.user_1.partner_id.ids)
self.group.with_user(self.user_portal).sudo().add_members(self.user_portal.partner_id.ids)
channel_members = self.env['mail.channel.member'].search([('channel_id', '=', self.group.id)])
self.assertEqual(len(channel_members), 2)
# User 2 is not in the group, they can not kick user 1
with self.assertRaises(AccessError):
channel_members.with_user(self.user_2).unlink()
# User 3 is in the group, they can kick user 1
channel_members.with_user(self.user_portal).unlink()
# ------------------------------------------------------------
# GROUP BASED CHANNELS
# ------------------------------------------------------------
def test_group_restricted_channel(self):
"""Test basics on group channel."""
channel_members = self.env['mail.channel.member'].search([('channel_id', '=', self.group_restricted_channel.id)])
self.assertFalse(channel_members)
# user 1 is in the channel, they can join the channel
self.group_restricted_channel.with_user(self.user_1).add_members(self.user_1.partner_id.ids)
channel_members = self.env['mail.channel.member'].search([('channel_id', '=', self.group_restricted_channel.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id)
# user 3 is not in the channel, they can not join
with self.assertRaises(AccessError):
self.group_restricted_channel.with_user(self.user_portal).add_members(self.user_portal.partner_id.ids)
channel_members = self.env['mail.channel.member'].search([('channel_id', '=', self.group_restricted_channel.id)])
with self.assertRaises(AccessError):
channel_members.with_user(self.user_portal).partner_id = self.user_portal.partner_id
channel_members = self.env['mail.channel.member'].search([('channel_id', '=', self.group_restricted_channel.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id)
# user 1 can not invite user 3 because they are not in the channel
with self.assertRaises(UserError):
self.group_restricted_channel.with_user(self.user_1).add_members(self.user_portal.partner_id.ids)
channel_members = self.env['mail.channel.member'].search([('channel_id', '=', self.group_restricted_channel.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id)
# but user 2 is in the channel and can be invited by user 1
self.group_restricted_channel.with_user(self.user_1).add_members(self.user_2.partner_id.ids)
channel_members = self.env['mail.channel.member'].search([('channel_id', '=', self.group_restricted_channel.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id | self.user_2.partner_id)
# ------------------------------------------------------------
# PUBLIC CHANNELS
# ------------------------------------------------------------
def test_public_channel(self):
""" Test access on public channels """
channel_members = self.env['mail.channel.member'].search([('channel_id', '=', self.public_channel.id)])
self.assertFalse(channel_members)
self.public_channel.with_user(self.user_1).add_members(self.user_1.partner_id.ids)
channel_members = self.env['mail.channel.member'].search([('channel_id', '=', self.public_channel.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id)
self.public_channel.with_user(self.user_2).add_members(self.user_2.partner_id.ids)
channel_members = self.env['mail.channel.member'].search([('channel_id', '=', self.public_channel.id)])
self.assertEqual(channel_members.mapped('partner_id'), self.user_1.partner_id | self.user_2.partner_id)
# portal/public users still cannot join a public channel, should go through dedicated controllers
with self.assertRaises(AccessError):
self.public_channel.with_user(self.user_portal).add_members(self.user_portal.partner_id.ids)
with self.assertRaises(AccessError):
self.public_channel.with_user(self.user_public).add_members(self.user_public.partner_id.ids)
def test_channel_member_invite_with_guest(self):
guest = self.env['mail.guest'].create({'name': 'Guest'})
partner = self.env['res.partner'].create({
'name': 'ToInvite',
'active': True,
'type': 'contact',
'user_ids': self.user_1,
})
self.public_channel.add_members(guest_ids=[guest.id])
search = self.env['res.partner'].search_for_channel_invite(partner.name, channel_id=self.public_channel.id)
self.assertEqual(len(search['partners']), 1)
self.assertEqual(search['partners'][0]['id'], partner.id)
# ------------------------------------------------------------
# UNREAD COUNTER TESTS
# ------------------------------------------------------------
def test_unread_counter_with_message_post(self):
channel_as_user_1 = self.env['mail.channel'].browse(self.env['mail.channel'].with_user(self.user_1).channel_create(group_id=None, name='Public channel')['id'])
channel_as_user_1.with_user(self.user_1).add_members(self.user_1.partner_id.ids)
channel_as_user_1.with_user(self.user_1).add_members(self.user_2.partner_id.ids)
channel_1_rel_user_2 = self.env['mail.channel.member'].search([
('channel_id', '=', channel_as_user_1.id),
('partner_id', '=', self.user_2.partner_id.id)
])
self.assertEqual(channel_1_rel_user_2.message_unread_counter, 0, "should not have unread message initially as notification type is ignored")
channel_as_user_1.message_post(body='Test', message_type='comment', subtype_xmlid='mail.mt_comment')
channel_1_rel_user_2 = self.env['mail.channel.member'].search([
('channel_id', '=', channel_as_user_1.id),
('partner_id', '=', self.user_2.partner_id.id)
])
self.assertEqual(channel_1_rel_user_2.message_unread_counter, 1, "should have 1 unread message after someone else posted a message")
def test_unread_counter_with_message_post_multi_channel(self):
channel_1_as_user_1 = self.env['mail.channel'].with_user(self.user_1).browse(self.env['mail.channel'].with_user(self.user_1).channel_create(group_id=None, name='wololo channel')['id'])
channel_2_as_user_2 = self.env['mail.channel'].with_user(self.user_2).browse(self.env['mail.channel'].with_user(self.user_2).channel_create(group_id=None, name='walala channel')['id'])
channel_1_as_user_1.add_members(self.user_2.partner_id.ids)
channel_2_as_user_2.add_members(self.user_1.partner_id.ids)
channel_2_as_user_2.add_members(self.user_3.partner_id.ids)
channel_1_as_user_1.message_post(body='Test', message_type='comment', subtype_xmlid='mail.mt_comment')
channel_1_as_user_1.message_post(body='Test 2', message_type='comment', subtype_xmlid='mail.mt_comment')
channel_2_as_user_2.message_post(body='Test', message_type='comment', subtype_xmlid='mail.mt_comment')
members = self.env['mail.channel.member'].search([('channel_id', 'in', (channel_1_as_user_1 + channel_2_as_user_2).ids)], order="id")
self.assertEqual(members.mapped('message_unread_counter'), [
0, # channel 1 user 1: posted last message
0, # channel 2 user 2: posted last message
2, # channel 1 user 2: received 2 messages (from message post)
1, # channel 2 user 1: received 1 message (from message post)
1, # channel 2 user 3: received 1 message (from message post)
])

View file

@ -1,9 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from odoo.addons.mail.tests.common import MailCommon
from odoo.exceptions import AccessError
from odoo.tests import Form, tagged, users
from odoo.tests import Form, HttpCase, tagged, users
from odoo.tools import mute_logger
@ -14,12 +16,11 @@ class TestMailComposer(MailCommon):
def setUpClass(cls):
super(TestMailComposer, cls).setUpClass()
cls.env['ir.config_parameter'].set_param('mail.restrict.template.rendering', True)
cls.user_employee.groups_id -= cls.env.ref('mail.group_mail_template_editor')
cls.user_employee.group_ids -= cls.env.ref('mail.group_mail_template_editor')
cls.test_record = cls.env['res.partner'].with_context(cls._test_context).create({
'name': 'Test',
})
cls.body_html = """<div>
<h1>Hello sir!</h1>
cls.body_html = """<h1>Hello sir!</h1>
<p>Here! <a href="https://www.example.com">
<!--[if mso]>
<i style="letter-spacing: 25px; mso-font-width: -100%; mso-text-raise: 30pt;">&nbsp;</i>
@ -28,19 +29,18 @@ class TestMailComposer(MailCommon):
<!--[if mso]>
<i style="letter-spacing: 25px; mso-font-width: -100%;">&nbsp;</i>
<![endif]-->
</a> Make good use of it.</p>
</div>"""
</a> Make good use of it.</p>"""
cls.mail_template = cls.env['mail.template'].create({
'auto_delete': True,
'body_html': cls.body_html,
'lang': '{{ object.lang }}',
'model_id': cls.env['ir.model']._get_id('res.partner'),
'subject': 'MSO FTW',
'name': 'Test template with mso conditionals',
'use_default_to': True,
'subject': 'MSO FTW',
})
@tagged('mail_composer')
class TestMailComposerForm(TestMailComposer):
""" Test mail composer form view usage. """
@ -48,23 +48,22 @@ class TestMailComposerForm(TestMailComposer):
@classmethod
def setUpClass(cls):
super(TestMailComposerForm, cls).setUpClass()
cls.user_employee.write({'groups_id': [
(4, cls.env.ref('base.group_private_addresses').id),
(4, cls.env.ref('base.group_partner_manager').id),
]})
cls.other_company = cls.env['res.company'].create({'name': 'Other Company'})
cls.user_employee.write({
'company_ids': [(4, cls.other_company.id)]
})
cls.partner_private, cls.partner_private_2, cls.partner_classic = cls.env['res.partner'].create([
{
'email': 'private.customer@text.example.com',
'phone': '0032455112233',
'name': 'Private Customer',
'type': 'private',
'company_id': cls.other_company.id,
},
{
'email': 'private.customer.2@test.example.com',
'phone': '0032455445566',
'name': 'Private Customer 2',
'type': 'private',
'company_id': cls.other_company.id,
},
{
'email': 'not.private@test.example.com',
@ -84,11 +83,11 @@ class TestMailComposerForm(TestMailComposer):
form = Form(self.env['mail.compose.message'].with_context({
'default_partner_ids': partner_classic.ids,
'default_model': test_record._name,
'default_res_id': test_record.id,
'default_res_ids': test_record.ids,
}))
form.body = '<p>Hello</p>'
self.assertEqual(
form.partner_ids._get_ids(), partner_classic.ids,
form.partner_ids.ids, partner_classic.ids,
'Default populates the field'
)
saved_form = form.save()
@ -103,7 +102,7 @@ class TestMailComposerForm(TestMailComposer):
message = self.test_record.message_ids[0]
self.assertEqual(message.body, '<p>Hello</p>')
self.assertEqual(message.partner_ids, partner_classic)
self.assertEqual(message.subject, f'Re: {test_record.name}')
self.assertEqual(message.subject, f'{test_record.name}')
@mute_logger('odoo.addons.mail.models.mail_mail')
@users('employee')
@ -116,11 +115,11 @@ class TestMailComposerForm(TestMailComposer):
form = Form(self.env['mail.compose.message'].with_context({
'default_partner_ids': (partner_private + partner_classic).ids,
'default_model': test_record._name,
'default_res_id': test_record.id,
'default_res_ids': test_record.ids,
}))
form.body = '<p>Hello</p>'
self.assertEqual(
sorted(form.partner_ids._get_ids()),
sorted(form.partner_ids.ids),
sorted((partner_private + partner_classic).ids),
'Default populates the field'
)
@ -136,15 +135,15 @@ class TestMailComposerForm(TestMailComposer):
message = self.test_record.message_ids[0]
self.assertEqual(message.body, '<p>Hello</p>')
self.assertEqual(message.partner_ids, partner_private + partner_classic)
self.assertEqual(message.subject, f'Re: {test_record.name}')
self.assertEqual(message.subject, f'{test_record.name}')
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.mail.models.mail_mail')
@users('employee')
def test_composer_default_recipients_private_norights(self):
""" Test usage of a private partner in composer when not having the
rights to see them, as default value """
self.user_employee.write({'groups_id': [
(3, self.env.ref('base.group_private_addresses').id),
self.user_employee.write({'company_ids': [
(3, self.other_company.id),
]})
with self.assertRaises(AccessError):
_name = self.partner_private.with_env(self.env).name
@ -156,18 +155,62 @@ class TestMailComposerForm(TestMailComposer):
_form = Form(self.env['mail.compose.message'].with_context({
'default_partner_ids': (self.partner_private + partner_classic).ids,
'default_model': test_record._name,
'default_res_id': test_record.id,
'default_res_ids': test_record.ids,
}))
@mute_logger('odoo.addons.mail.models.mail_mail')
@users('employee')
def test_composer_template_change_recipients_update(self):
"""Check that recipients only change when coming or going to a template with specific recipients."""
self.mail_template.write({
'email_to': self.partner_private.email_formatted,
'partner_to': False,
'use_default_to': False,
})
specific_recipient_template = self.mail_template
specific_recipient_template_copy = specific_recipient_template.copy(default={
'email_to': False,
'partner_to': f'{self.partner_private_2.id}',
})
default_recipient_template = specific_recipient_template.copy(default={
'email_to': False, 'partner_to': False, 'use_default_to': True,
})
default_recipient_template_copy = default_recipient_template.copy()
test_record = self.test_record.with_env(self.env)
default_recipient = test_record
self.assertTrue(default_recipient, self.env.registry['res.partner'])
cases = [
(self.partner_classic, False, specific_recipient_template, self.partner_private, 'No template -> Specific recipients'),
(self.partner_classic, specific_recipient_template, specific_recipient_template_copy, self.partner_private_2, 'Specific recipients -> Specific recipients'),
(self.partner_classic, specific_recipient_template_copy, default_recipient_template, self.partner_classic, 'Specific recipients -> Default recipients'),
(None, default_recipient_template, default_recipient_template_copy, default_recipient, 'Default recipients -> Default recipients'),
(self.partner_classic, default_recipient_template, default_recipient_template_copy, self.partner_classic, 'Default recipients -> Default recipients'),
(self.partner_classic, default_recipient_template_copy, specific_recipient_template, self.partner_private, 'Default recipients -> Specific recipients'),
(None, specific_recipient_template, False, False, 'Specific recipients -> No template'),
(None, default_recipient_template, False, False, 'Default recipients -> No template'),
]
for previous_partners, previous_template, new_template, expected_partners, case_name in cases:
with self.subTest(case=case_name):
composer_form = Form(self.env['mail.compose.message'].with_context({
'default_model': test_record._name,
'default_res_ids': test_record.ids,
'default_template_id': previous_template and previous_template.id,
} | ({'default_partner_ids': previous_partners.ids} if previous_partners is not None else {})
))
composer_form.template_id = new_template or self.env['mail.template']
self.assertEqual(sorted(composer_form.partner_ids.ids), sorted(expected_partners.ids if expected_partners else []))
@mute_logger('odoo.addons.mail.models.mail_mail')
@users('employee')
def test_composer_template_recipients_private(self):
""" Test usage of a private partner in composer, comint from template
""" Test usage of a private partner in composer, coming from template
value """
email_to_new = 'new.customer@test.example.com'
self.mail_template.write({
'email_to': f'{self.partner_private_2.email_formatted}, {email_to_new}',
'partner_to': f'{self.partner_private.id},{self.partner_classic.id}',
'use_default_to': False,
})
template = self.mail_template.with_env(self.env)
partner_private = self.partner_private.with_env(self.env)
@ -177,7 +220,7 @@ class TestMailComposerForm(TestMailComposer):
form = Form(self.env['mail.compose.message'].with_context({
'default_model': test_record._name,
'default_res_id': test_record.id,
'default_res_ids': test_record.ids,
'default_template_id': template.id,
}))
@ -192,15 +235,14 @@ class TestMailComposerForm(TestMailComposer):
self.assertEqual(new_partner.type, 'contact', 'Should create a new contact')
self.assertEqual(
sorted(form.partner_ids._get_ids()),
sorted(form.partner_ids.ids),
sorted((partner_private + partner_classic + partner_private_2 + new_partner).ids),
'Template populates the field with both email_to and partner_to'
)
saved_form = form.save()
self.assertEqual(
# saved_form.partner_ids, partner_private + partner_classic + partner_private_2 + new_partner,
saved_form.partner_ids, partner_classic + new_partner,
'Template value is kept at save (FIXME: loosing private partner)'
saved_form.partner_ids, partner_private + partner_classic + partner_private_2 + new_partner,
'Template value is kept at save'
)
with self.mock_mail_gateway():
@ -210,8 +252,8 @@ class TestMailComposerForm(TestMailComposer):
self.assertIn('<h1>Hello sir!</h1>', message.body)
# self.assertEqual(message.partner_ids, partner_private + partner_classic + partner_private_2 + new_partner)
self.assertEqual(
message.partner_ids, partner_classic + new_partner,
'FIXME: loosing private partner'
message.partner_ids, partner_private + partner_classic + partner_private_2 + new_partner,
'Should not loosing unreadable partners'
)
self.assertEqual(message.subject, 'MSO FTW')
@ -229,7 +271,7 @@ class TestMailComposerRendering(TestMailComposer):
'subject': 'MSO FTW',
})
values = mail_compose_message.get_mail_values(self.partner_employee.ids)
values = mail_compose_message._prepare_mail_values(self.partner_employee.ids)
self.assertIn(
self.body_html,
@ -254,10 +296,124 @@ class TestMailComposerRendering(TestMailComposer):
with self.mock_mail_gateway(mail_unlink_sent=True):
composer._action_send_mail()
values = composer.get_mail_values(self.partner_employee.ids)
values = composer._prepare_mail_values(self.partner_employee.ids)
self.assertIn(
self.body_html,
values[self.partner_employee.id]['body_html'],
'We must preserve (mso) comments in email html'
)
@tagged("mail_composer", "-at_install", "post_install")
class TestMailComposerUI(MailCommon, HttpCase):
def test_mail_composer_test_tour(self):
template_data = [
{
'name': 'Test template',
'partner_to': '{{ object.id }}',
},
{
'name': 'Test template for admin',
'user_id': self.env.ref('base.user_admin').id,
},
]
self.env['mail.template'].create([
{
**data,
'auto_delete': True,
'lang': '{{ object.lang }}',
'model_id': self.env['ir.model']._get_id('res.partner'),
}
for data in template_data
])
partner = self.env["res.partner"].create({"name": "Jane", "email": "jane@example.com"})
user_partner = self.env["res.partner"].create({"name": "Not A Demo User", "email": "NotADemoUser@mail.com"})
user = self.env["res.users"].create({
"name": "Not A Demo User",
"login": "nadu",
"partner_id": user_partner.id
})
partner.message_subscribe(partner_ids=[self.user_admin.partner_id.id])
with self.mock_mail_app():
self.start_tour(
f"/odoo/res.partner/{partner.id}",
"mail/static/tests/tours/mail_composer_test_tour.js",
login=self.user_employee.login
)
message_1, message_2, message_3 = self._new_msgs.filtered(lambda message: message.author_id == self.user_employee.partner_id)
self.assertIn(user.partner_id, message_1.partner_ids)
self.assertEqual(
sorted(message_1.attachment_ids.mapped('raw')),
sorted([b'hello, world', b'hi there']))
signature_pattern = r'<span data-o-mail-quote="1">--\nErnest</span>'
# For the first message, the user opened the full composer. Therefore,
# the signature should have been appended to the message body. As the user
# did not deleted it from the editor, the signature should still be
# present in the message body. The signature shouldn't be automatically
# added by the server as it has already been added by the full composer.
self.assertEqual(len(re.findall(signature_pattern, message_1.body)), 1)
self.assertFalse(message_1.email_add_signature)
# For the second message, the user opened the full composer. However, the
# user manually deleted the signature. As a result, the signature shouldn't
# be present in the message body. The signature shouldn't be automatically
# added by the server as it has already been added by the full composer.
self.assertEqual(len(re.findall(signature_pattern, message_2.body)), 0)
self.assertFalse(message_2.email_add_signature)
# For the third message, the user didn't open the full composer. Therefore,
# the signature shouldn't be added to the message body. However, the server
# should automatically add it to the message when sending it.
self.assertEqual(len(re.findall(signature_pattern, message_3.body)), 0)
self.assertTrue(message_3.email_add_signature)
def test_mail_html_composer_test_tour(self):
template_data = [
{
'name': 'Test template',
'partner_to': '{{ object.id }}',
},
{
'name': 'Test template for admin',
'user_id': self.env.ref('base.user_admin').id,
},
]
self.env['mail.template'].create([
{
**data,
'auto_delete': True,
'lang': '{{ object.lang }}',
'model_id': self.env['ir.model']._get_id('res.partner'),
}
for data in template_data
])
partner = self.env["res.partner"].create({"name": "Jane", "email": "jane@example.com"})
partner.message_subscribe(partner_ids=[self.user_admin.partner_id.id])
with self.mock_mail_app():
self.start_tour(
f"/odoo/res.partner/{partner.id}",
"mail/static/tests/tours/mail_html_composer_test_tour.js",
login=self.user_employee.login,
)
def test_send_attachment_without_body(self):
self.start_tour("/odoo/discuss", "create_thread_for_attachment_without_body",login="admin")
def test_mail_composer_autosave_tour(self):
partner = self.env["res.partner"].create(
{"name": "Jane", "email": "jane@example.com"})
with self.mock_mail_app():
self.start_tour(
f"/odoo/res.partner/{partner.id}",
"mail/static/tests/tours/mail_composer_autosave_tour.js",
login=self.user_employee.login
)
self.assertEqual(partner.function, "Director")

View file

@ -1,31 +0,0 @@
# -*- 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 tagged, HttpCase
from odoo import Command
@tagged('-at_install', 'post_install', 'mail_composer')
class TestMailFullComposer(MailCommon, HttpCase):
def test_full_composer_tour(self):
self.env['mail.template'].create({
'name': 'Test template',
'partner_to': '{{ object.id }}',
'lang': '{{ object.lang }}',
'auto_delete': True,
'model_id': self.ref('base.model_res_partner'),
})
user = self.env['res.users'].create({
'email': 'testuser@testuser.com',
'groups_id': [Command.set([self.ref('base.group_user'), self.ref('base.group_partner_manager')])],
'name': 'Test User',
'login': 'testuser',
'password': 'testuser',
})
partner = self.env["res.partner"].create({"name": "Jane", "email": "jane@example.com"})
with self.mock_mail_app():
self.start_tour(f"/web#id={partner.id}&model=res.partner", 'mail/static/tests/tours/mail_full_composer_test_tour.js', login='testuser')
message = self._new_msgs.filtered(lambda message: message.author_id == user.partner_id)
self.assertEqual(len(message), 1)

View file

@ -19,7 +19,7 @@ class MailCase(TransactionCase):
disconnected_smtpsession = mock.MagicMock()
disconnected_smtpsession.quit.side_effect = smtplib.SMTPServerDisconnected
mail = self.env["mail.mail"].create({})
with mock.patch("odoo.addons.base.models.ir_mail_server.IrMailServer.connect", return_value=disconnected_smtpsession):
with mock.patch("odoo.addons.base.models.ir_mail_server.IrMail_Server._connect__", return_value=disconnected_smtpsession):
with mock.patch("odoo.addons.mail.models.mail_mail._logger.info") as mock_logging_info:
mail.send()
disconnected_smtpsession.quit.assert_called_once()
@ -27,9 +27,4 @@ class MailCase(TransactionCase):
"Ignoring SMTPServerDisconnected while trying to quit non open session"
)
# if we get here SMTPServerDisconnected was not raised
self.assertEqual(mail.state, "exception")
self.assertEqual(
mail.failure_reason,
"Error without exception. Probably due to sending "
"an email without computed recipients."
)
self.assertEqual(mail.state, "outgoing")

View file

@ -1,16 +0,0 @@
from odoo.tests.common import TransactionCase
class TestMailMailStableSelection(TransactionCase):
"""Only relevant in stable as a hotfix. May be removed in master."""
def test_mail_mail_stable_selection(self):
# remove all selections
message_type_selections = self.env['ir.model.fields']._get('mail.message', 'message_type').selection_ids
message_type_selections.filtered(lambda s: s.value == 'auto_comment').unlink()
self.env['mail.mail']._fields_get_message_type_update_selection(self.env['mail.message']._fields['message_type'].selection)
# force convert to cache with specific language so it has to fetch related from DB
mail = self.env['mail.mail'].create({'subject': 'test', 'message_type': 'auto_comment'})
mail.invalidate_recordset(['message_type'])
self.assertEqual(mail.with_context(lang="en_US").message_type, 'auto_comment')

View file

@ -0,0 +1,64 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# from odoo import exceptions
from odoo.addons.mail.tests import common
from odoo.tests import new_test_user, tagged, users
@tagged("-at_install", "post_install", "mail_message")
class TestMailMessage(common.MailCommon):
@users("employee")
def test_can_star_message_without_write_access(self):
message = self.env["mail.message"].sudo().create({
"author_id": self.partner_admin.id,
"model": "res.partner",
"res_id": self.partner_admin.id,
"body": "Hey this is me!",
})
message = message.sudo(False)
self.env.user.group_ids -= self.env.ref("base.group_partner_manager")
self.assertFalse(message.has_access("write"))
message.toggle_message_starred()
self.assertIn(self.env.user.partner_id, message.starred_partner_ids)
self.env["mail.message"].unstar_all()
self.assertNotIn(self.env.user.partner_id, message.starred_partner_ids)
def test_mail_message_read_inexisting(self):
inexisting_message = self.env['mail.message'].with_user(self.user_employee).browse(-434264)
self.assertFalse(inexisting_message.exists())
self.assertTrue(inexisting_message.browse().has_access('read'), 'Should not crash (can read void)')
# TDE to check: cache pollution / inexisting not correctly tracked, ok-ish for stable
# with self.assertRaises(exceptions.AccessError):
# inexisting_message.check_access_rule('read')
def test_mail_message_read_access(self):
self.env['res.company'].invalidate_model(['name'])
message_c1 = self._add_messages(self.env.company, "Company Note 1", author=self.user_employee.partner_id)
message_c2 = self._add_messages(self.company_2, "Company Note 2", author=self.user_employee_c2.partner_id)
search_result = self.env["mail.message"].with_context(
allowed_company_ids=[self.env.company.id]
).with_user(self.user_employee).search([("model", "=", "res.company")])
self.assertIn(message_c1, search_result)
self.assertNotIn(message_c2, search_result)
def test_unlink_failure_message_notify_author(self):
recipient = new_test_user(self.env, login="Bob", email="invalid_email_addr")
with self.mock_mail_gateway():
message = self.env.user.partner_id.message_post(
body="Hello world!", partner_ids=recipient.partner_id.ids
)
self.assertEqual(message.notification_ids.failure_type, "mail_email_invalid")
self.assertEqual(message.notification_ids.res_partner_id, recipient.partner_id)
self.assertEqual(message.notification_ids.author_id, self.env.user.partner_id)
with self.assertBus(
[
(self.cr.dbname, "res.partner", recipient.partner_id.id),
(self.cr.dbname, "res.partner", self.env.user.partner_id.id),
],
[
{"type": "mail.message/delete", "payload": {"message_ids": [message.id]}},
{"type": "mail.message/delete", "payload": {"message_ids": [message.id]}},
],
):
message.unlink()

View file

@ -0,0 +1,142 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import requests
from http import HTTPStatus
from unittest.mock import patch
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
from odoo.tests.common import JsonRpcException, new_test_user, tagged
from odoo.tools import mute_logger
SAMPLE = {
"text": "<p>Al mal tiempo, buena cara.</p>",
"src": "es",
"en": "<p>To bad weather, good face.</p>",
"fr": "<p>Au mauvais temps, bonne tête.</p>",
"nl": "<script src='xss-min.js'/><p onclick='XSS()'>Bij slecht weer, goed gezicht.</p>",
"lang": {
"fr": "espagnol",
"en": "Spanish",
},
}
def mock_response(fun):
def wrapper(self, url, data=False, params=False, timeout=5):
response = requests.Response()
response.status_code = HTTPStatus.OK
content = {"data": fun(self, url=url, data=data, params=params)}
if not content["data"]:
response.status_code = HTTPStatus.BAD_REQUEST
content = {"error": {"message": "Mocked Error"}}
response._content = json.dumps(content).encode()
return response
return wrapper
# Google Cloud Translation Documentation: https://cloud.google.com/translate/docs/reference/api-overview?hl=en
@tagged("post_install", "-at_install", "mail_message")
class TestTranslationController(HttpCaseWithUserDemo):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env["res.lang"]._activate_lang("fr_FR")
cls.env["res.lang"]._activate_lang("en_US")
cls.env.ref("base.user_admin").write({"lang": "fr_FR"})
cls.api_key = "VALIDKEY"
cls.env["ir.config_parameter"].set_param("mail.google_translate_api_key", cls.api_key)
cls.message = cls.env["mail.message"].create(
{
"body": SAMPLE["text"],
"model": "res.partner",
"res_id": cls.env.ref("base.user_admin").partner_id.id,
"message_type": "comment",
}
)
cls.request_count = 0
@mock_response
def _patched_post(self, url, data, params, timeout=5):
self.request_count += 1
if f"/v2/detect?key={self.api_key}" in url:
result = {
"language": SAMPLE["src"],
"isReliable": True,
"confidence": 0.98,
}
return {"detections": [[result]]}
if f"/v2/?key={self.api_key}" in url:
return {"translations": [{"translatedText": SAMPLE[data.get("target")]}]}
def _mock_translation_request(self, data):
with patch.object(requests, "post", self._patched_post):
return self.make_jsonrpc_request("/mail/message/translate", data)
def test_update_message(self):
self.authenticate("admin", "admin")
result = self._mock_translation_request({"message_id": self.message.id})
self.assertFalse(result.get("error"))
self.assertEqual(self.env["mail.message.translation"].search_count([]), 1)
# The translation records should not be discarded if the body did not change.
self.make_jsonrpc_request(
"/mail/message/update_content",
{"message_id": self.message.id, "update_data": {"body": None, "attachment_ids": []}},
)
self.assertEqual(self.env["mail.message.translation"].search_count([]), 1)
self.make_jsonrpc_request(
"/mail/message/update_content",
{
"message_id": self.message.id,
"update_data": {"body": "update", "attachment_ids": []},
},
)
self.assertFalse(self.env["mail.message.translation"].search_count([]))
def test_translation_multi_users(self):
new_test_user(self.env, "user_test_fr", groups="base.group_user", lang="fr_FR")
new_test_user(self.env, "user_test_en", groups="base.group_user", lang="en_US")
for login, target_lang in [("user_test_fr", "fr"), ("user_test_en", "en"), ("admin", "fr")]:
self.authenticate(login, login)
result = self._mock_translation_request({"message_id": self.message.id})
self.assertFalse(result.get("error"))
self.assertEqual(result["body"], SAMPLE[target_lang])
self.assertEqual(result["lang_name"], SAMPLE["lang"][target_lang])
# There is one translation record per target language.
self.assertEqual(self.env["mail.message.translation"].search_count([]), 2)
# No API request should be sent if a translation value or source already exists.
self.assertEqual(self.request_count, 3)
def test_invalid_api_key(self):
self.env["ir.config_parameter"].set_param("mail.google_translate_api_key", "INVALIDKEY")
self.authenticate("demo", "demo")
result = self._mock_translation_request({"message_id": self.message.id})
self.assertNotIn("body", result)
self.assertNotIn("lang_name", result)
self.assertTrue(result["error"])
def test_html_sanitization(self):
self.env["res.lang"]._activate_lang("nl_NL")
new_test_user(self.env, "user_test_nl", groups="base.group_user", lang="nl_NL")
self.authenticate("user_test_nl", "user_test_nl")
result = self._mock_translation_request({"message_id": self.message.id})
self.assertFalse(result.get("error"))
self.assertHTMLEqual(result["body"], "<p>Bij slecht weer, goed gezicht.</p>")
translation = self.env["mail.message.translation"].search([])
self.assertEqual(len(translation), 1)
self.assertHTMLEqual(translation.body, "<p>Bij slecht weer, goed gezicht.</p>")
def test_access_right(self):
with self.assertRaises(JsonRpcException, msg="odoo.http.SessionExpiredException"):
self._mock_translation_request({"message_id": self.message.id})
new_test_user(self.env, "user_test_portal", groups="base.group_portal", lang="fr_FR")
self.authenticate("user_test_portal", "user_test_portal")
with self.assertRaises(JsonRpcException, msg="odoo.exceptions.AccessError"), mute_logger("odoo.http"):
self._mock_translation_request({"message_id": self.message.id})
def test_unknown_language(self):
self.authenticate("admin", "admin")
with patch.dict(SAMPLE, {"src": "unknown_by_babel_but_known_by_google_api"}):
result = self._mock_translation_request({"message_id": self.message.id})
self.assertEqual(result["body"], "<p>Au mauvais temps, bonne tête.</p>")

View file

@ -0,0 +1,26 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from freezegun import freeze_time
from odoo.tests import HttpCase, tagged, new_test_user
from ..models.mail_presence import PRESENCE_OUTDATED_TIMER
@tagged("-at_install", "post_install")
class TestMailPresence(HttpCase):
def test_bus_presence_auto_vacuum(self):
user = new_test_user(self.env, login="bob_user")
more_than_away_timer_ago = datetime.now() - timedelta(seconds=PRESENCE_OUTDATED_TIMER + 1)
more_than_away_timer_ago = more_than_away_timer_ago.replace(microsecond=0)
with freeze_time(more_than_away_timer_ago):
self.env["mail.presence"]._update_presence(user)
self.assertEqual(user.presence_ids.last_poll, more_than_away_timer_ago)
self.env["mail.presence"]._gc_bus_presence()
self.assertFalse(user.presence_ids)
def test_im_status_invalidation(self):
bob_user = new_test_user(self.env, login="bob_user")
self.assertEqual(bob_user.im_status, "offline")
self.env["mail.presence"]._update_presence(bob_user)
self.assertEqual(bob_user.im_status, "online")

View file

@ -4,9 +4,10 @@
from markupsafe import Markup
from unittest.mock import patch
from odoo import Command
from odoo.addons.mail.tests import common
from odoo.exceptions import AccessError
from odoo.tests import tagged, users
from odoo.tests import Form, tagged, users
class TestMailRenderCommon(common.MailCommon):
@ -41,6 +42,9 @@ class TestMailRenderCommon(common.MailCommon):
<h1>This is a test</h1>
""",
"""<b>Test</b>{{ '' if True else '<b>Code not executed</b>' }}""",
"""<b>Test</b> {{ '' ||| Bob }}""",
"""<b>Test</b> {{ '' ||| Bob }} |||""",
"""<b>Test</b> {{ '' ||| Bob }} ||| }}""",
]
cls.base_inline_template_bits_fr = [
'<p>Bonjour</p>',
@ -89,7 +93,15 @@ class TestMailRenderCommon(common.MailCommon):
'<p>Hello %s</p>' % cls.render_object.name,
"""<p>
<span>English Speaker</span>
</p>"""
</p>""",
"""
<p>26</p>
<h1>This is a test</h1>
""",
"""<b>Test</b>""",
"""<b>Test</b> Bob """,
"""<b>Test</b> Bob |||""",
"""<b>Test</b> Bob ||| }}"""
]
cls.base_rendered_fr = [
'<p>Bonjour</p>',
@ -98,6 +110,13 @@ class TestMailRenderCommon(common.MailCommon):
<span>Autre Narrateur</span>
</p>"""
]
cls.base_rendered_void = [
'<p>Hello</p>',
'<p>Hello </p>',
"""<p>
<span>English Speaker</span>
</p>"""
]
# link to mail template
cls.test_template = cls.env['mail.template'].create({
@ -105,7 +124,8 @@ class TestMailRenderCommon(common.MailCommon):
'subject': cls.base_inline_template_bits[0],
'body_html': cls.base_qweb_bits[1],
'model_id': cls.env['ir.model']._get('res.partner').id,
'lang': '{{ object.lang }}'
'lang': '{{ object.lang }}',
'use_default_to': True,
})
# some translations
@ -131,8 +151,8 @@ class TestMailRenderCommon(common.MailCommon):
notification_type='inbox',
signature='--\nErnest'
)
cls.user_rendering_restricted.groups_id -= cls.env.ref('mail.group_mail_template_editor')
cls.user_employee.groups_id += cls.env.ref('mail.group_mail_template_editor')
cls.env.ref('mail.group_mail_template_editor').write({'implied_by_ids': [Command.clear()]})
cls.user_employee.group_ids += cls.env.ref('mail.group_mail_template_editor')
@tagged('mail_render')
@ -172,7 +192,7 @@ class TestMailRender(TestMailRenderCommon):
preview = 'foo{{"false" if 1 > 2 else "true"}}bar'
result = self.env['mail.render.mixin']._prepend_preview(Markup(body), preview)
self.assertEqual(result, '''<div style="display:none;font-size:1px;height:0px;width:0px;opacity:0;">
foo<t t-out="&#34;false&#34; if 1 &gt; 2 else &#34;true&#34;"/>bar
foo<t t-out="&#34;false&#34; if 1 &gt; 2 else &#34;true&#34;"></t>bar
</div>body''')
@users('employee')
@ -200,6 +220,43 @@ class TestMailRender(TestMailRenderCommon):
)[partner.id]
self.assertEqual(rendered, expected)
@users('employee')
def test_render_field_no_records(self):
""" Test rendering on void IDs, or a list with dummy / falsy ID """
template = self.test_template.with_env(self.env)
partner = self.render_object.with_env(self.env)
for res_ids in ([], (), [False], [''], [None], [False, partner.id]): # various corner cases
for fname, expected_obj, expected_void in zip(['subject', 'body_html'], self.base_rendered, self.base_rendered_void):
with self.subTest():
rendered_all = template._render_field(
fname,
res_ids,
compute_lang=True
)
if res_ids:
self.assertTrue(res_ids[0] in rendered_all,
f'Rendering: key {repr(res_ids[0])} is considered as valid and should have an entry')
self.assertEqual(rendered_all[res_ids[0]], expected_void)
if len(res_ids) == 2: # second is partner
self.assertTrue(res_ids[1] in rendered_all)
self.assertEqual(rendered_all[res_ids[1]], expected_obj)
if not res_ids:
self.assertFalse(rendered_all,
'Rendering: void input -> void output')
@users('employee')
def test_render_field_not_existing(self):
""" Test trying to render a not-existing field: raise a proper ValueError
instead of crashing / raising a KeyError """
template = self.env['mail.template'].browse(self.test_template.ids)
partner = self.env['res.partner'].browse(self.render_object_fr.ids)
with self.assertRaises(ValueError):
_rendered = template._render_field(
'not_existing',
partner.ids,
compute_lang=True
)[partner.id]
@users('employee')
def test_render_template_inline_template(self):
partner = self.env['res.partner'].browse(self.render_object.ids)
@ -219,7 +276,7 @@ class TestMailRender(TestMailRenderCommon):
partner_ids = self.env['res.partner'].sudo().create([{
'name': f'test partner {n}'
} for n in range(20)]).ids
with patch('odoo.models.Model.get_base_url', new=_mock_get_base_url), self.assertQueryCount(7):
with patch('odoo.models.Model.get_base_url', new=_mock_get_base_url), self.assertQueryCount(13):
# make sure name isn't already in cache
self.env['res.partner'].browse(partner_ids).invalidate_recordset(['name', 'display_name'])
render_results = self.env['mail.render.mixin']._render_template(
@ -227,7 +284,7 @@ class TestMailRender(TestMailRenderCommon):
'res.partner',
partner_ids,
engine='inline_template',
post_process=True,
options={'post_process': True},
)
Partner = self.env['res.partner'].with_prefetch(partner_ids)
for partner_id, render_result in render_results.items():
@ -355,6 +412,107 @@ class TestMailRender(TestMailRenderCommon):
self.assertEqual(rendered, expected)
@tagged('mail_render', 'regex_render')
class TestRegexRendering(common.MailCommon):
def test_qweb_regex_rendering(self):
record = self.env['res.partner'].create({'name': 'Alice'})
def render(template):
return self.env['mail.render.mixin']._render_template_qweb(template, 'res.partner', record.ids)[record.id]
static_templates = (
('''<h1> Title </h1>''', '<h1> Title </h1>'),
('''<p t-out="object.name"/>''', '<p>Alice</p>'),
('''<p t-out="object.name"></p>''', '<p>Alice</p>'),
('''<P t-out="object.name" ></p >''', '<p>Alice</p>'),
('''<t t-out="object.name"/>''', 'Alice'),
('''<T t-out="object.name"/>''', 'Alice'),
('''<div><T t-out="object.name"/></div>''', '<div>Alice</div>'),
('''<h1 t-out="object.name"/>''', '<h1>Alice</h1>'),
('''<p t-out='object.name'/>''', '<p>Alice</p>'),
('''<p t-out="object.contact_name"/>''', '<p></p>'),
('''<p t-out="object.name">Default</p>''', '<p>Alice</p>'),
('''<p t-out='object.name'>Default</p>''', '<p>Alice</p>'),
('''<p t-out="object.contact_name">Default</p>''', '<p>Default</p>'),
('''<p t-out="object.name"/><p t-out="object.name">Default</p>''', '<p>Alice</p><p>Alice</p>'),
('''<p t-out="object.name"/><p t-out="object.contact_name">Default</p>''', '<p>Alice</p><p>Default</p>'),
('''<p
t-out="object.name"
/>''', '<p>Alice</p>'),
('''<p
t-out="object.contact_name"
>
Default
</p>''', '<p>Default</p>'),
('''<div><p t-out="object.name"/></div>''', '<div><p>Alice</p></div>'),
('''<div/aa t-out="object.name"></div/aa>''', '<div>Alice</div>'),
('''<div/aa='x' t-out="object.name"></div/aa='x'>''', '<div>Alice</div>'),
)
o_qweb_render = self.env['ir.qweb']._render
for template, expected in static_templates:
with (patch('odoo.addons.base.models.ir_qweb.IrQweb._render', side_effect=o_qweb_render) as qweb_render,
patch('odoo.addons.base.models.ir_qweb.unsafe_eval', side_effect=eval) as unsafe_eval):
self.assertEqual(render(template), expected)
self.assertFalse(qweb_render.called)
self.assertFalse(unsafe_eval.called)
with (patch('odoo.addons.base.models.ir_qweb.IrQweb._render', side_effect=o_qweb_render) as qweb_render,
patch('odoo.addons.base.models.ir_qweb.unsafe_eval', side_effect=eval) as unsafe_eval):
self.assertNotIn("<55", render('''<55 t-out="object.name"></55>'''))
self.assertFalse(qweb_render.called)
self.assertFalse(unsafe_eval.called)
# double check that we are able to catch the eval
non_static_templates = (
('''<p t-out=""/>''', '<p>()</p>'),
('''<p t-out="1+1"/>''', '<p>2</p>'),
('''<p t-out="env.context.get('test')"/>''', ''),
('''<p t-out="object.name" title="Test"/>''', '<p title="Test">Alice</p>'),
('''<p title="Test" t-out="object.name"/>''', '<p title="Test">Alice</p>'),
('''<p t-out="object.name"><img/></p>''', '<p>Alice</p>'),
('''<p t-out="object.parent_id.name"><img/></p>''', '<p><img/></p>'),
('''<p t-out="'<h1>test</h1>'"/>''', '<p>&lt;h1&gt;test&lt;/h1&gt;</p>'),
)
for template, expected in non_static_templates:
with (patch('odoo.addons.base.models.ir_qweb.IrQweb._render', side_effect=o_qweb_render) as qweb_render,
patch('odoo.addons.base.models.ir_qweb.unsafe_eval', side_effect=eval) as unsafe_eval):
rendered = render(template)
self.assertTrue(isinstance(rendered, Markup))
self.assertEqual(rendered, expected)
self.assertTrue(qweb_render.called)
self.assertTrue(unsafe_eval.called)
def test_inline_regex_rendering(self):
record = self.env['res.partner'].create({'name': 'Alice'})
def render(template):
return self.env['mail.render.mixin']._render_template_inline_template(template, 'res.partner', record.ids)[record.id]
static_templates = (
('''{{object.name}}''', 'Alice'),
('''{{object.contact_name}}''', ''),
('''{{object.name ||| Default}}''', 'Alice'),
('''{{object.contact_name ||| Default}}''', 'Default'),
)
for template, expected in static_templates:
with patch('odoo.tools.safe_eval.unsafe_eval', side_effect=eval) as unsafe_eval:
self.assertEqual(render(template), expected)
self.assertFalse(unsafe_eval.called)
self.assertFalse(self.env['mail.render.mixin']._has_unsafe_expression_template_inline_template(template, 'res.partner'))
non_static_templates = (
('''{{''}}''', ''),
('''{{1+1}}''', '2'),
('''{{object.env.context.get('test')}}''', ''),
)
for template, expected in non_static_templates:
with patch('odoo.tools.safe_eval.unsafe_eval', side_effect=eval) as unsafe_eval:
self.assertEqual(render(template), expected)
self.assertTrue(unsafe_eval.called)
self.assertTrue(self.env['mail.render.mixin']._has_unsafe_expression_template_inline_template(template, 'res.partner'))
@tagged('mail_render')
class TestMailRenderSecurity(TestMailRenderCommon):
""" Test security of rendering, based on qweb finding + restricted rendering
@ -415,6 +573,50 @@ class TestMailRenderSecurity(TestMailRenderCommon):
)[res_ids[0]]
self.assertIn('26', result, 'Template Editor should be able to render inline_template code')
@users('user_rendering_restricted')
def test_render_restricted_allow_template_defaults(self):
"""Check that default template values are implicitly allowed for the specific field they define."""
def patched_mail_template_default_values(model):
return {
'email_cc': '{{ object.user_ids and object.user_ids[0].email }}', # inline
'lang': '{{ object.user_ids and object.user_ids[0].lang }}', # inline
'body_html': '<p>Hi <t t-out="object.user_ids and object.user_ids[0].name"/></p>', # qweb
}
template_defaults = patched_mail_template_default_values(self.env['mail.template'])
partner_model_id = self.env['ir.model']._get_id('res.partner')
# check no default
template = Form(self.env['mail.template'].with_context({
'default_name': 'test_allow_template_defaults_nodefault_valid',
'default_model_id': partner_model_id,
}))
template = template.save()
self.assertFalse(template.lang)
self.assertFalse(template.email_cc)
self.assertFalse(template.body_html)
# sanity check, make sure the expressions are not allowed before the test (not in default allow list, etc...)
with self.assertRaises(AccessError, msg="Complex inline expression should fail if it is not the default."):
template.lang = template_defaults['lang']
with self.assertRaises(AccessError, msg="Complex qweb expression should fail if it is not the default."):
template.body_html = template_defaults['body_html']
with patch(
'odoo.addons.base.models.res_partner.ResPartner._mail_template_default_values',
new=patched_mail_template_default_values, create=True,
):
template = Form(self.env['mail.template'].with_context({
'default_name': 'test_allow_template_with_default',
'default_model_id': partner_model_id,
}))
template = template.save()
self.assertEqual(template.lang, template_defaults['lang'])
self.assertEqual(template.email_cc, template_defaults['email_cc'])
self.assertEqual(template.body_html, template_defaults['body_html'])
with self.assertRaises(AccessError, msg="Complex expressions should only be allowed if they are the default for that field."):
template.email_cc = template_defaults['lang']
@users('user_rendering_restricted')
def test_render_template_qweb_restricted(self):
model = 'res.partner'
@ -480,8 +682,8 @@ class TestMailRenderSecurity(TestMailRenderCommon):
def test_security_qweb_template_restricted(self):
"""Test if we correctly detect condition block (which might contains code)."""
res_ids = self.env['res.partner'].search([], limit=1).ids
with self.assertRaises(AccessError, msg='Simple user should not be able to render qweb code'):
self.env['mail.render.mixin']._render_template_qweb(self.base_qweb_bits[1], 'res.partner', res_ids)
with self.assertRaises(AccessError, msg='Simple user should not be able to render complex qweb code'):
self.env['mail.render.mixin']._render_template_qweb(self.base_qweb_bits[2], 'res.partner', res_ids)
@users('user_rendering_restricted')
def test_security_qweb_template_restricted_cached(self):
@ -489,13 +691,15 @@ class TestMailRenderSecurity(TestMailRenderCommon):
res_ids = self.env['res.partner'].search([], limit=1).ids
# Render with the admin first to fill the cache
self.env['mail.render.mixin'].with_user(self.user_admin)._render_template_qweb(
self.base_qweb_bits[1], 'res.partner', res_ids)
result = self.env['mail.render.mixin'].with_user(self.user_admin)._render_template_qweb(
self.base_qweb_bits[2], 'res.partner', res_ids)
self.assertEqual(result[res_ids[0]], "<p>\n <span>English Speaker</span>\n</p>")
# Check that it raise even when rendered previously by an admin
with self.assertRaises(AccessError, msg='Simple user should not be able to render qweb code'):
with self.assertRaises(AccessError, msg='Simple user should not be able to render complex qweb code'):
self.env['mail.render.mixin']._render_template_qweb(
self.base_qweb_bits[1], 'res.partner', res_ids)
self.base_qweb_bits[2], 'res.partner', res_ids)
@users('employee')
def test_security_qweb_template_unrestricted(self):

View file

@ -3,11 +3,10 @@
from markupsafe import Markup
from unittest.mock import patch
from odoo.addons.mail.tests.common import MailCommon
from odoo.exceptions import AccessError, UserError
from odoo.modules.module import get_module_resource
from odoo.tests import Form, tagged, users
from odoo.tools import convert_file
from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user
from odoo.exceptions import AccessError, ValidationError, UserError
from odoo.tests import Form, HttpCase, tagged, users
from odoo.tools import convert_file, mute_logger
@tagged('mail_template')
@ -18,7 +17,11 @@ class TestMailTemplate(MailCommon):
super(TestMailTemplate, cls).setUpClass()
# Enable the Jinja rendering restriction
cls.env['ir.config_parameter'].set_param('mail.restrict.template.rendering', True)
cls.user_employee.groups_id -= cls.env.ref('mail.group_mail_template_editor')
cls.user_employee.group_ids -= cls.env.ref('mail.group_mail_template_editor')
cls.test_partner = cls.env['res.partner'].create({
'email': 'test.rendering@test.example.com',
'name': 'Test Rendering',
})
cls.mail_template = cls.env['mail.template'].create({
'name': 'Test template',
@ -27,11 +30,67 @@ class TestMailTemplate(MailCommon):
'lang': '{{ object.lang }}',
'auto_delete': True,
'model_id': cls.env.ref('base.model_res_partner').id,
'use_default_to': False,
})
cls.user_employee_2 = mail_new_test_user(
cls.env,
company_id=cls.company_admin.id,
email='employee_2@test.com',
groups='base.group_user',
login='employee_2',
name='Albertine Another Employee',
)
@users('admin')
@mute_logger('odoo.addons.mail.models.mail_template')
@mute_logger('odoo.addons.mail.models.mail_render_mixin')
def test_invalid_template_on_save(self):
mail_template = self.env['mail.template'].create({
'name': 'Test template',
'model_id': self.env['ir.model']._get_id('res.users'),
'subject': 'Template {{ object.company_id.email }}',
'lang': '{{ object.partner_id.lang }}'
})
for fname in [
'body_html', 'email_cc', 'email_from', 'email_to',
'lang', 'partner_to', 'reply_to', 'scheduled_date',
'subject'
]:
with self.subTest(fname=fname):
if fname == 'body_html':
value_field = '<p>Hello <t t-out="object.unknown_field"/></p>'
value_fun = '<p>Hello <t t-out="object.is_portal_0()"/></p>'
else:
value_field = '{{ object.unknown_field }}'
value_fun = '{{ object.is_portal_0() }}'
# cannot update with a wrong field
with self.assertRaises(ValidationError):
mail_template.write({
fname: value_field,
})
with self.assertRaises(ValidationError):
mail_template.write({
fname: value_fun,
})
# Check templates having invalid object references can't be created
with self.assertRaises(ValidationError):
self.env['mail.template'].create({
'name': 'Test template',
'model_id': self.env['ir.model']._get('res.users').id,
fname: value_field,
})
# new model would crash at rendering
with self.assertRaises(ValidationError):
mail_template.write({
'model_id': self.env['ir.model']._get_id('res.partner'),
})
@users('employee')
def test_mail_compose_message_content_from_template(self):
form = Form(self.env['mail.compose.message'])
form = Form(self.env['mail.compose.message'].with_context(default_model='res.partner', active_ids=self.test_partner.ids))
form.template_id = self.mail_template
mail_compose_message = form.save()
@ -46,11 +105,31 @@ class TestMailTemplate(MailCommon):
'subject': '{{ 1 + 5 }}',
})
values = mail_compose_message.get_mail_values(self.partner_employee.ids)
values = mail_compose_message._prepare_mail_values(self.partner_employee.ids)
self.assertEqual(values[self.partner_employee.id]['subject'], '6', 'We must trust mail template values')
self.assertIn('13', values[self.partner_employee.id]['body_html'], 'We must trust mail template values')
@users('admin')
def test_mail_template_abstract_model(self):
"""Check abstract models cannot be set on templates."""
# create
with self.assertRaises(ValidationError):
self.env['mail.template'].create({
'name': 'Test abstract template',
'model_id': self.env['ir.model']._get('mail.thread').id, # abstract model
})
# write
template = self.env['mail.template'].create({
'name': 'Test abstract template',
'model_id': self.env['ir.model']._get('res.partner').id,
})
with self.assertRaises(ValidationError):
template.write({
'name': 'Test abstract template',
'model_id': self.env['ir.model']._get('mail.thread').id,
})
def test_mail_template_acl(self):
# Sanity check
self.assertTrue(self.user_admin.has_group('mail.group_mail_template_editor'))
@ -58,48 +137,198 @@ class TestMailTemplate(MailCommon):
self.assertFalse(self.user_employee.has_group('mail.group_mail_template_editor'))
self.assertFalse(self.user_employee.has_group('base.group_sanitize_override'))
model = self.env['ir.model']._get_id('res.users')
record = self.user_employee
# Group System can create / write / unlink mail template
mail_template = self.env['mail.template'].with_user(self.user_admin).create({'name': 'Test template'})
mail_template = self.env['mail.template'].with_user(self.user_admin).create({
'name': 'Test template',
'model_id': model,
})
self.assertEqual(mail_template.name, 'Test template')
mail_template.with_user(self.user_admin).name = 'New name'
self.assertEqual(mail_template.name, 'New name')
# Standard employee can create and edit non-dynamic templates
employee_template = self.env['mail.template'].with_user(self.user_employee).create({'body_html': '<p>foo</p>'})
employee_template = self.env['mail.template'].with_user(self.user_employee).create({'body_html': '<p>foo</p>', 'model_id': model})
employee_template.with_user(self.user_employee).body_html = '<p>bar</p>'
employee_template = self.env['mail.template'].with_user(self.user_employee).create({'email_to': 'foo@bar.com'})
employee_template = self.env['mail.template'].with_user(self.user_employee).create({
'email_to': 'foo@bar.com',
'model_id': model,
})
employee_template = employee_template.with_user(self.user_employee)
employee_template.with_user(self.user_employee).email_to = 'bar@foo.com'
employee_template.email_to = 'bar@foo.com'
# Standard employee cannot create and edit templates with dynamic qweb
# Standard employee cannot create and edit templates with forbidden expression
with self.assertRaises(AccessError):
self.env['mail.template'].with_user(self.user_employee).create({'body_html': '<p t-esc="\'foo\'"></p>'})
self.env['mail.template'].with_user(self.user_employee).create({'body_html': '''<p t-out="'foo'"></p>''', 'model_id': model})
# If no model is specify, he can not write allowed expression
with self.assertRaises(AccessError):
self.env['mail.template'].with_user(self.user_employee).create({'body_html': '''<p t-out="object.name"></p>'''})
# Standard employee cannot edit templates from another user, non-dynamic and dynamic
with self.assertRaises(AccessError):
mail_template.with_user(self.user_employee).body_html = '<p>foo</p>'
with self.assertRaises(AccessError):
mail_template.with_user(self.user_employee).body_html = '<p t-esc="\'foo\'"></p>'
mail_template.with_user(self.user_employee).body_html = '''<p t-out="'foo'"></p>'''
# Standard employee can edit his own templates if not dynamic
employee_template.with_user(self.user_employee).body_html = '<p>foo</p>'
employee_template.body_html = '<p>foo</p>'
# Standard employee cannot create and edit templates with dynamic inline fields
with self.assertRaises(AccessError):
self.env['mail.template'].with_user(self.user_employee).create({'email_to': '{{ object.partner_id.email }}'})
self.env['mail.template'].with_user(self.user_employee).create({'email_to': '{{ object.partner_id.email }}', 'model_id': model})
# Standard employee cannot edit his own templates if dynamic
with self.assertRaises(AccessError):
employee_template.with_user(self.user_employee).body_html = '<p t-esc="\'foo\'"></p>'
employee_template.body_html = '''<p t-out="'foo'"></p>'''
with self.assertRaises(AccessError):
employee_template.with_user(self.user_employee).email_to = '{{ object.partner_id.email }}'
forbidden_expressions = (
'object.partner_id.email',
'object.password',
"object.name or (1+1)",
'user.password',
'object.name or object.name',
'[a for a in (1,)]',
"object.name or f''",
"object.name or ''.format",
"object.name or f'{1+1}'",
"object.name or len('')",
"'abcd' or object.name",
"object.name and ''",
)
for expression in forbidden_expressions:
with self.assertRaises(AccessError):
employee_template.email_to = '{{ %s }}' % expression
with self.assertRaises(AccessError):
employee_template.email_to = '{{ %s ||| Bob}}' % expression
with self.assertRaises(AccessError):
employee_template.body_html = '<p t-out="%s"></p>' % expression
with self.assertRaises(AccessError):
employee_template.body_html = '<p t-esc="%s"></p>' % expression
# try to cheat with the context
with self.assertRaises(AccessError):
employee_template.with_context(raise_on_forbidden_code=False).email_to = '{{ %s }}' % expression
with self.assertRaises(AccessError):
employee_template.with_context(raise_on_forbidden_code=False).body_html = '<p t-esc="%s"></p>' % expression
# check that an admin can use the expression
mail_template.with_user(self.user_admin).email_to = '{{ %s }}' % expression
mail_template.with_user(self.user_admin).email_to = '{{ %s ||| Bob }}' % expression
mail_template.with_user(self.user_admin).body_html = '<p t-out="%s">Default</p>' % expression
mail_template.with_user(self.user_admin).body_html = '<p t-esc="%s">Default</p>' % expression
# hide qweb code in t-inner-content
code = '''<t t-inner-content="<p t-out='1+11'>Test</p>"></t>'''
body = self.env['mail.render.mixin']._render_template_qweb(code, 'res.partner', record.ids)[record.id]
self.assertNotIn('12', body)
code = '''<t t-inner-content="&lt;p t-out='1+11'&gt;Test&lt;/p&gt;"></t>'''
body = self.env['mail.render.mixin']._render_template_qweb(code, 'res.partner', record.ids)[record.id]
self.assertNotIn('12', body)
forbidden_qweb_expressions = (
'<p t-out="partner_id.name"></p>',
'<p t-esc="partner_id.name"></p>',
'<p t-debug=""></p>',
'<p t-set="x" t-value="object.name"></p>',
'<p t-set="x" t-value="object.name"></p>',
'<p t-groups="base.group_system"></p>',
'<t t-call="template"/>',
'<t t-set="namn" t-value="Hello {{world}} !"/>',
'<t t-att-test="object.name"/>',
'<p t-att-title="object.name"></p>',
# allowed expression with other attribute
'<p t-out="object.name" title="Test"></p>',
# allowed expression with child
'<p t-out="object.name"><img/></p>',
'<p t-out="object.password"></p>',
)
for expression in forbidden_qweb_expressions:
with self.assertRaises(AccessError):
employee_template.body_html = expression
self.assertTrue(self.env['mail.render.mixin']._has_unsafe_expression_template_qweb(expression, 'res.partner'))
# allowed expressions
allowed_qweb_expressions = (
'<p t-out="object.name"></p>',
'<p t-out="object.name"></p><img/>',
'<p t-out="object.name"></p><img title="Test"/>',
'<p t-out="object.name">Default</p>',
'<p t-out="object.partner_id.name">Default</p>',
)
o_qweb_render = self.env['ir.qweb']._render
for expression in allowed_qweb_expressions:
template = self.env['mail.template'].with_user(self.user_employee).create({
'body_html': expression,
'model_id': model,
})
self.assertFalse(self.env['mail.render.mixin']._has_unsafe_expression_template_qweb(expression, 'res.partner'))
with (patch('odoo.addons.base.models.ir_qweb.IrQweb._render', side_effect=o_qweb_render) as qweb_render,
patch('odoo.addons.base.models.ir_qweb.unsafe_eval', side_effect=eval) as unsafe_eval):
rendered = template._render_field('body_html', record.ids)[record.id]
self.assertNotIn('t-out', rendered)
self.assertFalse(qweb_render.called)
self.assertFalse(unsafe_eval.called)
# double check that we can detect the qweb rendering
mail_template.body_html = '<t t-out="1+1"/>'
with (patch('odoo.addons.base.models.ir_qweb.IrQweb._render', side_effect=o_qweb_render) as qweb_render,
patch('odoo.addons.base.models.ir_qweb.unsafe_eval', side_effect=eval) as unsafe_eval):
rendered = mail_template._render_field('body_html', record.ids)[record.id]
self.assertNotIn('t-out', rendered)
self.assertTrue(qweb_render.called)
self.assertTrue(unsafe_eval.called)
employee_template.email_to = 'Test {{ object.name }}'
with patch('odoo.tools.safe_eval.unsafe_eval', side_effect=eval) as unsafe_eval:
employee_template._render_field('email_to', record.ids)
self.assertFalse(unsafe_eval.called)
# double check that we can detect the eval call
mail_template.email_to = 'Test {{ 1+1 }}'
with patch('odoo.tools.safe_eval.unsafe_eval', side_effect=eval) as unsafe_eval:
mail_template._render_field('email_to', record.ids)
self.assertTrue(unsafe_eval.called)
# malformed HTML (html_normalize should prevent the regex rendering on the malformed HTML)
templates = (
# here sanitizer adds an 'equals void' after object.name as properties
# should have values
('''<p ou="<p t-out="object.name">"</p>''', '<p ou="&lt;p t-out=" object.name="">"</p>'),
('''<p title="'<p t-out='object.name'/>">''', '''<p title="'&lt;p t-out='object.name'/&gt;"></p>'''),
)
o_render = self.env['mail.render.mixin']._render_template_qweb_regex
for template, excepted in templates:
mail_template.body_html = template
with patch('odoo.addons.mail.models.mail_render_mixin.MailRenderMixin._render_template_qweb_regex', side_effect=o_render) as render:
rendered = mail_template._render_field('body_html', record.ids)[record.id]
self.assertEqual(rendered, excepted)
self.assertTrue(render.called)
record.name = '<b> test </b>'
mail_template.body_html = '<t t-out="object.name"/>'
with patch('odoo.addons.mail.models.mail_render_mixin.MailRenderMixin._render_template_qweb_regex', side_effect=o_render) as render:
rendered = mail_template._render_field('body_html', record.ids)[record.id]
self.assertEqual(rendered, "&lt;b&gt; test &lt;/b&gt;")
self.assertTrue(render.called)
# Check that the environment is the evaluation context
mail_template.with_user(self.user_admin).email_to = '{{ env.user.name }}'
rendered = mail_template._render_field('email_to', record.ids)[record.id]
self.assertIn(self.user_admin.name, rendered)
def test_mail_template_acl_translation(self):
''' Test that a user that doenn't have the group_mail_template_editor cannot create / edit
''' Test that a user that doesn't have the group_mail_template_editor cannot create / edit
translation with dynamic code if he cannot write dynamic code on the related record itself.
'''
@ -131,9 +360,108 @@ class TestMailTemplate(MailCommon):
# cannot write dynamic code on mail_template translation for employee without the group mail_template_editor.
with self.assertRaises(AccessError):
employee_template.with_context(lang='fr_FR').subject = '{{ object.foo }}'
employee_template.with_context(lang='fr_FR').subject = '{{ object.city }}'
employee_template.with_context(lang='fr_FR').sudo().subject = '{{ object.foo }}'
employee_template.with_context(lang='fr_FR').sudo().subject = '{{ object.city }}'
def test_mail_template_copy(self):
(self.user_employee + self.user_employee_2).write({
'group_ids': [(4, self.env.ref('mail.group_mail_template_editor').id)],
})
attachment_data_list = self._generate_attachments_data(4, self.mail_template._name, self.mail_template.id)
self.mail_template.write({
'attachment_ids': [
(0, 0, attachment_data)
for attachment_data in attachment_data_list[:2]
],
})
original_attachments = self.mail_template.attachment_ids
# users access template, can read attachments
for test_user in (self.user_employee, self.user_employee_2):
with self.subTest(user_name=test_user.name):
template = self.mail_template.with_user(test_user)
self.assertEqual(
set(template.attachment_ids.mapped('name')),
{'AttFileName_00.txt', 'AttFileName_01.txt'},
)
# other template for multi copy support
mail_template_2 = self.env['mail.template'].create({
'name': 'Test Template 2',
})
# employee make a private copy -> other template should still be readable
new_template, new_template_2 = (self.mail_template + mail_template_2).with_user(self.user_employee).copy()
new_template.user_id = self.user_employee
self.assertEqual(
set(new_template.attachment_ids.mapped('name')),
{'AttFileName_00.txt', 'AttFileName_01.txt'},
)
self.assertFalse(
new_template.attachment_ids & original_attachments,
'Template copy should copy attachments, not keep the same, to avoid ACLs / ownership issues',
)
self.assertFalse(new_template_2.attachment_ids, 'Should not take attachments from first template in multi copy')
self.assertEqual(new_template.name, f'{self.mail_template.name} (copy)', 'Default name should be the old one + copy')
self.assertEqual(new_template_2.name, f'{mail_template_2.name} (copy)', 'Default name should be the old one + copy')
# linked to their respective template
self.assertEqual(new_template.attachment_ids.mapped('res_id'), new_template.ids * 2)
self.assertEqual(original_attachments.mapped('res_id'), self.mail_template.ids * 2)
new_template_as2 = new_template.with_user(self.user_employee_2)
self.assertEqual(
set(new_template_as2.attachment_ids.mapped('name')),
{'AttFileName_00.txt', 'AttFileName_01.txt'},
)
# check default is correctly used instead of copy
newer_template, newer_template_2 = (self.mail_template + mail_template_2).with_user(self.user_employee).copy(default={
'attachment_ids': [
(0, 0, attachment_data_list[2]),
(0, 0, attachment_data_list[3]),
],
'name': 'My Copy',
})
self.assertEqual(
set(newer_template.attachment_ids.mapped('name')),
{'AttFileName_02.txt', 'AttFileName_03.txt'},
)
self.assertEqual(
set(newer_template_2.attachment_ids.mapped('name')),
{'AttFileName_02.txt', 'AttFileName_03.txt'},
)
self.assertFalse(
newer_template.attachment_ids & (original_attachments & new_template.attachment_ids),
'Template copy should copy attachments, not keep the same, to avoid ACLs / ownership issues',
)
self.assertFalse(
newer_template_2.attachment_ids & newer_template.attachment_ids,
'Template copy should copy attachments, not keep the same, to avoid ACLs / ownership issues',
)
self.assertEqual(newer_template.name, 'My Copy', 'Copy should respect given default')
self.assertEqual(newer_template_2.name, 'My Copy', 'Copy should respect given default')
# linked to their respective template
self.assertEqual(newer_template.attachment_ids.mapped('res_id'), newer_template.ids * 2)
self.assertEqual(newer_template_2.attachment_ids.mapped('res_id'), newer_template_2.ids * 2)
self.assertEqual(newer_template.attachment_ids.mapped('res_model'), [newer_template._name] * 2)
self.assertEqual(newer_template.attachment_ids.mapped('res_id'), newer_template.ids * 2)
self.assertEqual(newer_template.attachment_ids.mapped('res_model'), [newer_template._name] * 2)
self.assertEqual(original_attachments.mapped('res_id'), self.mail_template.ids * 2)
self.assertEqual(original_attachments.mapped('res_model'), [self.mail_template._name] * 2)
def test_mail_template_parse_partner_to(self):
for partner_to, expected in [
('1', [1]),
('1,2,3', [1, 2, 3]),
('1, 2, 3', [1, 2, 3]), # remove spaces
('[1, 2, 3]', [1, 2, 3]), # %r of a list
('(1, 2, 3)', [1, 2, 3]), # %r of a tuple
('1,[],2,"3"', [1, 2, 3]), # type tolerant
('(1, "wrong", 2, "partner_name", "3")', [1, 2, 3]), # fault tolerant
('res.partner(1, 2, 3)', [2]), # invalid input but avoid crash
]:
with self.subTest(partner_to=partner_to):
parsed = self.mail_template._parse_partner_to(partner_to)
self.assertListEqual(parsed, expected)
def test_server_archived_usage_protection(self):
""" Test the protection against using archived server (servers used cannot be archived) """
@ -154,13 +482,14 @@ class TestMailTemplate(MailCommon):
@tagged('mail_template')
class TestMailTemplateReset(MailCommon):
def _load(self, module, *args):
convert_file(self.cr, module='mail',
filename=get_module_resource(module, *args),
idref={}, mode='init', noupdate=False, kind='test')
def _load(self, module, filepath):
# pylint: disable=no-value-for-parameter
convert_file(self.env, module='mail',
filename=filepath,
idref={}, mode='init', noupdate=False)
def test_mail_template_reset(self):
self._load('mail', 'tests', 'test_mail_template.xml')
self._load('mail', 'tests/test_mail_template.xml')
mail_template = self.env.ref('mail.mail_template_test').with_context(lang=self.env.user.lang)
@ -192,9 +521,9 @@ class TestMailTemplateReset(MailCommon):
def test_mail_template_reset_translation(self):
""" Test if a translated value can be reset correctly when its translation exists/doesn't exist in the po file of the directory """
self._load('mail', 'tests', 'test_mail_template.xml')
self._load('mail', 'tests/test_mail_template.xml')
self.env['res.lang']._activate_lang('en_UK')
self.env['res.lang']._activate_lang('en_GB')
self.env['res.lang']._activate_lang('fr_FR')
mail_template = self.env.ref('mail.mail_template_test').with_context(lang='en_US')
mail_template.write({
@ -202,7 +531,7 @@ class TestMailTemplateReset(MailCommon):
'name': 'Mail: Mail Template',
})
mail_template.with_context(lang='en_UK').write({
mail_template.with_context(lang='en_GB').write({
'body_html': '<div>Hello UK</div>',
'name': 'Mail: Mail Template UK',
})
@ -226,24 +555,137 @@ class TestMailTemplateReset(MailCommon):
self.assertTrue(reset_action)
self.assertEqual(mail_template.body_html.strip(), Markup('<div>Hello Odoo</div>'))
self.assertEqual(mail_template.with_context(lang='en_UK').body_html.strip(), Markup('<div>Hello Odoo</div>'))
self.assertEqual(mail_template.with_context(lang='en_GB').body_html.strip(), Markup('<div>Hello Odoo</div>'))
self.assertEqual(mail_template.with_context(lang='fr_FR').body_html.strip(), Markup('<div>Hello Odoo FR</div>'))
self.assertEqual(mail_template.name, 'Mail: Test Mail Template')
self.assertEqual(mail_template.with_context(lang='en_UK').name, 'Mail: Test Mail Template')
self.assertEqual(mail_template.with_context(lang='en_GB').name, 'Mail: Test Mail Template')
self.assertEqual(mail_template.with_context(lang='fr_FR').name, 'Mail: Test Mail Template FR')
@tagged('-at_install', 'post_install')
class TestConfigRestrictEditor(MailCommon):
@tagged("mail_template", "-at_install", "post_install")
class TestMailTemplateUI(HttpCase):
def test_mail_template_dynamic_placeholder_tour(self):
# keep debug for technical fields visibility
self.start_tour('/odoo?debug=1', 'mail_template_dynamic_placeholder_tour', login='admin')
@tagged("mail_template", "-at_install", "post_install")
class TestTemplateConfigRestrictEditor(MailCommon):
def test_switch_icp_value(self):
# Sanity check
group = self.env.ref('mail.group_mail_template_editor')
self.assertTrue(self.user_employee.has_group('mail.group_mail_template_editor'))
self.assertFalse(self.user_employee.has_group('base.group_system'))
# Check that the group is on the user via the settings configuration and not that
# the right has been added specifically to this person.
self.assertIn(group, self.user_employee.all_group_ids)
self.assertNotIn(group, self.user_employee.group_ids)
self.env['ir.config_parameter'].set_param('mail.restrict.template.rendering', True)
self.assertFalse(self.user_employee.has_group('mail.group_mail_template_editor'))
self.env['ir.config_parameter'].set_param('mail.restrict.template.rendering', False)
self.assertTrue(self.user_employee.has_group('mail.group_mail_template_editor'))
@tagged("mail_template", "-at_install", "post_install")
class TestSearchTemplateCategory(MailCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
MailTemplate = cls.env['mail.template'].with_context(active_test=False)
ModelData = cls.env['ir.model.data']
cls.existing = MailTemplate.search([])
# Create templates
# 2 Hidden templates
cls.hidden_templates = MailTemplate.create([
{'name': 'Hidden Template 1', 'active': False},
{'name': 'Hidden Template 2', 'description': ''},
])
last = cls.hidden_templates[-1]
ModelData.create({
'name': f'mail_template_{last.id}',
'module': 'test_module',
'model': 'mail.template',
'res_id': last.id
})
# 5 Custom templates
cls.custom_templates = MailTemplate.create([
{'name': f'Custom Template {i + 1}', 'description': f'Desc {i + 1}'}
for i in range(4)
])
cls.custom_templates |= MailTemplate.create({'name': 'Custom Template empty', 'description': ''})
# 4 Base templates with XML ID
cls.base_templates = MailTemplate.create([
{'name': f'Base Template {i + 1}', 'description': f'Desc Base {i + 1}'}
for i in range(4)
])
for template in cls.base_templates:
ModelData.create({
'name': f'mail_template_{template.id}',
'module': 'test_module',
'model': 'mail.template',
'res_id': template.id
})
@users('employee')
def test_search_template_category(self):
MailTemplate = self.env['mail.template'].with_context(active_test=False)
# Search by hidden templates
hidden_domain = [('template_category', 'in', ['hidden_template'])]
hidden_templates = MailTemplate.search(hidden_domain) - self.existing
self.assertEqual(len(hidden_templates), len(self.hidden_templates), "Hidden templates count mismatch")
self.assertEqual(set(hidden_templates.mapped('template_category')), {'hidden_template'}, "Computed field doesn't match 'hidden_template'")
# Search by base templates
base_domain = [('template_category', 'in', ['base_template'])]
base_templates = MailTemplate.search(base_domain) - self.existing
self.assertEqual(len(base_templates), len(self.base_templates), "Base templates count mismatch")
self.assertEqual(set(base_templates.mapped('template_category')), {'base_template'}, "Computed field doesn't match 'base_template'")
# Search by custom templates
custom_domain = [('template_category', 'in', ['custom_template'])]
custom_templates = MailTemplate.search(custom_domain) - self.existing
self.assertEqual(len(custom_templates), len(self.custom_templates), "Custom templates count mismatch")
self.assertEqual(set(custom_templates.mapped('template_category')), {'custom_template'}, "Computed field doesn't match 'custom_template'")
# Combined search
combined_domain = [('template_category', 'in', ['hidden_template', 'base_template', 'custom_template'])]
combined_templates = MailTemplate.search(combined_domain) - self.existing
total_templates = len(self.hidden_templates) + len(self.base_templates) + len(self.custom_templates)
self.assertEqual(len(combined_templates), total_templates, "Combined templates count mismatch")
# Search with '=' operator
hidden_domain = [('template_category', '=', 'hidden_template')]
hidden_templates = MailTemplate.search(hidden_domain) - self.existing
self.assertEqual(len(hidden_templates), len(self.hidden_templates), "Hidden templates count mismatch")
# Search with '!=' operator
not_in_domain = [('template_category', '!=', 'hidden_template')]
not_in_templates = MailTemplate.search(not_in_domain) - self.existing
expected_templates = len(self.base_templates) + len(self.custom_templates)
self.assertEqual(len(not_in_templates), expected_templates, "Not in templates count mismatch")
# Search with 'not in' operator
not_in_domain = [('template_category', 'not in', ['hidden_template'])]
not_in_templates = MailTemplate.search(not_in_domain) - self.existing
expected_templates = len(self.base_templates) + len(self.custom_templates)
self.assertEqual(len(not_in_templates), expected_templates, "Not in templates count mismatch")
# Search with 'not in' operator
not_in_domain = [('template_category', 'not in', ['hidden_template', 'base_template'])]
not_in_templates = MailTemplate.search(not_in_domain) - self.existing
expected_templates = len(self.custom_templates)
self.assertEqual(len(not_in_templates), expected_templates, "Not in multi templates count mismatch")

View file

@ -10,6 +10,7 @@
<field name="model_id" ref="base.model_res_users"/>
<field name="email_from">"{{ object.company_id.name }}" &lt;{{ (object.company_id.email or user.email) }}&gt;</field>
<field name="email_to">{{ object.email_formatted }}</field>
<field name="use_default_to" eval="False"/>
<field name="body_html" type="html">
<div>Hello Odoo</div>
</field>

View file

@ -3,10 +3,9 @@
from odoo.addons.mail.tests.common import MailCommon
from odoo.tests import tagged, users
from odoo import tools
@tagged('mail_tools')
@tagged('mail_tools', 'res_partner')
class TestMailTools(MailCommon):
@classmethod
@ -17,37 +16,12 @@ class TestMailTools(MailCommon):
cls.test_partner = cls.env['res.partner'].create({
'country_id': cls.env.ref('base.be').id,
'email': cls._test_email,
'mobile': '0456001122',
'name': 'Alfred Astaire',
'phone': '0456334455',
})
cls.sources = [
# single email
'alfred.astaire@test.example.com',
' alfred.astaire@test.example.com ',
'Fredo The Great <alfred.astaire@test.example.com>',
'"Fredo The Great" <alfred.astaire@test.example.com>',
'Fredo "The Great" <alfred.astaire@test.example.com>',
# multiple emails
'alfred.astaire@test.example.com, evelyne.gargouillis@test.example.com',
'Fredo The Great <alfred.astaire@test.example.com>, Evelyne The Goat <evelyne.gargouillis@test.example.com>',
'"Fredo The Great" <alfred.astaire@test.example.com>, evelyne.gargouillis@test.example.com',
'"Fredo The Great" <alfred.astaire@test.example.com>, <evelyne.gargouillis@test.example.com>',
# text containing email
'Hello alfred.astaire@test.example.com how are you ?',
'<p>Hello alfred.astaire@test.example.com</p>',
# text containing emails
'Hello "Fredo" <alfred.astaire@test.example.com>, evelyne.gargouillis@test.example.com',
'Hello "Fredo" <alfred.astaire@test.example.com> and evelyne.gargouillis@test.example.com',
# falsy
'<p>Hello Fredo</p>',
'j\'adore écrire des @gmail.com ou "@gmail.com" a bit randomly',
'',
]
@users('employee')
def test_mail_find_partner_from_emails(self):
def test_find_partner_from_emails(self):
Partner = self.env['res.partner']
test_partner = Partner.browse(self.test_partner.ids)
self.assertEqual(test_partner.email, self._test_email)
@ -70,9 +44,6 @@ class TestMailTools(MailCommon):
# test with wildcard "_"
found = Partner._mail_find_partner_from_emails(['alfred_astaire@test.example.com'])
self.assertEqual(found, [self.env['res.partner']])
# sub-check: this search does not consider _ as a wildcard
found = Partner._mail_search_on_partner(['alfred_astaire@test.example.com'])
self.assertEqual(found, self.env['res.partner'])
# test partners with encapsulated emails
# ------------------------------------------------------------
@ -94,11 +65,43 @@ class TestMailTools(MailCommon):
# test with wildcard "_"
found = Partner._mail_find_partner_from_emails(['alfred_astaire@test.example.com'])
self.assertEqual(found, [self.env['res.partner']])
# sub-check: this search does not consider _ as a wildcard
found = Partner._mail_search_on_partner(['alfred_astaire@test.example.com'])
self.assertEqual(found, self.env['res.partner'])
@users('admin')
def test_mail_find_partner_from_emails_alias_localpart(self):
""" Test mail_find_partner_from_emails when dealing with aliases that
have alias_incoming_local enabled and emails include the local part. """
self.env['mail.alias'].create([{
'alias_name': 'test_localpart',
'alias_domain_id': self.env.company.alias_domain_id.id,
'alias_incoming_local': True,
'alias_model_id': self.env.ref('mail.model_res_partner').id,
}])
found = self.env['mail.thread']._partner_find_from_emails_single(['test_localpart@gmail.com'], no_create=False)
self.assertFalse(found, f'Found {found.email} / {found.name} instead of empty recordset')
# limit incoming-compat aliases to a fixed set of domains
self.env['ir.config_parameter'].set_param('mail.catchall.domain.allowed', "tartopoils.com, brutijus.com")
for test_email, email_normalized, done in [
('"Customer" <test_localpart@gmail.com>', 'test_localpart@gmail.com', True),
('"Customer" <test_localpart@tartopoils.com>', 'test_localpart@tartopoils.com', False),
('"Customer" <test_localpart@brutijus.com>', 'test_localpart@brutijus.com', False),
('"Customer" <test_localpart@brutijus.fr.com>', 'test_localpart@brutijus.fr.com', True),
]:
with self.subTest(check="Allowed domain support", test_email=test_email):
found = self.env['mail.thread']._partner_find_from_emails_single([test_email], no_create=False)
if not done:
self.assertFalse(found, f'Found {found.email} / {found.name} instead of empty recordset')
else:
self.assertTrue(found, 'Should have created a partner')
self.assertEqual(found.email_normalized, email_normalized)
self.assertEqual(found.name, 'Customer')
found = self.env['mail.thread']._partner_find_from_emails_single(['"Customer" <test_no_localpart@gmail.com>'], no_create=False)
self.assertTrue(found, 'Should have created a partner')
self.assertEqual(found.email_normalized, 'test_no_localpart@gmail.com')
self.assertEqual(found.name, 'Customer')
@users('employee')
def test_mail_find_partner_from_emails_followers(self):
""" Test '_mail_find_partner_from_emails' when dealing with records on
which followers have to be found based on email. Check multi email
@ -113,19 +116,19 @@ class TestMailTools(MailCommon):
test_partner = self.test_partner.with_env(self.env)
# standard test, no multi-email, to assert base behavior
sources = [(self._test_email, True), (self._test_email, False),]
expected = [follower_partner, test_partner]
for (source, follower_check), expected in zip(sources, expected):
cases = [(self._test_email, True), (self._test_email, False)]
for source, follower_check in cases:
expected_partner = follower_partner if follower_check else test_partner
with self.subTest(source=source, follower_check=follower_check):
partner = self.env['res.partner']._mail_find_partner_from_emails(
[source], records=linked_record if follower_check else None
)[0]
self.assertEqual(partner, expected)
self.assertEqual(partner, expected_partner)
# formatted email
encapsulated_test_email = f'"Robert Astaire" <{self._test_email}>'
(follower_partner + test_partner).sudo().write({'email': encapsulated_test_email})
sources = [
cases = [
(self._test_email, True), # normalized
(self._test_email, False), # normalized
(encapsulated_test_email, True), # encapsulated, same
@ -133,41 +136,34 @@ class TestMailTools(MailCommon):
(f'"AnotherName" <{self._test_email}', True), # same normalized, other name
(f'"AnotherName" <{self._test_email}', False), # same normalized, other name
]
expected = [follower_partner, test_partner,
follower_partner, test_partner,
follower_partner, test_partner,
follower_partner, test_partner]
for (source, follower_check), expected in zip(sources, expected):
for source, follower_check in cases:
expected_partner = follower_partner if follower_check else test_partner
with self.subTest(source=source, follower_check=follower_check):
partner = self.env['res.partner']._mail_find_partner_from_emails(
[source], records=linked_record if follower_check else None
)[0]
self.assertEqual(partner, expected,
self.assertEqual(partner, expected_partner,
'Mail: formatted email is recognized through usage of normalized email')
# multi-email
_test_email_2 = '"Robert Astaire" <not.alfredoastaire@test.example.com>'
(follower_partner + test_partner).sudo().write({'email': f'{self._test_email}, {_test_email_2}'})
sources = [
(self._test_email, True), # first email
(self._test_email, False), # first email
(_test_email_2, True), # second email
(_test_email_2, False), # second email
('not.alfredoastaire@test.example.com', True), # normalized second email in field
('not.alfredoastaire@test.example.com', False), # normalized second email in field
(f'{self._test_email}, {_test_email_2}', True), # multi-email, both matching, depends on comparison
(f'{self._test_email}, {_test_email_2}', False) # multi-email, both matching, depends on comparison
cases = [
(self._test_email, True, follower_partner), # first email
(self._test_email, False, test_partner), # first email
(_test_email_2, True, self.env['res.partner']), # second email
(_test_email_2, False, self.env['res.partner']), # second email
('not.alfredoastaire@test.example.com', True, self.env['res.partner']), # normalized second email in field
('not.alfredoastaire@test.example.com', False, self.env['res.partner']), # normalized second email in field
(f'{self._test_email}, {_test_email_2}', True, follower_partner), # multi-email, both matching, depends on comparison
(f'{self._test_email}, {_test_email_2}', False, test_partner), # multi-email, both matching, depends on comparison
]
expected = [follower_partner, test_partner,
self.env['res.partner'], self.env['res.partner'],
self.env['res.partner'], self.env['res.partner'],
follower_partner, test_partner]
for (source, follower_check), expected in zip(sources, expected):
for source, follower_check, expected_partner in cases:
with self.subTest(source=source, follower_check=follower_check):
partner = self.env['res.partner']._mail_find_partner_from_emails(
[source], records=linked_record if follower_check else None
)[0]
self.assertEqual(partner, expected,
self.assertEqual(partner, expected_partner,
'Mail (FIXME): partial recognition of multi email through email_normalize')
# test users with same email, priority given to current user
@ -180,13 +176,13 @@ class TestMailTools(MailCommon):
""" Test _mail_find_partner_from_emails when dealing with records in
a multicompany environment, returning a partner record with matching
company_id. """
self._activate_multi_company()
Partner = self.env['res.partner']
self.test_partner.company_id = self.company_2
self.test_partner.write({'name': 'Original - Company2'})
test_partner_no_company = self.test_partner.copy({'company_id': False})
test_partner_no_company = self.test_partner.copy({'name': 'NoCompany', 'company_id': False})
test_partner_company_2 = self.test_partner
test_partner_company_3 = test_partner_no_company.copy({'company_id': self.company_3.id})
test_partner_company_3 = test_partner_no_company.copy({'name': 'Company3', 'company_id': self.company_3.id})
records = [
None,
*Partner.create([
@ -201,91 +197,33 @@ class TestMailTools(MailCommon):
(test_partner_company_3, "Prefer same company as reference record."),
(test_partner_no_company, "Prefer non-specific partner for non-specific records."),
]
for record, (expected_partner, msg) in zip(records, expected_partners):
found = Partner._mail_find_partner_from_emails([self._test_email], records=record)
self.assertEqual(found, [expected_partner], msg)
for record, (expected, msg) in zip(records, expected_partners):
with self.subTest(record=record.name if record else 'NoRecord'):
found = Partner._mail_find_partner_from_emails([self._test_email], records=record)
self.assertEqual(found, [expected], f'Found {found[0].name} instead of {expected[0].name}: {msg}')
@users('employee')
def test_tools_email_re(self):
expected = [
# single email
['alfred.astaire@test.example.com'],
['alfred.astaire@test.example.com'],
['alfred.astaire@test.example.com'],
['alfred.astaire@test.example.com'],
['alfred.astaire@test.example.com'],
# multiple emails
['alfred.astaire@test.example.com', 'evelyne.gargouillis@test.example.com'],
['alfred.astaire@test.example.com', 'evelyne.gargouillis@test.example.com'],
['alfred.astaire@test.example.com', 'evelyne.gargouillis@test.example.com'],
['alfred.astaire@test.example.com', 'evelyne.gargouillis@test.example.com'],
# text containing email
['alfred.astaire@test.example.com'],
['alfred.astaire@test.example.com'],
# text containing emails
['alfred.astaire@test.example.com', 'evelyne.gargouillis@test.example.com'],
['alfred.astaire@test.example.com', 'evelyne.gargouillis@test.example.com'],
# falsy
[], [], [],
]
for src, exp in zip(self.sources, expected):
res = tools.email_re.findall(src)
self.assertEqual(
res, exp,
'Seems email_re is broken with %s (expected %r, received %r)' % (src, exp, res)
)
@tagged('mail_tools', 'mail_init')
class TestMailUtils(MailCommon):
@users('employee')
def test_tools_email_split_tuples(self):
expected = [
# single email
[('', 'alfred.astaire@test.example.com')],
[('', 'alfred.astaire@test.example.com')],
[('Fredo The Great', 'alfred.astaire@test.example.com')],
[('Fredo The Great', 'alfred.astaire@test.example.com')],
[('Fredo The Great', 'alfred.astaire@test.example.com')],
# multiple emails
[('', 'alfred.astaire@test.example.com'), ('', 'evelyne.gargouillis@test.example.com')],
[('Fredo The Great', 'alfred.astaire@test.example.com'), ('Evelyne The Goat', 'evelyne.gargouillis@test.example.com')],
[('Fredo The Great', 'alfred.astaire@test.example.com'), ('', 'evelyne.gargouillis@test.example.com')],
[('Fredo The Great', 'alfred.astaire@test.example.com'), ('', 'evelyne.gargouillis@test.example.com')],
# text containing email -> fallback on parsing to extract text from email
[('Hello', 'alfred.astaire@test.example.comhowareyou?')],
[('Hello', 'alfred.astaire@test.example.com')],
[('Hello Fredo', 'alfred.astaire@test.example.com'), ('', 'evelyne.gargouillis@test.example.com')],
[('Hello Fredo', 'alfred.astaire@test.example.com'), ('and', 'evelyne.gargouillis@test.example.com')],
# falsy -> probably not designed for that
[],
[('j\'adore écrire', "des@gmail.comou"), ('', '@gmail.com')], [],
]
def test_migrate_icp_to_domain(self):
""" Test ICP to alias domain migration """
self.env["ir.config_parameter"].set_param("mail.catchall.domain", "test.migration.com")
self.env["ir.config_parameter"].set_param("mail.bounce.alias", "migrate+bounce")
self.env["ir.config_parameter"].set_param("mail.catchall.alias", "migrate+catchall")
self.env["ir.config_parameter"].set_param("mail.default.from", "migrate+default_from")
for src, exp in zip(self.sources, expected):
res = tools.email_split_tuples(src)
self.assertEqual(
res, exp,
'Seems email_split_tuples is broken with %s (expected %r, received %r)' % (src, exp, res)
)
existing = self.env["mail.alias.domain"].search([('name', '=', 'test.migration.com')])
self.assertFalse(existing)
@users('employee')
def test_tools_single_email_re(self):
expected = [
# single email
['alfred.astaire@test.example.com'],
[], [], [], [], # formatting issue for single email re
# multiple emails -> couic
[], [], [], [],
# text containing email -> couic
[], [],
# text containing emails -> couic
[], [],
# falsy
[], [], [],
]
new = self.env["mail.alias.domain"]._migrate_icp_to_domain()
self.assertEqual(new.name, "test.migration.com")
self.assertEqual(new.bounce_alias, "migrate+bounce")
self.assertEqual(new.catchall_alias, "migrate+catchall")
self.assertEqual(new.default_from, "migrate+default_from")
for src, exp in zip(self.sources, expected):
res = tools.single_email_re.findall(src)
self.assertEqual(
res, exp,
'Seems single_email_re is broken with %s (expected %r, received %r)' % (src, exp, res)
)
again = self.env["mail.alias.domain"]._migrate_icp_to_domain()
self.assertEqual(again.name, "test.migration.com")
existing = self.env["mail.alias.domain"].search([('name', '=', 'test.migration.com')])
self.assertEqual(len(existing), 1, 'Should not migrate twice')

View file

@ -1,111 +1,197 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from contextlib import contextmanager
from markupsafe import Markup
from unittest.mock import patch
from uuid import uuid4
from odoo import tools
from odoo.addons.base.models.res_partner import ResPartner
from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user
from odoo.tests.common import Form, users
from odoo.tests import tagged
from odoo.tests import Form, tagged, users
from odoo.tools import mute_logger
# samples use effective TLDs from the Mozilla public suffix
# list at http://publicsuffix.org
SAMPLES = [
('"Raoul Grosbedon" <raoul@chirurgiens-dentistes.fr> ', 'Raoul Grosbedon', 'raoul@chirurgiens-dentistes.fr'),
('ryu+giga-Sushi@aizubange.fukushima.jp', '', 'ryu+giga-Sushi@aizubange.fukushima.jp'),
('Raoul chirurgiens-dentistes.fr', 'Raoul chirurgiens-dentistes.fr', ''),
(" Raoul O'hara <!@historicalsociety.museum>", "Raoul O'hara", '!@historicalsociety.museum'),
('Raoul Grosbedon <raoul@CHIRURGIENS-dentistes.fr> ', 'Raoul Grosbedon', 'raoul@CHIRURGIENS-dentistes.fr'),
('Raoul megaraoul@chirurgiens-dentistes.fr', 'Raoul', 'megaraoul@chirurgiens-dentistes.fr'),
('"Patrick Da Beast Poilvache" <PATRICK@example.com>', 'Patrick Poilvache', 'patrick@example.com'),
('Patrick Caché <patrick@EXAMPLE.COM>', 'Patrick Poilvache', 'patrick@example.com'),
('Patrick Caché <2patrick@EXAMPLE.COM>', 'Patrick Caché', '2patrick@example.com'),
]
@tagged('res_partner', 'mail_tools')
@tagged('res_partner', 'mail_tools', 'mail_thread_api')
class TestPartner(MailCommon):
def _check_find_or_create(self, test_string, expected_name, expected_email, expected_email_normalized=False, check_partner=False, should_create=False):
expected_email_normalized = expected_email_normalized or expected_email
partner = self.env['res.partner'].find_or_create(test_string)
if should_create and check_partner:
self.assertTrue(partner.id > check_partner.id, 'find_or_create failed - should have found existing')
elif check_partner:
self.assertEqual(partner, check_partner, 'find_or_create failed - should have found existing')
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.samples = [
('"Raoul Grosbedon" <raoul@chirurgiens-dentistes.fr> ', 'Raoul Grosbedon', 'raoul@chirurgiens-dentistes.fr'),
('ryu+giga-Sushi@aizubange.fukushima.jp', 'ryu+giga-sushi@aizubange.fukushima.jp', 'ryu+giga-sushi@aizubange.fukushima.jp'),
('Raoul chirurgiens-dentistes.fr', 'Raoul chirurgiens-dentistes.fr', ''),
(" Raoul O'hara <!@historicalsociety.museum>", "Raoul O'hara", '!@historicalsociety.museum'),
('Raoul Grosbedon <raoul@CHIRURGIENS-dentistes.fr> ', 'Raoul Grosbedon', 'raoul@chirurgiens-dentistes.fr'),
('Raoul megaraoul@chirurgiens-dentistes.fr', 'Raoul', 'megaraoul@chirurgiens-dentistes.fr'),
('"Patrick Da Beast Poilvache" <PATRICK@example.com>', 'Patrick Da Beast Poilvache', 'patrick@example.com'),
('Patrick Caché <patrick@EXAMPLE.COM>', 'Patrick Da Beast Poilvache', 'patrick@example.com'),
('Patrick Caché <patrick.2@EXAMPLE.COM>', 'Patrick Caché', 'patrick.2@example.com'),
# multi email
('"Multi Email" <multi.email@example.com>, multi.email.2@example.com', 'Multi Email', 'multi.email@example.com')
]
@contextmanager
def mockPartnerCalls(self):
_original_create = ResPartner.create
_original_search = ResPartner.search
self._new_partners = self.env['res.partner']
def _res_partner_create(model, *args, **kwargs):
records = _original_create(model, *args, **kwargs)
self._new_partners += records.sudo()
return records
with patch.object(ResPartner, 'create',
autospec=True, side_effect=_res_partner_create) as mock_partner_create, \
patch.object(ResPartner, 'search',
autospec=True, side_effect=_original_search) as mock_partner_search:
self._mock_partner_create = mock_partner_create
self._mock_partner_search = mock_partner_search
yield
def _check_find_or_create(self, test_string, expected_name, expected_email,
expected_email_normalized=False,
expected_partner=False):
expected_email_normalized = expected_email_normalized or tools.email_normalize(expected_email) or ''
with self.mockPartnerCalls():
partner = self.env['res.partner'].find_or_create(test_string)
if expected_partner:
self.assertEqual(
partner, expected_partner,
f'Should have found {expected_partner.name} ({expected_partner.id}), found {partner.name} ({partner.id}) instead')
self.assertFalse(self._new_partners)
else:
self.assertEqual(
partner, self._new_partners,
f'Should have created a partner, found {partner.name} ({partner.id}) instead'
)
self.assertEqual(partner.name, expected_name)
self.assertEqual(partner.email or '', expected_email)
self.assertEqual(partner.email_normalized or '', expected_email_normalized)
return partner
@users('admin')
def test_res_partner_find_or_create(self):
Partner = self.env['res.partner']
partner = Partner.browse(Partner.name_create(SAMPLES[0][0])[0])
self._check_find_or_create(
SAMPLES[0][0], SAMPLES[0][1], SAMPLES[0][2],
check_partner=partner, should_create=False
)
partner_2 = Partner.browse(Partner.name_create('sarah.john@connor.com')[0])
found_2 = self._check_find_or_create(
'john@connor.com', 'john@connor.com', 'john@connor.com',
check_partner=partner_2, should_create=True
)
new = self._check_find_or_create(
SAMPLES[1][0], SAMPLES[1][2].lower(), SAMPLES[1][2].lower(),
check_partner=found_2, should_create=True
)
new2 = self._check_find_or_create(
SAMPLES[2][0], SAMPLES[2][1], SAMPLES[2][2],
check_partner=new, should_create=True
)
self._check_find_or_create(
SAMPLES[3][0], SAMPLES[3][1], SAMPLES[3][2],
check_partner=new2, should_create=True
)
new4 = self._check_find_or_create(
SAMPLES[4][0], SAMPLES[0][1], SAMPLES[0][2],
check_partner=partner, should_create=False
)
self._check_find_or_create(
SAMPLES[5][0], SAMPLES[5][1], SAMPLES[5][2],
check_partner=new4, should_create=True
)
existing = Partner.create({
'name': SAMPLES[6][1],
'email': SAMPLES[6][0],
def test_address_tracking(self):
self.env.company.name = 'YourCompany'
company_partner = self.env.company.partner_id
# use some wacky formatting to check inlining
company_partner.country_id.address_format = """%(street)s
\n\n\n%(street2)s
%(city)s %(state_code)s %(zip)s
\n%(country_name)s\n
\n """
company_partner.write({
'city': 'Some City Name',
'street': 'Some Street Name',
'type': 'contact',
'zip': '94134',
'state_id': self.env.ref('base.state_us_5').id,
'country_id': self.env.ref('base.us').id,
})
self.assertEqual(existing.name, SAMPLES[6][1])
self.assertEqual(existing.email, SAMPLES[6][0])
self.assertEqual(existing.email_normalized, SAMPLES[6][2])
child_partner = self.env['res.partner'].create({
'name': 'Some Guy',
'parent_id': company_partner.id,
})
self.env.flush_all()
self.cr.precommit.run()
# keep track of setup messages
partners = (company_partner, child_partner)
partner_original_messages = (company_partner.message_ids, child_partner.message_ids)
new6 = self._check_find_or_create(
SAMPLES[7][0], SAMPLES[6][1], SAMPLES[6][0],
expected_email_normalized=SAMPLES[6][2],
check_partner=existing, should_create=False
company_partner.street = 'Some Other Street Name'
company_partner.city = 'Some Other City Name'
self.env.flush_all()
self.cr.precommit.run()
for partner, original_messages in zip(partners, partner_original_messages):
change_messages = partner.message_ids - original_messages
self.assertEqual(len(change_messages), 1)
tracking_values = change_messages.tracking_value_ids
self.assertIn(f'{self.env.company.name}, Some Street Name, Some City Name CA 94134, United States',
tracking_values.old_value_char)
self.assertIn(f'{self.env.company.name}, Some Other Street Name, Some Other City Name CA 94134, United States',
tracking_values.new_value_char)
# none of the address fields are logged at the same time
self.assertEqual(set(), set(partner._address_fields()) & set(tracking_values.sudo().field_id.mapped('name')))
def test_discuss_mention_suggestions_priority(self):
name = uuid4() # unique name to avoid conflict with already existing users
self.env['res.partner'].create([{'name': f'{name}-{i}-not-user'} for i in range(0, 2)])
for i in range(0, 2):
mail_new_test_user(self.env, login=f'{name}-{i}-portal-user', groups='base.group_portal')
mail_new_test_user(self.env, login=f'{name}-{i}-internal-user', groups='base.group_user')
# suggest portal user of this company in another company
suggested_partners = self.env["res.partner"].with_user(self.user_employee_c2).get_mention_suggestions("portal-user")
porter_user_suggested = [
p for p in suggested_partners['res.partner']
if p["name"] == f'{name}-1-portal-user (base.group_portal)'
]
self.assertEqual(len(porter_user_suggested), 1, "porter_user_suggested should contain one user")
store_data = self.env["res.partner"].get_mention_suggestions(name, limit=5)
partners_format = store_data["res.partner"]
self.assertEqual(len(partners_format), 5, "should have found limit (5) partners")
# return format for user is either a dict (there is a user and the dict is data) or a list of command (clear)
self.assertEqual(
[
next(
(
not u["share"]
for u in store_data["res.users"]
if u["id"] == p["main_user_id"]
),
False,
)
for p in partners_format
],
[True, True, False, False, False],
"should return internal users in priority",
)
self.assertEqual(
[bool(p["main_user_id"]) for p in partners_format],
[True, True, True, True, False],
"should return partners without users last",
)
self._check_find_or_create(
SAMPLES[8][0], SAMPLES[8][1], SAMPLES[8][2],
check_partner=new6, should_create=True
@users('admin')
def test_find_or_create(self):
""" Test 'find_or_create' method, calling name_create while parsing
input to find name and email. """
original_partner = self.env['res.partner'].browse(
self.env['res.partner'].name_create(self.samples[0][0])[0]
)
all_partners = []
for (text_input, expected_name, expected_email), expected_partner, find_idx in zip(
self.samples,
[original_partner, False, False, False, original_partner, False,
# patrick example
False, False, False,
# multi email
False],
[0, 0, 0, 0, 0, 0, 0, 6, 0, 0],
):
with self.subTest(text_input=text_input):
if not expected_partner and find_idx:
expected_partner = all_partners[find_idx]
all_partners.append(
self._check_find_or_create(
text_input, expected_name, expected_email,
expected_partner=expected_partner,
)
)
with self.assertRaises(ValueError):
self.env['res.partner'].find_or_create("Raoul chirurgiens-dentistes.fr", assert_valid_email=True)
@users('admin')
def test_res_partner_find_or_create_email(self):
def test_find_or_create_email_field(self):
""" Test 'find_or_create' tool used in mail, notably when linking emails
found in recipients to partners when sending emails using the mail
composer. """
composer. Test various combinations of problematic use cases like
formatting, multi-emails, ... """
partners = self.env['res.partner'].create([
{
'email': 'classic.format@test.example.com',
@ -156,7 +242,7 @@ class TestPartner(MailCommon):
self.assertEqual(partner.email, email_input)
partner.unlink() # do not mess with subsequent tests
# now input is multi email -> '_parse_partner_name' used in 'find_or_create'
# now input is multi email -> 'parse_contact_from_email' used in 'find_or_create'
# before trying to normalize is quite tolerant, allowing positive checks
for email_input, match_partner, exp_email_partner in [
('classic.format@test.example.com,another.email@test.example.com',
@ -177,19 +263,234 @@ class TestPartner(MailCommon):
if partner not in partners:
partner.unlink() # do not mess with subsequent tests
def test_res_partner_get_mention_suggestions_priority(self):
name = uuid4() # unique name to avoid conflict with already existing users
self.env['res.partner'].create([{'name': f'{name}-{i}-not-user'} for i in range(0, 2)])
for i in range(0, 2):
mail_new_test_user(self.env, login=f'{name}-{i}-portal-user', groups='base.group_portal')
mail_new_test_user(self.env, login=f'{name}-{i}-internal-user', groups='base.group_user')
partners_format = self.env['res.partner'].get_mention_suggestions(name, limit=5)
self.assertEqual(len(partners_format), 5, "should have found limit (5) partners")
# return format for user is either a dict (there is a user and the dict is data) or a list of command (clear)
self.assertEqual(list(map(lambda p: isinstance(p['user'], dict) and p['user']['isInternalUser'], partners_format)), [True, True, False, False, False], "should return internal users in priority")
self.assertEqual(list(map(lambda p: isinstance(p['user'], dict), partners_format)), [True, True, True, True, False], "should return partners without users last")
@users('employee_c2')
def test_find_or_create_from_emails(self):
""" Test for '_find_or_create_from_emails' allowing to find or create
partner based on emails in a batch-enabled and optimized fashion. """
with self.mockPartnerCalls():
partners = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails(
[item[0] for item in self.samples],
additional_values=None,
)
self.assertEqual(len(partners), len(self.samples))
self.assertEqual(len(self._new_partners), len(self.samples) - 2, 'Two duplicates in samples')
def test_res_partner_log_portal_group(self):
for (sample, exp_name, exp_email), partner in zip(self.samples, partners):
# specific to '_from_emails': name used as email is no email found
exp_email = exp_email or exp_name
with self.subTest(sample=sample):
self.assertFalse(partner.company_id)
self.assertEqual(partner.email, exp_email)
self.assertEqual(partner.email_normalized, tools.email_normalize(exp_email))
self.assertTrue(partner.id)
self.assertEqual(partner.lang, 'en_US')
self.assertEqual(partner.name, exp_name)
new_samples = self.samples + [
# new
('"New Customer" <new.customer@test.EXAMPLE.com>', 'New Customer', 'new.customer@test.example.com'),
# duplicate (see in sample)
('"Duplicated Raoul" <RAOUL@chirurgiens-dentistes.fr>', 'Raoul Grosbedon', 'raoul@chirurgiens-dentistes.fr'),
# new (even if invalid)
('Invalid', 'Invalid', ''),
# ignored, completely invalid
(False, False, False),
(None, False, False),
(' ', False, False),
('', False, False),
]
all_emails = [item[0] for item in new_samples]
with self.mockPartnerCalls():
partners = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails(
all_emails,
additional_values={
tools.email_normalize(email) or email: {
'company_id': self.env.company.id,
}
for email in all_emails if email and email.strip()
},
)
self.assertEqual(len(partners), len(new_samples))
self.assertEqual(len(self._new_partners), 2, 'Only 2 real new partners in new sample')
for (sample, exp_name, exp_email), partner in zip(new_samples, partners):
with self.subTest(sample=sample, exp_name=exp_name, exp_email=exp_email, partner=partner):
# specific to '_from_emails': name used as email is no email found
exp_email = exp_email or exp_name
exp_company = self.env.company if sample in [
'"New Customer" <new.customer@test.EXAMPLE.com>', # valid email, not known -> new customer
'Invalid' # invalid email, not known -> create a new partner
] else self.env['res.company']
if sample in [False, None, ' ', '']:
self.assertFalse(partner)
else:
exp_email_normalized = tools.email_normalize(exp_email)
self.assertEqual(partner.company_id, exp_company)
self.assertEqual(partner.email_normalized, exp_email_normalized)
self.assertEqual(partner.name, exp_name)
@users('employee_c2')
def test_find_or_create_from_emails_dupes_email_field(self):
""" Specific test for duplicates management: based on email to avoid
creating similar partners. """
# all same partner, same email 'test.customer@test.dupe.example.com'
email_dupes_samples = [
'"Formatted Customer" <test.customer@TEST.DUPE.EXAMPLE.COM>',
'test.customer@test.dupe.example.com',
'"Another Name" <test.customer@TEST.DUPE.EXAMPLE.COM>',
'"Mix of both" <test.customer@test.dupe.EXAMPLE.COM',
]
email_expected_name = "Formatted Customer" # first found email will setup partner info
email_expected_email = 'test.customer@test.dupe.example.com' # normalized version of given email
# all same partner, same invalid email 'test.customer.invalid.email'
name_dupes_samples = [
'test.customer.invalid.email',
'test.customer.invalid.email',
]
name_expected_name = 'test.customer.invalid.email' # invalid email kept as both name and email
name_expected_email = 'test.customer.invalid.email' # invalid email kept as both name and email
partners = self.env['res.partner']
for samples, (expected_name, expected_email) in [
(email_dupes_samples, (email_expected_name, email_expected_email)),
(name_dupes_samples, (name_expected_name, name_expected_email)),
]:
with self.subTest(samples=samples, expected_name=expected_name, expected_email=expected_email):
with self.mockPartnerCalls():
partner_list = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails(
samples,
additional_values=None,
)
# calls
self.assertEqual(self._mock_partner_create.call_count, 1)
self.assertEqual(self._mock_partner_search.call_count, 1)
self.assertEqual(len(self._new_partners), 1)
# results
self.assertEqual(len(partner_list), len(samples))
self.assertTrue(len(set(partner.id for partner in partner_list)) == 1 and partner_list[0].id, 'Should have a unique new partner')
for partner in partner_list:
self.assertEqual(partner.email, expected_email)
self.assertEqual(partner.name, expected_name)
partners += partner_list[0]
self.assertEqual(len(partners), 2,
'Should have created one partner for valid email, one for invalid email')
new_samples = [
'"Another Customer" <test.different.1@TEST.DUPE.EXAMPLE.COM', # actually a new valid email
'"First Duplicate" <test.customer@TEST.DUPE.example.com', # duplicated of valid email created above
'test.customer.invalid.email', # duplicate of an invalid email created above
# multi email
'"Multi Email Another" <TEST.different.1@test.dupe.example.com>, other.customer@other.example.com',
'"Multi Email" <other.customer.2@test.dupe.example.com>, test.different.1@test.dupe.example.com',
'Invalid, Multi Format other.customer.😊@test.dupe.example.com, "A Name" <yt.another.customer@new.example.com>',
'"Unicode Formatted" <other.customer.😊@test.dupe.example.com>', # duplicate of above
]
expected = [
(False, "Another Customer", "test.different.1@test.dupe.example.com"),
(partners[0], "Formatted Customer", "test.customer@test.dupe.example.com"),
(partners[1], "test.customer.invalid.email", "test.customer.invalid.email"),
# multi email support
(False, "Another Customer", "test.different.1@test.dupe.example.com"),
(False, "Multi Email", "other.customer.2@test.dupe.example.com"),
(False, "Multi Format", "other.customer.😊@test.dupe.example.com"),
(False, "Multi Format", "other.customer.😊@test.dupe.example.com"),
]
with self.mockPartnerCalls():
new_partners = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails(
new_samples,
additional_values=None,
)
# calls
self.assertEqual(self._mock_partner_create.call_count, 1)
self.assertEqual(self._mock_partner_search.call_count, 1,
'Search once, even with both normalized and invalid emails')
self.assertEqual(len(self._new_partners), 3)
self.assertEqual(
sorted(self._new_partners.mapped('email')),
sorted(['other.customer.2@test.dupe.example.com',
'other.customer.😊@test.dupe.example.com',
'test.different.1@test.dupe.example.com']))
# results
self.assertEqual(len(new_partners), len(new_samples))
for partner, (expected_partner, expected_name, expected_email) in zip(new_partners, expected):
with self.subTest(partner=partner, expected_name=expected_name):
if expected_partner:
self.assertEqual(partner, expected_partner)
else:
self.assertIn(partner, self._new_partners)
self.assertEqual(partner.email, expected_email)
self.assertEqual(partner.name, expected_name)
no_new_samples = [
# only duplicates
'"Another Duplicate" <test.different.1@TEST.DUPE.EXAMPLE.COM',
'"First Duplicate2" <test.customer@TEST.DUPE.example.com',
# falsy values
'"Falsy" <falsy>',
'falsy',
' ',
]
expected = [
(new_partners[0], "Another Customer", "test.different.1@test.dupe.example.com"),
(partners[0], "Formatted Customer", "test.customer@test.dupe.example.com"),
(False, '"Falsy" <falsy>', '"Falsy" <falsy>'),
(False, "falsy", "falsy"),
(False, False, False),
]
with self.mockPartnerCalls():
no_new_partners = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails(
no_new_samples,
additional_values=None,
)
# calls
self.assertEqual(self._mock_partner_create.call_count, 1)
self.assertEqual(self._mock_partner_search.call_count, 1)
self.assertEqual(len(self._new_partners), 2)
self.assertEqual(sorted(self._new_partners.mapped('email')), ['"Falsy" <falsy>', "falsy"])
for partner, (expected_partner, expected_name, expected_email) in zip(no_new_partners, expected):
with self.subTest(partner=partner, expected_name=expected_name):
if expected_partner:
self.assertEqual(partner, expected_partner)
elif not expected_name and not expected_email:
self.assertEqual(partner, self.env['res.partner'])
else:
self.assertIn(partner, self._new_partners)
self.assertEqual(partner.email, expected_email)
self.assertEqual(partner.name, expected_name)
def test_message_get_default_recipients(self):
""" Specific use case: partner should contact himself """
partners = self.env['res.partner'].create([
{'name': 'Raoulette', 'email': '"Raoulette" <raoulette@example.com>'},
{'name': 'Ignassette', 'email': 'wrong'}
])
defaults = partners._message_get_default_recipients()
for partner in partners:
with self.subTest(partner=partner.name):
self.assertEqual(defaults[partner.id], {
'email_cc': '', 'email_to': '',
'partner_ids': partner.ids,
})
def test_message_get_suggested_recipients(self):
""" Specific use case: partner should contact himself """
partners = self.env['res.partner'].create([
{'name': 'Raoulette', 'email': '"Raoulette" <raoulette@example.com>'},
{'name': 'Ignassette', 'email': 'wrong'}
])
for partner in partners:
with self.subTest(partner_name=partner.name):
suggested = partner._message_get_suggested_recipients()
self.assertEqual(suggested, [{
'create_values': {},
'email': partner.email_normalized,
'name': partner.name,
'partner_id': partner.id,
}])
def test_log_portal_group(self):
Users = self.env['res.users']
subtype_note = self.env.ref('mail.mt_note')
group_portal, group_user = self.env.ref('base.group_portal'), self.env.ref('base.group_user')
@ -205,7 +506,7 @@ class TestPartner(MailCommon):
self.assertNotIn('Portal Access Granted', new_msg.body)
self.assertIn('Contact created', new_msg.body)
new_user.write({'groups_id': [(4, group_portal.id), (3, group_user.id)]})
new_user.write({'group_ids': [(4, group_portal.id), (3, group_user.id)]})
new_msg = new_user.message_ids[0]
self.assertIn('Portal Access Granted', new_msg.body)
self.assertEqual(new_msg.subtype_id, subtype_note)
@ -213,7 +514,7 @@ class TestPartner(MailCommon):
# check at create
new_user = Users.create({
'email': 'micheline.2@test.example.com',
'groups_id': [(4, group_portal.id)],
'group_ids': [(4, group_portal.id)],
'login': 'michmich.2',
'name': 'Micheline Portal',
})
@ -223,7 +524,41 @@ class TestPartner(MailCommon):
self.assertEqual(new_msg.subtype_id, subtype_note)
@users('admin')
def test_res_partner_merge_wizards(self):
def test_name_create_corner_cases(self):
""" Test parsing (and fallback) or name given to name_create that should
try to correctly find name and email, even with malformed input. Relies
on 'parse_contact_from_email' and 'email_normalize'. """
samples = [
'Raoul raoul@grosbedon.fr',
'Raoul chirurgiens-dentistes.fr',
'invalid',
'False',
# (simili) void values
'', ' ', False, None,
# email only
'lenny.bar@gmail.com',
]
expected = [
('Raoul', 'raoul@grosbedon.fr'),
('Raoul chirurgiens-dentistes.fr', False),
('invalid', False),
('False', False),
# (simili) void values: always False
('', False), ('', False), ('', False), ('', False),
# email only: email used as both name and email
('lenny.bar@gmail.com', 'lenny.bar@gmail.com')
]
for (expected_name, expected_email), sample in zip(expected, samples):
with self.subTest(sample=sample):
partner = self.env['res.partner'].browse(
self.env['res.partner'].name_create(sample)[0]
)
self.assertEqual(partner.name, expected_name)
self.assertEqual(partner.email, expected_email)
@users('admin')
@mute_logger('odoo.addons.base.partner.merge', 'odoo.tests')
def test_partner_merge_wizards(self):
Partner = self.env['res.partner']
p1 = Partner.create({'name': 'Customer1', 'email': 'test1@test.example.com'})
@ -234,10 +569,10 @@ class TestPartner(MailCommon):
# add some mail related documents
p1.message_subscribe(partner_ids=p3.ids)
p1_act1 = p1.activity_schedule(act_type_xmlid='mail.mail_activity_data_todo')
p1_act1 = p1.activity_schedule(act_type_xmlid='mail.mail_activity_data_todo', user_id=self.user_admin.id)
p1_msg1 = p1.message_post(
body='<p>Log on P1</p>',
subtype_id=self.env.ref('mail.mt_comment').id
body=Markup('<p>Log on P1</p>'),
subtype_id=self.env.ref('mail.mt_comment').id,
)
self.assertEqual(p1.activity_ids, p1_act1)
self.assertEqual(p1.message_follower_ids.partner_id, self.partner_admin + p3)

View file

@ -0,0 +1,60 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user
from odoo.tests.common import HttpCase, tagged
@tagged("-at_install", "post_install")
class TestResRole(MailCommon, HttpCase):
def test_post_mention_role(self):
"""Test mention with role"""
contact = self.env["res.partner"].create({"name": "A contact"})
role_discuss = self.env["res.role"].create({"name": "rd-Discuss"})
role_js = self.env["res.role"].create({"name": "rd-JS"})
user_discuss = mail_new_test_user(
self.env,
login="user_d",
name="Discuss User",
notification_type="inbox",
role_ids=[Command.link(role_discuss.id)],
)
user_js = mail_new_test_user(
self.env,
login="user_js",
name="JS User",
notification_type="inbox",
role_ids=[Command.link(role_js.id)],
)
user_discuss_js = mail_new_test_user(
self.env,
login="user_djs",
name="Discuss JS User",
notification_type="inbox",
role_ids=[Command.link(role_discuss.id), Command.link(role_js.id)],
)
self.authenticate("employee", "employee")
for [roles, expected_users] in [
(self.env["res.role"], self.env["res.users"]),
(role_discuss, user_discuss + user_discuss_js),
(role_js, user_js + user_discuss_js),
(role_discuss + role_js, user_discuss + user_js + user_discuss_js),
]:
data = self.make_jsonrpc_request(
"/mail/message/post",
{
"thread_model": "res.partner",
"thread_id": contact.id,
"post_data": {
"body": "irrelevant",
"message_type": "comment",
"role_ids": roles.ids,
"subtype_xmlid": "mail.mt_note",
},
},
)
message = next(filter(lambda m: m["id"] == data["message_id"], data["store_data"]["mail.message"]))
self.assertEqual(
message["partner_ids"],
expected_users.partner_id.ids
)

View file

@ -1,17 +1,64 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from freezegun import freeze_time
from psycopg2 import IntegrityError
from unittest import skip
from unittest.mock import patch
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
from odoo.addons.base.models.res_users import ResUsersPatchedInTest
from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user
from odoo.tests import RecordCapturer, tagged
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
from odoo.tests import RecordCapturer, tagged, users
from odoo.tools import mute_logger
@tagged('-at_install', 'post_install', 'mail_tools', 'res_users')
class TestNotifySecurityUpdate(MailCommon):
@users('employee')
def test_security_update_email(self):
""" User should be notified on old email address when the email changes """
with self.mock_mail_gateway():
self.env.user.write({'email': 'new@example.com'})
self.assertSentEmail(
'"YourTestCompany" <your.company@example.com>',
['e.e@example.com'],
subject='Security Update: Email Changed',
)
@users('employee')
def test_security_update_login(self):
with self.mock_mail_gateway():
self.env.user.write({'login': 'newlogin'})
self.assertSentEmail(
'"YourTestCompany" <your.company@example.com>',
[self.env.user.email_formatted],
subject='Security Update: Login Changed',
)
@users('employee')
def test_security_update_password(self):
with self.mock_mail_gateway():
self.env.user.write({'password': 'newpassword'})
self.assertSentEmail(
'"YourTestCompany" <your.company@example.com>',
[self.env.user.email_formatted],
subject='Security Update: Password Changed',
)
@tagged('-at_install', 'post_install', 'mail_tools', 'res_users')
class TestUser(MailCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.portal_user = cls._create_portal_user()
@mute_logger('odoo.sql_db')
def test_notification_type_constraint(self):
with self.assertRaises(IntegrityError, msg='Portal user can not receive notification in Odoo'):
@ -24,27 +71,101 @@ class TestUser(MailCommon):
groups='base.group_portal',
)
def test_notification_type_convert_internal_inbox_to_portal(self):
"""Tests an internal user using inbox notifications converted to portal
is automatically set to email notifications"""
user = mail_new_test_user(
self.env,
login='user_test_constraint_3',
name='Test User 3',
email='user_test_constraint_3@test.example.com',
notification_type='inbox',
groups='base.group_user',
)
# Ensure the internal user has well the inbox notification type
self.assertEqual(user.notification_type, 'inbox')
self.assertIn(self.env.ref('mail.group_mail_notification_type_inbox'), user.group_ids)
# Change the internal user to portal, and make sure it automatically converts from inbox to email notifications
user.write({'group_ids': [
(3, self.env.ref('base.group_user').id),
(4, self.env.ref('base.group_portal').id),
]})
self.assertEqual(user.notification_type, 'email')
self.assertNotIn(self.env.ref('mail.group_mail_notification_type_inbox'), user.group_ids)
admin = mail_new_test_user(
self.env,
login="user_test_constraint_4",
name="Test User 4",
email="user_test_constraint_3@test.example.com",
notification_type='inbox',
groups='base.group_erp_manager',
)
# Re-check that no error occurs when we have overlapping writes on admin user
admin.write({
'notification_type': 'email',
'group_ids': [
(3, self.env.ref('base.group_user').id),
(3, self.env.ref('base.group_erp_manager').id),
(4, self.env.ref('base.group_portal').id),
],
})
self.assertFalse(admin._is_admin())
self.assertTrue(admin._is_portal())
self.assertEqual(admin.notification_type, 'email')
self.assertNotIn(self.env.ref('mail.group_mail_notification_type_inbox'), admin.group_ids)
@freeze_time("2025-06-18 08:45:12")
def test_out_of_office(self):
""" Test Out-of-Office computation, defined on user itself. """
test_user = self.user_employee.with_user(self.user_employee)
portal_user = self.portal_user
now = datetime(2025, 6, 8, 8, 45, 12)
for ooo_from, ooo_to, exp_ooo in [
(False, False, False),
(now - timedelta(hours=1), False, True), # only a from is ok
(False, now + timedelta(hours=1), False), # invalid interval
(now - timedelta(hours=1), now + timedelta(hours=1), True),
(now, now, True),
(now - timedelta(hours=4), now - timedelta(hours=2), False), # past
(now + timedelta(hours=2), now + timedelta(hours=4), False), # future
(now + timedelta(hours=2), False, False), # future, from only
]:
with self.subTest(ooo_from=ooo_from, ooo_to=ooo_to):
with self.mock_datetime_and_now(now): # also mock cr.now()
test_user.write({
'out_of_office_from': ooo_from,
'out_of_office_to': ooo_to,
})
self.assertEqual(test_user.is_out_of_office, exp_ooo)
portal_user.write({
'out_of_office_from': ooo_from,
'out_of_office_to': ooo_to,
})
self.assertFalse(portal_user.is_out_of_office, 'Portal users are never OOO')
def test_web_create_users(self):
src = [
'POILUCHETTE@test.example.com',
'"Jean Poilvache" <POILVACHE@test.example.com>',
]
with self.mock_mail_gateway(), \
RecordCapturer(self.env['res.users'], []) as capture:
RecordCapturer(self.env['res.users']) as capture:
self.env['res.users'].web_create_users(src)
exp_emails = ['poiluchette@test.example.com', 'poilvache@test.example.com']
# check reset password are effectively sent
for user_email in exp_emails:
self.assertMailMailWEmails(
[user_email], 'sent',
author=self.user_root.partner_id,
email_values={
'email_from': self.env.company.partner_id.email_formatted,
},
fields_values={
'email_from': self.env.company.partner_id.email_formatted,
},
# do not use assertMailMailWEmails as mails are removed whatever we
# try to do, code is using a savepoint to avoid storing mail.mail
# in DB
self.assertSentEmail(
self.env.company.partner_id.email_formatted,
[user_email],
email_from=self.env.company.partner_id.email_formatted,
)
# order does not seem guaranteed
@ -58,7 +179,8 @@ class TestUser(MailCommon):
sorted(exp_emails)
)
@tagged('-at_install', 'post_install')
@tagged('-at_install', 'post_install', 'res_users')
class TestUserTours(HttpCaseWithUserDemo):
def test_user_modify_own_profile(self):
@ -69,6 +191,82 @@ class TestUserTours(HttpCaseWithUserDemo):
'name': 'Marc Demo',
'user_id': self.user_demo.id,
})
self.user_demo.group_ids += self.env.ref('hr.group_hr_user')
self.user_demo.tz = "Europe/Brussels"
self.start_tour("/web", "mail/static/tests/tours/user_modify_own_profile_tour.js", login="demo")
self.assertEqual(self.user_demo.email, "updatedemail@example.com")
self.user_demo.notification_type = "email"
# avoid 'reload_context' action in the middle of the tour to ease steps and form save checks
with patch.object(ResUsersPatchedInTest, 'preference_save', lambda self: True):
self.start_tour(
"/odoo",
"mail/static/tests/tours/user_modify_own_profile_tour.js",
login="demo",
)
self.assertEqual(self.user_demo.notification_type, "inbox")
@tagged("post_install", "-at_install")
class TestUserSettings(MailCommon):
@skip('Crashes in post_install, probably because other modules force creation through inverse (e.g. voip)')
def test_create_portal_user(self):
portal_group = self.env.ref('base.group_portal')
user = self.env.user.create({
'name': 'A portal user',
'login': 'portal_test',
'group_ids': [(6, 0, [portal_group.id])],
})
self.assertFalse(user.res_users_settings_ids, 'Portal users should not have settings by default')
def test_create_internal_user(self):
user = self.env.user.create({
'name': 'A internal user',
'login': 'test_user',
})
self.assertTrue(user.res_users_settings_ids, 'Internal users should have settings by default')
@users('employee')
def test_find_or_create_for_user_should_create_record_if_not_existing(self):
self.user_employee.res_users_settings_ids.unlink() # pre autocreate or a portal user switching to internal user
settings = self.user_employee.res_users_settings_ids
self.assertFalse(settings, "no records should exist")
self.env['res.users.settings']._find_or_create_for_user(self.user_employee)
settings = self.user_employee.res_users_settings_ids
self.assertTrue(settings, "a record should be created after _find_or_create_for_user is called")
@users('employee')
def test_find_or_create_for_user_should_return_correct_res_users_settings(self):
self.user_employee.res_users_settings_ids.unlink()
settings = self.env['res.users.settings'].create({
'user_id': self.user_employee.id,
})
result = self.env['res.users.settings']._find_or_create_for_user(self.user_employee)
self.assertEqual(result, settings, "Correct mail user settings should be returned")
@users('employee')
def test_set_res_users_settings_should_send_notification_on_bus(self):
settings = self.user_employee.res_users_settings_id
settings.is_discuss_sidebar_category_chat_open = False
settings.is_discuss_sidebar_category_channel_open = False
with self.assertBus(
[(self.cr.dbname, 'res.partner', self.partner_employee.id)],
[{
'type': 'res.users.settings',
'payload': {
'id': settings.id,
'is_discuss_sidebar_category_chat_open': True,
},
}]):
settings.set_res_users_settings({'is_discuss_sidebar_category_chat_open': True})
@users('employee')
def test_set_res_users_settings_should_set_settings_properly(self):
settings = self.user_employee.res_users_settings_id
settings.set_res_users_settings({'is_discuss_sidebar_category_chat_open': True})
self.assertEqual(
settings.is_discuss_sidebar_category_chat_open,
True,
"category state should be updated correctly"
)

View file

@ -1,58 +0,0 @@
# -*- 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 users
class TestResUsersSettings(MailCommon):
@users('employee')
def test_find_or_create_for_user_should_create_record_if_not_existing(self):
settings = self.user_employee.res_users_settings_ids
self.assertFalse(settings, "no records should exist")
self.env['res.users.settings']._find_or_create_for_user(self.user_employee)
settings = self.user_employee.res_users_settings_ids
self.assertTrue(settings, "a record should be created after _find_or_create_for_user is called")
@users('employee')
def test_find_or_create_for_user_should_return_correct_res_users_settings(self):
settings = self.env['res.users.settings'].create({
'user_id': self.user_employee.id,
})
result = self.env['res.users.settings']._find_or_create_for_user(self.user_employee)
self.assertEqual(result, settings, "Correct mail user settings should be returned")
@users('employee')
def test_set_res_users_settings_should_send_notification_on_bus(self):
settings = self.env['res.users.settings'].create({
'is_discuss_sidebar_category_channel_open': False,
'is_discuss_sidebar_category_chat_open': False,
'user_id': self.user_employee.id,
})
with self.assertBus(
[(self.cr.dbname, 'res.partner', self.partner_employee.id)],
[{
'type': 'res.users.settings/insert',
'payload': {
'id': settings.id,
'is_discuss_sidebar_category_chat_open': True,
},
}]):
settings.set_res_users_settings({'is_discuss_sidebar_category_chat_open': True})
@users('employee')
def test_set_res_users_settings_should_set_settings_properly(self):
settings = self.env['res.users.settings'].create({
'is_discuss_sidebar_category_channel_open': False,
'is_discuss_sidebar_category_chat_open': False,
'user_id': self.user_employee.id,
})
settings.set_res_users_settings({'is_discuss_sidebar_category_chat_open': True})
self.assertEqual(
settings.is_discuss_sidebar_category_chat_open,
True,
"category state should be updated correctly"
)

View file

@ -1,873 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil.relativedelta import relativedelta
from odoo import fields
from odoo.addons.mail.tests.common import MailCommon
from odoo.tests import tagged
from odoo.tests.common import users
from odoo.tools import mute_logger
@tagged('RTC')
class TestChannelInternals(MailCommon):
@users('employee')
@mute_logger('odoo.models.unlink')
def test_01_join_call(self):
"""Join call should remove existing sessions, remove invitation, create a new session, and return data."""
channel = self.env['mail.channel'].browse(self.env['mail.channel'].channel_create(name='Test Channel', group_id=self.env.ref('base.group_user').id)['id'])
channel_member = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.partner_id == self.user_employee.partner_id)
channel_member._rtc_join_call()
self.env['bus.bus'].sudo().search([]).unlink()
with self.assertBus(
[
(self.cr.dbname, 'res.partner', self.user_employee.partner_id.id), # end of previous session
(self.cr.dbname, 'mail.channel', channel.id), # update sessions
(self.cr.dbname, 'mail.channel', channel.id), # update sessions
],
[
{
'type': 'mail.channel.rtc.session/ended',
'payload': {
'sessionId': channel_member.rtc_session_ids.id,
},
},
{
'type': 'mail.channel/rtc_sessions_update',
'payload': {
'id': channel.id,
'rtcSessions': [('insert-and-unlink', [{'id': channel_member.rtc_session_ids.id}])],
},
},
{
'type': 'mail.channel/rtc_sessions_update',
'payload': {
'id': channel.id,
'rtcSessions': [('insert', [{
'id': channel_member.rtc_session_ids.id + 1,
'channelMember': {
"id": channel_member.id,
"channel": {"id": channel_member.channel_id.id},
"persona": {
"partner": {
"id": channel_member.partner_id.id,
"name": channel_member.partner_id.name,
"im_status": channel_member.partner_id.im_status,
},
},
},
'isCameraOn': False,
'isDeaf': False,
'isSelfMuted': False,
'isScreenSharingOn': False,
}])],
},
},
]
):
res = channel_member._rtc_join_call()
self.assertEqual(res, {
'iceServers': False,
'rtcSessions': [
('insert', [{
'id': channel_member.rtc_session_ids.id,
'channelMember': {
"id": channel_member.id,
"channel": {"id": channel_member.channel_id.id},
"persona": {
"partner": {
"id": channel_member.partner_id.id,
"name": channel_member.partner_id.name,
"im_status": channel_member.partner_id.im_status,
},
},
},
'isCameraOn': False,
'isDeaf': False,
'isSelfMuted': False,
'isScreenSharingOn': False,
}]),
('insert-and-unlink', [{'id': channel_member.rtc_session_ids.id - 1}]),
],
'sessionId': channel_member.rtc_session_ids.id,
})
@users('employee')
@mute_logger('odoo.models.unlink')
def test_10_start_call_in_chat_should_invite_all_members_to_call(self):
test_user = self.env['res.users'].sudo().create({'name': "Test User", 'login': 'test'})
channel = self.env['mail.channel'].browse(self.env['mail.channel'].channel_get(partners_to=(self.user_employee.partner_id + test_user.partner_id).ids)['id'])
channel_member = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.partner_id == self.user_employee.partner_id)
channel_member_test_user = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.partner_id == test_user.partner_id)
channel_member._rtc_join_call()
last_rtc_session_id = channel_member.rtc_session_ids.id
channel_member._rtc_leave_call()
self.env['bus.bus'].sudo().search([]).unlink()
with self.assertBus(
[
(self.cr.dbname, 'mail.channel', channel.id), # update new session
(self.cr.dbname, 'mail.channel', channel.id), # message_post "started a live conference" (not asserted below)
(self.cr.dbname, 'res.partner', self.user_employee.partner_id.id), # update of last interest (not asserted below)
(self.cr.dbname, 'res.partner', test_user.partner_id.id), # update of last interest (not asserted below)
(self.cr.dbname, 'res.partner', test_user.partner_id.id), # incoming invitation
(self.cr.dbname, 'mail.channel', channel.id), # update list of invitations
],
[
{
'type': 'mail.channel/rtc_sessions_update',
'payload': {
'id': channel.id,
'rtcSessions': [('insert', [{
'id': last_rtc_session_id + 1,
'channelMember': {
"id": channel_member.id,
"channel": {"id": channel_member.channel_id.id},
"persona": {
"partner": {
"id": channel_member.partner_id.id,
"name": channel_member.partner_id.name,
"im_status": channel_member.partner_id.im_status,
},
},
},
'isCameraOn': False,
'isDeaf': False,
'isSelfMuted': False,
'isScreenSharingOn': False,
}])],
},
},
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'invitedMembers': [('insert', [{
'id': channel_member_test_user.id,
'channel': {'id': channel_member_test_user.channel_id.id},
'persona': {
'partner': {
'id': channel_member_test_user.partner_id.id,
'name': channel_member_test_user.partner_id.name,
'im_status': channel_member_test_user.partner_id.im_status,
},
},
}])],
},
},
]
):
res = channel_member._rtc_join_call()
self.assertIn('invitedMembers', res)
self.assertEqual(res['invitedMembers'], [('insert', [{
'id': channel_member_test_user.id,
'channel': {'id': channel_member_test_user.channel_id.id},
'persona': {
'partner': {
'id': channel_member_test_user.partner_id.id,
'name': channel_member_test_user.partner_id.name,
'im_status': channel_member_test_user.partner_id.im_status,
},
},
}])])
@users('employee')
@mute_logger('odoo.models.unlink')
def test_11_start_call_in_group_should_invite_all_members_to_call(self):
test_user = self.env['res.users'].sudo().create({'name': "Test User", 'login': 'test'})
test_guest = self.env['mail.guest'].sudo().create({'name': "Test Guest"})
channel = self.env['mail.channel'].browse(self.env['mail.channel'].create_group(partners_to=(self.user_employee.partner_id + test_user.partner_id).ids)['id'])
channel.add_members(guest_ids=test_guest.ids)
channel_member_test_user = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.partner_id == test_user.partner_id)
channel_member_test_guest = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.guest_id == test_guest)
channel_member = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.partner_id == self.user_employee.partner_id)
channel_member._rtc_join_call()
last_rtc_session_id = channel_member.rtc_session_ids.id
channel_member._rtc_leave_call()
self.env['bus.bus'].sudo().search([]).unlink()
with self.assertBus(
[
(self.cr.dbname, 'mail.channel', channel.id), # update new session
(self.cr.dbname, 'mail.channel', channel.id), # message_post "started a live conference" (not asserted below)
(self.cr.dbname, 'res.partner', self.user_employee.partner_id.id), # update of last interest (not asserted below)
(self.cr.dbname, 'res.partner', test_user.partner_id.id), # update of last interest (not asserted below)
(self.cr.dbname, 'res.partner', test_user.partner_id.id), # incoming invitation
(self.cr.dbname, 'mail.guest', test_guest.id), # incoming invitation
(self.cr.dbname, 'mail.channel', channel.id), # update list of invitations
],
[
{
'type': 'mail.channel/rtc_sessions_update',
'payload': {
'id': channel.id,
'rtcSessions': [('insert', [{
'id': last_rtc_session_id + 1,
'channelMember': {
"id": channel_member.id,
"channel": {"id": channel_member.channel_id.id},
"persona": {
"partner": {
"id": channel_member.partner_id.id,
"name": channel_member.partner_id.name,
"im_status": channel_member.partner_id.im_status,
},
},
},
'isCameraOn': False,
'isDeaf': False,
'isSelfMuted': False,
'isScreenSharingOn': False,
}])],
},
},
{
'type': 'mail.channel/rtc_sessions_update',
'payload': {
'id': channel.id,
'rtcSessions': [('insert', [{
'id': last_rtc_session_id + 1,
'channelMember': {
"id": channel_member.id,
"channel": {"id": channel_member.channel_id.id},
"persona": {
"partner": {
"id": channel_member.partner_id.id,
"name": channel_member.partner_id.name,
"im_status": channel_member.partner_id.im_status,
},
},
},
'isCameraOn': False,
'isDeaf': False,
'isSelfMuted': False,
'isScreenSharingOn': False,
}])],
},
},
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'invitedMembers': [('insert', [
{
'id': channel_member_test_user.id,
'channel': {'id': channel_member_test_user.channel_id.id},
'persona': {
'partner': {
'id': channel_member_test_user.partner_id.id,
'name': channel_member_test_user.partner_id.name,
'im_status': channel_member_test_user.partner_id.im_status,
},
},
},
{
'id': channel_member_test_guest.id,
'channel': {'id': channel_member_test_guest.channel_id.id},
'persona': {
'guest': {
'id': channel_member_test_guest.guest_id.id,
'name': channel_member_test_guest.guest_id.name,
'im_status': channel_member_test_guest.guest_id.im_status,
},
},
},
])],
},
},
]
):
res = channel_member._rtc_join_call()
self.assertIn('invitedMembers', res)
self.assertEqual(res['invitedMembers'], [('insert', [
{
'id': channel_member_test_user.id,
'channel': {'id': channel_member_test_user.channel_id.id},
'persona': {
'partner': {
'id': channel_member_test_user.partner_id.id,
'name': channel_member_test_user.partner_id.name,
'im_status': channel_member_test_user.partner_id.im_status,
},
},
},
{
'id': channel_member_test_guest.id,
'channel': {'id': channel_member_test_guest.channel_id.id},
'persona': {
'guest': {
'id': channel_member_test_guest.guest_id.id,
'name': channel_member_test_guest.guest_id.name,
'im_status': channel_member_test_guest.guest_id.im_status,
},
},
},
])])
@users('employee')
@mute_logger('odoo.models.unlink')
def test_20_join_call_should_cancel_pending_invitations(self):
test_user = self.env['res.users'].sudo().create({'name': "Test User", 'login': 'test'})
test_guest = self.env['mail.guest'].sudo().create({'name': "Test Guest"})
channel = self.env['mail.channel'].browse(self.env['mail.channel'].create_group(partners_to=(self.user_employee.partner_id + test_user.partner_id).ids)['id'])
channel.add_members(guest_ids=test_guest.ids)
channel_member = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.partner_id == self.user_employee.partner_id)
channel_member._rtc_join_call()
channel_member_test_user = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.partner_id == test_user.partner_id)
self.env['bus.bus'].sudo().search([]).unlink()
with self.assertBus(
[
(self.cr.dbname, 'res.partner', test_user.partner_id.id), # update invitation
(self.cr.dbname, 'mail.channel', channel.id), # update list of invitations
(self.cr.dbname, 'mail.channel', channel.id), # update sessions
],
[
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'rtcInvitingSession': [('unlink',)],
},
},
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'invitedMembers': [('insert-and-unlink', [{
'id': channel_member_test_user.id,
'channel': {'id': channel_member_test_user.channel_id.id},
'persona': {
'partner': {
'id': channel_member_test_user.partner_id.id,
'name': channel_member_test_user.partner_id.name,
'im_status': channel_member_test_user.partner_id.im_status,
},
},
}])],
},
},
{
'type': 'mail.channel/rtc_sessions_update',
'payload': {
'id': channel.id,
'rtcSessions': [('insert', [
{
'id': channel_member.rtc_session_ids.id + 1,
'channelMember': {
"id": channel_member_test_user.id,
"channel": {"id": channel_member_test_user.channel_id.id},
"persona": {
"partner": {
"id": channel_member_test_user.partner_id.id,
"name": channel_member_test_user.partner_id.name,
"im_status": channel_member_test_user.partner_id.im_status,
},
},
},
'isCameraOn': False,
'isDeaf': False,
'isSelfMuted': False,
'isScreenSharingOn': False,
},
])],
},
},
]
):
channel_member_test_user._rtc_join_call()
channel_member_test_guest = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.guest_id == test_guest)
self.env['bus.bus'].sudo().search([]).unlink()
with self.assertBus(
[
(self.cr.dbname, 'mail.guest', test_guest.id), # update invitation
(self.cr.dbname, 'mail.channel', channel.id), # update list of invitations
(self.cr.dbname, 'mail.channel', channel.id), # update sessions
],
[
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'rtcInvitingSession': [('unlink',)],
},
},
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'invitedMembers': [('insert-and-unlink', [{
'id': channel_member_test_guest.id,
'channel': {'id': channel_member_test_guest.channel_id.id},
'persona': {
'guest': {
'id': channel_member_test_guest.guest_id.id,
'name': channel_member_test_guest.guest_id.name,
'im_status': channel_member_test_guest.guest_id.im_status,
},
},
}])],
},
},
{
'type': 'mail.channel/rtc_sessions_update',
'payload': {
'id': channel.id,
'rtcSessions': [('insert', [
{
'id': channel_member.rtc_session_ids.id + 2,
'channelMember': {
"id": channel_member_test_guest.id,
"channel": {"id": channel_member_test_guest.channel_id.id},
"persona": {
"guest": {
"id": channel_member_test_guest.guest_id.id,
"name": channel_member_test_guest.guest_id.name,
'im_status': channel_member_test_guest.guest_id.im_status,
},
},
},
'isCameraOn': False,
'isDeaf': False,
'isSelfMuted': False,
'isScreenSharingOn': False,
},
])],
},
},
]
):
channel_member_test_guest._rtc_join_call()
@users('employee')
@mute_logger('odoo.models.unlink')
def test_21_leave_call_should_cancel_pending_invitations(self):
test_user = self.env['res.users'].sudo().create({'name': "Test User", 'login': 'test'})
test_guest = self.env['mail.guest'].sudo().create({'name': "Test Guest"})
channel = self.env['mail.channel'].browse(self.env['mail.channel'].create_group(partners_to=(self.user_employee.partner_id + test_user.partner_id).ids)['id'])
channel.add_members(guest_ids=test_guest.ids)
channel_member = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.partner_id == self.user_employee.partner_id)
channel_member._rtc_join_call()
channel_member_test_user = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.partner_id == test_user.partner_id)
self.env['bus.bus'].sudo().search([]).unlink()
with self.assertBus(
[
(self.cr.dbname, 'res.partner', test_user.partner_id.id), # update invitation
(self.cr.dbname, 'mail.channel', channel.id), # update list of invitations
],
[
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'rtcInvitingSession': [('unlink',)],
},
},
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'invitedMembers': [('insert-and-unlink', [{
'id': channel_member_test_user.id,
'channel': {'id': channel_member_test_user.channel_id.id},
'persona': {
'partner': {
'id': channel_member_test_user.partner_id.id,
'name': channel_member_test_user.partner_id.name,
'im_status': channel_member_test_user.partner_id.im_status,
},
},
}])],
},
},
]
):
channel_member_test_user._rtc_leave_call()
channel_member_test_guest = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.guest_id == test_guest)
self.env['bus.bus'].sudo().search([]).unlink()
with self.assertBus(
[
(self.cr.dbname, 'mail.guest', test_guest.id), # update invitation
(self.cr.dbname, 'mail.channel', channel.id), # update list of invitations
],
[
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'rtcInvitingSession': [('unlink',)],
},
},
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'invitedMembers': [('insert-and-unlink', [{
'id': channel_member_test_guest.id,
'channel': {'id': channel_member_test_guest.channel_id.id},
'persona': {
'guest': {
'id': channel_member_test_guest.guest_id.id,
'name': channel_member_test_guest.guest_id.name,
'im_status': channel_member_test_guest.guest_id.im_status,
},
},
}])],
},
},
]
):
channel_member_test_guest._rtc_leave_call()
@users('employee')
@mute_logger('odoo.models.unlink')
def test_25_lone_call_participant_leaving_call_should_cancel_pending_invitations(self):
test_user = self.env['res.users'].sudo().create({'name': "Test User", 'login': 'test'})
test_guest = self.env['mail.guest'].sudo().create({'name': "Test Guest"})
channel = self.env['mail.channel'].browse(self.env['mail.channel'].create_group(partners_to=(self.user_employee.partner_id + test_user.partner_id).ids)['id'])
channel.add_members(guest_ids=test_guest.ids)
channel_member = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.partner_id == self.user_employee.partner_id)
channel_member_test_user = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.partner_id == test_user.partner_id)
channel_member_test_guest = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.guest_id == test_guest)
channel_member._rtc_join_call()
self.env['bus.bus'].sudo().search([]).unlink()
with self.assertBus(
[
(self.cr.dbname, 'res.partner', self.user_employee.partner_id.id), # end session
(self.cr.dbname, 'res.partner', test_user.partner_id.id), # update invitation
(self.cr.dbname, 'mail.guest', test_guest.id), # update invitation
(self.cr.dbname, 'mail.channel', channel.id), # update list of invitations
(self.cr.dbname, 'mail.channel', channel.id), # update sessions
],
[
{
'type': 'mail.channel.rtc.session/ended',
'payload': {
'sessionId': channel_member.rtc_session_ids.id,
},
},
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'rtcInvitingSession': [('unlink',)],
},
},
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'rtcInvitingSession': [('unlink',)],
},
},
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'invitedMembers': [('insert-and-unlink', [
{
'id': channel_member_test_user.id,
'channel': {'id': channel_member_test_user.channel_id.id},
'persona': {
'partner': {
'id': channel_member_test_user.partner_id.id,
'name': channel_member_test_user.partner_id.name,
'im_status': channel_member_test_user.partner_id.im_status,
},
},
},
{
'id': channel_member_test_guest.id,
'channel': {'id': channel_member_test_guest.channel_id.id},
'persona': {
'guest': {
'id': channel_member_test_guest.guest_id.id,
'name': channel_member_test_guest.guest_id.name,
'im_status': channel_member_test_guest.guest_id.im_status,
},
},
},
])],
},
},
{
'type': 'mail.channel/rtc_sessions_update',
'payload': {
'id': channel.id,
'rtcSessions': [('insert-and-unlink', [{'id': channel_member.rtc_session_ids.id}])],
},
},
]
):
channel_member._rtc_leave_call()
@users('employee')
@mute_logger('odoo.models.unlink')
def test_30_add_members_while_in_call_should_invite_new_members_to_call(self):
test_user = self.env['res.users'].sudo().create({'name': "Test User", 'login': 'test'})
test_guest = self.env['mail.guest'].sudo().create({'name': "Test Guest"})
channel = self.env['mail.channel'].browse(self.env['mail.channel'].create_group(partners_to=self.user_employee.partner_id.ids)['id'])
channel_member = channel.sudo().channel_member_ids.filtered(lambda member: member.partner_id == self.user_employee.partner_id)
channel_member._rtc_join_call()
self.env['bus.bus'].sudo().search([]).unlink()
with self.mock_bus():
channel.add_members(partner_ids=test_user.partner_id.ids, guest_ids=test_guest.ids, invite_to_rtc_call=True)
channel_member_test_user = channel.sudo().channel_member_ids.filtered(lambda member: member.partner_id == test_user.partner_id)
channel_member_test_guest = channel.sudo().channel_member_ids.filtered(lambda member: member.guest_id == test_guest)
found_bus_notifs = self.assertBusNotifications(
[
(self.cr.dbname, 'res.partner', test_user.partner_id.id), # channel joined (not asserted below)
(self.cr.dbname, 'mail.channel', channel.id), # message_post "invited" (not asserted below)
(self.cr.dbname, 'res.partner', self.user_employee.partner_id.id), # update of last interest (not asserted below)
(self.cr.dbname, 'res.partner', test_user.partner_id.id), # update of last interest (not asserted below)
(self.cr.dbname, 'mail.channel', channel.id), # new members (not asserted below)
(self.cr.dbname, 'res.partner', test_user.partner_id.id), # incoming invitation
(self.cr.dbname, 'mail.guest', test_guest.id), # incoming invitation
(self.cr.dbname, 'mail.channel', channel.id), # update list of invitations
(self.cr.dbname, 'res.partner', self.user_employee.partner_id.id), # update of last interest (not asserted below)
(self.cr.dbname, 'res.partner', test_user.partner_id.id), # update of last interest (not asserted below)
(self.cr.dbname, 'mail.channel', channel.id), # new member (guest) (not asserted below)
(self.cr.dbname, 'mail.guest', test_guest.id), # channel joined for guest (not asserted below)
],
message_items=[
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'rtcInvitingSession': {
'id': channel_member.rtc_session_ids.id,
'channelMember': {
"id": channel_member.id,
"channel": {"id": channel_member.channel_id.id},
"persona": {
"partner": {
"id": channel_member.partner_id.id,
"name": channel_member.partner_id.name,
"im_status": channel_member.partner_id.im_status,
},
},
},
'isCameraOn': False,
'isDeaf': False,
'isSelfMuted': False,
'isScreenSharingOn': False,
},
},
},
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'rtcInvitingSession': {
'id': channel_member.rtc_session_ids.id,
'channelMember': {
"id": channel_member.id,
"channel": {"id": channel_member.channel_id.id},
"persona": {
"partner": {
"id": channel_member.partner_id.id,
"name": channel_member.partner_id.name,
"im_status": channel_member.partner_id.im_status,
},
},
},
'isCameraOn': False,
'isDeaf': False,
'isSelfMuted': False,
'isScreenSharingOn': False,
},
},
},
{
'type': 'mail.thread/insert',
'payload': {
'id': channel.id,
'model': 'mail.channel',
'invitedMembers': [('insert', [
{
'id': channel_member_test_user.id,
'channel': {'id': channel_member_test_user.channel_id.id},
'persona': {
'partner': {
'id': channel_member_test_user.partner_id.id,
'name': channel_member_test_user.partner_id.name,
'im_status': channel_member_test_user.partner_id.im_status,
},
},
},
{
'id': channel_member_test_guest.id,
'channel': {'id': channel_member_test_guest.channel_id.id},
'persona': {
'guest': {
'id': channel_member_test_guest.guest_id.id,
'name': channel_member_test_guest.guest_id.name,
'im_status': channel_member_test_guest.guest_id.im_status,
},
},
},
])],
},
},
],
)
self.assertEqual(self._new_bus_notifs, found_bus_notifs)
@users('employee')
@mute_logger('odoo.models.unlink')
def test_40_leave_call_should_remove_existing_sessions_of_user_in_channel_and_return_data(self):
channel = self.env['mail.channel'].browse(self.env['mail.channel'].create_group(partners_to=self.user_employee.partner_id.ids)['id'])
channel_member = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.partner_id == self.user_employee.partner_id)
channel_member._rtc_join_call()
self.env['bus.bus'].sudo().search([]).unlink()
with self.assertBus(
[
(self.cr.dbname, 'res.partner', self.user_employee.partner_id.id), # end session
(self.cr.dbname, 'mail.channel', channel.id), # update list of sessions
],
[
{
'type': 'mail.channel.rtc.session/ended',
'payload': {
'sessionId': channel_member.rtc_session_ids.id,
},
},
{
'type': 'mail.channel/rtc_sessions_update',
'payload': {
'id': channel.id,
'rtcSessions': [('insert-and-unlink', [{'id': channel_member.rtc_session_ids.id}])],
},
},
],
):
channel_member._rtc_leave_call()
@users('employee')
@mute_logger('odoo.models.unlink')
def test_50_garbage_collect_should_remove_old_sessions_and_notify_data(self):
channel = self.env['mail.channel'].browse(self.env['mail.channel'].create_group(partners_to=self.user_employee.partner_id.ids)['id'])
channel_member = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.partner_id == self.user_employee.partner_id)
channel_member._rtc_join_call()
channel_member.rtc_session_ids.flush_model()
channel_member.rtc_session_ids._write({'write_date': fields.Datetime.now() - relativedelta(days=2)})
self.env['bus.bus'].sudo().search([]).unlink()
with self.assertBus(
[
(self.cr.dbname, 'res.partner', self.user_employee.partner_id.id), # session ended
(self.cr.dbname, 'mail.channel', channel.id), # update list of sessions
],
[
{
'type': 'mail.channel.rtc.session/ended',
'payload': {
'sessionId': channel_member.rtc_session_ids.id,
},
},
{
'type': 'mail.channel/rtc_sessions_update',
'payload': {
'id': channel.id,
'rtcSessions': [('insert-and-unlink', [{'id': channel_member.rtc_session_ids.id}])],
},
},
],
):
self.env['mail.channel.rtc.session'].sudo()._gc_inactive_sessions()
self.assertFalse(channel_member.rtc_session_ids)
@users('employee')
@mute_logger('odoo.models.unlink')
def test_51_action_disconnect_should_remove_selected_session_and_notify_data(self):
channel = self.env['mail.channel'].browse(self.env['mail.channel'].create_group(partners_to=self.user_employee.partner_id.ids)['id'])
channel_member = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.partner_id == self.user_employee.partner_id)
channel_member._rtc_join_call()
self.env['bus.bus'].sudo().search([]).unlink()
with self.assertBus(
[
(self.cr.dbname, 'res.partner', self.user_employee.partner_id.id), # session ended
(self.cr.dbname, 'mail.channel', channel.id), # update list of sessions
],
[
{
'type': 'mail.channel.rtc.session/ended',
'payload': {
'sessionId': channel_member.rtc_session_ids.id,
},
},
{
'type': 'mail.channel/rtc_sessions_update',
'payload': {
'id': channel.id,
'rtcSessions': [('insert-and-unlink', [{'id': channel_member.rtc_session_ids.id}])],
},
},
],
):
channel_member.rtc_session_ids.action_disconnect()
self.assertFalse(channel_member.rtc_session_ids)
@users('employee')
@mute_logger('odoo.models.unlink')
def test_60_rtc_sync_sessions_should_gc_and_return_outdated_and_active_sessions(self):
channel = self.env['mail.channel'].browse(self.env['mail.channel'].create_group(partners_to=self.user_employee.partner_id.ids)['id'])
channel_member = channel.sudo().channel_member_ids.filtered(lambda channel_member: channel_member.partner_id == self.user_employee.partner_id)
join_call_values = channel_member._rtc_join_call()
test_guest = self.env['mail.guest'].sudo().create({'name': "Test Guest"})
test_channel_member = self.env['mail.channel.member'].create({
'guest_id': test_guest.id,
'channel_id': channel.id,
})
test_session = self.env['mail.channel.rtc.session'].sudo().create({'channel_member_id': test_channel_member.id})
test_session.flush_model()
test_session._write({'write_date': fields.Datetime.now() - relativedelta(days=2)})
unused_ids = [9998, 9999]
self.env['bus.bus'].sudo().search([]).unlink()
with self.assertBus(
[
(self.cr.dbname, 'mail.guest', test_guest.id), # session ended
(self.cr.dbname, 'mail.channel', channel.id), # update list of sessions
],
[
{
'type': 'mail.channel.rtc.session/ended',
'payload': {
'sessionId': test_session.id,
},
},
{
'type': 'mail.channel/rtc_sessions_update',
'payload': {
'id': channel.id,
'rtcSessions': [('insert-and-unlink', [{'id': test_session.id}])],
},
},
],
):
current_rtc_sessions, outdated_rtc_sessions = channel_member._rtc_sync_sessions(check_rtc_session_ids=[join_call_values['sessionId']] + unused_ids)
self.assertEqual(channel_member.rtc_session_ids, current_rtc_sessions)
self.assertEqual(unused_ids, outdated_rtc_sessions.ids)
self.assertFalse(outdated_rtc_sessions.exists())

View file

@ -1,8 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
class TestUserModifyOwnProfile(HttpCaseWithUserDemo):
pass

View file

@ -0,0 +1,77 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.http import root, SESSION_ROTATION_INTERVAL
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
from odoo.addons.bus.models.bus import channel_with_db, json_dump
class TestWebsocketController(HttpCaseWithUserDemo):
def test_im_status_offline_on_websocket_closed(self):
self.authenticate("demo", "demo")
self.env["mail.presence"]._update_presence(self.user_demo)
self.env.cr.precommit.run() # trigger the creation of bus.bus records
self.env["bus.bus"].search([]).unlink()
self.make_jsonrpc_request("/websocket/on_closed", {})
self.env.cr.precommit.run() # trigger the creation of bus.bus records
message = self.make_jsonrpc_request(
"/websocket/peek_notifications",
{
"channels": [f"odoo-presence-res.partner_{self.partner_demo.id}"],
"last": 0,
"is_first_poll": True,
},
)["notifications"][0]["message"]
self.assertEqual(message["type"], "bus.bus/im_status_updated")
self.assertEqual(message["payload"]["partner_id"], self.partner_demo.id)
self.assertEqual(message["payload"]["im_status"], "offline")
self.assertEqual(message["payload"]["presence_status"], "offline")
def test_receive_missed_presences_on_peek_notifications(self):
self.authenticate("demo", "demo")
self.env["mail.presence"]._update_presence(self.user_demo)
self.env.cr.precommit.run() # trigger the creation of bus.bus records
# First request will get notifications and trigger the creation
# of the missed presences one.
last_id = self.env["bus.bus"]._bus_last_id()
self.make_jsonrpc_request(
"/websocket/peek_notifications",
{
"channels": [f"odoo-presence-res.partner_{self.partner_demo.id}"],
"last": last_id,
"is_first_poll": True,
},
)
self.env.cr.precommit.run() # trigger the creation of bus.bus records
notification = self.make_jsonrpc_request(
"/websocket/peek_notifications",
{
"channels": [f"odoo-presence-res.partner_{self.partner_demo.id}"],
"last": last_id,
"is_first_poll": True,
},
)["notifications"][0]
bus_record = self.env["bus.bus"].search([("id", "=", int(notification["id"]))])
self.assertEqual(
bus_record.channel, json_dump(channel_with_db(self.env.cr.dbname, self.partner_demo))
)
self.assertEqual(notification["message"]["type"], "bus.bus/im_status_updated")
self.assertEqual(notification["message"]["payload"]["partner_id"], self.partner_demo.id)
self.assertEqual(notification["message"]["payload"]["im_status"], "online")
self.assertEqual(notification["message"]["payload"]["presence_status"], "online")
def test_do_not_rotate_session_when_updating_presence(self):
self.authenticate('admin', 'admin')
self.url_open('/odoo')
original_session = self.opener.cookies['session_id']
original_session_obj = root.session_store.get(original_session)
original_session_obj['create_time'] -= SESSION_ROTATION_INTERVAL
root.session_store.save(original_session_obj)
self.make_jsonrpc_request('/websocket/peek_notifications', {
'channels': [],
'last': 0,
'is_first_poll': True,
})
self.make_jsonrpc_request('/websocket/update_bus_presence', {'inactivity_period': 0})
self.assertEqual(self.opener.cookies['session_id'], original_session)
self.url_open("/odoo")
self.assertNotEqual(self.opener.cookies['session_id'], original_session)