From d9452d2060f8f6d8c5b77b2c5c7f257b33859ce3 Mon Sep 17 00:00:00 2001 From: Ernad Husremovic Date: Mon, 9 Mar 2026 09:32:39 +0100 Subject: [PATCH] 19.0 vanilla --- .../README.md | 31 +- .../pyproject.toml | 8 +- .../test_base_automation/__manifest__.py | 6 + .../models/test_base_automation.py | 119 +- .../security/ir.model.access.csv | 13 +- .../static/tests/tour/base_automation_tour.js | 671 ++++ .../test_base_automation/tests/__init__.py | 2 + .../test_base_automation/tests/test_flow.py | 2002 ++++++++++-- .../tests/test_server_actions.py | 77 + .../test_base_automation/tests/test_tour.py | 304 ++ odoo-bringout-oca-ocb-test_crm_full/README.md | 31 +- .../pyproject.toml | 26 +- .../test_crm_full/__manifest__.py | 1 + .../test_crm_full/tests/common.py | 2 - .../test_crm_full/tests/test_performance.py | 25 +- .../README.md | 38 +- .../pyproject.toml | 29 +- .../test_discuss_full/__manifest__.py | 47 +- .../static/tests/tours/avatar_card_tour.js | 60 + .../tours/chatbot_redirect_to_portal_tour.js | 28 + .../tours/im_livechat_session_open_tour.js | 17 + .../test_discuss_full/tests/__init__.py | 7 +- .../tests/test_avatar_card_tour.py | 138 + .../tests/test_im_livechat_portal.py | 52 + .../tests/test_livechat_hr_holidays.py | 52 + .../tests/test_livechat_session_open.py | 21 + .../tests/test_performance.py | 2723 +++++++++++------ .../tests/test_performance_inbox.py | 82 + .../tests/test_res_partner.py | 12 + .../README.md | 34 +- .../pyproject.toml | 35 +- .../test_event_full/__manifest__.py | 9 +- .../test_event_full/data/event_type_data.xml | 122 - ...st_template_reference_field_widget.test.js | 124 + .../src/js/tours/wevent_performance_tour.js | 129 +- .../src/js/tours/wevent_register_tour.js | 263 +- .../test_event_full/tests/__init__.py | 2 +- .../test_event_full/tests/common.py | 196 +- .../test_event_full/tests/test_event_crm.py | 3 +- .../tests/test_event_discount.py | 78 - .../test_event_full/tests/test_event_event.py | 82 +- .../test_event_full/tests/test_event_mail.py | 380 ++- .../tests/test_event_security.py | 46 +- .../test_event_full/tests/test_performance.py | 95 +- .../test_event_full/tests/test_wevent_menu.py | 41 + .../tests/test_wevent_register.py | 21 +- odoo-bringout-oca-ocb-test_mail/README.md | 33 +- .../pyproject.toml | 9 +- .../test_mail/__manifest__.py | 14 +- .../test_mail/data/data.xml | 13 + .../test_mail/data/mail_template_data.xml | 26 + .../test_mail/data/subtype_data.xml | 9 + .../test_mail/data/test_mail_data.py | 43 +- .../test_mail/models/__init__.py | 6 +- .../test_mail/models/mail_test_access.py | 60 +- .../test_mail/models/mail_test_lead.py | 55 + .../test_mail/models/mail_test_ticket.py | 266 ++ .../models/test_mail_corner_case_models.py | 182 +- .../models/test_mail_feature_models.py | 95 + .../test_mail/models/test_mail_models.py | 322 +- .../models/test_mail_thread_models.py | 2 +- .../test_mail/security/ir.model.access.csv | 31 +- .../test_mail/security/test_mail_security.xml | 69 +- .../test_mail/static/tests/activity.test.js | 1199 ++++++++ .../static/tests/activity_mobile.test.js | 35 + .../test_mail/static/tests/activity_tests.js | 676 ---- .../static/tests/attachment_view.test.js | 252 ++ .../test_mail/static/tests/chatter.test.js | 197 ++ .../test_mail/static/tests/chatter_tests.js | 77 - .../tests/helpers/model_definitions_setup.js | 5 - .../static/tests/mobile/activity_tests.js | 48 - .../mock_server/models/mail_test_activity.js | 6 + .../models/mail_test_multi_company.js | 5 + .../models/mail_test_multi_company_read.js | 6 + .../models/mail_test_properties.js | 5 + .../mock_server/models/mail_test_simple.js | 5 + .../mail_test_simple_main_attachment.js | 5 + .../mock_server/models/mail_test_track_all.js | 10 + .../tests/mock_server/models/res_currency.js | 5 + .../static/tests/properties_field.test.js | 73 + .../tests/systray_activity_menu.test.js | 129 + .../tests/systray_activity_menu_tests.js | 165 - .../static/tests/test_mail_test_helpers.js | 32 + .../tests/tours/mail_activity_view_tour.js | 69 + .../static/tests/tracking_value.test.js | 377 +++ .../static/tests/tracking_value_tests.js | 438 --- .../test_mail/tests/__init__.py | 22 +- .../test_mail/tests/common.py | 12 +- .../tests/test_controller_attachment.py | 35 + .../test_mail/tests/test_controller_binary.py | 47 + .../test_mail/tests/test_controller_thread.py | 167 + .../test_mail/tests/test_invite.py | 44 +- .../test_mail/tests/test_ir_actions.py | 97 +- .../test_mail/tests/test_ir_attachment.py | 61 + .../test_mail/tests/test_mail_activity.py | 1236 ++++---- .../tests/test_mail_activity_mixin.py | 730 +++++ .../tests/test_mail_activity_plan.py | 410 +++ .../test_mail/tests/test_mail_alias.py | 1002 ++++++ .../test_mail/tests/test_mail_composer.py | 2624 ++++++++++++---- .../tests/test_mail_composer_mixin.py | 132 +- .../test_mail/tests/test_mail_flow.py | 558 ++++ .../test_mail/tests/test_mail_followers.py | 343 ++- .../test_mail/tests/test_mail_gateway.py | 1243 +++++--- .../test_mail/tests/test_mail_mail.py | 825 +++-- .../test_mail/tests/test_mail_management.py | 5 +- .../test_mail/tests/test_mail_message.py | 325 +- .../tests/test_mail_message_security.py | 278 +- .../test_mail/tests/test_mail_multicompany.py | 354 ++- .../test_mail/tests/test_mail_push.py | 708 +++++ .../tests/test_mail_scheduled_message.py | 239 ++ .../test_mail/tests/test_mail_security.py | 5 +- .../test_mail/tests/test_mail_template.py | 317 +- .../tests/test_mail_template_preview.py | 62 +- .../tests/test_mail_thread_internals.py | 1335 +++++++- .../tests/test_mail_thread_mixins.py | 207 +- .../tests/test_message_management.py | 130 - .../test_mail/tests/test_message_post.py | 1390 ++++++--- .../test_mail/tests/test_message_track.py | 1024 +++++-- .../test_mail/tests/test_performance.py | 1364 ++++++--- .../test_mail/tests/test_ui.py | 21 - .../README.md | 31 +- .../pyproject.toml | 28 +- .../test_mail_full/__init__.py | 3 - .../test_mail_full/__manifest__.py | 11 +- .../test_mail_full/controllers/__init__.py | 3 + .../test_mail_full/controllers/portal.py | 28 + .../test_mail_full/i18n/bs.po | 224 -- .../models/test_mail_models_mail.py | 79 +- .../security/ir.model.access.csv | 9 +- .../tests/helpers/model_definitions_setup.js | 5 - .../static/tests/messaging_menu_patch.test.js | 61 + .../mock_server/models/mail_test_rating.js | 5 + .../channel_preview_view_tests.js | 55 - .../thread_needaction_preview_tests.js | 64 - .../tests/test_mail_full_test_helpers.js | 9 + .../static/tests/tours/load_more_tour.js | 19 + .../tests/tours/message_actions_tour.js | 87 + .../tours/portal_composer_actions_tour.js | 34 + .../tests/tours/portal_copy_link_tour.js | 22 + .../tests/tours/portal_no_copy_link_tour.js | 28 + .../tests/tours/portal_rating_tour.js.js | 46 + .../test_mail_full/tests/__init__.py | 9 +- .../tests/test_controller_attachment.py | 39 + .../tests/test_controller_reaction.py | 80 + .../tests/test_controller_thread.py | 171 ++ .../tests/test_controller_update.py | 48 + .../tests/test_ir_mail_server.py | 122 + .../{test_odoobot.py => test_mail_bot.py} | 14 +- .../tests/test_mail_performance.py | 469 ++- .../tests/test_mail_thread_internals.py | 6 +- .../test_mail_full/tests/test_mass_mailing.py | 145 +- .../test_mail_full/tests/test_portal.py | 227 +- .../test_mail_full/tests/test_rating.py | 363 ++- .../test_mail_full/tests/test_res_users.py | 24 +- .../test_mail_full/tests/test_ui.py | 111 + .../views/test_portal_template.xml | 14 + odoo-bringout-oca-ocb-test_resource/README.md | 31 +- .../pyproject.toml | 8 +- .../test_resource/__manifest__.py | 1 + .../test_resource/models/test_resource.py | 2 +- .../test_resource/tests/__init__.py | 11 +- .../test_resource/tests/common.py | 218 +- .../test_resource/tests/test_calendar.py | 485 +++ .../test_resource/tests/test_mixin.py | 517 ++++ .../test_resource/tests/test_performance.py | 2 +- .../test_resource/tests/test_resource.py | 1305 +------- .../tests/test_resource_errors.py | 28 + .../test_resource/tests/test_timezones.py | 256 ++ odoo-bringout-oca-ocb-test_website/README.md | 31 +- .../pyproject.toml | 12 +- .../test_website/__manifest__.py | 18 +- .../test_website/controllers/main.py | 34 +- .../test_website/data/test_website_data.xml | 36 +- .../test_website/models/__init__.py | 1 - .../test_website/models/model.py | 51 +- .../models/res_config_settings.py | 11 - .../test_website/models/website.py | 6 +- .../test_website/security/ir.model.access.csv | 20 +- .../security/test_website_security.xml | 11 +- .../static/src/interactions/test_error.js | 15 + .../test_website/static/src/js/test_error.js | 30 - .../static/tests/field_html_file_upload.js | 137 - .../static/tests/tours/custom_snippets.js | 117 +- .../static/tests/tours/error_views.js | 200 +- .../test_website/static/tests/tours/form.js | 59 + .../static/tests/tours/image_link.js | 65 +- .../tests/tours/image_upload_progress.js | 171 +- .../static/tests/tours/json_auth.js | 21 +- .../static/tests/tours/page_manager.js | 76 +- .../static/tests/tours/replace_media.js | 115 +- .../static/tests/tours/reset_views.js | 100 +- .../static/tests/tours/restricted_editor.js | 174 ++ .../tests/tours/snippet_background_video.js | 83 + .../static/tests/tours/systray.js | 242 +- .../static/tests/tours/translation.js | 499 +++ .../tests/tours/website_controller_page.js | 66 + .../tests/tours/website_field_sanitize.js | 60 + .../tests/tours/website_page_properties.js | 382 +++ .../static/tests/tours/website_settings.js | 82 +- .../test_website/tests/__init__.py | 9 + .../test_website/tests/asset_tag.xml | 35 + .../test_website/tests/template_qweb_test.xml | 32 + .../tests/test_controller_args.py | 13 +- .../test_website/tests/test_form.py | 14 + .../test_website/tests/test_fuzzy.py | 22 +- .../tests/test_image_upload_progress.py | 12 +- .../test_website/tests/test_is_multilang.py | 2 +- .../test_website/tests/test_menu.py | 14 +- .../test_website/tests/test_page.py | 5 +- .../test_website/tests/test_page_manager.py | 9 +- .../test_website/tests/test_performance.py | 3 +- .../test_website/tests/test_qweb.py | 88 + .../test_website/tests/test_redirect.py | 17 +- .../test_website/tests/test_reset_views.py | 10 +- .../tests/test_restricted_editor.py | 52 + .../test_website/tests/test_session.py | 67 +- .../test_website/tests/test_settings.py | 3 +- .../tests/test_snippet_background_video.py | 10 + .../test_website/tests/test_systray.py | 42 +- .../test_website/tests/test_theme_ir_asset.py | 77 + .../test_website/tests/test_translation.py | 129 + .../test_views_during_module_operation.py | 24 +- .../tests/test_website_controller_page.py | 195 ++ .../tests/test_website_field_sanitize.py | 32 + .../tests/test_website_page_properties.py | 27 + .../test_website/views/templates.xml | 58 +- .../views/test_model_multi_website_views.xml | 48 +- .../test_website/views/test_model_views.xml | 31 +- .../README.md | 35 +- .../pyproject.toml | 20 +- .../test_website_modules/__manifest__.py | 5 + .../static/tests/tours/configurator_flow.js | 230 +- .../test_website_modules/tests/__init__.py | 2 + .../tests/test_configurator.py | 2 +- .../tests/test_controllers.py | 151 + .../tests/test_performance.py | 382 +++ .../README.md | 33 +- .../pyproject.toml | 14 +- .../test_website_slides_full/__manifest__.py | 5 +- .../data/product_demo.xml | 6 +- .../tours/slides_certification_member.js | 164 + .../tests/test_ui_wslides.py | 135 +- .../tours/slides_certification_member.js | 176 -- 243 files changed, 30797 insertions(+), 10815 deletions(-) create mode 100644 odoo-bringout-oca-ocb-test_base_automation/test_base_automation/static/tests/tour/base_automation_tour.js create mode 100644 odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/test_server_actions.py create mode 100644 odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/test_tour.py create mode 100644 odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/static/tests/tours/avatar_card_tour.js create mode 100644 odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/static/tests/tours/chatbot_redirect_to_portal_tour.js create mode 100644 odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/static/tests/tours/im_livechat_session_open_tour.js create mode 100644 odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_avatar_card_tour.py create mode 100644 odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_im_livechat_portal.py create mode 100644 odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_livechat_hr_holidays.py create mode 100644 odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_livechat_session_open.py create mode 100644 odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_performance_inbox.py create mode 100644 odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_res_partner.py delete mode 100644 odoo-bringout-oca-ocb-test_event_full/test_event_full/data/event_type_data.xml create mode 100644 odoo-bringout-oca-ocb-test_event_full/test_event_full/static/src/js/tests/test_template_reference_field_widget.test.js delete mode 100644 odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_discount.py create mode 100644 odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_wevent_menu.py create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/models/mail_test_lead.py create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/models/mail_test_ticket.py create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_feature_models.py create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/activity.test.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/activity_mobile.test.js delete mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/activity_tests.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/attachment_view.test.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/chatter.test.js delete mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/chatter_tests.js delete mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/helpers/model_definitions_setup.js delete mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mobile/activity_tests.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_activity.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_multi_company.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_multi_company_read.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_properties.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_simple.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_simple_main_attachment.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_track_all.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/res_currency.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/properties_field.test.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/systray_activity_menu.test.js delete mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/systray_activity_menu_tests.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/test_mail_test_helpers.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/tours/mail_activity_view_tour.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/tracking_value.test.js delete mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/tracking_value_tests.js create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_controller_attachment.py create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_controller_binary.py create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_controller_thread.py create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_ir_attachment.py create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_activity_mixin.py create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_activity_plan.py create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_alias.py create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_flow.py create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_push.py create mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_scheduled_message.py delete mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_message_management.py delete mode 100644 odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_ui.py delete mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/i18n/bs.po delete mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/helpers/model_definitions_setup.js create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/messaging_menu_patch.test.js create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/mock_server/models/mail_test_rating.js delete mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/qunit_suite_tests/channel_preview_view_tests.js delete mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/qunit_suite_tests/thread_needaction_preview_tests.js create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/test_mail_full_test_helpers.js create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/load_more_tour.js create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/message_actions_tour.js create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_composer_actions_tour.js create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_copy_link_tour.js create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_no_copy_link_tour.js create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_rating_tour.js.js create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_attachment.py create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_reaction.py create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_thread.py create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_update.py create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_ir_mail_server.py rename odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/{test_odoobot.py => test_mail_bot.py} (89%) create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_ui.py create mode 100644 odoo-bringout-oca-ocb-test_mail_full/test_mail_full/views/test_portal_template.xml create mode 100644 odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_calendar.py create mode 100644 odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_mixin.py create mode 100644 odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_resource_errors.py create mode 100644 odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_timezones.py delete mode 100644 odoo-bringout-oca-ocb-test_website/test_website/models/res_config_settings.py create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/static/src/interactions/test_error.js delete mode 100644 odoo-bringout-oca-ocb-test_website/test_website/static/src/js/test_error.js delete mode 100644 odoo-bringout-oca-ocb-test_website/test_website/static/tests/field_html_file_upload.js create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/form.js create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/restricted_editor.js create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/snippet_background_video.js create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/translation.js create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/website_controller_page.js create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/website_field_sanitize.js create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/website_page_properties.js create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/tests/asset_tag.xml create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/tests/template_qweb_test.xml create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/tests/test_form.py create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/tests/test_qweb.py create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/tests/test_restricted_editor.py create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/tests/test_snippet_background_video.py create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/tests/test_theme_ir_asset.py create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/tests/test_translation.py create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/tests/test_website_controller_page.py create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/tests/test_website_field_sanitize.py create mode 100644 odoo-bringout-oca-ocb-test_website/test_website/tests/test_website_page_properties.py create mode 100644 odoo-bringout-oca-ocb-test_website_modules/test_website_modules/tests/test_controllers.py create mode 100644 odoo-bringout-oca-ocb-test_website_modules/test_website_modules/tests/test_performance.py create mode 100644 odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/static/tests/tours/slides_certification_member.js delete mode 100644 odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/tests/tours/slides_certification_member.js diff --git a/odoo-bringout-oca-ocb-test_base_automation/README.md b/odoo-bringout-oca-ocb-test_base_automation/README.md index 8ed964a..3b81d0f 100644 --- a/odoo-bringout-oca-ocb-test_base_automation/README.md +++ b/odoo-bringout-oca-ocb-test_base_automation/README.md @@ -12,37 +12,14 @@ pip install odoo-bringout-oca-ocb-test_base_automation ## Dependencies -This addon depends on: - base_automation -## Manifest Information - -- **Name**: Test - Base Automation -- **Version**: 1.0 -- **Category**: Hidden -- **License**: LGPL-3 -- **Installable**: True - ## Source -Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `test_base_automation`. +- Repository: https://github.com/OCA/OCB +- Branch: 19.0 +- Path: addons/test_base_automation ## License -This package maintains the original LGPL-3 license from the upstream Odoo project. - -## Documentation - -- Overview: doc/OVERVIEW.md -- Architecture: doc/ARCHITECTURE.md -- Models: doc/MODELS.md -- Controllers: doc/CONTROLLERS.md -- Wizards: doc/WIZARDS.md -- Reports: doc/REPORTS.md -- Security: doc/SECURITY.md -- Install: doc/INSTALL.md -- Usage: doc/USAGE.md -- Configuration: doc/CONFIGURATION.md -- Dependencies: doc/DEPENDENCIES.md -- Troubleshooting: doc/TROUBLESHOOTING.md -- FAQ: doc/FAQ.md +This package preserves the original LGPL-3 license. diff --git a/odoo-bringout-oca-ocb-test_base_automation/pyproject.toml b/odoo-bringout-oca-ocb-test_base_automation/pyproject.toml index 0026791..7375130 100644 --- a/odoo-bringout-oca-ocb-test_base_automation/pyproject.toml +++ b/odoo-bringout-oca-ocb-test_base_automation/pyproject.toml @@ -1,12 +1,14 @@ [project] name = "odoo-bringout-oca-ocb-test_base_automation" version = "16.0.0" -description = "Test - Base Automation - Base Automation Tests: Ensure Flow Robustness" +description = "Test - Base Automation - + Base Automation Tests: Ensure Flow Robustness + " authors = [ { name = "Ernad Husremovic", email = "hernad@bring.out.ba" } ] dependencies = [ - "odoo-bringout-oca-ocb-base_automation>=16.0.0", + "odoo-bringout-oca-ocb-base_automation>=19.0.0", "requests>=2.25.1" ] readme = "README.md" @@ -16,7 +18,7 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Office/Business", ] diff --git a/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/__manifest__.py b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/__manifest__.py index aa06d31..550aad3 100644 --- a/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/__manifest__.py +++ b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/__manifest__.py @@ -14,6 +14,12 @@ tests independently to functional aspects of other models.""", 'data': [ 'security/ir.model.access.csv', ], + 'assets': { + 'web.assets_tests': [ + 'test_base_automation/static/tests/**/*', + ], + }, 'installable': True, + 'author': 'Odoo S.A.', 'license': 'LGPL-3', } diff --git a/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/models/test_base_automation.py b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/models/test_base_automation.py index 8a58328..f664540 100644 --- a/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/models/test_base_automation.py +++ b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/models/test_base_automation.py @@ -5,8 +5,8 @@ from dateutil import relativedelta from odoo import fields, models, api -class LeadTest(models.Model): - _name = "base.automation.lead.test" +class BaseAutomationLeadTest(models.Model): + _name = 'base.automation.lead.test' _description = "Automated Rule Test" name = fields.Char(string='Subject', required=True) @@ -15,8 +15,9 @@ class LeadTest(models.Model): ('pending', 'Pending'), ('done', 'Closed')], string="Status", readonly=True, default='draft') active = fields.Boolean(default=True) + tag_ids = fields.Many2many('test_base_automation.tag') partner_id = fields.Many2one('res.partner', string='Partner') - date_action_last = fields.Datetime(string='Last Action', readonly=True) + date_automation_last = fields.Datetime(string='Last Automation', readonly=True) employee = fields.Boolean(compute='_compute_employee_deadline', store=True) line_ids = fields.One2many('base.automation.line.test', 'lead_id') @@ -24,12 +25,26 @@ class LeadTest(models.Model): deadline = fields.Boolean(compute='_compute_employee_deadline', store=True) is_assigned_to_admin = fields.Boolean(string='Assigned to admin user') + stage_id = fields.Many2one( + 'test_base_automation.stage', string='Stage', + compute='_compute_stage_id', readonly=False, store=True) + + @api.depends('state') + def _compute_stage_id(self): + Test_Base_AutomationStage = self.env['test_base_automation.stage'] + for task in self: + if not task.stage_id and task.state == 'draft': + task.stage_id = ( + Test_Base_AutomationStage.search([('name', 'ilike', 'new')], limit=1) + or Test_Base_AutomationStage.create({'name': 'New'}) + ) + @api.depends('partner_id.employee', 'priority') def _compute_employee_deadline(self): # this method computes two fields on purpose; don't split it for record in self: record.employee = record.partner_id.employee - if not record.priority: + if not record.priority or not record.create_date: record.deadline = False else: record.deadline = record.create_date + relativedelta.relativedelta(days=3) @@ -42,8 +57,15 @@ class LeadTest(models.Model): return result -class LineTest(models.Model): - _name = "base.automation.line.test" +class BaseAutomationLeadThreadTest(models.Model): + _name = 'base.automation.lead.thread.test' + _description = "Automated Rule Test With Thread" + _inherit = ['base.automation.lead.test', 'mail.thread'] + + user_id = fields.Many2one("res.users") + +class BaseAutomationLineTest(models.Model): + _name = 'base.automation.line.test' _description = "Automated Rule Line Test" name = fields.Char() @@ -51,31 +73,37 @@ class LineTest(models.Model): user_id = fields.Many2one('res.users') -class ModelWithAccess(models.Model): - _name = "base.automation.link.test" +class BaseAutomationLinkTest(models.Model): + _name = 'base.automation.link.test' _description = "Automated Rule Link Test" name = fields.Char() linked_id = fields.Many2one('base.automation.linked.test', ondelete='cascade') -class ModelWithoutAccess(models.Model): - _name = "base.automation.linked.test" +class BaseAutomationLinkedTest(models.Model): + _name = 'base.automation.linked.test' _description = "Automated Rule Linked Test" name = fields.Char() another_field = fields.Char() -class Project(models.Model): - _name = _description = 'test_base_automation.project' +class Test_Base_AutomationProject(models.Model): + _name = 'test_base_automation.project' + _description = 'test_base_automation.project' name = fields.Char() task_ids = fields.One2many('test_base_automation.task', 'project_id') + stage_id = fields.Many2one('test_base_automation.stage') + tag_ids = fields.Many2many('test_base_automation.tag') + priority = fields.Selection([('0', 'Low'), ('1', 'Normal'), ('2', 'High')], default='1') + user_ids = fields.Many2many('res.users') -class Task(models.Model): - _name = _description = 'test_base_automation.task' +class Test_Base_AutomationTask(models.Model): + _name = 'test_base_automation.task' + _description = 'test_base_automation.task' name = fields.Char() parent_id = fields.Many2one('test_base_automation.task') @@ -83,9 +111,72 @@ class Task(models.Model): 'test_base_automation.project', compute='_compute_project_id', recursive=True, store=True, readonly=False, ) + allocated_hours = fields.Float() + trigger_hours = fields.Float("Save time to trigger effective hours") + remaining_hours = fields.Float("Time Remaining", compute='_compute_remaining_hours', store=True, readonly=True, help="Number of allocated hours minus the number of hours spent.") + effective_hours = fields.Float("Time Spent", compute='_compute_effective_hours', compute_sudo=True, store=True) @api.depends('parent_id.project_id') def _compute_project_id(self): for task in self: if not task.project_id: task.project_id = task.parent_id.project_id + + @api.depends('trigger_hours') + def _compute_effective_hours(self): + for task in self: + task.effective_hours = task.trigger_hours + + @api.depends('effective_hours') + def _compute_remaining_hours(self): + for task in self: + if not task.allocated_hours: + task.remaining_hours = 0.0 + else: + task.remaining_hours = task.allocated_hours - task.effective_hours + + +class Test_Base_AutomationStage(models.Model): + _name = 'test_base_automation.stage' + _description = 'test_base_automation.stage' + name = fields.Char() + + +class Test_Base_AutomationTag(models.Model): + _name = 'test_base_automation.tag' + _description = 'test_base_automation.tag' + name = fields.Char() + + +# pylint: disable=E0102 +class BaseAutomationLeadThreadTest(models.Model): # noqa: F811 + _name = 'base.automation.lead.thread.test' + _inherit = ["base.automation.lead.test", "mail.thread"] + _description = "Threaded Lead Test" + + +class BaseAutomationModelWithRecnameChar(models.Model): + _name = 'base.automation.model.with.recname.char' + _description = "Model with Char as _rec_name" + _rec_name = "description" + description = fields.Char() + user_id = fields.Many2one('res.users', string='Responsible') + + +class BaseAutomationModelWithRecnameM2o(models.Model): + _name = 'base.automation.model.with.recname.m2o' + _description = "Model with Many2one as _rec_name and name_create" + _rec_name = "user_id" + user_id = fields.Many2one("base.automation.model.with.recname.char", string='Responsible') + + @api.model + def name_create(self, name): + name = name.strip() + user = self.env["base.automation.model.with.recname.char"].search([('description', '=ilike', name)], limit=1) + if user: + user_id = user.id + else: + user_id, _user_name = self.env["base.automation.model.with.recname.char"].name_create(name) + + record = self.create({'user_id': user_id}) + return record.id, record.display_name diff --git a/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/security/ir.model.access.csv b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/security/ir.model.access.csv index 24605b6..002716a 100644 --- a/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/security/ir.model.access.csv +++ b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/security/ir.model.access.csv @@ -1,7 +1,12 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_base_automation_lead_test,access_base_automation_lead_test,model_base_automation_lead_test,base.group_system,1,1,1,1 +access_base_automation_lead_thread_test,access_base_automation_lead_thread_test,model_base_automation_lead_thread_test,base.group_system,1,1,1,1 access_base_automation_line_test,access_base_automation_line_test,model_base_automation_line_test,base.group_system,1,1,1,1 -access_base_automation_link_test,access_base_automation_link_test,model_base_automation_link_test,,1,1,1,1 -access_base_automation_linked_test,access_base_automation_linked_test,model_base_automation_linked_test,,1,1,1,1 -access_test_base_automation_project,access_test_base_automation_project,model_test_base_automation_project,,1,1,1,1 -access_test_base_automation_task,access_test_base_automation_task,model_test_base_automation_task,,1,1,1,1 +access_base_automation_link_test,access_base_automation_link_test,model_base_automation_link_test,base.group_user,1,1,1,1 +access_base_automation_linked_test,access_base_automation_linked_test,model_base_automation_linked_test,base.group_user,1,1,1,1 +access_base_automation_model_with_recname_char,access_base_automation_model_with_recname_char,model_base_automation_model_with_recname_char,base.group_user,1,1,1,1 +access_base_automation_model_with_recname_m2o,access_base_automation_model_with_recname_m2o,model_base_automation_model_with_recname_m2o,base.group_user,1,1,1,1 +access_test_base_automation_project,access_test_base_automation_project,model_test_base_automation_project,base.group_user,1,1,1,1 +access_test_base_automation_task,access_test_base_automation_task,model_test_base_automation_task,base.group_user,1,1,1,1 +access_test_base_automation_stage,access_test_base_automation_stage,model_test_base_automation_stage,base.group_user,1,1,1,1 +access_test_base_automation_tag,access_test_base_automation_tag,model_test_base_automation_tag,base.group_user,1,1,1,1 diff --git a/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/static/tests/tour/base_automation_tour.js b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/static/tests/tour/base_automation_tour.js new file mode 100644 index 0000000..0832011 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/static/tests/tour/base_automation_tour.js @@ -0,0 +1,671 @@ +import { registry } from "@web/core/registry"; +import { stepUtils } from "@web_tour/tour_utils"; + +function assertEqual(actual, expected) { + if (actual !== expected) { + throw new Error(`Assert failed: expected: ${expected} ; got: ${actual}`); + } +} + +registry.category("web_tour.tours").add("test_base_automation", { + steps: () => [ + stepUtils.showAppsMenuItem(), + { + content: "Create new rule", + trigger: ".o_control_panel button.o-kanban-button-new", + run: "click", + }, + { + content: "Enter rule name", + trigger: ".o_form_renderer .oe_title .o_input", + run: "edit Test rule", + }, + { + content: "Select model", + trigger: '.o_form_renderer .o_group div[name="model_id"] input', + run: "edit res.partner", + }, + { + trigger: ".dropdown-menu:contains(Contact)", + }, + { + content: "Select model contact", + trigger: ".dropdown-menu li a:contains(Contact):not(:has(.fa-spin))", + run: "click", + }, + { + content: "Open select", + trigger: ".o_form_renderer #trigger_0", + run: "click", + }, + { + trigger: ".o_select_menu_item:contains(On create and edit)", + run: "click", + }, + { + content: "Add new action", + trigger: '.o_form_renderer div[name="action_server_ids"] button', + run: "click", + }, + { + content: "Set new action to update the record", + trigger: + ".modal .modal-content .o_form_renderer [name='state'] span[value*='object_write']", + run: "click", + }, + { + content: "Focus on the 'update_path' field", + trigger: + ".modal .modal-content .o_form_renderer [name='update_path'] .o_model_field_selector", + run: "click", + }, + { + content: "Input field name", + trigger: ".o_model_field_selector_popover .o_model_field_selector_popover_search input", + run: "edit Job Position", + }, + { + content: "Select field", + trigger: + '.o_model_field_selector_popover .o_model_field_selector_popover_page li[data-name="function"] button', + run: "click", + }, + { + content: "Open update select", + trigger: + '.modal .modal-content .o_form_renderer div[name="value"] textarea', + run: "edit Test", + }, + { + content: "Open update select", + trigger: ".modal .modal-content .o_form_button_save", + run: "click", + }, + { + trigger: "body:not(:has(.modal))", + }, + ...stepUtils.saveForm(), + ], +}); + +registry.category("web_tour.tours").add("test_base_automation_on_tag_added", { + steps: () => [ + stepUtils.showAppsMenuItem(), + { + trigger: ".o_control_panel button.o-kanban-button-new", + run: "click", + }, + { + trigger: ".o_form_renderer .oe_title .o_input", + run: "edit Test rule", + }, + { + trigger: '.o_form_renderer .o_group div[name="model_id"] input', + run: "edit test_base_automation.project", + }, + { + trigger: + ".dropdown-menu li a:contains(test_base_automation.project):not(:has(.fa-spin))", + run: "click", + }, + { + content: "Open select", + trigger: ".o_form_renderer #trigger_0", + run: "click", + }, + { + trigger: ".o_select_menu_menu", + run() { + const options = [...this.anchor.querySelectorAll(".o_select_menu_item")].map( + (el) => el.textContent + ); + + assertEqual( + JSON.stringify(options), + JSON.stringify([ + "Stage is set to", + "User is set", + "Tag is added", + "Priority is set to", + "Based on date field", + "After creation", + "After last update", + "On create", + "On create and edit", + "On deletion", + "On UI change", + "On webhook" + ]) + ); + }, + }, + { + trigger: ".o_select_menu_item:contains(Tag is added)", + run: "click", + }, + { + trigger: '.o_form_renderer div[name="trg_field_ref"] input', + run: "edit test", + }, + { + trigger: ".dropdown-menu li a:contains(test):not(:has(.fa-spin))", + run: "click", + }, + { + trigger: '.o_form_renderer div[name="action_server_ids"] button', + run: "click", + }, + { + trigger: + ".modal .modal-content .o_form_renderer [name='state'] span[value*='object_write']", + run: "click", + }, + { + content: "Focus on the 'update_path' field", + trigger: + ".modal .modal-content .o_form_renderer [name='update_path'] .o_model_field_selector", + run: "click", + }, + { + content: "Input field name", + trigger: + ".o_model_field_selector_popover .o_model_field_selector_popover_search input", + run: "edit Name", + }, + { + content: "Select field", + trigger: + '.o_model_field_selector_popover .o_model_field_selector_popover_page li[data-name="name"] button', + run: "click", + }, + { + trigger: + '.modal .modal-content .o_form_renderer div[name="value"] textarea', + run: "edit Test", + }, + { + trigger: ".modal .modal-content .o_form_button_save", + run: "click", + }, + { + trigger: "body:not(:has(.modal))", + }, + { + trigger: '.o_form_renderer div[name="action_server_ids"] button', + run: "click", + }, + { + trigger: + ".modal .modal-content .o_form_renderer [name='state'] span[value*='object_write']", + run: "click", + }, + { + content: "Focus on the 'update_path' field", + trigger: + ".modal .modal-content .o_form_renderer [name='update_path'] .o_model_field_selector", + run: "click", + }, + { + content: "Input field name", + trigger: + ".o_model_field_selector_popover .o_model_field_selector_popover_search input", + run: "edit Priority", + }, + { + content: "Select field", + trigger: + '.o_model_field_selector_popover .o_model_field_selector_popover_page li[data-name="priority"] button', + run: "click", + }, + { + trigger: + '.modal .modal-content .o_form_renderer div[name="selection_value"] input', + run: "edit High", + }, + { + trigger: ".dropdown-menu li a:contains(High):not(:has(.fa-spin))", + run: "click", + }, + { + trigger: ".modal .modal-content .o_form_button_save", + run: "click", + }, + { + trigger: "body:not(:has(.modal-content))", + }, + ...stepUtils.saveForm(), + { + trigger: ".breadcrumb .o_back_button a", + run: "click", + }, + { + trigger: ".o_base_automation_kanban_view .o_kanban_record", + run() { + assertEqual( + this.anchor.querySelector(".o_automation_base_info").textContent, + "Test ruletest_base_automation.projectTag is addedtest" + ); + assertEqual( + this.anchor.querySelector(".o_automation_actions").textContent, + "Update test_base_automation.projectUpdate test_base_automation.project" + ); + }, + }, + ], +}); + +registry.category("web_tour.tours").add("test_open_automation_from_grouped_kanban", { + steps: () => [ + { + trigger: ".o_kanban_header:contains(test tag)", + run: "hover && click .o_kanban_view .o_group_config button.dropdown-toggle", + }, + { + trigger: ".dropdown-menu .o_column_automations", + run: "click", + }, + { + trigger: ".o_base_automation_kanban_view .o_control_panel button.o-kanban-button-new", + run: "click", + }, + { + trigger: ".o_form_view", + run() { + assertEqual( + this.anchor.querySelector(".o_field_widget[name='trigger'] input").value, + "Tag is added" + ); + assertEqual( + this.anchor.querySelector(".o_field_widget[name='trg_field_ref'] input").value, + "test tag" + ); + }, + }, + { + trigger: ".o_form_view .o_field_widget[name='name'] input", + run: "edit From Tour", + }, + ...stepUtils.saveForm(), + ], +}); + +registry.category("web_tour.tours").add("test_kanban_automation_view_stage_trigger", { + steps: () => [ + { + trigger: ".o_base_automation_kanban_view", + }, + { + trigger: ".o_kanban_record .fs-2:contains(Test Stage)", + }, + { + trigger: ".o_kanban_record .o_tag:contains(Stage value)", + }, + ], +}); + +registry.category("web_tour.tours").add("test_kanban_automation_view_time_trigger", { + steps: () => [ + { + trigger: ".o_base_automation_kanban_view", + }, + { + trigger: ".o_automation_base_info > div > div > span:nth-child(1):contains(1)", + }, + { + trigger: ".o_automation_base_info .text-lowercase:contains(hours)", + }, + { + trigger: `.o_kanban_record .o_tag:contains("Last Automation (Automated Rule Test)")`, + }, + ], +}); + +registry.category("web_tour.tours").add("test_kanban_automation_view_time_updated_trigger", { + steps: () => [ + { + trigger: ".o_base_automation_kanban_view", + }, + { + trigger: ".o_automation_base_info > div > div > span:nth-child(1):contains(1)", + async run() { + const lowercaseTexts = document.querySelectorAll( + ".o_automation_base_info .text-lowercase" + ); + assertEqual(lowercaseTexts.length, 2); + assertEqual(lowercaseTexts[0].innerText, "hours"); + assertEqual(lowercaseTexts[1].innerText, "after last update"); + }, + }, + ], +}); + +registry.category("web_tour.tours").add("test_kanban_automation_view_create_action", { + steps: () => [ + { + trigger: ".o_base_automation_kanban_view", + }, + { + trigger: "div[name='action_server_ids']:contains(Create Contact with name NameX)", + async run() { + assertEqual(document.querySelectorAll(".fa.fa-plus-square").length, 1); + }, + }, + ], +}); + +registry.category("web_tour.tours").add("test_resize_kanban", { + steps: () => [ + { + trigger: ".o_base_automation_kanban_view", + }, + { + trigger: + ".o_automation_actions:contains(Set Active To False Set Active To False Set Active To False)", + async run() { + document.body.style.setProperty("width", "500px"); + window.dispatchEvent(new Event("resize")); + }, + }, + { + trigger: ".o_automation_actions:contains(Set Active To False 2 actions)", + }, + ], +}); + +registry.category("web_tour.tours").add("test_form_view_resequence_actions", { + steps: () => [ + { + trigger: + ".o_form_renderer .o_field_widget[name='action_server_ids'] .o_kanban_renderer", + async run() { + assertEqual( + this.anchor.innerText, + "Update Active 0\nUpdate Active 1\nUpdate Active 2" + ); + }, + }, + { + trigger: + ".o_form_renderer .o_field_widget[name='action_server_ids'] .o_kanban_record:nth-child(3)", + run: "drag_and_drop(.o_form_renderer .o_field_widget[name='action_server_ids'] .o_kanban_record:nth-child(1))", + }, + ...stepUtils.saveForm(), + { + trigger: + ".o_form_renderer .o_field_widget[name='action_server_ids'] .o_kanban_renderer", + async run() { + assertEqual( + this.anchor.innerText, + "Update Active 2\nUpdate Active 0\nUpdate Active 1" + ); + }, + }, + { + trigger: + ".o_form_renderer .o_field_widget[name='action_server_ids'] .o_kanban_view .o_cp_buttons button", + run: "click", + }, + { + trigger: ".modal-content .o_form_renderer", + run() { + const allFields = this.anchor.querySelectorAll(".o_field_widget[name]"); + assertEqual( + Array.from(allFields) + .map((el) => el.getAttribute("name")) + .includes("model_id"), + false + ); + }, + }, + { + trigger: ".modal-content .o_form_renderer [name='state'] span[value*='followers']", + run: "click", + }, + { + trigger: + ".modal-content .o_form_renderer [name='state'] span.active[value*='followers']", + }, + { + trigger: ".modal-content .o_form_button_cancel", + run: "click", + }, + { + trigger: "body:not(:has(.modal-content))", + }, + ], +}); + +registry.category("web_tour.tours").add("test_form_view_model_id", { + steps: () => [ + { + trigger: ".o_field_widget[name='model_id'] input", + run: "edit base.automation.line.test", + }, + { + trigger: ".dropdown-menu li a:contains(Automated Rule Line Test)", + run: "click", + }, + { + trigger: ".o_field_widget[name='trigger'] input", + run: "click", + }, + { + trigger: ".o_select_menu_menu", + run() { + assertEqual( + Array.from(this.anchor.querySelectorAll(".o_select_menu_group")) + .map((el) => el.textContent) + .join(", "), + "Values Updated, Timing Conditions, Custom, External" + ); + assertEqual( + Array.from(this.anchor.querySelectorAll(".o_select_menu_item")) + .map((el) => el.textContent) + .join(", "), + "User is set, Based on date field, After creation, After last update, On create, On create and edit, On deletion, On UI change, On webhook" + ); + } + }, + { + trigger: ".o_field_widget[name='model_id'] input", + run: "edit test_base_automation.project", + }, + { + trigger: ".dropdown-menu li a:contains(test_base_automation.project)", + run: "click", + }, + { + trigger: ".o_field_widget[name='trigger'] input", + run: "click", + }, + { + trigger: ".o_select_menu_menu", + run() { + assertEqual( + Array.from(this.anchor.querySelectorAll(".o_select_menu_group")) + .map((el) => el.textContent) + .join(", "), + "Values Updated, Timing Conditions, Custom, External" + ); + assertEqual( + Array.from(this.anchor.querySelectorAll(".o_select_menu_item")) + .map((el) => el.textContent) + .join(", "), + "Stage is set to, User is set, Tag is added, Priority is set to, Based on date field, After creation, After last update, On create, On create and edit, On deletion, On UI change, On webhook" + ); + } + }, + { + trigger: ".o_form_button_cancel", + run: "click", + }, + { + trigger: ".o_base_automation_kanban_view", + }, + ], +}); + +registry.category("web_tour.tours").add("test_form_view_custom_reference_field", { + steps: () => [ + { + trigger: ".o_field_widget[name='model_id'] input", + run: "edit test_base_automation.project", + }, + { + trigger: ".dropdown-menu li a:contains(test_base_automation.project)", + run: "click", + }, + { + trigger: "body:not(:has(.o_field_widget[name='trg_field_ref']))", + }, + { + content: "Open select", + trigger: ".o_form_renderer #trigger_0", + run: "click", + }, + { + trigger: ".o_select_menu_item:contains(Stage is set to)", + run: "click", + }, + { + trigger: ".o_field_widget[name='trg_field_ref'] input", + run: "fill test", + }, + { + trigger: + ".o_field_widget[name='trg_field_ref'] .o-autocomplete--dropdown-menu:not(:has(a .fa-spin)", + run() { + assertEqual(this.anchor.innerText, "test stage\nSearch more..."); + }, + }, + { + content: "Open select", + trigger: ".o_form_renderer #trigger_0", + run: "click", + }, + { + trigger: ".o_select_menu_item:contains(Tag is added)", + run: "click", + }, + { + trigger: + ".o_field_widget[name='trg_field_ref'] :not(:has(.o-autocomplete--dropdown-menu))", + }, + { + trigger: ".o_field_widget[name='trg_field_ref'] input", + run: "fill test", + }, + { + trigger: + ".o_field_widget[name='trg_field_ref'] .o-autocomplete--dropdown-menu:not(:has(a .fa-spin)", + run() { + assertEqual(this.anchor.innerText, "test tag\nSearch more..."); + }, + }, + { + trigger: ".o_form_button_cancel", + run: "click", + }, + { + trigger: ".o_base_automation_kanban_view", + }, + ], +}); + +registry.category("web_tour.tours").add("test_form_view_mail_triggers", { + steps: () => [ + { + trigger: ".o_field_widget[name='model_id'] input", + run: "edit base.automation.lead.test", + }, + { + trigger: ".dropdown-menu li a:contains(Automated Rule Test)", + run: "click", + }, + { + trigger: ".o_field_widget[name='trigger'] input", + run: "click", + }, + { + trigger: ".o_select_menu_menu", + run() { + assertEqual( + Array.from(this.anchor.querySelectorAll(".o_select_menu_group")) + .map((el) => el.textContent) + .join(", "), + "Values Updated, Timing Conditions, Custom, External" + ); + }, + }, + { + trigger: ".o_field_widget[name='model_id'] input", + run: "edit base.automation.lead.thread.test", + }, + { + trigger: ".dropdown-menu li a:contains(Threaded Lead Test)", + run: "click", + }, + { + trigger: ".o_field_widget[name='trigger'] input", + run: "click", + }, + { + trigger: ".o_select_menu_menu", + run() { + assertEqual( + Array.from(this.anchor.querySelectorAll(".o_select_menu_group ")) + .map((el) => el.textContent) + .join(", "), + "Values Updated, Email Events, Timing Conditions, Custom, External" + ); + } + }, + { + trigger: "button.o_form_button_cancel", + run: "click", + }, + { + trigger: "body:not(:has(button.o_form_button_cancel)", + }, + ], +}); + +registry.category("web_tour.tours").add("base_automation.on_change_rule_creation", { + url: "/odoo/action-base_automation.base_automation_act", + steps: () => [ + { + trigger: ".o-kanban-button-new", + run: "click", + }, + { + trigger: ".o_field_widget[name=name] input", + run: "edit Test rule", + }, + { + trigger: ".o_field_widget[name=model_id] input", + run: "edit ir.ui.view", + }, + { + trigger: ".ui-menu-item > a:text(View)", + run: "click", + }, + { + content: "Open select", + trigger: ".o_form_renderer #trigger_0", + run: "click", + }, + { + trigger: ".o_select_menu_item:contains(On UI change)", + run: "click", + }, + { + trigger: ".o_field_widget[name=on_change_field_ids] input", + run: "edit Active", + }, + { + trigger: ".ui-menu-item > a:text(Active)", + run: "click", + }, + ...stepUtils.saveForm(), + ], +}); diff --git a/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/__init__.py b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/__init__.py index b96c2ec..a85f5d0 100644 --- a/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/__init__.py +++ b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/__init__.py @@ -2,3 +2,5 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import test_flow +from . import test_server_actions +from . import test_tour diff --git a/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/test_flow.py b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/test_flow.py index e62f850..ff7e047 100644 --- a/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/test_flow.py +++ b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/test_flow.py @@ -1,112 +1,63 @@ # # -*- coding: utf-8 -*- # # Part of Odoo. See LICENSE file for full copyright and licensing details. - -from unittest.mock import patch +import datetime +import json import sys +from freezegun import freeze_time +from unittest.mock import patch +from odoo import Command from odoo.addons.base.tests.common import TransactionCaseWithUserDemo -from odoo.tests import common, tagged -from odoo.exceptions import AccessError +from odoo.exceptions import AccessError, ValidationError +from odoo.tests import Form, common, tagged, WhitespaceInsensitive +from odoo.tools import mute_logger + + +def create_automation(self, **kwargs): + """ + Create a transient automation with the given data and actions + The created automation is cleaned up at the end of the calling test + """ + vals = {'name': 'Automation'} + vals.update(kwargs) + actions_data = vals.pop('_actions', []) + if not isinstance(actions_data, list): + actions_data = [actions_data] + automation_id = self.env['base.automation'].create(vals) + action_ids = self.env['ir.actions.server'].create( + [ + { + 'name': 'Action', + 'base_automation_id': automation_id.id, + 'model_id': automation_id.model_id.id, + 'usage': 'base_automation', + **action, + } + for action in actions_data + ] + ) + action_ids.flush_recordset() + automation_id.write({'action_server_ids': [Command.set(action_ids.ids)]}) + self.addCleanup(automation_id.unlink) + return automation_id @tagged('post_install', '-at_install') class BaseAutomationTest(TransactionCaseWithUserDemo): - def setUp(self): super(BaseAutomationTest, self).setUp() self.user_root = self.env.ref('base.user_root') self.user_admin = self.env.ref('base.user_admin') - - self.test_mail_template_automation = self.env['mail.template'].create({ - 'name': 'Template Automation', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'body_html': """<div>Email automation</div>""", - }) - - self.res_partner_1 = self.env['res.partner'].create({'name': 'My Partner'}) - self.env['base.automation'].create([ + self.lead_model = self.env.ref('test_base_automation.model_base_automation_lead_test') + self.project_model = self.env.ref('test_base_automation.model_test_base_automation_project') + self.test_mail_template_automation = self.env['mail.template'].create( { - 'name': 'Base Automation: test rule on create', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'state': 'code', - 'code': "records.write({'user_id': %s})" % (self.user_demo.id), - 'trigger': 'on_create', - 'active': True, - 'filter_domain': "[('state', '=', 'draft')]", - }, { - 'name': 'Base Automation: test rule on write', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'state': 'code', - 'code': "records.write({'user_id': %s})" % (self.user_demo.id), - 'trigger': 'on_write', - 'active': True, - 'filter_domain': "[('state', '=', 'done')]", - 'filter_pre_domain': "[('state', '=', 'open')]", - }, { - 'name': 'Base Automation: test rule on recompute', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'state': 'code', - 'code': "records.write({'user_id': %s})" % (self.user_demo.id), - 'trigger': 'on_write', - 'active': True, - 'filter_domain': "[('employee', '=', True)]", - }, { - 'name': 'Base Automation: test recursive rule', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'state': 'code', - 'code': """ -record = model.browse(env.context['active_id']) -if 'partner_id' in env.context['old_values'][record.id]: - record.write({'state': 'draft'})""", - 'trigger': 'on_write', - 'active': True, - }, { - 'name': 'Base Automation: test rule on secondary model', - 'model_id': self.env.ref('test_base_automation.model_base_automation_line_test').id, - 'state': 'code', - 'code': "records.write({'user_id': %s})" % (self.user_demo.id), - 'trigger': 'on_create', - 'active': True, - }, { - 'name': 'Base Automation: test rule on write check context', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'state': 'code', - 'code': """ -record = model.browse(env.context['active_id']) -if 'user_id' in env.context['old_values'][record.id]: - record.write({'is_assigned_to_admin': (record.user_id.id == 1)})""", - 'trigger': 'on_write', - 'active': True, - }, { - 'name': 'Base Automation: test rule with trigger', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'trigger_field_ids': [(4, self.env.ref('test_base_automation.field_base_automation_lead_test__state').id)], - 'state': 'code', - 'code': """ -record = model.browse(env.context['active_id']) -record['name'] = record.name + 'X'""", - 'trigger': 'on_write', - 'active': True, - }, { - 'name': 'Base Automation: test send an email', - 'mail_post_method': 'email', - 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, - 'template_id': self.test_mail_template_automation.id, - 'trigger_field_ids': [(4, self.env.ref('test_base_automation.field_base_automation_lead_test__deadline').id)], - 'state': 'mail_post', - 'code': """ -record = model.browse(env.context['active_id']) -record['name'] = record.name + 'X'""", - 'trigger': 'on_write', - 'active': True, - 'filter_domain': "[('deadline', '!=', False)]", - 'filter_pre_domain': "[('deadline', '=', False)]", + 'name': 'Template Automation', + 'model_id': self.env['ir.model']._get_id("base.automation.lead.thread.test"), + 'body_html': """<div>Email automation</div>""", } - ]) - - def tearDown(self): - super().tearDown() - self.env['base.automation']._unregister_hook() + ) + self.res_partner_1 = self.env['res.partner'].create({'name': 'My Partner'}) def create_lead(self, **kwargs): vals = { @@ -114,164 +65,170 @@ record['name'] = record.name + 'X'""", 'user_id': self.user_root.id, } vals.update(kwargs) - return self.env['base.automation.lead.test'].create(vals) + lead = self.env['base.automation.lead.test'].create(vals) + self.addCleanup(lead.unlink) + return lead - def test_00_check_to_state_open_pre(self): + def create_line(self, **kwargs): + vals = { + 'name': 'Line Test', + 'user_id': self.user_root.id, + } + vals.update(kwargs) + line = self.env['base.automation.line.test'].create(vals) + self.addCleanup(line.unlink) + return line + + def create_project(self, **kwargs): + vals = {'name': 'Project Test'} + vals.update(kwargs) + project = self.env['test_base_automation.project'].create(vals) + self.addCleanup(project.unlink) + return project + + def create_stage(self, **kwargs): + vals = {'name': 'Stage Test'} + vals.update(kwargs) + stage = self.env['test_base_automation.stage'].create(vals) + self.addCleanup(stage.unlink) + return stage + + def create_tag(self, **kwargs): + vals = {'name': 'Tag Test'} + vals.update(kwargs) + tag = self.env['test_base_automation.tag'].create(vals) + self.addCleanup(tag.unlink) + return tag + + def test_000_on_create_or_write(self): """ - Check that a new record (with state = open) doesn't change its responsible - when there is a precondition filter which check that the state is open. + Test case: on save, simple case + - trigger: on_create_or_write """ - lead = self.create_lead(state='open') + # --- Without the automation --- + lead = self.create_lead() + self.assertEqual(lead.state, 'draft') + self.assertEqual(lead.user_id, self.user_root) + + # --- With the automation --- + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + _actions={'state': 'code', 'code': "record.write({'user_id': %s})" % (self.user_demo.id)}, + ) + + # Write a lead should trigger the automation + lead.write({'state': 'open'}) self.assertEqual(lead.state, 'open') - self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.") + self.assertEqual(lead.user_id, self.user_demo) - def test_01_check_to_state_draft_post(self): + # Create a lead should trigger the automation + lead2 = self.create_lead() + self.assertEqual(lead2.state, 'draft') + self.assertEqual(lead2.user_id, self.user_demo) + + def test_001_on_create_or_write(self): """ - Check that a new record changes its responsible when there is a postcondition - filter which check that the state is draft. + Test case: on save, with filter_domain + - trigger: on_create_or_write + - apply when: state is 'draft' """ - lead = self.create_lead() - self.assertEqual(lead.state, 'draft', "Lead state should be 'draft'") - self.assertEqual(lead.user_id, self.user_demo, "Responsible should be change on creation of Lead with state 'draft'.") + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_domain="[('state', '=', 'draft')]", + _actions={'state': 'code', 'code': "record.write({'user_id': %s})" % (self.user_demo.id)}, + ) - def test_02_check_from_draft_to_done_with_steps(self): - """ - A new record is created and goes from states 'open' to 'done' via the - other states (open, pending and cancel). We have a rule with: - - precondition: the record is in "open" - - postcondition: that the record is "done". - If the state goes from 'open' to 'done' the responsible is changed. - If those two conditions aren't verified, the responsible remains the same. - """ - lead = self.create_lead(state='open') - self.assertEqual(lead.state, 'open', "Lead state should be 'open'") - self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.") - # change state to pending and check that responsible has not changed - lead.write({'state': 'pending'}) - self.assertEqual(lead.state, 'pending', "Lead state should be 'pending'") - self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.") - # change state to done and check that responsible has not changed - lead.write({'state': 'done'}) - self.assertEqual(lead.state, 'done', "Lead state should be 'done'") - self.assertEqual(lead.user_id, self.user_root, "Responsible should not chang on creation of Lead with state from 'pending' to 'done'.") - - def test_03_check_from_draft_to_done_without_steps(self): - """ - A new record is created and goes from states 'open' to 'done' via the - other states (open, pending and cancel). We have a rule with: - - precondition: the record is in "open" - - postcondition: that the record is "done". - If the state goes from 'open' to 'done' the responsible is changed. - If those two conditions aren't verified, the responsible remains the same. - """ - lead = self.create_lead(state='open') - self.assertEqual(lead.state, 'open', "Lead state should be 'open'") - self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.") - # change state to done and check that responsible has changed - lead.write({'state': 'done'}) - self.assertEqual(lead.state, 'done', "Lead state should be 'done'") - self.assertEqual(lead.user_id, self.user_demo, "Responsible should be change on write of Lead with state from 'open' to 'done'.") - - def test_10_recomputed_field(self): - """ - Check that a rule is executed whenever a field is recomputed after a - change on another model. - """ - partner = self.res_partner_1 - partner.write({'employee': False}) - lead = self.create_lead(state='open', partner_id=partner.id) - self.assertFalse(lead.employee, "Customer field should updated to False") - self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.") - # change partner, recompute on lead should trigger the rule - partner.write({'employee': True}) - self.env.flush_all() - self.assertTrue(lead.employee, "Customer field should updated to True") - self.assertEqual(lead.user_id, self.user_demo, "Responsible should be change on write of Lead when Customer becomes True.") - - def test_11_recomputed_field(self): - """ - Check that a rule is executed whenever a field is recomputed and the - context contains the target field - """ - partner = self.res_partner_1 - lead = self.create_lead(state='draft', partner_id=partner.id) - self.assertFalse(lead.deadline, 'There should not be a deadline defined') - # change priority and user; this triggers deadline recomputation, and - # the server action should set the boolean field to True - lead.write({'priority': True, 'user_id': self.user_root.id}) - self.assertTrue(lead.deadline, 'Deadline should be defined') - self.assertTrue(lead.is_assigned_to_admin, 'Lead should be assigned to admin') - - def test_11b_recomputed_field(self): - mail_automation = self.env['base.automation'].search([('name', '=', 'Base Automation: test send an email')]) - send_mail_count = 0 - - def _patched_get_actions(*args, **kwargs): - obj = args[0] - if '__action_done' not in obj._context: - obj = obj.with_context(__action_done={}) - return mail_automation.with_env(obj.env) - - def _patched_send_mail(*args, **kwargs): - nonlocal send_mail_count - send_mail_count += 1 - - patchers = [ - patch('odoo.addons.base_automation.models.base_automation.BaseAutomation._get_actions', _patched_get_actions), - patch('odoo.addons.mail.models.mail_template.MailTemplate.send_mail', _patched_send_mail), - ] - - self.startPatcher(patchers[0]) - - lead = self.create_lead() - self.assertFalse(lead.priority) - self.assertFalse(lead.deadline) - - self.startPatcher(patchers[1]) - - lead.write({'priority': True}) - - self.assertTrue(lead.priority) - self.assertTrue(lead.deadline) - - - self.assertEqual(send_mail_count, 1) - - def test_12_recursive(self): - """ Check that a rule is executed recursively by a secondary change. """ + # Create a lead with state=open should not trigger the automation lead = self.create_lead(state='open') self.assertEqual(lead.state, 'open') self.assertEqual(lead.user_id, self.user_root) - # change partner; this should trigger the rule that modifies the state - partner = self.res_partner_1 - lead.write({'partner_id': partner.id}) + + # Write a lead to state=draft should trigger the automation + lead.write({'state': 'draft'}) self.assertEqual(lead.state, 'draft') + self.assertEqual(lead.user_id, self.user_demo) - def test_20_direct_line(self): - """ - Check that a rule is executed after creating a line record. - """ - line = self.env['base.automation.line.test'].create({'name': "Line"}) - self.assertEqual(line.user_id, self.user_demo) + # Create a lead with state=draft should trigger the automation + lead_2 = self.create_lead() + self.assertEqual(lead_2.state, 'draft') + self.assertEqual(lead_2.user_id, self.user_demo) - def test_20_indirect_line(self): + def test_002_on_create_or_write(self): """ - Check that creating a lead with a line executes rules on both records. + Test case: on save, with filter_pre_domain and filter_domain + - trigger: on_create_or_write + - before update filter: state is 'open' + - apply when: state is 'done' """ - lead = self.create_lead(line_ids=[(0, 0, {'name': "Line"})]) - self.assertEqual(lead.state, 'draft', "Lead state should be 'draft'") - self.assertEqual(lead.user_id, self.user_demo, "Responsible should change on creation of Lead test line.") - self.assertEqual(len(lead.line_ids), 1, "New test line is not created") - self.assertEqual(lead.line_ids.user_id, self.user_demo, "Responsible should be change on creation of Lead test line.") + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_pre_domain="[('state', '=', 'open')]", + filter_domain="[('state', '=', 'done')]", + _actions={'state': 'code', 'code': "record.write({'user_id': %s})" % (self.user_demo.id)}, + ) + + # Create a lead with state=open should not trigger the automation + lead = self.create_lead(state='open') + self.assertEqual(lead.state, 'open') + self.assertEqual(lead.user_id, self.user_root) + + # Write a lead to state=pending THEN to state=done should not trigger the automation + lead.write({'state': 'pending'}) + self.assertEqual(lead.state, 'pending') + self.assertEqual(lead.user_id, self.user_root) + lead.write({'state': 'done'}) + self.assertEqual(lead.state, 'done') + self.assertEqual(lead.user_id, self.user_root) + + # Write a lead from state=open to state=done should trigger the automation + lead.write({'state': 'open'}) + self.assertEqual(lead.state, 'open') + self.assertEqual(lead.user_id, self.user_root) + lead.write({'state': 'done'}) + self.assertEqual(lead.state, 'done') + self.assertEqual(lead.user_id, self.user_demo) + + # Create a lead with state=open then write it to state=done should trigger the automation + lead_2 = self.create_lead(state='open') + self.assertEqual(lead_2.state, 'open') + self.assertEqual(lead_2.user_id, self.user_root) + lead_2.write({'state': 'done'}) + self.assertEqual(lead_2.state, 'done') + self.assertEqual(lead_2.user_id, self.user_demo) + + # Create a lead with state=done should trigger the automation, + # as verifying the filter_pre_domain does not make sense on create + lead_3 = self.create_lead(state='done') + self.assertEqual(lead_3.state, 'done') + self.assertEqual(lead_3.user_id, self.user_demo) + + def test_003_on_create_or_write(self): + """ Check that the on_create_or_write trigger works as expected with trigger fields. """ + lead_state_field = self.env.ref('test_base_automation.field_base_automation_lead_test__state') + automation = create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + trigger_field_ids=[Command.link(lead_state_field.id)], + _actions={ + 'state': 'code', + 'code': """ +if env.context.get('old_values', None): # on write only + record = model.browse(env.context['active_id']) + record['name'] = record.name + 'X'""", + }, + ) - def test_21_trigger_fields(self): - """ - Check that the rule with trigger is executed only once per pertinent update. - """ lead = self.create_lead(name="X") lead.priority = True partner1 = self.res_partner_1 - lead.partner_id = partner1.id + lead.partner_id = partner1 self.assertEqual(lead.name, 'X', "No update until now.") lead.state = 'open' @@ -284,8 +241,8 @@ record['name'] = record.name + 'X'""", self.assertEqual(lead.name, 'XXXX', "One update should have happened.") # change the rule to trigger on partner_id - rule = self.env['base.automation'].search([('name', '=', 'Base Automation: test rule with trigger')]) - rule.write({'trigger_field_ids': [(6, 0, [self.env.ref('test_base_automation.field_base_automation_lead_test__partner_id').id])]}) + lead_partner_id_field = self.env.ref('test_base_automation.field_base_automation_lead_test__partner_id') + automation.write({'trigger_field_ids': [Command.set([lead_partner_id_field.id])]}) partner2 = self.env['res.partner'].create({'name': 'A new partner'}) lead.name = 'X' @@ -297,8 +254,265 @@ record['name'] = record.name + 'X'""", self.assertEqual(lead.name, 'XX', "No update should have happened.") lead.partner_id = partner1 self.assertEqual(lead.name, 'XXX', "One update should have happened.") + lead.partner_id = partner1 + self.assertEqual(lead.name, 'XXX', "No update should have happened.") - def test_30_modelwithoutaccess(self): + def test_010_recompute(self): + """ + Test case: automation is applied whenever a field is recomputed + after a change on another model. + - trigger: on_create_or_write + - apply when: employee is True + """ + partner = self.res_partner_1 + partner.write({'employee': False}) + + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_domain="[('employee', '=', True)]", + _actions={'state': 'code', 'code': "record.write({'user_id': %s})" % (self.user_demo.id)}, + ) + + lead = self.create_lead(partner_id=partner.id) + self.assertEqual(lead.partner_id, partner) + self.assertEqual(lead.employee, False) + self.assertEqual(lead.user_id, self.user_root) + + # change partner, recompute on lead should trigger the rule + partner.write({'employee': True}) + self.env.flush_all() # ensures the recomputation is done + self.assertEqual(lead.partner_id, partner) + self.assertEqual(lead.employee, True) + self.assertEqual(lead.user_id, self.user_demo) + + def test_011_recompute(self): + """ + Test case: automation is applied whenever a field is recomputed. + The context contains the target field. + - trigger: on_create_or_write + """ + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + _actions={ + 'state': 'code', + 'code': """ +if env.context.get('old_values', None): # on write + if 'user_id' in env.context['old_values'][record.id]: + record.write({'is_assigned_to_admin': (record.user_id.id == 1)})""", + }, + ) + + partner = self.res_partner_1 + lead = self.create_lead(state='draft', partner_id=partner.id) + self.assertEqual(lead.deadline, False) + self.assertEqual(lead.is_assigned_to_admin, False) + + # change priority and user; this triggers deadline recomputation, and + # the server action should set is_assigned_to_admin field to True + lead.write({'priority': True, 'user_id': self.user_root.id}) + self.assertNotEqual(lead.deadline, False) + self.assertEqual(lead.is_assigned_to_admin, True) + + def test_012_recompute(self): + """ + Test case: automation is applied whenever a field is recomputed. + - trigger: on_create_or_write + - if updating fields: [deadline] + """ + active_field = self.env.ref("test_base_automation.field_base_automation_lead_test__active") + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + trigger_field_ids=[Command.link(active_field.id)], + _actions={ + 'state': 'code', + 'code': """ +if not env.context.get('old_values', None): # on create + record.write({'state': 'open'}) +else: + record.write({'priority': not record.priority})""", + }, + ) + + lead = self.create_lead(state='draft', priority=False) + self.assertEqual(lead.state, 'open') # the rule has set the state to open on create + self.assertEqual(lead.priority, False) + + # change state; the rule should not be triggered + lead.write({'state': 'pending'}) + self.assertEqual(lead.state, 'pending') + self.assertEqual(lead.priority, False) + + # change active; the rule should be triggered + lead.write({'active': False}) + self.assertEqual(lead.state, 'pending') + self.assertEqual(lead.priority, True) + + # change active again; the rule should still be triggered + lead.write({'active': True}) + self.assertEqual(lead.state, 'pending') + self.assertEqual(lead.priority, False) + + def test_013_recompute(self): + """ + Test case: automation is applied whenever a field is recomputed + - trigger: on_create_or_write + - if updating fields: [deadline] + - before update filter: deadline is not set + - apply when: deadline is set + """ + deadline_field = self.env.ref("test_base_automation.field_base_automation_lead_test__deadline") + create_automation( + self, + model_id=self.env['ir.model']._get_id('base.automation.lead.thread.test'), + trigger='on_create_or_write', + trigger_field_ids=[Command.link(deadline_field.id)], + filter_pre_domain="[('deadline', '=', False)]", + filter_domain="[('deadline', '!=', False)]", + _actions={ + 'state': 'mail_post', + 'mail_post_method': 'email', + 'template_id': self.test_mail_template_automation.id, + }, + ) + + send_mail_count = 0 + + def _patched_send_mail(*args, **kwargs): + nonlocal send_mail_count + send_mail_count += 1 + + patcher = patch('odoo.addons.mail.models.mail_template.MailTemplate.send_mail', _patched_send_mail) + self.startPatcher(patcher) + + lead = self.env['base.automation.lead.thread.test'].create({ + 'name': "Lead Test", + 'user_id': self.user_root.id, + }) + self.addCleanup(lead.unlink) + self.assertEqual(lead.priority, False) + self.assertEqual(lead.deadline, False) + self.assertEqual(send_mail_count, 0) + + lead.write({'priority': True}) + self.assertEqual(lead.priority, True) + self.assertNotEqual(lead.deadline, False) + self.assertEqual(send_mail_count, 1) + + def test_020_recursive(self): + """ Check that a rule is executed recursively by a secondary change. """ + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + _actions={ + 'state': 'code', + 'code': """ +if env.context.get('old_values', None): # on write + if 'partner_id' in env.context['old_values'][record.id]: + record.write({'state': 'draft'})""", + }, + ) + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_domain="[('state', '=', 'draft')]", + _actions={'state': 'code', 'code': "record.write({'user_id': %s})" % (self.user_demo.id)}, + ) + + lead = self.create_lead(state='open') + self.assertEqual(lead.state, 'open') + self.assertEqual(lead.user_id, self.user_root) + + # change partner; this should trigger the rule that modifies the state + # and then the rule that modifies the user + partner = self.res_partner_1 + lead.write({'partner_id': partner.id}) + self.assertEqual(lead.state, 'draft') + self.assertEqual(lead.user_id, self.user_demo) + + def test_021_recursive(self): + """ Check what it does with a recursive infinite loop """ + automations = [ + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_domain="[('state', '=', 'draft')]", + _actions={'state': 'code', 'code': "record.write({'state': 'pending'})"}, + ), + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_domain="[('state', '=', 'pending')]", + _actions={'state': 'code', 'code': "record.write({'state': 'open'})"}, + ), + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_domain="[('state', '=', 'open')]", + _actions={'state': 'code', 'code': "record.write({'state': 'done'})"}, + ), + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + filter_domain="[('state', '=', 'done')]", + _actions={'state': 'code', 'code': "record.write({'state': 'draft'})"}, + ), + ] + + def _patch(*args, **kwargs): + self.assertEqual(args[0], automations.pop(0)) + + patcher = patch('odoo.addons.base_automation.models.base_automation.BaseAutomation._process', _patch) + self.startPatcher(patcher) + + lead = self.create_lead(state='draft') + self.assertEqual(lead.state, 'draft') + self.assertEqual(len(automations), 0) # all automations have been processed # CHECK if proper assertion ? + + def test_030_submodel(self): + """ Check that a rule on a submodel is executed when the parent is modified. """ + # --- Without the automations --- + line = self.create_line() + self.assertEqual(line.user_id, self.user_root) + + lead = self.create_lead(line_ids=[(0, 0, {'name': 'Line', 'user_id': self.user_root.id})]) + self.assertEqual(lead.user_id, self.user_root) + self.assertEqual(lead.line_ids.user_id, self.user_root) + + # --- With the automations --- + comodel = self.env.ref('test_base_automation.model_base_automation_line_test') + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + _actions={'state': 'code', 'code': "record.write({'user_id': %s})" % (self.user_demo.id)}, + ) + create_automation( + self, + model_id=comodel.id, + trigger='on_create_or_write', + _actions={'state': 'code', 'code': "record.write({'user_id': %s})" % (self.user_demo.id)}, + ) + + line = self.create_line(user_id=self.user_root.id) + self.assertEqual(line.user_id, self.user_demo) # rule on secondary model + + lead = self.create_lead(line_ids=[(0, 0, {'name': 'Line', 'user_id': self.user_root.id})]) + self.assertEqual(lead.user_id, self.user_demo) # rule on primary model + self.assertEqual(lead.line_ids.user_id, self.user_demo) # rule on secondary model + + def test_040_modelwithoutaccess(self): """ Ensure a domain on a M2O without user access doesn't fail. We create a base automation with a filter on a model the user haven't access to @@ -309,28 +523,26 @@ record['name'] = record.name + 'X'""", - create a record in the non restricted model in demo """ Model = self.env['base.automation.link.test'] + model_id = self.env.ref('test_base_automation.model_base_automation_link_test') Comodel = self.env['base.automation.linked.test'] - - access = self.env.ref("test_base_automation.access_base_automation_linked_test") - access.group_id = self.env['res.groups'].create({ + comodel_access = self.env.ref('test_base_automation.access_base_automation_linked_test') + comodel_access.group_id = self.env['res.groups'].create({ 'name': "Access to base.automation.linked.test", - "users": [(6, 0, [self.user_admin.id,])] + "user_ids": [Command.link(self.user_admin.id)], }) # sanity check: user demo has no access to the comodel of 'linked_id' with self.assertRaises(AccessError): - Comodel.with_user(self.user_demo).check_access_rights('read') + Comodel.with_user(self.user_demo).check_access('read') # check base automation with filter that performs Comodel.search() - self.env['base.automation'].create({ - 'name': 'test no access', - 'model_id': self.env['ir.model']._get_id("base.automation.link.test"), - 'trigger': 'on_create_or_write', - 'filter_pre_domain': "[('linked_id.another_field', '=', 'something')]", - 'state': 'code', - 'active': True, - 'code': "action = [rec.name for rec in records]" - }) + create_automation( + self, + model_id=model_id.id, + trigger='on_create_or_write', + filter_pre_domain="[('linked_id.another_field', '=', 'something')]", + _actions={'state': 'code', 'code': 'action = [rec.name for rec in records]'}, + ) Comodel.create([ {'name': 'a first record', 'another_field': 'something'}, {'name': 'another record', 'another_field': 'something different'}, @@ -341,23 +553,807 @@ record['name'] = record.name + 'X'""", rec2.write({'name': 'another value'}) # check base automation with filter that performs Comodel.name_search() - self.env['base.automation'].create({ - 'name': 'test no name access', - 'model_id': self.env['ir.model']._get_id("base.automation.link.test"), - 'trigger': 'on_create_or_write', - 'filter_pre_domain': "[('linked_id', '=', 'whatever')]", - 'state': 'code', - 'active': True, - 'code': "action = [rec.name for rec in records]" - }) + create_automation( + self, + model_id=model_id.id, + trigger='on_create_or_write', + filter_pre_domain="[('linked_id', '=', 'whatever')]", + _actions={'state': 'code', 'code': 'action = [rec.name for rec in records]'}, + ) rec3 = Model.create({'name': 'a random record'}) rec3.write({'name': 'a first record'}) rec4 = Model.with_user(self.user_demo).create({'name': 'again another record'}) rec4.write({'name': 'another value'}) + def test_050_on_create_or_write_with_create_record(self): + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_create_or_write', + _actions={ + 'state': 'object_create', + 'crud_model_id': self.project_model.id, + 'value': 'foo', + }, + ) + lead = self.create_lead() + search_result = self.env['test_base_automation.project'].name_search('foo') + self.assertEqual(len(search_result), 1, 'One record on the project model should have been created') + + lead.write({'name': 'renamed lead'}) + search_result = self.env['test_base_automation.project'].name_search('foo') + self.assertEqual(len(search_result), 2, 'Another record on the project model should have been created') + + # ---------------------------- + # The following does not work properly as it is a known + # limitation of the implementation since at least 14.0 + # -> AssertionError: 4 != 3 : Another record on the secondary model should have been created + + # # write on a field that is a dependency of another computed field + # lead.write({'priority': True}) + # search_result = self.env['test_base_automation.project'].name_search('foo') + # self.assertEqual(len(search_result), 3, 'Another record on the secondary model should have been created') + # ---------------------------- + + def test_060_on_stage_set(self): + stage_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.project_model.id), + ('name', '=', 'stage_id'), + ]) + stage1 = self.create_stage() + stage2 = self.create_stage() + create_automation( + self, + model_id=self.project_model.id, + trigger='on_stage_set', + trigger_field_ids=[stage_field.id], + filter_domain="[('stage_id', '=', %s)]" % stage1.id, + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + project = self.create_project() + self.assertEqual(project.name, 'Project Test') + project.write({'stage_id': stage1.id}) + self.assertEqual(project.name, 'Project Test!') + project.write({'stage_id': stage1.id}) + self.assertEqual(project.name, 'Project Test!') + project.write({'stage_id': stage2.id}) + self.assertEqual(project.name, 'Project Test!') + project.write({'stage_id': False}) + self.assertEqual(project.name, 'Project Test!') + project.write({'stage_id': stage1.id}) + self.assertEqual(project.name, 'Project Test!!') + + def test_070_on_user_set(self): + user_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.lead_model.id), + ('name', '=', 'user_id'), + ]) + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_user_set', + trigger_field_ids=[user_field.id], + filter_domain="[('user_id', '!=', False)]", + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + + lead = self.create_lead() + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'user_id': self.user_demo.id}) + self.assertEqual(lead.name, 'Lead Test!!') + lead.write({'user_id': self.user_demo.id}) + self.assertEqual(lead.name, 'Lead Test!!') + lead.write({'user_id': self.user_admin.id}) + self.assertEqual(lead.name, 'Lead Test!!!') + lead.write({'user_id': False}) + self.assertEqual(lead.name, 'Lead Test!!!') + lead.write({'user_id': self.user_demo.id}) + self.assertEqual(lead.name, 'Lead Test!!!!') + + def test_071_on_user_set(self): + # same test as above but with the user_ids many2many on a project + user_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.project_model.id), + ('name', '=', 'user_ids'), + ]) + create_automation( + self, + model_id=self.project_model.id, + trigger='on_user_set', + trigger_field_ids=[user_field.id], + filter_domain="[('user_ids', '!=', False)]", + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + + project = self.create_project() + self.assertEqual(project.name, 'Project Test') + project.write({'user_ids': [Command.set([self.user_demo.id])]}) + self.assertEqual(project.name, 'Project Test!') + project.write({'user_ids': [Command.set([self.user_demo.id])]}) + self.assertEqual(project.name, 'Project Test!') + project.write({'user_ids': [Command.link(self.user_admin.id)]}) + self.assertEqual(project.name, 'Project Test!!') + # Unlinking a user while there are still other users does trigger the automation + # This behavior could be changed in the future but needs a bit of investigation + project.write({'user_ids': [Command.unlink(self.user_admin.id)]}) + self.assertEqual(project.name, 'Project Test!!!') + project.write({'user_ids': [Command.set([])]}) + self.assertEqual(project.name, 'Project Test!!!') + project.write({'user_ids': [Command.set([self.user_demo.id])]}) + self.assertEqual(project.name, 'Project Test!!!!') + + def test_080_on_tag_set(self): + tag_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.project_model.id), + ('name', '=', 'tag_ids'), + ]) + tag1 = self.create_tag() + create_automation( + self, + model_id=self.project_model.id, + trigger='on_tag_set', + trigger_field_ids=[tag_field.id], + filter_pre_domain="[('tag_ids', 'not in', [%s])]" % tag1.id, + filter_domain="[('tag_ids', 'in', [%s])]" % tag1.id, + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + project = self.create_project() + self.assertEqual(project.name, 'Project Test') + project.write({'tag_ids': [Command.set([tag1.id])]}) + self.assertEqual(project.name, 'Project Test!') + project.write({'tag_ids': [Command.set([tag1.id])]}) + self.assertEqual(project.name, 'Project Test!') + + tag2 = self.create_tag() + project.write({'tag_ids': [Command.link(tag2.id)]}) + self.assertEqual(project.name, 'Project Test!') + project.write({'tag_ids': [Command.clear()]}) + self.assertEqual(project.name, 'Project Test!') + project.write({'tag_ids': [Command.set([tag2.id])]}) + self.assertEqual(project.name, 'Project Test!') + project.write({'tag_ids': [Command.link(tag1.id)]}) + self.assertEqual(project.name, 'Project Test!!') + + def test_090_on_state_set(self): + state_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.lead_model.id), + ('name', '=', 'state'), + ]) + + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_state_set', + trigger_field_ids=[state_field.id], + filter_domain="[('state', '=', 'done')]", + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + + lead = self.create_lead() + self.assertEqual(lead.name, 'Lead Test') + lead.write({'state': 'open'}) + self.assertEqual(lead.name, 'Lead Test') + lead.write({'state': 'done'}) + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'state': 'done'}) + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'state': 'open'}) + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'state': 'done'}) + self.assertEqual(lead.name, 'Lead Test!!') + + def test_100_on_priority_set(self): + priority_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.project_model.id), + ('name', '=', 'priority'), + ]) + create_automation( + self, + model_id=self.project_model.id, + trigger='on_priority_set', + trigger_field_ids=[priority_field.id], + filter_domain="[('priority', '=', '2')]", + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + project = self.create_project() + self.assertEqual(project.name, 'Project Test') + self.assertEqual(project.priority, '1') + project.write({'priority': '0'}) + self.assertEqual(project.name, 'Project Test') + project.write({'priority': '2'}) + self.assertEqual(project.name, 'Project Test!') + project.write({'priority': '2'}) + self.assertEqual(project.name, 'Project Test!') + project.write({'priority': '0'}) + self.assertEqual(project.name, 'Project Test!') + project.write({'priority': '2'}) + self.assertEqual(project.name, 'Project Test!!') + + def test_110_on_archive(self): + active_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.lead_model.id), + ('name', '=', 'active'), + ]) + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_archive', + trigger_field_ids=[active_field.id], + filter_domain="[('active', '=', False)]", + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + lead = self.create_lead() + self.assertEqual(lead.name, 'Lead Test') + lead.write({'active': False}) + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'active': True}) + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'active': False}) + self.assertEqual(lead.name, 'Lead Test!!') + lead.write({'active': False}) + self.assertEqual(lead.name, 'Lead Test!!') + + def test_110_on_unarchive(self): + active_field = self.env['ir.model.fields'].search([ + ('model_id', '=', self.lead_model.id), + ('name', '=', 'active'), + ]) + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_unarchive', + trigger_field_ids=[active_field.id], + filter_domain="[('active', '=', True)]", + _actions={'state': 'object_write', 'evaluation_type': 'equation', 'update_path': 'name', 'value': "record.name + '!'"}, + ) + lead = self.create_lead() + self.assertEqual(lead.name, 'Lead Test') + lead.write({'active': False}) + self.assertEqual(lead.name, 'Lead Test') + lead.write({'active': True}) + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'active': False}) + self.assertEqual(lead.name, 'Lead Test!') + lead.write({'active': True}) + self.assertEqual(lead.name, 'Lead Test!!') + lead.write({'active': True}) + self.assertEqual(lead.name, 'Lead Test!!') + + def test_120_on_change(self): + Model = self.env.get(self.lead_model.model) + lead_name_field = self.env['ir.model.fields']._get(self.lead_model.model, "name") + self.assertEqual(lead_name_field.name in Model._onchange_methods, False) + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_change', + filter_domain="[('name', 'like', 'IMPORTANT')]", + on_change_field_ids=[lead_name_field.id], + _actions={ + 'state': 'code', + 'code': """ +action = { + 'value': { + 'priority': '[IMPORTANT]' in record.name, + } +} + """, + }, + ) + self.assertEqual(lead_name_field.name in Model._onchange_methods, True) + + with Form(self.env[self.lead_model.model]) as f: + self.assertEqual(f.priority, False) + f.name = 'Lead Test' + self.assertEqual(f.priority, False) + + # changed because contains "IMPORTANT", true because contains "[IMPORTANT]" + f.name = 'Lead Test [IMPORTANT]' + self.assertEqual(f.priority, True) + + # not changed because does not contain "IMPORTANT" + f.name = 'Lead Test' + self.assertEqual(f.priority, True) + + # changed because contains "IMPORTANT", false because does not contain "[IMPORTANT]" + f.name = 'Lead Test [NOT IMPORTANT]' + self.assertEqual(f.priority, False) + + # changed because contains "IMPORTANT", true because contains "[IMPORTANT]" + f.name = 'Lead Test [IMPORTANT]' + self.assertEqual(f.priority, True) + + def test_121_on_change_with_domain_field_not_in_view(self): + lead_name_field = self.env['ir.model.fields']._get(self.lead_model.model, "name") + create_automation( + self, + model_id=self.lead_model.id, + trigger='on_change', + filter_domain="[('active', '!=', False)]", + on_change_field_ids=[lead_name_field.id], + _actions={ + 'state': 'code', + 'code': """ +action = { + 'value': { + 'priority': '[IMPORTANT]' in record.name, + } +} + """, + }, + ) + my_view = self.env["ir.ui.view"].create({ + "name": "My View", + "model": self.lead_model.model, + "type": "form", + "arch": """ +
+ + + + """, + }) + record = self.env[self.lead_model.model].create({ + "name": "Test Lead", + "active": False, + "priority": False, + }) + self.assertEqual(record.priority, False) + with Form(record, view=my_view) as f: + f.name = "[IMPORTANT] Lead" + self.assertEqual(record.priority, False) + + record.name = "Test Lead" + record.active = True + self.assertEqual(record.priority, False) + with Form(record, view=my_view) as f: + f.name = "[IMPORTANT] Lead" + self.assertEqual(record.priority, True) + + def test_130_on_unlink(self): + automation = create_automation( + self, + model_id=self.lead_model.id, + trigger='on_unlink', + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + + called_count = 0 + + def _patch(*args, **kwargs): + nonlocal called_count + called_count += 1 + self.assertEqual(args[0], automation) + + patcher = patch('odoo.addons.base_automation.models.base_automation.BaseAutomation._process', _patch) + self.startPatcher(patcher) + + lead = self.create_lead() + self.assertEqual(called_count, 0) + lead.unlink() + self.assertEqual(called_count, 1) + + @property + def automation_cron(self): + return self.env.ref('base_automation.ir_cron_data_base_automation_check') + + def test_004_check_method_trigger_field(self): + model = self.env["ir.model"]._get("base.automation.lead.test") + TIME_TRIGGERS = [ + 'on_time', + 'on_time_created', + 'on_time_updated', + ] + self.env["base.automation"].search([('trigger', 'in', TIME_TRIGGERS)]).active = False + + automation = self.env["base.automation"].create({ + "name": "Cron BaseAuto", + "trigger": "on_time", + "model_id": model.id, + }) + + # first run, check we have a field set + # this does not happen using the UI where the trigger is forced to be set + self.assertFalse(automation.last_run) + with self.assertLogs('odoo.addons.base_automation', 'WARNING') as capture, self.enter_registry_test_mode(): + self.automation_cron.method_direct_trigger() + self.assertRegex(capture.output[0], r"Missing date trigger") + automation.trg_date_id = model.field_id.filtered(lambda f: f.name == 'date_automation_last') + + # normal run + with self.enter_registry_test_mode(): + self.automation_cron.method_direct_trigger() + self.assertTrue(automation.last_run) + + @common.freeze_time('2020-01-01 03:00:00') + def test_004_check_method_process(self): + model = self.env["ir.model"]._get("base.automation.lead.test") + TIME_TRIGGERS = [ + 'on_time', + 'on_time_created', + 'on_time_updated', + ] + self.env["base.automation"].search([('trigger', 'in', TIME_TRIGGERS)]).active = False + + automation = self.env["base.automation"].create({ + "name": "Cron BaseAuto", + "trigger": "on_time", + "model_id": model.id, + "trg_date_id": model.field_id.filtered(lambda f: f.name == 'date_automation_last').id, + "trg_date_range": 2, + "trg_date_range_type": "minutes", + "trg_date_range_mode": "after", + }) + + with ( + patch.object(automation.__class__, '_process', side_effect=automation._process) as mock, + self.enter_registry_test_mode(), + ): + with patch.object(self.env.cr, '_now', now := datetime.datetime.now()): + past_date = now - datetime.timedelta(1) + self.env["base.automation.lead.test"].create([{ + 'name': f'lead {i}', + # 2 without a date, 8 set in past, 5 set in future (10, 11, ... minutes after now) + 'date_automation_last': False if i < 2 else past_date if i < 10 else now + datetime.timedelta(minutes=i), + } for i in range(15)]) + with common.freeze_time('2020-01-01 03:02:01'), patch.object(self.env.cr, '_now', datetime.datetime.now()): + # process records + self.automation_cron.method_direct_trigger() + self.assertEqual(mock.call_count, 10) + self.assertEqual(automation.last_run, self.env.cr.now()) + with common.freeze_time('2020-01-01 03:13:59'), patch.object(self.env.cr, '_now', datetime.datetime.now()): + # 2 in the future (because of timing) + # 10 previously done records because we use the date_automation_last as trigger without delay + self.automation_cron.method_direct_trigger() + self.assertEqual(mock.call_count, 22) + self.assertEqual(automation.last_run, self.env.cr.now()) + # test triggering using a calendar + automation.trg_date_calendar_id = self.env["resource.calendar"].search([], limit=1).ensure_one() + automation.trg_date_range_type = 'day' + self.env["base.automation.lead.test"].create({'name': 'calendar'}) # for the run + with common.freeze_time('2020-02-02 03:11:00'), patch.object(self.env.cr, '_now', datetime.datetime.now()): + self.automation_cron.method_direct_trigger() + self.assertEqual(mock.call_count, 38) + + def test_005_check_model_with_different_rec_name_char(self): + model = self.env["ir.model"]._get("base.automation.model.with.recname.char") + + create_automation( + self, + model_id=self.project_model.id, + trigger='on_create_or_write', + _actions={ + 'state': 'object_create', + 'crud_model_id': model.id, + 'value': "Test _rec_name Automation", + }, + ) + + self.create_project() + record_count = self.env[model.model].search_count([('description', '=', 'Test _rec_name Automation')]) + self.assertEqual(record_count, 1, "Only one record should have been created") + + def test_006_check_model_with_different_m2o_name_create(self): + model = self.env["ir.model"]._get("base.automation.model.with.recname.m2o") + + create_automation( + self, + model_id=self.project_model.id, + trigger='on_create_or_write', + _actions={ + 'state': 'object_create', + 'crud_model_id': model.id, + 'value': "Test _rec_name Automation", + }, + ) + + self.create_project() + record_count = self.env[model.model].search_count([('user_id', '=', 'Test _rec_name Automation')]) + self.assertEqual(record_count, 1, "Only one record should have been created") + + def test_140_copy_should_copy_actions(self): + """ Copying an automation should copy its actions. """ + automation = create_automation( + self, + model_id=self.lead_model.id, + trigger='on_change', + _actions={'state': 'code', 'code': "record.write({'name': record.name + '!'})"}, + ) + action_ids = automation.action_server_ids + + copy_automation = automation.copy() + copy_action_ids = copy_automation.action_server_ids + # Same number of actions but id should be different + self.assertEqual(len(action_ids), 1) + self.assertEqual(len(copy_action_ids), len(action_ids)) + self.assertNotEqual(copy_action_ids, action_ids) + + def test_add_followers_1(self): + create_automation(self, + model_id=self.env["ir.model"]._get("base.automation.lead.thread.test").id, + trigger="on_create", + _actions={ + "state": "followers", + "followers_type": "generic", + "followers_partner_field_name": "user_id.partner_id" + } + ) + user = self.env["res.users"].create({"login": "maggot_brain", "name": "Eddie Hazel"}) + thread_test = self.env["base.automation.lead.thread.test"].create({ + "name": "free your mind", + "user_id": user.id, + }) + self.assertEqual(thread_test.message_follower_ids.partner_id, user.partner_id) + + def test_add_followers_2(self): + user = self.env["res.users"].create({"login": "maggot_brain", "name": "Eddie Hazel"}) + create_automation(self, + model_id=self.env["ir.model"]._get("base.automation.lead.thread.test").id, + trigger="on_create", + _actions={ + "state": "followers", + "followers_type": "specific", + "partner_ids": [Command.link(user.partner_id.id)] + } + ) + thread_test = self.env["base.automation.lead.thread.test"].create({ + "name": "free your mind", + }) + self.assertEqual(thread_test.message_follower_ids.partner_id, user.partner_id) + + def test_cannot_have_actions_with_warnings(self): + with self.assertRaises(ValidationError) as e: + create_automation( + self, + model_id=self.env['ir.model']._get('ir.actions.server').id, + trigger='on_time', + _actions={ + 'name': 'Send Webhook Notification', + 'state': 'webhook', + 'webhook_field_ids': [self.env['ir.model.fields']._get('ir.actions.server', 'code').id], + }, + ) + self.assertEqual(e.exception.args[0], "Following child actions have warnings: Send Webhook Notification") + @common.tagged('post_install', '-at_install') class TestCompute(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.ref('base.user_admin').write({ + 'email': 'mitchell.admin@example.com', + }) + + def test_automation_form_view(self): + automation_form = Form(self.env['base.automation'], view='base_automation.view_base_automation_form') + + # Initialize some fields + automation_form.name = "Test Automation" + automation_form.model_id = self.env.ref('test_base_automation.model_test_base_automation_project') + automation_form.trigger = 'on_create_or_write' + self.assertEqual(automation_form.trigger_field_ids.ids, []) + self.assertEqual(automation_form.filter_domain, False) + automation = automation_form.save() + self.assertEqual(automation.filter_pre_domain, False) + + # Changing the model must reset the trigger + automation_form.model_id = self.env.ref('test_base_automation.model_base_automation_lead_test') + self.assertEqual(automation_form.trigger, False) + self.assertEqual(automation_form.trigger_field_ids.ids, []) + self.assertEqual(automation_form.filter_domain, False) + + # Some triggers must preset a filter_domain and trigger_field_ids + ## State is set to... + automation_form.trigger = 'on_state_set' + state_field_id = self.env.ref('test_base_automation.field_base_automation_lead_test__state').id + self.assertEqual(automation_form.trigger_field_ids.ids, [state_field_id]) + self.assertEqual(automation_form.filter_domain, False) + automation_form.trg_selection_field_id = self.env['ir.model.fields.selection'].search([ + ('field_id', '=', state_field_id), + ('value', '=', 'pending'), + ]) + self.assertEqual(automation_form.trigger_field_ids.ids, [state_field_id]) + self.assertEqual(automation_form.filter_domain, repr([('state', '=', 'pending')])) + automation = automation_form.save() + self.assertEqual(automation.filter_pre_domain, False) + + ## Priority is set to... + automation_form.model_id = self.env.ref('test_base_automation.model_test_base_automation_project') + automation_form.trigger = 'on_priority_set' + priority_field_id = self.env.ref('test_base_automation.field_test_base_automation_project__priority').id + self.assertEqual(automation_form.trigger_field_ids.ids, [priority_field_id]) + self.assertEqual(automation_form.filter_domain, False) + automation_form.trg_selection_field_id = self.env['ir.model.fields.selection'].search([ + ('field_id', '=', priority_field_id), + ('value', '=', '2'), + ]) + self.assertEqual(automation_form.trigger_field_ids.ids, [priority_field_id]) + self.assertEqual(automation_form.filter_domain, repr([('priority', '=', '2')])) + automation = automation_form.save() + self.assertEqual(automation.filter_pre_domain, False) + + ## Stage is set to... + automation_form.model_id = self.env.ref('test_base_automation.model_base_automation_lead_test') + automation_form.trigger = 'on_stage_set' + stage_field_id = self.env.ref('test_base_automation.field_base_automation_lead_test__stage_id').id + self.assertEqual(automation_form.trigger_field_ids.ids, [stage_field_id]) + self.assertEqual(automation_form.filter_domain, False) + new_lead_stage = self.env['test_base_automation.stage'].create({'name': 'New'}) + automation_form.trg_field_ref = new_lead_stage.id + self.assertEqual(automation_form.filter_domain, repr([('stage_id', '=', new_lead_stage.id)])) + self.assertEqual(automation_form.trigger_field_ids.ids, [stage_field_id]) + automation = automation_form.save() + self.assertEqual(automation.filter_pre_domain, False) + + ## User is set + automation_form.trigger = 'on_user_set' + self.assertEqual(automation_form.trigger_field_ids.ids, [ + self.env.ref('test_base_automation.field_base_automation_lead_test__user_id').id + ]) + self.assertEqual(automation_form.filter_domain, repr([('user_id', '!=', False)])) + automation = automation_form.save() + self.assertEqual(automation.filter_pre_domain, False) + + ## On archive + automation_form.trigger = 'on_archive' + self.assertEqual(automation_form.trigger_field_ids.ids, [ + self.env.ref('test_base_automation.field_base_automation_lead_test__active').id + ]) + self.assertEqual(automation_form.filter_domain, repr([('active', '=', False)])) + automation = automation_form.save() + self.assertEqual(automation.filter_pre_domain, False) + + ## On unarchive + automation_form.trigger = 'on_unarchive' + self.assertEqual(automation_form.trigger_field_ids.ids, [ + self.env.ref('test_base_automation.field_base_automation_lead_test__active').id + ]) + self.assertEqual(automation_form.filter_domain, repr([('active', '=', True)])) + automation = automation_form.save() + self.assertEqual(automation.filter_pre_domain, False) + + ## Tag is set to... + automation_form.trigger = 'on_tag_set' + a_lead_tag = self.env['test_base_automation.tag'].create({'name': '*AWESOME*'}) + automation_form.trg_field_ref = a_lead_tag.id + self.assertEqual(automation_form.filter_domain, repr([('tag_ids', 'in', [a_lead_tag.id])])) + self.assertEqual(automation_form.trigger_field_ids.ids, [ + self.env.ref('test_base_automation.field_base_automation_lead_test__tag_ids').id + ]) + automation = automation_form.save() + self.assertEqual(automation.filter_pre_domain, repr([('tag_ids', 'not in', [a_lead_tag.id])])) + + def test_automation_form_view_on_change_filter_domain(self): + a_lead_tag = self.env['test_base_automation.tag'].create({'name': '*AWESOME*'}) + automation = self.env['base.automation'].create({ + 'name': 'Test Automation', + 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, + 'trigger': 'on_tag_set', + 'trg_field_ref': a_lead_tag.id, + }) + self.assertEqual(automation.filter_pre_domain, repr([('tag_ids', 'not in', [a_lead_tag.id])])) + self.assertEqual(automation.filter_domain, repr([('tag_ids', 'in', [a_lead_tag.id])])) + self.assertEqual(automation.trigger_field_ids.ids, [ + self.env.ref('test_base_automation.field_base_automation_lead_test__tag_ids').id + ]) + self.assertEqual(automation.on_change_field_ids.ids, []) + + # Change the trigger to "On save" will erase the domains and the trigger fields + automation_form = Form(automation, view='base_automation.view_base_automation_form') + automation_form.trigger = 'on_create_or_write' + automation = automation_form.save() + self.assertEqual(automation.filter_pre_domain, False) + self.assertEqual(automation.filter_domain, False) + self.assertEqual(automation.trigger_field_ids.ids, []) + self.assertEqual(automation.on_change_field_ids.ids, []) + + # Change the domain will append each used field to the trigger fields + automation_form.filter_domain = repr([('priority', '=', True), ('employee', '=', False)]) + automation = automation_form.save() + self.assertEqual(automation.filter_pre_domain, False) + self.assertEqual(automation.filter_domain, repr([('priority', '=', True), ('employee', '=', False)])) + self.assertSetEqual(set(automation.trigger_field_ids.ids), { + self.env.ref('test_base_automation.field_base_automation_lead_test__priority').id, + self.env.ref('test_base_automation.field_base_automation_lead_test__employee').id, + }) + self.assertEqual(automation.on_change_field_ids.ids, []) + + # Change the trigger fields will not change the domain + automation_form.trigger_field_ids.add( + self.env.ref('test_base_automation.field_base_automation_lead_test__tag_ids') + ) + automation = automation_form.save() + self.assertEqual(automation.filter_pre_domain, False) + self.assertEqual(automation.filter_domain, repr([('priority', '=', True), ('employee', '=', False)])) + self.assertItemsEqual(automation.trigger_field_ids.ids, [ + self.env.ref('test_base_automation.field_base_automation_lead_test__priority').id, + self.env.ref('test_base_automation.field_base_automation_lead_test__employee').id, + self.env.ref('test_base_automation.field_base_automation_lead_test__tag_ids').id + ]) + self.assertEqual(automation.on_change_field_ids.ids, []) + + # Erase the domain will remove corresponding fields from the trigger fields + automation_form.filter_domain = False + automation = automation_form.save() + self.assertEqual(automation.filter_pre_domain, False) + self.assertEqual(automation.filter_domain, False) + self.assertEqual(automation.trigger_field_ids.ids, [ + self.env.ref('test_base_automation.field_base_automation_lead_test__tag_ids').id + ]) + self.assertEqual(automation.on_change_field_ids.ids, []) + + def test_automation_form_view_time_triggers(self): + # Starting from a "On save" automation + on_save_automation = self.env['base.automation'].create({ + 'name': 'Test Automation', + 'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, + 'trigger': 'on_create_or_write', + 'filter_domain': repr([('employee', '=', False)]), + 'trigger_field_ids': self.env.ref('test_base_automation.field_base_automation_lead_test__employee') + }) + + automation = on_save_automation.copy() + self.assertEqual(automation.filter_pre_domain, False) + self.assertEqual(automation.filter_domain, repr([('employee', '=', False)])) + self.assertEqual(automation.trg_date_id.id, False) + self.assertEqual(automation.trigger_field_ids.ids, [ + self.env.ref('test_base_automation.field_base_automation_lead_test__employee').id + ]) + + # Changing to a time trigger must erase domains and trigger fields + ## Change the trigger to "On time created" + automation_form = Form(automation, view='base_automation.view_base_automation_form') + automation_form.trigger = 'on_time_created' + self.assertEqual(automation_form.filter_domain, False) + self.assertEqual(automation_form.trg_date_id, self.env.ref('test_base_automation.field_base_automation_lead_test__create_date')) + self.assertEqual(automation_form.trigger_field_ids.ids, []) + automation = automation_form.save() + self.assertEqual(automation.filter_pre_domain, False) + + ## Change the trigger to "On time updated" + automation = on_save_automation.copy() + automation_form = Form(automation, view='base_automation.view_base_automation_form') + automation_form.trigger = 'on_time_updated' + self.assertEqual(automation_form.filter_domain, False) + self.assertEqual(automation_form.trg_date_id, self.env.ref('test_base_automation.field_base_automation_lead_test__write_date')) + self.assertEqual(automation_form.trigger_field_ids.ids, []) + automation = automation_form.save() + self.assertEqual(automation.filter_pre_domain, False) + + ## Change the trigger to "On time" + automation = on_save_automation.copy() + automation_form = Form(automation, view='base_automation.view_base_automation_form') + automation_form.trigger = 'on_time' + automation_form.trg_date_id = self.env.ref('test_base_automation.field_base_automation_lead_test__create_date') + self.assertEqual(automation_form.filter_domain, False) + self.assertEqual(automation_form.trg_date_id, self.env.ref('test_base_automation.field_base_automation_lead_test__create_date')) + self.assertEqual(automation_form.trigger_field_ids.ids, []) + automation = automation_form.save() + self.assertEqual(automation.filter_pre_domain, False) + + def test_automation_form_view_with_default_values_in_context(self): + # Use case where default model, trigger and filter_domain in context + context = { + 'default_name': 'Test Automation', + 'default_model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id, + 'default_trigger': 'on_create_or_write', + 'default_filter_domain': repr([('state', '=', 'draft')]), + } + # Create form should be pre-filled with the default values + automation = self.env['base.automation'].with_context(context) + default_trigger_field_ids = [self.env.ref('test_base_automation.field_base_automation_lead_test__state').id] + automation_form = Form(automation, view='base_automation.view_base_automation_form') + self.assertEqual(automation_form.name, context.get('default_name')) + self.assertEqual(automation_form.model_id.id, context.get('default_model_id')) + self.assertEqual(automation_form.trigger, context.get('default_trigger')) + self.assertEqual(automation_form.trigger_field_ids.ids, default_trigger_field_ids, + 'trigger_field_ids should match the fields in the default filter domain.') + + automation_form.trigger = 'on_stage_set' + self.assertNotEqual(automation_form.trigger_field_ids.ids, default_trigger_field_ids, + 'When user changes trigger, the trigger_field_ids field should be updated') + def test_inversion(self): """ If a stored field B depends on A, an update to the trigger for A should trigger the recomputaton of A, then B. @@ -366,7 +1362,7 @@ class TestCompute(common.TransactionCase): ??? and _order is affected ??? a flush will be triggered, forcing the computation of B, based on the previous A. - This happens if a rule has has a non-empty filter_pre_domain, even if + This happens if a rule has a non-empty filter_pre_domain, even if it's an empty list (``'[]'`` as opposed to ``False``). """ company1 = self.env['res.partner'].create({ @@ -386,37 +1382,62 @@ class TestCompute(common.TransactionCase): r.parent_id = company2 self.assertEqual(r.display_name, 'Awiclo, Bob') - self.env['base.automation'].create({ - 'name': "test rule", - 'filter_pre_domain': False, - 'trigger': 'on_create_or_write', - 'state': 'code', # no-op action - 'model_id': self.env.ref('base.model_res_partner').id, - }) + create_automation( + self, + model_id=self.env.ref('base.model_res_partner').id, + filter_pre_domain=False, + trigger='on_create_or_write', + _actions={'state': 'code'}, # no-op action + ) r.parent_id = company1 self.assertEqual(r.display_name, 'Gorofy, Bob') - self.env['base.automation'].create({ - 'name': "test rule", - 'filter_pre_domain': '[]', - 'trigger': 'on_create_or_write', - 'state': 'code', # no-op action - 'model_id': self.env.ref('base.model_res_partner').id, - }) + create_automation( + self, + model_id=self.env.ref('base.model_res_partner').id, + filter_pre_domain='[]', + trigger='on_create_or_write', + _actions={'state': 'code'}, # no-op action + ) r.parent_id = company2 self.assertEqual(r.display_name, 'Awiclo, Bob') + def test_computation_sequence(self): + """ This test ensure sequential computation is done and all fields are correctly set + when a filter_pre_domain trigger computation of one of the chain element + """ + project = self.env['test_base_automation.project'].create({}) + task = self.env['test_base_automation.task'].create({ + 'project_id': project.id, + 'allocated_hours': 100, + }) + + # this action is executed every time a task is modified + create_automation( + self, + model_id=self.env.ref('test_base_automation.model_test_base_automation_task').id, + trigger='on_create_or_write', + filter_pre_domain="[('remaining_hours', '>', 0)]", + _actions={'state': 'code'}, # no-op action + ) + + task.trigger_hours = 5 + self.assertRecordValues(task, [{ + 'effective_hours': 5, + 'remaining_hours': 95, + }]) + def test_recursion(self): project = self.env['test_base_automation.project'].create({}) # this action is executed every time a task is assigned to project - self.env['base.automation'].create({ - 'name': 'dummy', - 'model_id': self.env['ir.model']._get_id('test_base_automation.task'), - 'state': 'code', - 'trigger': 'on_create_or_write', - 'filter_domain': repr([('project_id', '=', project.id)]), - }) + create_automation( + self, + model_id=self.env.ref('test_base_automation.model_test_base_automation_task').id, + trigger='on_create_or_write', + filter_domain=repr([('project_id', '=', project.id)]), + _actions={'state': 'code'}, # no-op action + ) # create one task in project with 10 subtasks; all the subtasks are # automatically assigned to project, too @@ -425,16 +1446,16 @@ class TestCompute(common.TransactionCase): subtasks.flush_model() # This test checks what happens when a stored recursive computed field - # is marked to compute on many records, and automated actions are + # is marked to compute on many records, and automation rules are # triggered depending on that field. In this case, we trigger the # recomputation of 'project_id' on 'subtasks' by deleting their parent # task. # - # An issue occurs when the domain of automated actions is evaluated by + # An issue occurs when the domain of automation rules is evaluated by # method search(), because the latter flushes the fields to search on, # which are also the ones being recomputed. Combined with the fact # that recursive fields are not computed in batch, this leads to a huge - # amount of recursive calls between the automated action and flush(). + # amount of recursive calls between the automation rule and flush(). # # The execution of task.unlink() looks like this: # - mark 'project_id' to compute on subtasks @@ -460,3 +1481,418 @@ class TestCompute(common.TransactionCase): task.unlink() finally: sys.setrecursionlimit(limit) + + def test_mail_triggers(self): + lead_model = self.env["ir.model"]._get("base.automation.lead.test") + with self.assertRaises(ValidationError): + create_automation(self, trigger="on_message_sent", model_id=lead_model.id) + + lead_thread_model = self.env["ir.model"]._get("base.automation.lead.thread.test") + automation = create_automation(self, trigger="on_message_sent", model_id=lead_thread_model.id, _actions={ + "state": "object_write", + "update_path": "active", + "update_boolean_value": "false" + }) + + ext_partner = self.env["res.partner"].create({"name": "ext", "email": "email@server.com"}) + internal_partner = self.env["res.users"].browse(2).partner_id + + obj = self.env["base.automation.lead.thread.test"].create({"name": "test"}) + obj.message_subscribe([ext_partner.id, internal_partner.id]) + + obj.message_post(author_id=internal_partner.id, message_type="comment", subtype_xmlid="mail.mt_comment") + self.assertFalse(obj.active) + + obj.active = True + obj.message_post(author_id=internal_partner.id, subtype_xmlid="mail.mt_comment") + self.assertTrue(obj.active) + + obj.message_post(author_id=ext_partner.id, message_type="comment") + self.assertTrue(obj.active) + + obj.message_post(author_id=internal_partner.id, message_type="comment") + self.assertTrue(obj.active) + obj.message_post(author_id=internal_partner.id, subtype_xmlid="mail.mt_comment", message_type="comment") + self.assertFalse(obj.active) + + obj.active = True + # message doesn't have author_id, so it should be considered as external the automation should't be triggered + obj.message_post(author_id=False, email_from="test_abla@test.test", message_type="email", subtype_xmlid="mail.mt_comment") + self.assertTrue(obj.active) + + automation.trigger = "on_message_received" + obj.active = True + obj.message_post(author_id=internal_partner.id, subtype_xmlid="mail.mt_comment", message_type="comment") + self.assertTrue(obj.active) + + obj.message_post(author_id=ext_partner.id, message_type="comment") + self.assertTrue(obj.active) + + obj.message_post(author_id=ext_partner.id, subtype_xmlid="mail.mt_comment", message_type="comment") + self.assertFalse(obj.active) + + obj.active = True + obj.message_post(author_id=ext_partner.id, subtype_xmlid="mail.mt_comment") + self.assertTrue(obj.active) + + obj.message_post(author_id=False, email_from="test_abla@test.test", message_type="email", subtype_xmlid="mail.mt_comment") + self.assertFalse(obj.active) + + def test_multiple_mail_triggers(self): + lead_model = self.env["ir.model"]._get("base.automation.lead.test") + with self.assertRaises(ValidationError): + create_automation(self, trigger="on_message_sent", model_id=lead_model.id) + + lead_thread_model = self.env["ir.model"]._get("base.automation.lead.thread.test") + + create_automation(self, trigger="on_message_sent", model_id=lead_thread_model.id, _actions={ + "state": "object_write", + "update_path": "active", + "update_boolean_value": "false" + }) + create_automation(self, trigger="on_message_sent", model_id=lead_thread_model.id, _actions={ + "state": "object_write", + "evaluation_type": "equation", + "update_path": "name", + "value": "record.name + '!'" + }) + + ext_partner = self.env["res.partner"].create({"name": "ext", "email": "email@server.com"}) + internal_partner = self.env["res.users"].browse(2).partner_id + + obj = self.env["base.automation.lead.thread.test"].create({"name": "test"}) + obj.message_subscribe([ext_partner.id, internal_partner.id]) + + obj.message_post(author_id=internal_partner.id, message_type="comment", subtype_xmlid="mail.mt_comment") + self.assertFalse(obj.active) + self.assertEqual(obj.name, "test!") + + def test_compute_on_create(self): + lead_model = self.env['ir.model']._get('base.automation.lead.test') + stage_field = self.env['ir.model.fields']._get('base.automation.lead.test', 'stage_id') + new_stage = self.env['test_base_automation.stage'].create({'name': 'New'}) + + create_automation( + self, + model_id=lead_model.id, + trigger='on_stage_set', + trigger_field_ids=[stage_field.id], + _actions={ + 'state': 'object_create', + 'crud_model_id': self.env['ir.model']._get('res.partner').id, + 'value': "Test Partner Automation", + }, + filter_domain=repr([('stage_id', '=', new_stage.id)]), + ) + + # Tricky case: the record is created with 'stage_id' being false, and + # the field is marked for recomputation. The field is then recomputed + # while evaluating 'filter_domain', which causes the execution of the + # automation. And as the domain is satisfied, the automation is + # processed again, but it must detect that it has just been run! + self.env['base.automation.lead.test'].create({ + 'name': 'Test Lead', + }) + + # check that the automation has been run once + partner_count = self.env['res.partner'].search_count([('name', '=', 'Test Partner Automation')]) + self.assertEqual(partner_count, 1, "Only one partner should have been created") + + def test_00_form_save_update_related_model_id(self): + with Form(self.env['ir.actions.server'], view="base.view_server_action_form") as f: + f.name = "Test Action" + f.model_id = self.env["ir.model"]._get("res.partner") + f.state = "object_write" + f.update_path = "user_id" + f.evaluation_type = "value" + f.resource_ref = "res.users,2" + + res_users_model = self.env["ir.model"]._get("res.users") + self.assertEqual(f.update_related_model_id, res_users_model) + + def test_01_form_object_write_o2m_field(self): + aks_partner = self.env["res.partner"].create({"name": "A Kind Shepherd"}) + bs_partner = self.env["res.partner"].create({"name": "Black Sheep"}) + + # test the 'object_write' type shows a resource_ref field for o2many + f = Form(self.env['ir.actions.server'], view="base.view_server_action_form") + f.name = "Adopt The Black Sheep" + f.model_id = self.env["ir.model"]._get("res.partner") + f.state = "object_write" + f.evaluation_type = "value" + f.update_path = "child_ids" + self.assertEqual(f.update_m2m_operation, "add") + self.assertEqual(f.value_field_to_show, "resource_ref") + f.resource_ref = f"res.partner,{bs_partner.id}" + action = f.save() + + # test the action runs correctly + action.with_context( + active_model="res.partner", + active_id=aks_partner.id, + ).run() + self.assertEqual(aks_partner.child_ids, bs_partner) + self.assertEqual(bs_partner.parent_id, aks_partner) + + # also check with 'remove' operation + f.update_m2m_operation = "remove" + action = f.save() + action.with_context( + active_model="res.partner", + active_id=aks_partner.id, + ).run() + self.assertEqual(aks_partner.child_ids.ids, []) + self.assertEqual(bs_partner.parent_id.id, False) + + def test_02_form_object_write_with_sequence(self): + test_partner = self.env["res.partner"].create({"name": "Test Partner"}) + test_sequence = self.env["ir.sequence"].create({ + "name": "Test Sequence", + "padding": 4, + "prefix": "PARTNER/", + "suffix": "/TEST", + }) + + f = Form(self.env['ir.actions.server'], view="base.view_server_action_form") + f.model_id = self.env["ir.model"]._get("res.partner") + f.state = "object_write" + f.evaluation_type = "sequence" + self.assertEqual(f.warning, False) + f.update_path = "active" + self.assertEqual(f.warning, "A sequence must only be used with character fields.") + f.update_path = "ref" + self.assertEqual(f.warning, False) + f.sequence_id = test_sequence + + action = f.save() + self.assertEqual(test_partner.ref, False) + action.with_context( + active_model="res.partner", + active_id=test_partner.id, + ).run() + self.assertEqual(test_partner.ref, "PARTNER/0001/TEST") + + def test_03_server_action_code_history_wizard(self): + self.env.user.tz = 'Europe/Brussels' # UTC +2 for May 2025 + + def get_history(action): + return self.env["ir.actions.server.history"].search([("action_id", "=", action.id)]) + + def assert_history(action, expected): + history = get_history(action) + self.assertRecordValues(history, expected) + + expected = [] + + with freeze_time("2025-05-01 08:00:00"): + self.env.cr._now = datetime.datetime.now() # reset transaction's NOW + action = self.env["ir.actions.server"].create({ + "name": "Test Action", + "model_id": self.env["ir.model"]._get("res.partner").id, + "state": "code", + "code": "pass", + }) + expected.insert(0, { + "code": "pass", + "display_name": WhitespaceInsensitive(f"May 1, 2025, 10:00:00 AM - {self.env.ref('base.user_root').name}"), + }) + assert_history(action, expected) + + with freeze_time("2025-05-01 08:30:00"): + self.env.cr._now = datetime.datetime.now() # reset transaction's NOW + action.with_user(self.env.ref('base.user_admin')).write({"code": "hello"}) + expected.insert(0, { + "code": "hello", + "display_name": WhitespaceInsensitive(f"May 1, 2025, 10:30:00 AM - {self.env.ref('base.user_admin').name}"), + }) + assert_history(action, expected) + + with freeze_time("2025-05-05 11:30:00"): + self.env.cr._now = datetime.datetime.now() # reset transaction's NOW + action.with_user(self.env.ref('base.user_admin')).write({"code": "coucou"}) + expected.insert(0, { + "code": "coucou", + "display_name": WhitespaceInsensitive(f"May 5, 2025, 1:30:00 PM - {self.env.ref('base.user_admin').name}"), + }) + assert_history(action, expected) + + with freeze_time("2025-05-12 09:30:00"): + self.env.cr._now = datetime.datetime.now() # reset transaction's NOW + with Form(self.env['server.action.history.wizard'].with_context(default_action_id=action.id)) as wizard_form: + self.assertRecordValues(wizard_form.revision, [ + { + "code": "hello", + "display_name": WhitespaceInsensitive(f"May 1, 2025, 10:30:00 AM - {self.env.ref('base.user_admin').name}"), + } + ]) + first_diff = str(wizard_form.code_diff) + wizard_form.revision = get_history(action)[-1] + second_diff = str(wizard_form.code_diff) + self.assertNotEqual(first_diff, second_diff) + wizard_form.record.restore_revision() + + self.assertEqual(action.code, "pass") + expected.insert(0, { + "code": "pass", + "display_name": WhitespaceInsensitive(f"May 12, 2025, 11:30:00 AM - {self.env.ref('base.user_root').name}"), + }) + assert_history(action, expected) + + def test_server_action_code_history_wizard_with_no_timezone(self): + self.env.user.tz = False + + def get_history(action): + return self.env["ir.actions.server.history"].search([("action_id", "=", action.id)]) + + def assert_history(action, expected): + history = get_history(action) + self.assertRecordValues(history, expected) + + expected = [] + + with freeze_time("2025-05-01 10:00:00"): + self.env.cr._now = datetime.datetime.now() # reset transaction's NOW + action = self.env["ir.actions.server"].create({ + "name": "Test Action", + "model_id": self.env["ir.model"]._get("res.partner").id, + "state": "code", + "code": "pass", + }) + expected.insert(0, { + "code": "pass", + "display_name": WhitespaceInsensitive(f"May 1, 2025, 10:00:00 AM - {self.env.ref('base.user_root').name}"), + }) + assert_history(action, expected) + + with freeze_time("2025-05-01 10:00:00"): + self.env.cr._now = datetime.datetime.now() # reset transaction's NOW + action.with_user(self.env.ref('base.user_admin')).write({"code": "hello"}) + expected.insert(0, { + "code": "hello", + "display_name": WhitespaceInsensitive(f"May 1, 2025, 10:00:00 AM - {self.env.ref('base.user_admin').name}"), + }) + assert_history(action, expected) + + with freeze_time("2025-05-12 10:00:00"): + self.env.cr._now = datetime.datetime.now() # reset transaction's NOW + with Form(self.env['server.action.history.wizard'].with_context(default_action_id=action.id)) as wizard_form: + self.assertRecordValues(wizard_form.revision, [ + { + "code": "pass", + "display_name": WhitespaceInsensitive(f"May 1, 2025, 10:00:00 AM - {self.env.ref('base.user_root').name}"), + } + ]) + first_diff = str(wizard_form.code_diff) + wizard_form.revision = get_history(action)[-1] + second_diff = str(wizard_form.code_diff) + self.assertNotEqual(first_diff, second_diff) + wizard_form.record.restore_revision() + + self.assertEqual(action.code, "pass") + expected.insert(0, { + "code": "pass", + "display_name": WhitespaceInsensitive(f"May 12, 2025, 10:00:00 AM - {self.env.ref('base.user_root').name}"), + }) + assert_history(action, expected) + + +@common.tagged("post_install", "-at_install") +class TestHttp(common.HttpCase): + def test_webhook_trigger(self): + model = self.env["ir.model"]._get("base.automation.linked.test") + record_getter = "model.search([('name', '=', payload['name'])]) if payload.get('name') else None" + automation = create_automation(self, trigger="on_webhook", model_id=model.id, record_getter=record_getter, _actions={ + "state": "object_write", + "update_path": "another_field", + "value": "written" + }) + + obj = self.env[model.model].create({"name": "some name"}) + response = self.url_open(automation.url, data=json.dumps({"name": "some name"})) + self.assertEqual(response.json(), {"status": "ok"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(obj.another_field, "written") + + obj.another_field = False + with mute_logger("odoo.addons.base_automation.models.base_automation"): + response = self.url_open(automation.url, data=json.dumps({})) + self.assertEqual(response.json(), {"status": "error"}) + self.assertEqual(response.status_code, 500) + self.assertEqual(obj.another_field, False) + + response = self.url_open("/web/hook/0123456789", data=json.dumps({"name": "some name"})) + self.assertEqual(response.json(), {"status": "error"}) + self.assertEqual(response.status_code, 404) + + def test_payload_in_action_server(self): + model = self.env["ir.model"]._get("base.automation.linked.test") + record_getter = "model.search([('name', '=', payload['name'])]) if payload.get('name') else None" + automation = create_automation(self, trigger="on_webhook", model_id=model.id, record_getter=record_getter, _actions={ + "state": "code", + "code": "record.write({'another_field': json.dumps(payload)})" + }) + + obj = self.env[model.model].create({"name": "some name"}) + self.url_open(automation.url, data=json.dumps({"name": "some name", "test_key": "test_value"}), headers={"Content-Type": "application/json"}) + self.assertEqual(json.loads(obj.another_field), { + "name": "some name", + "test_key": "test_value", + }) + + obj.another_field = "" + self.url_open(automation.url + "?test_param=test_value&name=some%20name") + self.assertEqual(json.loads(obj.another_field), { + "name": "some name", + "test_param": "test_value", + }) + + def test_webhook_send_and_receive(self): + model = self.env["ir.model"]._get("base.automation.linked.test") + obj = self.env[model.model].create({"name": "some name"}) + + automation_receiver = create_automation(self, trigger="on_webhook", model_id=model.id, _actions={ + "state": "code", + "code": "record.write({'another_field': json.dumps(payload)})" + }) + name_field_id = self.env.ref("test_base_automation.field_base_automation_linked_test__name") + automation_sender = create_automation(self, trigger="on_write", model_id=model.id, trigger_field_ids=[(6, 0, [name_field_id.id])], _actions={ + "name": "Send Webhook Notification", + "state": "webhook", + "webhook_url": automation_receiver.url, + }) + + # Changing the name will make an http request, post-commitedly + obj.name = "new_name" + self.cr.flush() + with self.allow_requests(all_requests=True): + self.cr.postcommit.run() # webhooks run in postcommit + self.cr.clear() + self._wait_remaining_requests() # just in case the request timeouts + self.assertEqual(json.loads(obj.another_field), { + '_action': f'Send Webhook Notification(#{automation_sender.action_server_ids[0].id})', + "_id": obj.id, + "_model": obj._name, + }) + + def test_on_change_get_views_cache(self): + model_name = "base.automation.lead.test" + my_view = self.env["ir.ui.view"].create({ + "name": "My View", + "model": model_name, + "type": "form", + "arch": "
", + }) + self.assertEqual( + self.env[model_name].get_view(my_view.id)["arch"], + '
' + ) + model = self.env["ir.model"]._get(model_name) + active_field = self.env["ir.model.fields"]._get(model_name, "active") + create_automation(self, trigger="on_change", model_id=model.id, on_change_field_ids=[Command.set([active_field.id])], _actions={ + "state": "code", + "code": "", + }) + self.assertEqual( + self.env[model_name].get_view(my_view.id)["arch"], + '
' + ) diff --git a/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/test_server_actions.py b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/test_server_actions.py new file mode 100644 index 0000000..1372c97 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/test_server_actions.py @@ -0,0 +1,77 @@ +# # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.base.models.ir_actions import ServerActionWithWarningsError +from odoo.exceptions import ValidationError +from odoo.addons.base.tests.test_ir_actions import TestServerActionsBase + + +class TestServerActionsValidation(TestServerActionsBase): + def test_multi_action_children_warnings(self): + self.action.write({ + 'state': 'multi', + 'child_ids': [self.test_server_action.id] + }) + self.assertEqual(self.action.model_id.model, "res.partner") + self.assertEqual(self.test_server_action.model_id.model, "ir.actions.server") + self.assertEqual(self.action.warning, "Following child actions should have the same model (Contact): TestDummyServerAction") + + new_action = self.action.copy() + with self.assertRaises(ValidationError) as ve: + new_action.write({ + 'child_ids': [self.action.id] + }) + self.assertEqual(ve.exception.args[0], "Following child actions have warnings: TestAction") + + def test_webhook_payload_includes_group_restricted_fields(self): + self.test_server_action.write({ + 'state': 'webhook', + 'webhook_field_ids': [self.env['ir.model.fields']._get('ir.actions.server', 'code').id], + }) + self.assertEqual(self.test_server_action.warning, "Group-restricted fields cannot be included in " + "webhook payloads, as it could allow any user to " + "accidentally leak sensitive information. You will " + "have to remove the following fields from the webhook payload:\n" + "- Python Code") + + def test_recursion_in_child(self): + new_action = self.action.copy() + self.action.write({ + 'state': 'multi', + 'child_ids': [new_action.id] + }) + with self.assertRaises(ValidationError) as ve: + new_action.write({ + 'child_ids': [self.action.id] + }) + self.assertEqual(ve.exception.args[0], "Recursion found in child server actions") + + def test_non_relational_field_traversal(self): + self.action.write({ + 'state': 'object_write', + 'update_path': 'parent_id.name', + 'value': 'TestNew', + }) + with self.assertRaises(ValidationError) as ve: + self.action.write({'update_path': 'parent_id.name.something_else'}) + self.assertEqual(ve.exception.args[0], "The path contained by the field " + "'Field to Update Path' contains a non-relational field" + " (Name) that is not the last field in the path. You " + "can't traverse non-relational fields (even in the quantum" + " realm). Make sure only the last field in the path is non-relational.") + + def test_python_bad_expr(self): + with self.assertRaises(ValidationError) as ve: + self.test_server_action.write({'code': 'this is invalid python code'}) + self.assertEqual( + ve.exception.args[0], + "SyntaxError : invalid syntax at line 1\n" + "this is invalid python code\n") + + def test_cannot_run_if_warnings(self): + self.action.write({ + 'state': 'multi', + 'child_ids': [self.test_server_action.id] + }) + self.assertTrue(self.action.warning) + with self.assertRaises(ServerActionWithWarningsError) as e: + self.action.run() + self.assertEqual(e.exception.args[0], "Server action TestAction has one or more warnings, address them first.") diff --git a/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/test_tour.py b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/test_tour.py new file mode 100644 index 0000000..19e5621 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/test_tour.py @@ -0,0 +1,304 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from urllib.parse import urlencode +import ast + +from odoo import Command + +from odoo.tests import HttpCase, tagged + + +def _urlencode_kwargs(**kwargs): + return urlencode(kwargs) + + +@tagged("post_install_l10n", "post_install", "-at_install") +class BaseAutomationTestUi(HttpCase): + def _neutralize_preexisting_automations(self, neutralize_action=True): + self.env["base.automation"].with_context(active_test=False).search([]).write({"active": False}) + if neutralize_action: + context = ast.literal_eval(self.env.ref("base_automation.base_automation_act").context) + del context["search_default_inactive"] + self.env.ref("base_automation.base_automation_act").context = str(context) + + def test_01_base_automation_tour(self): + self._neutralize_preexisting_automations() + self.start_tour("/odoo/action-base_automation.base_automation_act?debug=tests", "test_base_automation", login="admin") + base_automation = self.env["base.automation"].search([]) + self.assertEqual(base_automation.model_id.model, "res.partner") + self.assertEqual(base_automation.trigger, "on_create_or_write") + self.assertEqual(base_automation.action_server_ids.state, "object_write") # only one action + self.assertEqual(base_automation.action_server_ids.model_name, "res.partner") + self.assertEqual(base_automation.action_server_ids.update_field_id.name, "function") + self.assertEqual(base_automation.action_server_ids.value, "Test") + + def test_base_automation_on_tag_added(self): + self._neutralize_preexisting_automations() + self.env["test_base_automation.tag"].create({"name": "test"}) + self.start_tour("/odoo/action-base_automation.base_automation_act?debug=tests", "test_base_automation_on_tag_added", login="admin") + + def test_open_automation_from_grouped_kanban(self): + self._neutralize_preexisting_automations() + + test_view = self.env["ir.ui.view"].create( + { + "name": "test_view", + "model": "test_base_automation.project", + "type": "kanban", + "arch": """ + + + + + + + + """, + } + ) + test_action = self.env["ir.actions.act_window"].create( + { + "name": "test action", + "res_model": "test_base_automation.project", + "view_ids": [Command.create({"view_id": test_view.id, "view_mode": "kanban"})], + } + ) + tag = self.env["test_base_automation.tag"].create({"name": "test tag"}) + self.env["test_base_automation.project"].create({"name": "test", "tag_ids": [Command.link(tag.id)]}) + + self.start_tour(f"/odoo/action-{test_action.id}?debug=0", "test_open_automation_from_grouped_kanban", login="admin") + base_auto = self.env["base.automation"].search([]) + self.assertEqual(base_auto.name, "From Tour") + self.assertEqual(base_auto.model_name, "test_base_automation.project") + self.assertEqual(base_auto.trigger_field_ids.name, "tag_ids") + self.assertEqual(base_auto.trigger, "on_tag_set") + self.assertEqual(base_auto.trg_field_ref_model_name, "test_base_automation.tag") + self.assertEqual(base_auto.trg_field_ref, tag.id) + + def test_kanban_automation_view_stage_trigger(self): + self._neutralize_preexisting_automations() + + project_model = self.env.ref('test_base_automation.model_test_base_automation_project') + stage_field = self.env['ir.model.fields'].search([ + ('model_id', '=', project_model.id), + ('name', '=', 'stage_id'), + ]) + test_stage = self.env['test_base_automation.stage'].create({'name': 'Stage value'}) + + automation = self.env["base.automation"].create({ + "name": "Test Stage", + "trigger": "on_stage_set", + "model_id": project_model.id, + "trigger_field_ids": [stage_field.id], + "trg_field_ref": test_stage, + }) + + action = { + "name": "Set Active To False", + "base_automation_id": automation.id, + "state": "object_write", + "update_path": "user_ids.active", + "value": False, + "model_id": project_model.id + } + automation.write({"action_server_ids": [Command.create(action)]}) + + self.start_tour( + "/odoo/action-base_automation.base_automation_act", + "test_kanban_automation_view_stage_trigger", login="admin" + ) + + def test_kanban_automation_view_time_trigger(self): + self._neutralize_preexisting_automations() + model = self.env['ir.model']._get("base.automation.lead.test") + + date_field = self.env['ir.model.fields'].search([ + ('model_id', '=', model.id), + ('name', '=', 'date_automation_last'), + ]) + + self.env["base.automation"].create({ + "name": "Test Date", + "trigger": "on_time", + "model_id": model.id, + "trg_date_range": 1, + "trg_date_range_type": "hour", + "trg_date_id": date_field.id, + }) + + self.start_tour( + "/odoo/action-base_automation.base_automation_act", + "test_kanban_automation_view_time_trigger", login="admin" + ) + + def test_kanban_automation_view_time_updated_trigger(self): + self._neutralize_preexisting_automations() + model = self.env.ref("base.model_res_partner") + + self.env["base.automation"].create({ + "name": "Test Date", + "trigger": "on_time_updated", + "model_id": model.id, + "trg_date_range": 1, + "trg_date_range_type": "hour", + }) + + self.start_tour( + "/odoo/action-base_automation.base_automation_act", + "test_kanban_automation_view_time_updated_trigger", login="admin" + ) + + def test_kanban_automation_view_create_action(self): + self._neutralize_preexisting_automations() + model = self.env.ref("base.model_res_partner") + + automation = self.env["base.automation"].create({ + "name": "Test", + "trigger": "on_create_or_write", + "model_id": model.id, + }) + + action = { + "name": "Create Contact with name NameX", + "base_automation_id": automation.id, + "state": "object_create", + "value": "NameX", + "model_id": model.id + } + + automation.write({"action_server_ids": [Command.create(action)]}) + + self.start_tour( + "/odoo/action-base_automation.base_automation_act", + "test_kanban_automation_view_create_action", login="admin" + ) + + def test_resize_kanban(self): + self._neutralize_preexisting_automations() + model = self.env.ref("base.model_res_partner") + + automation = self.env["base.automation"].create( + { + "name": "Test", + "trigger": "on_create_or_write", + "model_id": model.id, + } + ) + + action = { + "name": "Set Active To False", + "base_automation_id": automation.id, + "state": "object_write", + "update_path": "active", + "value": False, + "model_id": model.id, + } + automation.write({"action_server_ids": [Command.create(action) for i in range(3)]}) + + self.start_tour( + "/odoo/action-base_automation.base_automation_act", + "test_resize_kanban", + login="admin", + ) + + def test_form_view(self): + model = self.env.ref("base.model_res_partner") + automation = self.env["base.automation"].create( + { + "name": "Test", + "trigger": "on_create_or_write", + "model_id": model.id, + } + ) + action = { + "name": "Update Active", + "base_automation_id": automation.id, + "state": "object_write", + "update_path": "active", + "update_boolean_value": "false", + "model_id": model.id, + } + automation.write( + {"action_server_ids": [Command.create(dict(action, name=action["name"] + f" {i}", sequence=i)) for i in range(3)]} + ) + self.assertEqual( + automation.action_server_ids.mapped("name"), + ["Update Active 0", "Update Active 1", "Update Active 2"], + ) + + onchange_link_passes = 0 + origin_link_onchange = type(self.env["ir.actions.server"]).onchange + + def _onchange_base_auto_link(self_model, *args): + nonlocal onchange_link_passes + onchange_link_passes += 1 + res = origin_link_onchange(self_model, *args) + if onchange_link_passes == 1: + default_keys = {k: v for k, v in self_model.env.context.items() if k.startswith("default_")} + self.assertEqual( + default_keys, + {"default_model_id": model.id, "default_usage": "base_automation"}, + ) + if onchange_link_passes == 2: + self.assertEqual(res["value"]["name"], "Add Followers") + + return res + + self.patch(type(self.env["ir.actions.server"]), "onchange", _onchange_base_auto_link) + + self.start_tour( + ( + f"/odoo/action-base_automation.base_automation_act/{automation.id}?debug=0" + ), + "test_form_view_resequence_actions", + login="admin", + ) + self.assertEqual(onchange_link_passes, 2) + self.assertEqual( + automation.action_server_ids.mapped("name"), + ["Update Active 2", "Update Active 0", "Update Active 1"], + ) + + def test_form_view_model_id(self): + self.start_tour( + ( + "/odoo/action-base_automation.base_automation_act/new?view_type='form'&debug=0)" + ), + "test_form_view_model_id", + login="admin", + ) + + def test_form_view_custom_reference_field(self): + self.env["test_base_automation.stage"].create({"name": "test stage"}) + self.env["test_base_automation.tag"].create({"name": "test tag"}) + self.start_tour( + ( + "/odoo/action-base_automation.base_automation_act/new?view_type='form'&debug=0)" + ), + "test_form_view_custom_reference_field", + login="admin", + ) + + def test_form_view_mail_triggers(self): + self.start_tour( + ( + "/odoo/action-base_automation.base_automation_act/new?view_type='form'&debug=0)" + ), + "test_form_view_mail_triggers", + login="admin", + ) + + def test_on_change_rule_creation(self): + """ test on_change rule creation from the UI """ + self.start_tour("/odoo/action-base_automation.base_automation_act", 'base_automation.on_change_rule_creation', login="admin") + + rule = self.env['base.automation'].search([], order="create_date desc", limit=1)[0] + view_model = self.env['ir.model']._get("ir.ui.view") + active_field = self.env['ir.model.fields'].search([ + ('name', '=', 'active'), + ('model', '=', 'ir.ui.view'), + ])[0] + self.assertEqual(rule.name, "Test rule") + self.assertEqual(rule.model_id, view_model) + self.assertEqual(rule.trigger, 'on_change') + self.assertEqual(len(rule.on_change_field_ids), 1) + self.assertEqual(rule.on_change_field_ids[0], active_field) diff --git a/odoo-bringout-oca-ocb-test_crm_full/README.md b/odoo-bringout-oca-ocb-test_crm_full/README.md index bf8f679..0c7bd83 100644 --- a/odoo-bringout-oca-ocb-test_crm_full/README.md +++ b/odoo-bringout-oca-ocb-test_crm_full/README.md @@ -12,7 +12,6 @@ pip install odoo-bringout-oca-ocb-test_crm_full ## Dependencies -This addon depends on: - crm - crm_iap_enrich - crm_iap_mine @@ -24,34 +23,12 @@ This addon depends on: - website_crm_partner_assign - website_crm_livechat -## Manifest Information - -- **Name**: Test Full Crm Flow -- **Version**: 1.0 -- **Category**: Hidden/Tests -- **License**: LGPL-3 -- **Installable**: False - ## Source -Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `test_crm_full`. +- Repository: https://github.com/OCA/OCB +- Branch: 19.0 +- Path: addons/test_crm_full ## License -This package maintains the original LGPL-3 license from the upstream Odoo project. - -## Documentation - -- Overview: doc/OVERVIEW.md -- Architecture: doc/ARCHITECTURE.md -- Models: doc/MODELS.md -- Controllers: doc/CONTROLLERS.md -- Wizards: doc/WIZARDS.md -- Reports: doc/REPORTS.md -- Security: doc/SECURITY.md -- Install: doc/INSTALL.md -- Usage: doc/USAGE.md -- Configuration: doc/CONFIGURATION.md -- Dependencies: doc/DEPENDENCIES.md -- Troubleshooting: doc/TROUBLESHOOTING.md -- FAQ: doc/FAQ.md +This package preserves the original LGPL-3 license. diff --git a/odoo-bringout-oca-ocb-test_crm_full/pyproject.toml b/odoo-bringout-oca-ocb-test_crm_full/pyproject.toml index 101fe71..07483ce 100644 --- a/odoo-bringout-oca-ocb-test_crm_full/pyproject.toml +++ b/odoo-bringout-oca-ocb-test_crm_full/pyproject.toml @@ -1,21 +1,23 @@ [project] name = "odoo-bringout-oca-ocb-test_crm_full" version = "16.0.0" -description = "Test Full Crm Flow - Odoo addon" +description = "Test Full Crm Flow - + Odoo addon + " authors = [ { name = "Ernad Husremovic", email = "hernad@bring.out.ba" } ] dependencies = [ - "odoo-bringout-oca-ocb-crm>=16.0.0", - "odoo-bringout-oca-ocb-crm_iap_enrich>=16.0.0", - "odoo-bringout-oca-ocb-crm_iap_mine>=16.0.0", - "odoo-bringout-oca-ocb-crm_sms>=16.0.0", - "odoo-bringout-oca-ocb-event_crm>=16.0.0", - "odoo-bringout-oca-ocb-sale_crm>=16.0.0", - "odoo-bringout-oca-ocb-website_crm>=16.0.0", - "odoo-bringout-oca-ocb-website_crm_iap_reveal>=16.0.0", - "odoo-bringout-oca-ocb-website_crm_partner_assign>=16.0.0", - "odoo-bringout-oca-ocb-website_crm_livechat>=16.0.0", + "odoo-bringout-oca-ocb-crm>=19.0.0", + "odoo-bringout-oca-ocb-crm_iap_enrich>=19.0.0", + "odoo-bringout-oca-ocb-crm_iap_mine>=19.0.0", + "odoo-bringout-oca-ocb-crm_sms>=19.0.0", + "odoo-bringout-oca-ocb-event_crm>=19.0.0", + "odoo-bringout-oca-ocb-sale_crm>=19.0.0", + "odoo-bringout-oca-ocb-website_crm>=19.0.0", + "odoo-bringout-oca-ocb-website_crm_iap_reveal>=19.0.0", + "odoo-bringout-oca-ocb-website_crm_partner_assign>=19.0.0", + "odoo-bringout-oca-ocb-website_crm_livechat>=19.0.0", "requests>=2.25.1" ] readme = "README.md" @@ -25,7 +27,7 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Office/Business", ] diff --git a/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/__manifest__.py b/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/__manifest__.py index 86302d6..8f2caec 100644 --- a/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/__manifest__.py +++ b/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/__manifest__.py @@ -20,5 +20,6 @@ backend. It notably includes IAP bridges modules to test their impact. """, 'website_crm_partner_assign', 'website_crm_livechat', ], + 'author': 'Odoo S.A.', 'license': 'LGPL-3', } diff --git a/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/tests/common.py b/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/tests/common.py index bce1f74..101be0c 100644 --- a/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/tests/common.py +++ b/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/tests/common.py @@ -13,7 +13,6 @@ class TestCrmFullCommon(TestCrmCommon, MockIAPReveal, MockVisitor): @classmethod def setUpClass(cls): super(TestCrmFullCommon, cls).setUpClass() - cls._init_mail_gateway() cls._activate_multi_company() # Context data: dates @@ -34,7 +33,6 @@ class TestCrmFullCommon(TestCrmCommon, MockIAPReveal, MockVisitor): 'email': 'partner.email.%02d@test.example.com' % idx, 'function': 'Noisy Customer', 'lang': 'fr_BE', - 'mobile': '04569999%02d' % idx, 'name': 'PartnerCustomer', 'phone': '04560000%02d' % idx, 'street': 'Super Street, %092d' % idx, diff --git a/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/tests/test_performance.py b/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/tests/test_performance.py index 571f5e4..551a4a1 100644 --- a/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/tests/test_performance.py +++ b/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/tests/test_performance.py @@ -4,8 +4,7 @@ from freezegun import freeze_time from odoo.addons.test_crm_full.tests.common import TestCrmFullCommon -from odoo.tests.common import users, warmup, Form -from odoo.tests import tagged +from odoo.tests import Form, users, warmup, tagged @tagged('crm_performance', 'post_install', '-at_install', '-standard') @@ -15,10 +14,13 @@ class CrmPerformanceCase(TestCrmFullCommon): super(CrmPerformanceCase, self).setUp() # patch registry to simulate a ready environment self.patch(self.env.registry, 'ready', True) + # we don't use mock_mail_gateway thus want to mock smtp to test the stack + self._mock_smtplib_connection() + self._flush_tracking() self.user_sales_leads.write({ - 'groups_id': [ + 'group_ids': [ (4, self.env.ref('event.group_event_user').id), (4, self.env.ref('im_livechat.im_livechat_group_user').id), ] @@ -40,16 +42,15 @@ class TestCrmPerformance(CrmPerformanceCase): """ Test multiple lead creation (import) """ batch_size = 10 country_be = self.env.ref('base.be') - lang_be_id = self.env['res.lang']._lang_get_id('fr_BE') + lang_be_id = self.env['res.lang']._get_data(code='fr_BE').id - with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=194): # tcf 193 / com 194 + with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=192): # tcf 191 self.env.cr._now = self.reference_now # force create_date to check schedulers crm_values = [ {'country_id': country_be.id, 'email_from': 'address.email.%02d@test.example.com' % idx, 'function': 'Noisy Customer', 'lang_id': lang_be_id, - 'mobile': '04551111%02d' % idx, 'name': 'Test Lead %02d' % idx, 'phone': '04550000%02d' % idx, 'street': 'Super Street, %092d' % idx, @@ -70,14 +71,13 @@ class TestCrmPerformance(CrmPerformanceCase): country_be = self.env.ref('base.be') lang_be = self.env['res.lang']._lang_get('fr_BE') - with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=189): # tcf only: 173 - com runbot: 174/175 + with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=145): # tcf 142 / com 144 self.env.cr._now = self.reference_now # force create_date to check schedulers with Form(self.env['crm.lead']) as lead_form: lead_form.country_id = country_be lead_form.email_from = 'address.email@test.example.com' lead_form.function = 'Noisy Customer' lead_form.lang_id = lang_be - lead_form.mobile = '0455111100' lead_form.name = 'Test Lead' lead_form.phone = '0455000011' lead_form.street = 'Super Street, 00' @@ -89,7 +89,7 @@ class TestCrmPerformance(CrmPerformanceCase): @warmup def test_lead_create_form_partner(self): """ Test a single lead creation using Form with a partner """ - with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=199): # tcf 186 / com 188 + with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=144): # tcf 141 / com 143 self.env.cr._now = self.reference_now # force create_date to check schedulers with self.debug_mode(): # {'invisible': ['|', ('type', '=', 'opportunity'), ('is_partner_visible', '=', False)]} @@ -105,16 +105,15 @@ class TestCrmPerformance(CrmPerformanceCase): def test_lead_create_single_address(self): """ Test multiple lead creation (import) """ country_be = self.env.ref('base.be') - lang_be_id = self.env['res.lang']._lang_get_id('fr_BE') + lang_be_id = self.env['res.lang']._get_data(code='fr_BE').id - with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=43): # tcf only: 41 - com runbot: 42 + with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=30): # tcf 29 self.env.cr._now = self.reference_now # force create_date to check schedulers crm_values = [ {'country_id': country_be.id, 'email_from': 'address.email.00@test.example.com', 'function': 'Noisy Customer', 'lang_id': lang_be_id, - 'mobile': '0455111100', 'name': 'Test Lead', 'phone': '0455000000', 'street': 'Super Street, 00', @@ -127,7 +126,7 @@ class TestCrmPerformance(CrmPerformanceCase): @warmup def test_lead_create_single_partner(self): """ Test multiple lead creation (import) """ - with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=49): # tcf only: 47 - com runbot: 48 + with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=30): # tcf 29 self.env.cr._now = self.reference_now # force create_date to check schedulers crm_values = [ {'partner_id': self.partners[0].id, diff --git a/odoo-bringout-oca-ocb-test_discuss_full/README.md b/odoo-bringout-oca-ocb-test_discuss_full/README.md index 032e3a1..5c8746a 100644 --- a/odoo-bringout-oca-ocb-test_discuss_full/README.md +++ b/odoo-bringout-oca-ocb-test_discuss_full/README.md @@ -10,45 +10,27 @@ pip install odoo-bringout-oca-ocb-test_discuss_full ## Dependencies -This addon depends on: - calendar - crm - crm_livechat +- hr_attendance +- hr_fleet - hr_holidays +- hr_homeworking - im_livechat - mail - mail_bot -- note +- project_todo - website_livechat - -## Manifest Information - -- **Name**: Test Discuss (full) -- **Version**: 1.0 -- **Category**: Hidden -- **License**: LGPL-3 -- **Installable**: True +- website_sale +- website_slides ## Source -Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `test_discuss_full`. +- Repository: https://github.com/OCA/OCB +- Branch: 19.0 +- Path: addons/test_discuss_full ## License -This package maintains the original LGPL-3 license from the upstream Odoo project. - -## Documentation - -- Overview: doc/OVERVIEW.md -- Architecture: doc/ARCHITECTURE.md -- Models: doc/MODELS.md -- Controllers: doc/CONTROLLERS.md -- Wizards: doc/WIZARDS.md -- Reports: doc/REPORTS.md -- Security: doc/SECURITY.md -- Install: doc/INSTALL.md -- Usage: doc/USAGE.md -- Configuration: doc/CONFIGURATION.md -- Dependencies: doc/DEPENDENCIES.md -- Troubleshooting: doc/TROUBLESHOOTING.md -- FAQ: doc/FAQ.md +This package preserves the original LGPL-3 license. diff --git a/odoo-bringout-oca-ocb-test_discuss_full/pyproject.toml b/odoo-bringout-oca-ocb-test_discuss_full/pyproject.toml index 0467327..3ea8c23 100644 --- a/odoo-bringout-oca-ocb-test_discuss_full/pyproject.toml +++ b/odoo-bringout-oca-ocb-test_discuss_full/pyproject.toml @@ -1,20 +1,27 @@ [project] name = "odoo-bringout-oca-ocb-test_discuss_full" version = "16.0.0" -description = "Test Discuss (full) - Test of Discuss with all possible overrides installed." +description = "Test Discuss (full) - + Test of Discuss with all possible overrides installed. + " authors = [ { name = "Ernad Husremovic", email = "hernad@bring.out.ba" } ] dependencies = [ - "odoo-bringout-oca-ocb-calendar>=16.0.0", - "odoo-bringout-oca-ocb-crm>=16.0.0", - "odoo-bringout-oca-ocb-crm_livechat>=16.0.0", - "odoo-bringout-oca-ocb-hr_holidays>=16.0.0", - "odoo-bringout-oca-ocb-im_livechat>=16.0.0", - "odoo-bringout-oca-ocb-mail>=16.0.0", - "odoo-bringout-oca-ocb-mail_bot>=16.0.0", - "odoo-bringout-oca-ocb-note>=16.0.0", - "odoo-bringout-oca-ocb-website_livechat>=16.0.0", + "odoo-bringout-oca-ocb-calendar>=19.0.0", + "odoo-bringout-oca-ocb-crm>=19.0.0", + "odoo-bringout-oca-ocb-crm_livechat>=19.0.0", + "odoo-bringout-oca-ocb-hr_attendance>=19.0.0", + "odoo-bringout-oca-ocb-hr_fleet>=19.0.0", + "odoo-bringout-oca-ocb-hr_holidays>=19.0.0", + "TODO_MAP-hr_homeworking>=19.0.0", + "odoo-bringout-oca-ocb-im_livechat>=19.0.0", + "odoo-bringout-oca-ocb-mail>=19.0.0", + "odoo-bringout-oca-ocb-mail_bot>=19.0.0", + "TODO_MAP-project_todo>=19.0.0", + "odoo-bringout-oca-ocb-website_livechat>=19.0.0", + "odoo-bringout-oca-ocb-website_sale>=19.0.0", + "odoo-bringout-oca-ocb-website_slides>=19.0.0", "requests>=2.25.1" ] readme = "README.md" @@ -24,7 +31,7 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Office/Business", ] diff --git a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/__manifest__.py b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/__manifest__.py index 7e9acd9..bd6bffe 100644 --- a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/__manifest__.py +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/__manifest__.py @@ -2,23 +2,34 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. { - 'name': 'Test Discuss (full)', - 'version': '1.0', - 'category': 'Hidden', - 'sequence': 9877, - 'summary': 'Test of Discuss with all possible overrides installed.', - 'description': """Test of Discuss with all possible overrides installed, including feature and performance tests.""", - 'depends': [ - 'calendar', - 'crm', - 'crm_livechat', - 'hr_holidays', - 'im_livechat', - 'mail', - 'mail_bot', - 'note', - 'website_livechat', + "name": "Test Discuss (full)", + "version": "1.0", + "category": "Productivity/Discuss", + "sequence": 9877, + "summary": "Test of Discuss with all possible overrides installed.", + "description": """Test of Discuss with all possible overrides installed, including feature and performance tests.""", + "depends": [ + "calendar", + "crm", + "crm_livechat", + "hr_attendance", + "hr_fleet", + "hr_holidays", + "hr_homeworking", + "im_livechat", + "mail", + "mail_bot", + "project_todo", + "website_livechat", + "website_sale", + "website_slides", ], - 'installable': True, - 'license': 'LGPL-3', + "installable": True, + "assets": { + "web.assets_tests": [ + "test_discuss_full/static/tests/tours/**/*", + ], + }, + "author": "Odoo S.A.", + "license": "LGPL-3", } diff --git a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/static/tests/tours/avatar_card_tour.js b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/static/tests/tours/avatar_card_tour.js new file mode 100644 index 0000000..c5c8073 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/static/tests/tours/avatar_card_tour.js @@ -0,0 +1,60 @@ +import { registry } from "@web/core/registry"; + +const steps = [ + { + content: "Open the avatar card popover", + trigger: ".o-mail-Message-avatar", + run: "click", + }, + { + content: "Check that the employee's work email is displayed", + trigger: ".o_avatar_card:contains(test_employee@test.com)", + }, + { + content: "Check that the employee's department is displayed", + trigger: ".o_avatar_card:contains(Test Department)", + }, + { + content: "Check that the employee's work phone is displayed", + trigger: ".o_avatar_card:contains(123456789)", + }, + { + content: "Check that the employee's holiday status is displayed", + trigger: ".o_avatar_card:contains(Back on)", + }, +]; + +registry.category("web_tour.tours").add("avatar_card_tour", { + steps: () => [ + ...steps, + { + content: "Check that the employee's job title is displayed", + trigger: ".o_avatar_card:contains(Test Job Title)", + }, + { + trigger: ".o-mail-ActivityMenu-counter:text('2')", + }, + { + trigger: ".o_switch_company_menu button", + run: "click", + }, + { + trigger: `[role=button][title='Switch to Company 2']`, + run: "click", + expectUnloadPage: true, + }, + { + trigger: ".o-mail-ActivityMenu-counter:text('1')", + }, + ], +}); + +registry.category("web_tour.tours").add("avatar_card_tour_no_hr_access", { + steps: () => [ + ...steps, + { + content: "Check that the employee's job title is displayed", + trigger: ":not(.o_avatar_card:contains(Test Job Title))", + }, + ], +}); diff --git a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/static/tests/tours/chatbot_redirect_to_portal_tour.js b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/static/tests/tours/chatbot_redirect_to_portal_tour.js new file mode 100644 index 0000000..4cbca7e --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/static/tests/tours/chatbot_redirect_to_portal_tour.js @@ -0,0 +1,28 @@ +import { registry } from "@web/core/registry"; + +registry.category("web_tour.tours").add("chatbot_redirect_to_portal", { + url: "/contactus", + steps: () => [ + { + trigger: ".o-livechat-root:shadow .o-livechat-LivechatButton", + run: "click", + }, + { + trigger: + ".o-livechat-root:shadow .o-mail-Message:contains(Hello, were do you want to go?)", + run: "click", + }, + { + trigger: ".o-livechat-root:shadow li button:contains(Go to the portal page)", + run: "click", + expectUnloadPage: true, + }, + { + trigger: ".o-livechat-root:shadow .o-mail-Message:contains('Go to the portal page')", + }, + { trigger: "#chatterRoot:shadow .o-mail-Chatter" }, + { + trigger: ".o-livechat-root:shadow .o-mail-Message:last:contains('Tadam')", + }, + ], +}); diff --git a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/static/tests/tours/im_livechat_session_open_tour.js b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/static/tests/tours/im_livechat_session_open_tour.js new file mode 100644 index 0000000..617e938 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/static/tests/tours/im_livechat_session_open_tour.js @@ -0,0 +1,17 @@ +import { registry } from "@web/core/registry"; + +registry.category("web_tour.tours").add("im_livechat_session_open", { + steps: () => [ + { + trigger: "button.o_switch_view.o_list", + run: "click", + }, + { + trigger: ".o_data_cell:contains(Visitor)", + run: "click", + }, + { + trigger: ".o-mail-Thread:contains('The conversation is empty.')", + }, + ], +}); diff --git a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/__init__.py b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/__init__.py index 72c0b71..56caed8 100644 --- a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/__init__.py +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/__init__.py @@ -1,4 +1,9 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import test_avatar_card_tour +from . import test_livechat_hr_holidays +from . import test_im_livechat_portal from . import test_performance +from . import test_performance_inbox +from . import test_livechat_session_open +from . import test_res_partner diff --git a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_avatar_card_tour.py b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_avatar_card_tour.py new file mode 100644 index 0000000..374ba8b --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_avatar_card_tour.py @@ -0,0 +1,138 @@ +from datetime import date, timedelta + +from odoo import Command +from odoo.tests import tagged, users +from odoo.tests.common import HttpCase, new_test_user +from odoo.addons.mail.tests.common import MailCommon + + +@tagged("post_install", "-at_install") +class TestAvatarCardTour(MailCommon, HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + new_test_user( + cls.env, + login="hr_user", + company_ids=[Command.link(cls.env.company.id), Command.link(cls.company_2.id)], + groups="hr.group_hr_user", + ) + + # hr setup for multi-company + department = ( + cls.env["hr.department"] + .with_company(cls.company_2) + .create({"name": "Test Department", "company_id": cls.company_2.id}) + ) + job = ( + cls.env["hr.job"] + .with_company(cls.company_2) + .create({"name": "Test Job Title", "company_id": cls.company_2.id}) + ) + other_partner = ( + cls.env["res.partner"] + .with_company(cls.company_2) + .create({ + "name": "Test Other Partner", + "company_id": cls.company_2.id, + "phone": "987654321", + }) + ) + test_employee = ( + cls.env["hr.employee"] + .with_company(cls.company_2) + .create({ + "name": "Test Employee", + "user_id": cls.user_employee_c2.id, + "company_id": cls.company_2.id, + "department_id": department.id, + "job_id": job.id, + "address_id": other_partner.id, + "work_email": "test_employee@test.com", + "work_phone": "123456789", + }) + ) + cls.test_employee = test_employee + cls.user_employee_c2.write({"employee_ids": [Command.link(test_employee.id)]}) + new_test_user( + cls.env, + login="base_user", + company_ids=[Command.link(cls.env.company.id), Command.link(cls.company_2.id)], + ) + + # hr_holidays setup for multi-company + leave_type = ( + cls.env["hr.leave.type"] + .with_company(cls.company_2) + .create( + { + "name": "Time Off multi company", + "company_id": cls.company_2.id, + "time_type": "leave", + "requires_allocation": False, + } + ) + ) + cls.env["hr.leave"].with_company(cls.company_2).with_context( + leave_skip_state_check=True + ).create( + { + "name": "Test Leave", + "company_id": cls.company_2.id, + "holiday_status_id": leave_type.id, + "employee_id": cls.test_employee.id, + "request_date_from": (date.today() - timedelta(days=1)), + "request_date_to": (date.today() + timedelta(days=1)), + "state": "validate", + } + ) + cls.test_record = cls.env["hr.department"].create( + [ + {"name": "Test", "company_id": cls.env.company.id}, + {"name": "Test 2", "company_id": cls.env.company.id}, + {"name": "Test 3", "company_id": cls.company_2.id}, + ] + ) + + def _setup_channel(self, user): + self.user_employee_c2.partner_id.sudo().with_user(self.user_employee_c2).message_post( + body="Test message in chatter", + message_type="comment", + subtype_xmlid="mail.mt_comment", + ) + activity_type_todo = "mail.mail_activity_data_todo" + self.test_record[0].activity_schedule( + activity_type_todo, + summary="Test Activity for Company 2", + user_id=user.id, + ) + self.test_record[1].activity_schedule( + activity_type_todo, + summary="Another Test Activity for Company 2", + user_id=user.id, + ) + self.test_record[2].activity_schedule( + activity_type_todo, + summary="Test Activity for Company 3", + user_id=user.id, + ) + + @users("admin", "hr_user") + def test_avatar_card_tour_multi_company(self): + # Clear existing activities to avoid interference with the test + self.env["mail.activity"].with_user(self.env.user).search([]).unlink() + self._setup_channel(self.env.user) + self.start_tour( + f"/odoo/res.partner/{self.user_employee_c2.partner_id.id}", + "avatar_card_tour", + login=self.env.user.login, + ) + + @users("base_user") + def test_avatar_card_tour_multi_company_no_hr_access(self): + self._setup_channel(self.env.user) + self.start_tour( + f"/odoo/res.partner/{self.user_employee_c2.partner_id.id}", + "avatar_card_tour_no_hr_access", + login=self.env.user.login, + ) diff --git a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_im_livechat_portal.py b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_im_livechat_portal.py new file mode 100644 index 0000000..a9c2102 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_im_livechat_portal.py @@ -0,0 +1,52 @@ +from odoo import Command, tests +from odoo.addons.website_livechat.tests.test_chatbot_ui import TestLivechatChatbotUI + + +@tests.common.tagged("post_install", "-at_install") +class TestImLivechatPortal(TestLivechatChatbotUI): + def test_chatbot_redirect_to_portal(self): + project = self.env["project.project"].create({"name": "Portal Project"}) + task = self.env["project.task"].create( + {"name": "Test Task Name Match", "project_id": project.id} + ) + chatbot_redirect_script = self.env["chatbot.script"].create({"title": "Redirection Bot"}) + question_step = self.env["chatbot.script.step"].create( + [ + { + "chatbot_script_id": chatbot_redirect_script.id, + "message": "Hello, were do you want to go?", + "step_type": "question_selection", + }, + { + "chatbot_script_id": chatbot_redirect_script.id, + "message": "Tadam, we are on the page you asked for!", + "step_type": "text", + }, + ] + )[0] + self.env["chatbot.script.answer"].create( + [ + { + "name": "Go to the portal page", + "redirect_link": f"/my/tasks/{task.id}?access_token={task.access_token}", + "script_step_id": question_step.id, + }, + ] + ) + livechat_channel = self.env["im_livechat.channel"].create( + { + "name": "Redirection Channel", + "rule_ids": [ + Command.create( + { + "regex_url": "/", + "chatbot_script_id": chatbot_redirect_script.id, + } + ) + ], + } + ) + default_website = self.env.ref("website.default_website") + default_website.channel_id = livechat_channel.id + self.env.ref("website.default_website").channel_id = livechat_channel.id + self.start_tour("/contactus", "chatbot_redirect_to_portal") diff --git a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_livechat_hr_holidays.py b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_livechat_hr_holidays.py new file mode 100644 index 0000000..368b0e4 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_livechat_hr_holidays.py @@ -0,0 +1,52 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from dateutil.relativedelta import relativedelta + +from odoo import Command, fields +from odoo.tests.common import HttpCase, tagged +from odoo.addons.mail.tests.common import MailCommon + + +@tagged("post_install", "-at_install") +class TestLivechatHrHolidays(HttpCase, MailCommon): + """Tests for bridge between im_livechat and hr_holidays modules.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["mail.presence"]._update_presence(cls.user_employee) + leave_type = cls.env["hr.leave.type"].create( + {"name": "Legal Leaves", "requires_allocation": False, "time_type": "leave"} + ) + employee = cls.env["hr.employee"].create({"user_id": cls.user_employee.id}) + cls.env["hr.leave"].with_context(leave_skip_state_check=True).create( + { + "employee_id": employee.id, + "holiday_status_id": leave_type.id, + "request_date_from": fields.Datetime.today() + relativedelta(days=-2), + "request_date_to": fields.Datetime.today() + relativedelta(days=2), + "state": "validate", + } + ) + + def test_operator_available_on_leave(self): + """Test operator is available on leave when they are online.""" + livechat_channel = self.env["im_livechat.channel"].create( + {"name": "support", "user_ids": [Command.link(self.user_employee.id)]} + ) + self.assertEqual(self.user_employee.im_status, "leave_online") + self.assertEqual(livechat_channel.available_operator_ids, self.user_employee) + + def test_operator_limit_on_leave(self): + """Test livechat limit is correctly applied when operator is on leave and online.""" + livechat_channel = self.env["im_livechat.channel"].create( + { + "max_sessions_mode": "limited", + "max_sessions": 1, + "name": "support", + "user_ids": [Command.link(self.user_employee.id)], + } + ) + self.make_jsonrpc_request("/im_livechat/get_session", {"channel_id": livechat_channel.id}) + self.assertEqual(self.user_employee.im_status, "leave_online") + self.assertFalse(livechat_channel.available_operator_ids) diff --git a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_livechat_session_open.py b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_livechat_session_open.py new file mode 100644 index 0000000..3370c0d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_livechat_session_open.py @@ -0,0 +1,21 @@ +import odoo +from odoo.addons.im_livechat.tests.common import TestImLivechatCommon +from odoo.tests import new_test_user + + +@odoo.tests.tagged("-at_install", "post_install") +class TestImLivechatSessions(TestImLivechatCommon): + def test_livechat_session_open(self): + new_test_user( + self.env, + login="operator", + groups="base.group_user,im_livechat.im_livechat_group_manager", + ) + self.make_jsonrpc_request( + "/im_livechat/get_session", {"channel_id": self.livechat_channel.id} + ) + action = self.env.ref("im_livechat.discuss_channel_action_from_livechat_channel") + self.start_tour( + f"/odoo/livechat/{self.livechat_channel.id}/action-{action.id}", "im_livechat_session_open", + login="operator" + ) diff --git a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_performance.py b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_performance.py index bcfc7a2..4dc1cb0 100644 --- a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_performance.py +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_performance.py @@ -1,35 +1,179 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from datetime import date from dateutil.relativedelta import relativedelta +from freezegun import freeze_time +from unittest.mock import patch, PropertyMock -from odoo import Command -from odoo.tests.common import users, tagged, TransactionCase, warmup -from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT +from odoo import Command, fields +from odoo.fields import Domain +from odoo.addons.mail.tests.common import MailCommon +from odoo.addons.mail.tools.discuss import Store +from odoo.tests.common import users, tagged, HttpCase, warmup -@tagged('post_install', '-at_install') -class TestDiscussFullPerformance(TransactionCase): +@tagged('post_install', '-at_install', 'is_query_count') +class TestDiscussFullPerformance(HttpCase, MailCommon): + # Queries for _query_count_init_store (in order): + # 1: search res_partner (odooot ref exists) + # 1: search res_groups (internalUserGroupId ref exists) + # 8: odoobot format: + # - fetch res_partner (_read_format) + # - search res_users (_compute_im_status) + # - search presence (_compute_im_status) + # - fetch presence (_compute_im_status) + # - _get_on_leave_ids (_compute_im_status hr_holidays override) + # - search employee (_compute_im_status hr_homeworking override) + # - fetch employee (_compute_im_status hr_homeworking override) + # - fetch res_users (_read_format) + # - fetch hr_employee (res.users _to_store) + # 5: settings: + # - search res_users_settings (_find_or_create_for_user) + # - fetch res_users_settings (_format_settings) + # - search res_users_settings_volumes (_format_settings) + # - search res_users_settings_embedded_action (_format_settings) + # - search res_lang_res_users_settings_rel (_format_settings) + # - search im_livechat_expertise_res_users_settings_rel (_format_settings) + # 2: hasCannedResponses + # - fetch res_groups_users_rel + # - search mail_canned_response + _query_count_init_store = 19 + # Queries for _query_count_init_messaging (in order): + # 1: insert res_device_log + # 3: _search_is_member (for current user, first occurence _search_is_member for chathub given channel ids) + # - fetch res_users + # - search discuss_channel_member + # - fetch discuss_channel + # 1. search discuss_channel (chathub given channel ids) + # 2: _get_channels_as_member + # - search discuss_channel (member_domain) + # - search discuss_channel (pinned_member_domain) + # 2: _init_messaging (discuss) + # - fetch discuss_channel_member (is_self) + # - _compute_message_unread + # 3: _init_messaging (mail) + # - search bus_bus (_bus_last_id) + # - _get_needaction_count (inbox counter) + # - search mail_message (starred counter) + # 23: _process_request_for_all (discuss): + # - search discuss_channel (channels_domain) + # 22: channel add: + # - read group member (prefetch _compute_self_member_id from _compute_is_member) + # - read group member (_compute_invited_member_ids) + # - search discuss_channel_rtc_session + # - fetch discuss_channel_rtc_session + # - search member (channel_member_ids) + # - fetch discuss_channel_member (manual prefetch) + # 10: member _to_store: + # 10: partner _to_store: + # - fetch res_partner (partner _to_store) + # - fetch res_users (_compute_im_status) + # - search mail_presence (_compute_im_status) + # - fetch mail_presence (_compute_im_status) + # - _get_on_leave_ids (_compute_im_status override) + # - search hr_employee (_compute_im_status override) + # - fetch hr_employee (_compute_im_status override) + # - search hr_employee (res.users._to_store override) + # - search hr_leave (leave_date_to) + # - fetch res_users (_compute_main_user_id) + # - search bus_bus (_bus_last_id) + # - search ir_attachment (_compute_avatar_128) + # - count discuss_channel_member (member_count) + # - _compute_message_needaction + # - search discuss_channel_res_groups_rel (group_ids) + # - fetch res_groups (group_public_id) + _query_count_init_messaging = 35 + # Queries for _query_count_discuss_channels (in order): + # 1: insert res_device_log + # 3: _search_is_member (for current user, first occurence _get_channels_as_member) + # - fetch res_users + # - search discuss_channel_member + # - fetch discuss_channel + # 2: _get_channels_as_member + # - search discuss_channel (member_domain) + # - search discuss_channel (pinned_member_domain) + # 36: channel _to_store_defaults: + # - read group member (prefetch _compute_self_member_id from _compute_is_member) + # - read group member (_compute_invited_member_ids) + # - search discuss_channel_rtc_session + # - fetch discuss_channel_rtc_session + # - search member (channel_member_ids) + # - search member (channel_name_member_ids) + # - fetch discuss_channel_member (manual prefetch) + # 18: member _to_store: + # - search im_livechat_channel_member_history (livechat member type) + # - fetch im_livechat_channel_member_history (livechat member type) + # 13: partner _to_store: + # - fetch res_partner (partner _to_store) + # - fetch res_users (_compute_im_status) + # - search mail_presence (_compute_im_status) + # - fetch mail_presence (_compute_im_status) + # - _get_on_leave_ids (_compute_im_status override) + # - search hr_employee (_compute_im_status override) + # - fetch hr_employee (_compute_im_status override) + # - search hr_employee (res.users._to_store override) + # - search hr_leave (leave_date_to) + # - search res_users_settings (livechat username) + # - fetch res_users_settings (livechat username) + # - fetch res_users (_compute_main_user_id) + # - fetch res_country (livechat override) + # 3: guest _to_store: + # - fetch mail_guest + # - fetch mail_presence (_compute_im_status) + # - fetch res_country + # - search bus_bus (_bus_last_id from _to_store_defaults) + # - search ir_attachment (_compute_avatar_128) + # - count discuss_channel_member (member_count) + # - _compute_message_needaction + # - search discuss_channel_res_groups_rel (group_ids) + # - fetch im_livechat_channel_member_history (requested_by_operator) + # - fetch res_groups (group_ids) + # - _compute_message_unread + # - fetch im_livechat_channel + # 2: fetch livechat_expertise_ids + # - fetch livechat_conversation_tag_ids + # - read livechat_conversation_tag_ids + # 1: _get_last_messages + # 20: message _to_store: + # - search mail_message_schedule + # - fetch mail_message + # - search mail_message_res_partner_starred_rel + # - search message_attachment_rel + # - search mail_link_preview + # - search mail_message_res_partner_rel + # - search mail_message_reaction + # - search mail_notification + # - search rating_rating + # - fetch mail_notification + # - search mail_message_subtype + # - search discuss_call_history + # - fetch mail_message_reaction + # - fetch mail_message_subtype + # - fetch partner (_author_to_store) + # - search user (_author_to_store) + # - fetch user (_author_to_store) + # - fetch discuss_call_history + # - search mail_tracking_value + # - _compute_rating_stats + _query_count_discuss_channels = 64 + def setUp(self): super().setUp() + self.maxDiff = None self.group_user = self.env.ref('base.group_user') - self.env['mail.shortcode'].search([]).unlink() - self.shortcodes = self.env['mail.shortcode'].create([ - {'source': 'hello', 'substitution': 'Hello. How may I help you?'}, - {'source': 'bye', 'substitution': 'Thanks for your feedback. Good bye!'}, - ]) + self.livechat_user = self.env.ref("im_livechat.im_livechat_group_user") + self.password = 'Pl1bhD@2!kXZ' self.users = self.env['res.users'].create([ { 'email': 'e.e@example.com', - 'groups_id': [Command.link(self.group_user.id)], + 'group_ids': [Command.link(self.group_user.id), Command.link(self.livechat_user.id)], 'login': 'emp', 'name': 'Ernest Employee', 'notification_type': 'inbox', 'odoobot_state': 'disabled', + 'password': self.password, 'signature': '--\nErnest', }, - {'name': 'test1', 'login': 'test1', 'email': 'test1@example.com'}, + {'name': 'test1', 'login': 'test1', 'password': self.password, 'email': 'test1@example.com', 'country_id': self.env.ref('base.in').id}, {'name': 'test2', 'login': 'test2', 'email': 'test2@example.com'}, {'name': 'test3', 'login': 'test3'}, {'name': 'test4', 'login': 'test4'}, @@ -49,959 +193,1668 @@ class TestDiscussFullPerformance(TransactionCase): 'user_id': user.id, } for user in self.users]) self.leave_type = self.env['hr.leave.type'].create({ - 'requires_allocation': 'no', + 'requires_allocation': False, 'name': 'Legal Leaves', 'time_type': 'leave', }) self.leaves = self.env['hr.leave'].create([{ - 'date_from': date.today() + relativedelta(days=-2), - 'date_to': date.today() + relativedelta(days=2), + 'request_date_from': fields.Datetime.today() + relativedelta(days=-2), + 'request_date_to': fields.Datetime.today() + relativedelta(days=2), 'employee_id': employee.id, 'holiday_status_id': self.leave_type.id, } for employee in self.employees]) - - @users('emp') - @warmup - def test_init_messaging(self): - """Test performance of `_init_messaging`.""" - self.channel_general = self.env.ref('mail.channel_all_employees') # Unfortunately #general cannot be deleted. Assertions below assume data from a fresh db. + self.authenticate(self.users[0].login, self.password) + Channel = self.env["discuss.channel"].with_user(self.users[0]) + self.channel_general = self.env.ref('mail.channel_all_employees') # Unfortunately #general cannot be deleted. Assertions below assume data from a fresh db with demo. self.channel_general.message_ids.unlink() # Remove messages to avoid depending on demo data. - self.env['mail.channel'].sudo().search([('id', '!=', self.channel_general.id)]).unlink() + self.channel_general.last_interest_dt = False # Reset state + self.channel_general.channel_member_ids.sudo().last_interest_dt = False # Reset state + self.env['discuss.channel'].sudo().search([('id', '!=', self.channel_general.id)]).unlink() self.user_root = self.env.ref('base.user_root') # create public channels - self.channel_channel_public_1 = self.env['mail.channel'].browse(self.env['mail.channel'].channel_create(name='public channel 1', group_id=None)['id']) - self.channel_channel_public_1.add_members((self.users[0] + self.users[2] + self.users[3] + self.users[4] + self.users[8]).partner_id.ids) - self.channel_channel_public_2 = self.env['mail.channel'].browse(self.env['mail.channel'].channel_create(name='public channel 2', group_id=None)['id']) - self.channel_channel_public_2.add_members((self.users[0] + self.users[2] + self.users[4] + self.users[7] + self.users[9]).partner_id.ids) + self.channel_channel_public_1 = Channel._create_channel( + name="public channel 1", group_id=None + ) + self.channel_channel_public_1._add_members(users=self.users[0] | self.users[2] | self.users[3] | self.users[4] | self.users[8]) + self.channel_channel_public_2 = Channel._create_channel( + name="public channel 2", group_id=None + ) + self.channel_channel_public_2._add_members(users=self.users[0] | self.users[2] | self.users[4] | self.users[7] | self.users[9]) # create group-restricted channels - self.channel_channel_group_1 = self.env['mail.channel'].browse(self.env['mail.channel'].channel_create(name='group restricted channel 1', group_id=self.env.ref('base.group_user').id)['id']) - self.channel_channel_group_1.add_members((self.users[0] + self.users[2] + self.users[3] + self.users[6] + self.users[12]).partner_id.ids) - self.channel_channel_group_2 = self.env['mail.channel'].browse(self.env['mail.channel'].channel_create(name='group restricted channel 2', group_id=self.env.ref('base.group_user').id)['id']) - self.channel_channel_group_2.add_members((self.users[0] + self.users[2] + self.users[6] + self.users[7] + self.users[13]).partner_id.ids) + self.channel_channel_group_1 = Channel._create_channel( + name="group restricted channel 1", group_id=self.env.ref("base.group_user").id + ) + self.channel_channel_group_1._add_members(users=self.users[0] | self.users[2] | self.users[3] | self.users[6] | self.users[12]) + self.channel_channel_group_2 = Channel._create_channel( + name="group restricted channel 2", group_id=self.env.ref("base.group_user").id + ) + self.channel_channel_group_2._add_members(users=self.users[0] | self.users[2] | self.users[6] | self.users[7] | self.users[13]) # create chats - self.channel_chat_1 = self.env['mail.channel'].browse(self.env['mail.channel'].channel_get((self.users[0] + self.users[14]).partner_id.ids)['id']) - self.channel_chat_2 = self.env['mail.channel'].browse(self.env['mail.channel'].channel_get((self.users[0] + self.users[15]).partner_id.ids)['id']) - self.channel_chat_3 = self.env['mail.channel'].browse(self.env['mail.channel'].channel_get((self.users[0] + self.users[2]).partner_id.ids)['id']) - self.channel_chat_4 = self.env['mail.channel'].browse(self.env['mail.channel'].channel_get((self.users[0] + self.users[3]).partner_id.ids)['id']) + self.channel_chat_1 = Channel._get_or_create_chat((self.users[0] + self.users[14]).partner_id.ids) + self.channel_chat_2 = Channel._get_or_create_chat((self.users[0] + self.users[15]).partner_id.ids) + self.channel_chat_3 = Channel._get_or_create_chat((self.users[0] + self.users[2]).partner_id.ids) + self.channel_chat_4 = Channel._get_or_create_chat((self.users[0] + self.users[3]).partner_id.ids) # create groups - self.channel_group_1 = self.env['mail.channel'].browse(self.env['mail.channel'].create_group((self.users[0] + self.users[12]).partner_id.ids)['id']) + self.channel_group_1 = Channel._create_group((self.users[0] + self.users[12]).partner_id.ids) # create livechats - im_livechat_channel = self.env['im_livechat.channel'].sudo().create({'name': 'support', 'user_ids': [Command.link(self.users[0].id)]}) - self.users[0].im_status = 'online' # make available for livechat (ignore leave) - self.channel_livechat_1 = self.env['mail.channel'].browse(im_livechat_channel._open_livechat_mail_channel(anonymous_name='anon 1', previous_operator_id=self.users[0].partner_id.id, user_id=self.users[1].id, country_id=self.env.ref('base.in').id)['id']) - self.channel_livechat_1.with_user(self.users[1]).message_post(body="test") - self.channel_livechat_2 = self.env['mail.channel'].browse(im_livechat_channel.with_user(self.env.ref('base.public_user'))._open_livechat_mail_channel(anonymous_name='anon 2', previous_operator_id=self.users[0].partner_id.id, country_id=self.env.ref('base.be').id)['id']) - self.channel_livechat_2.with_user(self.env.ref('base.public_user')).sudo().message_post(body="test") + self.im_livechat_channel = self.env['im_livechat.channel'].sudo().create({'name': 'support', 'user_ids': [Command.link(self.users[0].id)]}) + self.env['mail.presence']._update_presence(self.users[0]) + self.authenticate('test1', self.password) + self.channel_livechat_1 = Channel.browse( + self.make_jsonrpc_request( + "/im_livechat/get_session", + { + "channel_id": self.im_livechat_channel.id, + "previous_operator_id": self.users[0].partner_id.id, + }, + )["channel_id"] + ) + self.channel_livechat_1.with_user(self.users[1]).message_post(body="test", message_type="comment") + # add conversation tags into livechat channels + self.conversation_tag = self.env["im_livechat.conversation.tag"].create({"name": "Support", "color": 1}) + self.channel_livechat_1.livechat_conversation_tag_ids = [Command.link(self.conversation_tag.id)] + self.authenticate(None, None) + with patch( + "odoo.http.GeoIP.country_code", + new_callable=PropertyMock(return_value=self.env.ref("base.be").code), + ): + self.channel_livechat_2 = Channel.browse( + self.make_jsonrpc_request( + "/im_livechat/get_session", + { + "channel_id": self.im_livechat_channel.id, + "previous_operator_id": self.users[0].partner_id.id, + }, + )["channel_id"] + ) + self.guest = self.channel_livechat_2.channel_member_ids.guest_id.sudo() + self.make_jsonrpc_request("/mail/message/post", { + "post_data": { + "body": "test", + "message_type": "comment", + }, + "thread_id": self.channel_livechat_2.id, + "thread_model": "discuss.channel", + }, cookies={ + self.guest._cookie_name: self.guest._format_auth_cookie(), + }) # add needaction self.users[0].notification_type = 'inbox' - message = self.channel_channel_public_1.message_post(body='test', message_type='comment', author_id=self.users[2].partner_id.id, partner_ids=self.users[0].partner_id.ids) + message_0 = self.channel_channel_public_1.message_post( + body="test", + message_type="comment", + author_id=self.users[2].partner_id.id, + partner_ids=self.users[0].partner_id.ids, + ) + members = self.channel_channel_public_1.channel_member_ids + member = members.filtered(lambda m: m.partner_id == self.users[0].partner_id).with_user(self.users[0]) + member._mark_as_read(message_0.id) # add star - message.toggle_message_starred() + message_0.toggle_message_starred() self.env.company.sudo().name = 'YourCompany' + # add folded channel + members = self.channel_chat_1.channel_member_ids + member = members.with_user(self.users[0]).filtered(lambda m: m.is_self) + # add call invitation + members = self.channel_channel_group_1.channel_member_ids + member_0 = members.with_user(self.users[0]).filtered(lambda m: m.is_self) + member_2 = members.with_user(self.users[2]).filtered(lambda m: m.is_self) + self.channel_channel_group_1_invited_member = member_0 + # sudo: discuss.channel.rtc.session - creating a session in a test file + data = {"channel_id": self.channel_channel_group_1.id, "channel_member_id": member_2.id} + session = self.env["discuss.channel.rtc.session"].sudo().create(data) + member_0.rtc_inviting_session_id = session + # add some reactions with different users on different messages + message_1 = self.channel_general.message_post( + body="test", message_type="comment", author_id=self.users[0].partner_id.id + ) + self.authenticate(self.users[0].login, self.password) + self._add_reactions(message_0, ["😊", "😏"]) + self._add_reactions(message_1, ["😊"]) + self.authenticate(self.users[1].login, self.password) + self._add_reactions(message_0, ["😊", "😏"]) + self._add_reactions(message_1, ["😊", "😁"]) + self.authenticate(self.users[2].login, self.password) + self._add_reactions(message_0, ["😊", "😁"]) + self._add_reactions(message_1, ["😊", "😁", "👍"]) + self.env.cr.precommit.run() # trigger the creation of bus.bus records - self.maxDiff = None - self.env.flush_all() - self.env.invalidate_all() - with self.assertQueryCount(emp=self._get_query_count()): - init_messaging = self.users[0].with_user(self.users[0])._init_messaging() + def _add_reactions(self, message, reactions): + for reaction in reactions: + self.make_jsonrpc_request( + "/mail/message/reaction", + { + "action": "add", + "content": reaction, + "message_id": message.id, + }, + ) - self.assertEqual(init_messaging, self._get_init_messaging_result()) + def _run_test(self, /, *, fn, count, results): + self.authenticate(self.users[0].login, self.password) + self.env["res.lang"]._get_data(code="en_US") # cache language for validation + with self.assertQueryCount(emp=count): + if self.warm: + with self.env.cr._enable_logging(): + res = fn() + else: + res = fn() + self.assertEqual(res, results) - def _get_init_messaging_result(self): - """ - Returns the result of a call to init_messaging. - - The point of having a separate getter is to allow it to be overriden. + @freeze_time("2025-04-22 21:18:33") + @users('emp') + @warmup + def test_10_init_store_data(self): + """Test performance of `_init_store_data`.""" + + def test_fn(): + store = Store() + self.env["res.users"].with_user(self.users[0])._init_store_data(store) + return store.get_result() + + self._run_test( + fn=test_fn, + count=self._query_count_init_store, + results=self._get_init_store_data_result(), + ) + + @freeze_time("2025-04-22 21:18:33") + @users('emp') + @warmup + def test_20_init_messaging(self): + """Test performance of `init_messaging`.""" + self._run_test( + fn=lambda: self.make_jsonrpc_request( + "/mail/data", + {"fetch_params": [["discuss.channel", [self.channel_chat_1.id]], "init_messaging"]}, + ), + count=self._query_count_init_messaging, + results=self._get_init_messaging_result(), + ) + + @freeze_time("2025-04-22 21:18:33") + @users("emp") + @warmup + def test_30_discuss_channels(self): + """Test performance of `/mail/data` with `channels_as_member`.""" + self._run_test( + fn=lambda: self.make_jsonrpc_request( + "/mail/data", {"fetch_params": ["channels_as_member"]} + ), + count=self._query_count_discuss_channels, + results=self._get_discuss_channels_result(), + ) + + def _get_init_store_data_result(self): + """Returns the result of a call to init_messaging. + The point of having a separate getter is to allow it to be overriden. """ + xmlid_to_res_id = self.env["ir.model.data"]._xmlid_to_res_id + user_0 = self.users[0] + partner_0 = user_0.partner_id return { - 'hasLinkPreviewFeature': True, - 'needaction_inbox_counter': 1, - 'starred_counter': 1, - 'channels': [ + "res.partner": self._filter_partners_fields( { - 'authorizedGroupFullName': self.group_user.full_name, - 'channel': { - 'anonymous_country': [('clear',)], - 'anonymous_name': False, - 'avatarCacheKey': self.channel_general._get_avatar_cache_key(), - 'channel_type': 'channel', - 'channelMembers': [('insert', sorted([{ - 'channel': { - 'id': self.channel_general.id, - }, - 'id': self.channel_general.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'email': 'e.e@example.com', - 'id': self.users[0].partner_id.id, - 'im_status': 'offline', - 'name': 'Ernest Employee', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[0].id, - 'isInternalUser': True, - }, - }, - }, - }], key=lambda member_data: member_data['id']))], - 'custom_channel_name': False, - 'id': self.channel_general.id, - 'memberCount': len(self.group_user.users | self.user_root), - 'serverMessageUnreadCounter': 0, - }, - 'create_uid': self.user_root.id, - 'defaultDisplayMode': False, - 'description': 'General announcements for all employees.', - 'group_based_subscription': True, - 'id': self.channel_general.id, - 'invitedMembers': [('insert', [])], - 'is_minimized': False, - 'is_pinned': True, - 'last_interest_dt': self.channel_general.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), - 'last_message_id': False, - 'message_needaction_counter': 0, - 'name': 'general', - 'rtcSessions': [('insert', [])], - 'seen_message_id': False, - 'state': 'open', - 'uuid': self.channel_general.uuid, + "active": False, + "avatar_128_access_token": self.user_root.partner_id._get_avatar_128_access_token(), + "email": "odoobot@example.com", + "id": self.user_root.partner_id.id, + "im_status": "bot", + "im_status_access_token": self.user_root.partner_id._get_im_status_access_token(), + "is_company": False, + "main_user_id": self.user_root.id, + "name": "OdooBot", + "write_date": fields.Datetime.to_string(self.user_root.partner_id.write_date), }, { - 'authorizedGroupFullName': False, - 'channel': { - 'anonymous_country': [('clear',)], - 'anonymous_name': False, - 'avatarCacheKey': self.channel_channel_public_1._get_avatar_cache_key(), - 'channel_type': 'channel', - 'channelMembers': [('insert', sorted([{ - 'channel': { - 'id': self.channel_channel_public_1.id, - }, - 'id': self.channel_channel_public_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'email': 'e.e@example.com', - 'id': self.users[0].partner_id.id, - 'im_status': 'offline', - 'name': 'Ernest Employee', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[0].id, - 'isInternalUser': True, - }, - }, - }, - }], key=lambda member_data: member_data['id']))], - 'custom_channel_name': False, - 'id': self.channel_channel_public_1.id, - 'memberCount': 5, - 'serverMessageUnreadCounter': 0, - }, - 'create_uid': self.env.user.id, - 'defaultDisplayMode': False, - 'description': False, - 'group_based_subscription': False, - 'id': self.channel_channel_public_1.id, - 'invitedMembers': [('insert', [])], - 'is_minimized': False, - 'is_pinned': True, - 'last_interest_dt': self.channel_channel_public_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), - 'last_message_id': next(res['message_id'] for res in self.channel_channel_public_1._channel_last_message_ids()), - 'message_needaction_counter': 1, - 'name': 'public channel 1', - 'rtcSessions': [('insert', [])], - 'seen_message_id': next(res['message_id'] for res in self.channel_channel_public_1._channel_last_message_ids()), - 'state': 'open', - 'uuid': self.channel_channel_public_1.uuid, + "active": True, + "avatar_128_access_token": partner_0._get_avatar_128_access_token(), + "id": partner_0.id, + "im_status": "online", + "im_status_access_token": partner_0._get_im_status_access_token(), + "main_user_id": user_0.id, + "name": "Ernest Employee", + "write_date": fields.Datetime.to_string(partner_0.write_date), + }, + ), + "res.users": self._filter_users_fields( + { + "employee_ids": [], + "id": self.user_root.id, + "partner_id": self.partner_root.id, + "share": False, }, { - 'authorizedGroupFullName': False, - 'channel': { - 'anonymous_country': [('clear',)], - 'anonymous_name': False, - 'avatarCacheKey': self.channel_channel_public_2._get_avatar_cache_key(), - 'channel_type': 'channel', - 'channelMembers': [('insert', sorted([{ - 'channel': { - 'id': self.channel_channel_public_2.id, - }, - 'id': self.channel_channel_public_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'email': 'e.e@example.com', - 'id': self.users[0].partner_id.id, - 'im_status': 'offline', - 'name': 'Ernest Employee', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[0].id, - 'isInternalUser': True, - }, - }, - }, - }], key=lambda member_data: member_data['id']))], - 'custom_channel_name': False, - 'id': self.channel_channel_public_2.id, - 'memberCount': 5, - 'serverMessageUnreadCounter': 0, - }, - 'create_uid': self.env.user.id, - 'defaultDisplayMode': False, - 'description': False, - 'group_based_subscription': False, - 'id': self.channel_channel_public_2.id, - 'invitedMembers': [('insert', [])], - 'is_minimized': False, - 'is_pinned': True, - 'last_interest_dt': self.channel_channel_public_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), - 'last_message_id': next(res['message_id'] for res in self.channel_channel_public_2._channel_last_message_ids()), - 'message_needaction_counter': 0, - 'name': 'public channel 2', - 'rtcSessions': [('insert', [])], - 'seen_message_id': next(res['message_id'] for res in self.channel_channel_public_2._channel_last_message_ids()), - 'state': 'open', - 'uuid': self.channel_channel_public_2.uuid, + "id": user_0.id, + "is_admin": False, + "is_livechat_manager": False, + "notification_type": "inbox", + "partner_id": partner_0.id, + "share": False, + "signature": ["markup", str(user_0.signature)], }, - { - 'authorizedGroupFullName': self.group_user.full_name, - 'channel': { - 'anonymous_country': [('clear',)], - 'anonymous_name': False, - 'avatarCacheKey': self.channel_channel_group_1._get_avatar_cache_key(), - 'channel_type': 'channel', - 'channelMembers': [('insert', sorted([{ - 'channel': { - 'id': self.channel_channel_group_1.id, - }, - 'id': self.channel_channel_group_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'email': 'e.e@example.com', - 'id': self.users[0].partner_id.id, - 'im_status': 'offline', - 'name': 'Ernest Employee', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[0].id, - 'isInternalUser': True, - }, - }, - }, - }], key=lambda member_data: member_data['id']))], - 'custom_channel_name': False, - 'id': self.channel_channel_group_1.id, - 'memberCount': 5, - 'serverMessageUnreadCounter': 0, - }, - 'create_uid': self.env.user.id, - 'defaultDisplayMode': False, - 'description': False, - 'group_based_subscription': False, - 'id': self.channel_channel_group_1.id, - 'invitedMembers': [('insert', [])], - 'is_minimized': False, - 'is_pinned': True, - 'last_interest_dt': self.channel_channel_group_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), - 'last_message_id': next(res['message_id'] for res in self.channel_channel_group_1._channel_last_message_ids()), - 'message_needaction_counter': 0, - 'name': 'group restricted channel 1', - 'rtcSessions': [('insert', [])], - 'seen_message_id': next(res['message_id'] for res in self.channel_channel_group_1._channel_last_message_ids()), - 'state': 'open', - 'uuid': self.channel_channel_group_1.uuid, + ), + "Store": { + "channel_types_with_seen_infos": sorted(["chat", "group", "livechat"]), + "action_discuss_id": xmlid_to_res_id("mail.action_discuss"), + "hasCannedResponses": True, + "hasGifPickerFeature": False, + "hasLinkPreviewFeature": True, + "has_access_livechat": True, + "hasMessageTranslationFeature": False, + "has_access_create_lead": False, + "internalUserGroupId": self.env.ref("base.group_user").id, + "mt_comment": self.env.ref("mail.mt_comment").id, + "mt_note": self.env.ref("mail.mt_note").id, + "odoobot": self.user_root.partner_id.id, + "self_partner": self.users[0].partner_id.id, + "settings": { + "channel_notifications": False, + "id": self.env["res.users.settings"]._find_or_create_for_user(self.users[0]).id, + "is_discuss_sidebar_category_channel_open": True, + "is_discuss_sidebar_category_chat_open": True, + "livechat_expertise_ids": [], + "livechat_lang_ids": [], + "livechat_username": False, + "push_to_talk_key": False, + "use_push_to_talk": False, + "user_id": {"id": self.users[0].id}, + "voice_active_duration": 200, + "volumes": [("ADD", [])], + "embedded_actions_config_ids": {}, }, - { - 'authorizedGroupFullName': self.group_user.full_name, - 'channel': { - 'anonymous_country': [('clear',)], - 'anonymous_name': False, - 'avatarCacheKey': self.channel_channel_group_2._get_avatar_cache_key(), - 'channel_type': 'channel', - 'channelMembers': [('insert', sorted([{ - 'channel': { - 'id': self.channel_channel_group_2.id, - }, - 'id': self.channel_channel_group_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'email': 'e.e@example.com', - 'id': self.users[0].partner_id.id, - 'im_status': 'offline', - 'name': 'Ernest Employee', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[0].id, - 'isInternalUser': True, - }, - }, - }, - }], key=lambda member_data: member_data['id']))], - 'custom_channel_name': False, - 'id': self.channel_channel_group_2.id, - 'memberCount': 5, - 'serverMessageUnreadCounter': 0, - }, - 'create_uid': self.env.user.id, - 'defaultDisplayMode': False, - 'description': False, - 'group_based_subscription': False, - 'id': self.channel_channel_group_2.id, - 'invitedMembers': [('insert', [])], - 'is_minimized': False, - 'is_pinned': True, - 'last_interest_dt': self.channel_channel_group_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), - 'last_message_id': next(res['message_id'] for res in self.channel_channel_group_2._channel_last_message_ids()), - 'message_needaction_counter': 0, - 'name': 'group restricted channel 2', - 'rtcSessions': [('insert', [])], - 'seen_message_id': next(res['message_id'] for res in self.channel_channel_group_2._channel_last_message_ids()), - 'state': 'open', - 'uuid': self.channel_channel_group_2.uuid, - }, - { - 'authorizedGroupFullName': False, - 'channel': { - 'anonymous_country': [('clear',)], - 'anonymous_name': False, - 'avatarCacheKey': self.channel_group_1._get_avatar_cache_key(), - 'channel_type': 'group', - 'channelMembers': [('insert', sorted([ - { - 'channel': { - 'id': self.channel_group_1.id, - }, - 'id': self.channel_group_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'email': 'e.e@example.com', - 'id': self.users[0].partner_id.id, - 'im_status': 'offline', - 'name': 'Ernest Employee', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[0].id, - 'isInternalUser': True, - }, - }, - }, - }, - { - 'channel': { - 'id': self.channel_group_1.id, - }, - 'id': self.channel_group_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[12].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'email': False, - 'id': self.users[12].partner_id.id, - 'im_status': 'offline', - 'name': 'test12', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[12].id, - 'isInternalUser': True, - }, - }, - }, - }, - ], key=lambda member_data: member_data['id']))], - 'custom_channel_name': False, - 'id': self.channel_group_1.id, - 'memberCount': 2, - 'serverMessageUnreadCounter': 0, - }, - 'create_uid': self.env.user.id, - 'defaultDisplayMode': False, - 'description': False, - 'group_based_subscription': False, - 'id': self.channel_group_1.id, - 'invitedMembers': [('insert', [])], - 'is_minimized': False, - 'is_pinned': True, - 'last_interest_dt': self.channel_group_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), - 'last_message_id': False, - 'message_needaction_counter': 0, - 'name': '', - 'rtcSessions': [('insert', [])], - 'seen_message_id': False, - 'seen_partners_info': [ - { - 'fetched_message_id': False, - 'id': self.channel_group_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'partner_id': self.users[0].partner_id.id, - 'seen_message_id': False, - }, - { - 'fetched_message_id': False, - 'id': self.channel_group_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[12].partner_id).id, - 'partner_id': self.users[12].partner_id.id, - 'seen_message_id': False, - } - ], - 'state': 'open', - 'uuid': self.channel_group_1.uuid, - }, - { - 'authorizedGroupFullName': False, - 'channel': { - 'anonymous_country': [('clear',)], - 'anonymous_name': False, - 'avatarCacheKey': self.channel_chat_1._get_avatar_cache_key(), - 'channel_type': 'chat', - 'channelMembers': [('insert', sorted([ - { - 'channel': { - 'id': self.channel_chat_1.id, - }, - 'id': self.channel_chat_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'email': 'e.e@example.com', - 'id': self.users[0].partner_id.id, - 'im_status': 'offline', - 'name': 'Ernest Employee', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[0].id, - 'isInternalUser': True, - }, - }, - }, - }, - { - 'channel': { - 'id': self.channel_chat_1.id, - }, - 'id': self.channel_chat_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[14].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'email': False, - 'id': self.users[14].partner_id.id, - 'im_status': 'offline', - 'name': 'test14', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[14].id, - 'isInternalUser': True, - }, - }, - }, - }, - ], key=lambda member_data: member_data['id']))], - 'custom_channel_name': False, - 'id': self.channel_chat_1.id, - 'memberCount': 2, - 'serverMessageUnreadCounter': 0, - }, - 'create_uid': self.env.user.id, - 'defaultDisplayMode': False, - 'description': False, - 'group_based_subscription': False, - 'id': self.channel_chat_1.id, - 'invitedMembers': [('insert', [])], - 'is_minimized': False, - 'is_pinned': True, - 'last_interest_dt': self.channel_chat_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), - 'last_message_id': False, - 'message_needaction_counter': 0, - 'name': 'Ernest Employee, test14', - 'rtcSessions': [('insert', [])], - 'seen_partners_info': [ - { - 'fetched_message_id': False, - 'id': self.channel_chat_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'partner_id': self.users[0].partner_id.id, - 'seen_message_id': False, - }, - { - 'fetched_message_id': False, - 'id': self.channel_chat_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[14].partner_id).id, - 'partner_id': self.users[14].partner_id.id, - 'seen_message_id': False, - }, - ], - 'seen_message_id': False, - 'state': 'open', - 'uuid': self.channel_chat_1.uuid, - }, - { - 'authorizedGroupFullName': False, - 'channel': { - 'anonymous_country': [('clear',)], - 'anonymous_name': False, - 'avatarCacheKey': self.channel_chat_2._get_avatar_cache_key(), - 'channel_type': 'chat', - 'channelMembers': [('insert', sorted([ - { - 'channel': { - 'id': self.channel_chat_2.id, - }, - 'id': self.channel_chat_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'email': 'e.e@example.com', - 'id': self.users[0].partner_id.id, - 'im_status': 'offline', - 'name': 'Ernest Employee', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[0].id, - 'isInternalUser': True, - }, - }, - }, - }, - { - 'channel': { - 'id': self.channel_chat_2.id, - }, - 'id': self.channel_chat_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[15].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'email': False, - 'id': self.users[15].partner_id.id, - 'im_status': 'offline', - 'name': 'test15', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[15].id, - 'isInternalUser': True, - }, - }, - }, - }, - ], key=lambda member_data: member_data['id']))], - 'custom_channel_name': False, - 'id': self.channel_chat_2.id, - 'memberCount': 2, - 'serverMessageUnreadCounter': 0, - }, - 'create_uid': self.env.user.id, - 'defaultDisplayMode': False, - 'description': False, - 'group_based_subscription': False, - 'id': self.channel_chat_2.id, - 'invitedMembers': [('insert', [])], - 'is_minimized': False, - 'is_pinned': True, - 'last_interest_dt': self.channel_chat_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), - 'last_message_id': False, - 'message_needaction_counter': 0, - 'name': 'Ernest Employee, test15', - 'rtcSessions': [('insert', [])], - 'seen_partners_info': [ - { - 'fetched_message_id': False, - 'id': self.channel_chat_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'partner_id': self.users[0].partner_id.id, - 'seen_message_id': False, - }, - { - 'fetched_message_id': False, - 'id': self.channel_chat_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[15].partner_id).id, - 'partner_id': self.users[15].partner_id.id, - 'seen_message_id': False, - }, - ], - 'seen_message_id': False, - 'state': 'open', - 'uuid': self.channel_chat_2.uuid, - }, - { - 'authorizedGroupFullName': False, - 'channel': { - 'anonymous_country': [('clear',)], - 'anonymous_name': False, - 'avatarCacheKey': self.channel_chat_3._get_avatar_cache_key(), - 'channel_type': 'chat', - 'channelMembers': [('insert', sorted([ - { - 'channel': { - 'id': self.channel_chat_3.id, - }, - 'id': self.channel_chat_3.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'email': 'e.e@example.com', - 'id': self.users[0].partner_id.id, - 'im_status': 'offline', - 'name': 'Ernest Employee', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[0].id, - 'isInternalUser': True, - }, - }, - }, - }, - { - 'channel': { - 'id': self.channel_chat_3.id, - }, - 'id': self.channel_chat_3.channel_member_ids.filtered(lambda m: m.partner_id == self.users[2].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'email': 'test2@example.com', - 'id': self.users[2].partner_id.id, - 'im_status': 'offline', - 'name': 'test2', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[2].id, - 'isInternalUser': True, - }, - }, - }, - }, - ], key=lambda member_data: member_data['id']))], - 'custom_channel_name': False, - 'id': self.channel_chat_3.id, - 'memberCount': 2, - 'serverMessageUnreadCounter': 0, - }, - 'create_uid': self.env.user.id, - 'defaultDisplayMode': False, - 'description': False, - 'group_based_subscription': False, - 'id': self.channel_chat_3.id, - 'invitedMembers': [('insert', [])], - 'is_minimized': False, - 'is_pinned': True, - 'last_interest_dt': self.channel_chat_3.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), - 'last_message_id': False, - 'message_needaction_counter': 0, - 'name': 'Ernest Employee, test2', - 'rtcSessions': [('insert', [])], - 'seen_partners_info': [ - { - 'fetched_message_id': False, - 'id': self.channel_chat_3.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'partner_id': self.users[0].partner_id.id, - 'seen_message_id': False, - }, - { - 'fetched_message_id': False, - 'id': self.channel_chat_3.channel_member_ids.filtered(lambda m: m.partner_id == self.users[2].partner_id).id, - 'partner_id': self.users[2].partner_id.id, - 'seen_message_id': False, - }, - ], - 'seen_message_id': False, - 'state': 'open', - 'uuid': self.channel_chat_3.uuid, - }, - { - 'authorizedGroupFullName': False, - 'channel': { - 'anonymous_country': [('clear',)], - 'anonymous_name': False, - 'avatarCacheKey': self.channel_chat_4._get_avatar_cache_key(), - 'channel_type': 'chat', - 'channelMembers': [('insert', sorted([ - { - 'channel': { - 'id': self.channel_chat_4.id, - }, - 'id': self.channel_chat_4.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'email': 'e.e@example.com', - 'id': self.users[0].partner_id.id, - 'im_status': 'offline', - 'name': 'Ernest Employee', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[0].id, - 'isInternalUser': True, - }, - }, - }, - }, - { - 'channel': { - 'id': self.channel_chat_4.id, - }, - 'id': self.channel_chat_4.channel_member_ids.filtered(lambda m: m.partner_id == self.users[3].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'email': False, - 'id': self.users[3].partner_id.id, - 'im_status': 'offline', - 'name': 'test3', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[3].id, - 'isInternalUser': True, - }, - }, - }, - }, - ], key=lambda member_data: member_data['id']))], - 'custom_channel_name': False, - 'id': self.channel_chat_4.id, - 'memberCount': 2, - 'serverMessageUnreadCounter': 0, - }, - 'create_uid': self.env.user.id, - 'defaultDisplayMode': False, - 'description': False, - 'group_based_subscription': False, - 'id': self.channel_chat_4.id, - 'invitedMembers': [('insert', [])], - 'is_minimized': False, - 'is_pinned': True, - 'last_interest_dt': self.channel_chat_4.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), - 'last_message_id': False, - 'message_needaction_counter': 0, - 'name': 'Ernest Employee, test3', - 'rtcSessions': [('insert', [])], - 'seen_partners_info': [ - { - 'fetched_message_id': False, - 'id': self.channel_chat_4.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'partner_id': self.users[0].partner_id.id, - 'seen_message_id': False, - }, - { - 'fetched_message_id': False, - 'id': self.channel_chat_4.channel_member_ids.filtered(lambda m: m.partner_id == self.users[3].partner_id).id, - 'partner_id': self.users[3].partner_id.id, - 'seen_message_id': False, - }, - ], - 'seen_message_id': False, - 'state': 'open', - 'uuid': self.channel_chat_4.uuid, - }, - { - 'authorizedGroupFullName': False, - 'channel': { - 'anonymous_country': { - 'code': 'IN', - 'id': self.env.ref('base.in').id, - 'name': 'India', - }, - 'anonymous_name': False, - 'avatarCacheKey': self.channel_livechat_1._get_avatar_cache_key(), - 'channel_type': 'livechat', - 'channelMembers': [('insert', sorted([ - { - 'channel': { - 'id': self.channel_livechat_1.id, - }, - 'id': self.channel_livechat_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'country': [('clear',)], - 'id': self.users[0].partner_id.id, - 'is_public': False, - 'name': 'Ernest Employee', - }, - }, - }, - { - 'channel': { - 'id': self.channel_livechat_1.id, - }, - 'id': self.channel_livechat_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[1].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'country': [('clear',)], - 'id': self.users[1].partner_id.id, - 'is_public': False, - 'name': 'test1', - }, - }, - }, - ], key=lambda member_data: member_data['id']))], - 'custom_channel_name': False, - 'id': self.channel_livechat_1.id, - 'memberCount': 2, - 'serverMessageUnreadCounter': 0, - }, - 'create_uid': self.env.user.id, - 'defaultDisplayMode': False, - 'description': False, - 'group_based_subscription': False, - 'id': self.channel_livechat_1.id, - 'invitedMembers': [('insert', [])], - 'is_minimized': False, - 'is_pinned': True, - 'last_interest_dt': self.channel_livechat_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), - 'last_message_id': next(res['message_id'] for res in self.channel_livechat_1._channel_last_message_ids()), - 'message_needaction_counter': 0, - 'name': 'test1 Ernest Employee', - 'operator_pid': (self.users[0].partner_id.id, 'Ernest Employee'), - 'rtcSessions': [('insert', [])], - 'seen_partners_info': [ - { - 'fetched_message_id': False, - 'id': self.channel_livechat_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'partner_id': self.users[0].partner_id.id, - 'seen_message_id': False, - }, - { - 'fetched_message_id': next(res['message_id'] for res in self.channel_livechat_1._channel_last_message_ids()), - 'id': self.channel_livechat_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[1].partner_id).id, - 'partner_id': self.users[1].partner_id.id, - 'seen_message_id': next(res['message_id'] for res in self.channel_livechat_1._channel_last_message_ids()), - }, - ], - 'seen_message_id': False, - 'state': 'open', - 'uuid': self.channel_livechat_1.uuid, - }, - { - 'authorizedGroupFullName': False, - 'channel': { - 'anonymous_country': { - 'id': self.env.ref('base.be').id, - 'code': 'BE', - 'name': 'Belgium', - }, - 'anonymous_name': 'anon 2', - 'avatarCacheKey': self.channel_livechat_2._get_avatar_cache_key(), - 'channel_type': 'livechat', - 'channelMembers': [('insert', sorted([ - { - 'channel': { - 'id': self.channel_livechat_2.id, - }, - 'id': self.channel_livechat_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'persona': { - 'partner': { - 'active': True, - 'country': [('clear',)], - 'id': self.users[0].partner_id.id, - 'is_public': False, - 'name': 'Ernest Employee', - }, - }, - }, - { - 'channel': { - 'id': self.channel_livechat_2.id, - }, - 'id': self.channel_livechat_2.channel_member_ids.filtered(lambda m: m.partner_id == self.env.ref('base.public_partner')).id, - 'persona': { - 'partner': { - 'active': False, - 'id': self.env.ref('base.public_partner').id, - 'is_public': True, - 'name': 'Public user', - }, - }, - }, - ], key=lambda member_data: member_data['id']))], - 'custom_channel_name': False, - 'id': self.channel_livechat_2.id, - 'memberCount': 2, - 'serverMessageUnreadCounter': 0, - }, - 'create_uid': self.env.ref('base.public_user').id, - 'defaultDisplayMode': False, - 'description': False, - 'group_based_subscription': False, - 'id': self.channel_livechat_2.id, - 'invitedMembers': [('insert', [])], - 'is_minimized': False, - 'is_pinned': True, - 'last_interest_dt': self.channel_livechat_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), - 'last_message_id': next(res['message_id'] for res in self.channel_livechat_2._channel_last_message_ids()), - 'message_needaction_counter': 0, - 'name': 'anon 2 Ernest Employee', - 'operator_pid': (self.users[0].partner_id.id, 'Ernest Employee'), - 'rtcSessions': [('insert', [])], - 'seen_partners_info': [ - { - 'fetched_message_id': next(res['message_id'] for res in self.channel_livechat_2._channel_last_message_ids()), - 'id': self.channel_livechat_2.channel_member_ids.filtered(lambda m: m.partner_id == self.env.ref('base.public_partner')).id, - 'partner_id': self.env.ref('base.public_user').partner_id.id, - 'seen_message_id': next(res['message_id'] for res in self.channel_livechat_2._channel_last_message_ids()), - }, - { - 'fetched_message_id': False, - 'id': self.channel_livechat_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).id, - 'partner_id': self.users[0].partner_id.id, - 'seen_message_id': False, - }, - ], - 'seen_message_id': False, - 'state': 'open', - 'uuid': self.channel_livechat_2.uuid, - }, - ], - 'companyName': 'YourCompany', - 'shortcodes': [ - { - 'id': self.shortcodes[0].id, - 'source': 'hello', - 'substitution': 'Hello. How may I help you?', - }, - { - 'id': self.shortcodes[1].id, - 'source': 'bye', - 'substitution': 'Thanks for your feedback. Good bye!', - }, - ], - 'internalUserGroupId': self.env.ref('base.group_user').id, - 'menu_id': self.env['ir.model.data']._xmlid_to_res_id('mail.menu_root_discuss'), - 'partner_root': { - 'active': False, - 'email': 'odoobot@example.com', - 'id': self.user_root.partner_id.id, - 'im_status': 'bot', - 'name': 'OdooBot', - 'out_of_office_date_end': False, - 'user': [('clear',)], - }, - 'currentGuest': False, - 'current_partner': { - 'active': True, - 'email': 'e.e@example.com', - 'id': self.users[0].partner_id.id, - 'im_status': 'offline', - 'name': 'Ernest Employee', - 'out_of_office_date_end': False, - 'user': { - 'id': self.users[0].id, - 'isInternalUser': True, - }, - }, - 'current_user_id': self.users[0].id, - 'current_user_settings': { - 'id': self.env['res.users.settings']._find_or_create_for_user(self.users[0]).id, - 'is_discuss_sidebar_category_channel_open': True, - 'is_discuss_sidebar_category_chat_open': True, - 'is_discuss_sidebar_category_livechat_open': True, - 'push_to_talk_key': False, - 'use_push_to_talk': False, - 'user_id': {'id': self.users[0].id}, - 'voice_active_duration': 0, - 'volume_settings_ids': [('insert', [])], }, } - def _get_query_count(self): + def _get_init_messaging_result(self): + """Returns the result of a call to init_messaging. + The point of having a separate getter is to allow it to be overriden. """ - Returns the expected query count. - The point of having a separate getter is to allow it to be overriden. + # sudo: bus.bus: reading non-sensitive last id + bus_last_id = self.env["bus.bus"].sudo()._bus_last_id() + return { + "discuss.channel": self._filter_channels_fields( + self._expected_result_for_channel(self.channel_chat_1), + self._expected_result_for_channel(self.channel_channel_group_1), + ), + "discuss.channel.member": [ + self._res_for_member(self.channel_chat_1, self.users[0].partner_id), + self._res_for_member(self.channel_chat_1, self.users[14].partner_id), + self._res_for_member(self.channel_channel_group_1, self.users[0].partner_id), + self._res_for_member(self.channel_channel_group_1, self.users[2].partner_id), + ], + "discuss.channel.rtc.session": [ + self._expected_result_for_rtc_session(self.channel_channel_group_1, self.users[2]), + ], + "res.groups": [{'full_name': 'Role / User', 'id': self.env.ref("base.group_user").id}], + "res.partner": self._filter_partners_fields( + self._expected_result_for_persona(self.users[0]), + self._expected_result_for_persona(self.users[14]), + self._expected_result_for_persona(self.users[2], only_inviting=True), + ), + "res.users": self._filter_users_fields( + self._res_for_user(self.users[0]), + self._res_for_user(self.users[14]), + ), + "hr.employee": [ + self._res_for_employee(self.users[0].employee_ids[0]), + self._res_for_employee(self.users[14].employee_ids[0]), + ], + "Store": { + "inbox": { + "counter": 1, + "counter_bus_id": bus_last_id, + "id": "inbox", + "model": "mail.box", + }, + "starred": { + "counter": 1, + "counter_bus_id": bus_last_id, + "id": "starred", + "model": "mail.box", + }, + "initChannelsUnreadCounter": 3, + }, + } + + def _get_discuss_channels_result(self): + """Returns the result of a call to `/mail/data` with `channels_as_member`. + The point of having a separate getter is to allow it to be overriden. """ - return 81 + return { + "discuss.call.history": [ + { + "duration_hour": self.channel_channel_group_1.call_history_ids.duration_hour, + "end_dt": False, + "id": self.channel_channel_group_1.call_history_ids.id, + }, + ], + "discuss.channel": self._filter_channels_fields( + self._expected_result_for_channel(self.channel_general), + self._expected_result_for_channel(self.channel_channel_public_1), + self._expected_result_for_channel(self.channel_channel_public_2), + self._expected_result_for_channel(self.channel_channel_group_1), + self._expected_result_for_channel(self.channel_channel_group_2), + self._expected_result_for_channel(self.channel_group_1), + self._expected_result_for_channel(self.channel_chat_1), + self._expected_result_for_channel(self.channel_chat_2), + self._expected_result_for_channel(self.channel_chat_3), + self._expected_result_for_channel(self.channel_chat_4), + self._expected_result_for_channel(self.channel_livechat_1), + self._expected_result_for_channel(self.channel_livechat_2), + ), + "discuss.channel.member": [ + self._res_for_member(self.channel_general, self.users[0].partner_id), + self._res_for_member(self.channel_channel_public_1, self.users[0].partner_id), + self._res_for_member(self.channel_channel_public_2, self.users[0].partner_id), + self._res_for_member(self.channel_channel_group_1, self.users[0].partner_id), + self._res_for_member(self.channel_channel_group_1, self.users[2].partner_id), + self._res_for_member(self.channel_channel_group_2, self.users[0].partner_id), + self._res_for_member(self.channel_group_1, self.users[0].partner_id), + self._res_for_member(self.channel_group_1, self.users[12].partner_id), + self._res_for_member(self.channel_chat_1, self.users[0].partner_id), + self._res_for_member(self.channel_chat_1, self.users[14].partner_id), + self._res_for_member(self.channel_chat_2, self.users[0].partner_id), + self._res_for_member(self.channel_chat_2, self.users[15].partner_id), + self._res_for_member(self.channel_chat_3, self.users[0].partner_id), + self._res_for_member(self.channel_chat_3, self.users[2].partner_id), + self._res_for_member(self.channel_chat_4, self.users[0].partner_id), + self._res_for_member(self.channel_chat_4, self.users[3].partner_id), + self._res_for_member(self.channel_livechat_1, self.users[0].partner_id), + self._res_for_member(self.channel_livechat_1, self.users[1].partner_id), + self._res_for_member(self.channel_livechat_2, self.users[0].partner_id), + self._res_for_member(self.channel_livechat_2, guest=True), + ], + "discuss.channel.rtc.session": [ + self._expected_result_for_rtc_session(self.channel_channel_group_1, self.users[2]), + ], + "im_livechat.channel": [ + self._expected_result_for_livechat_channel(), + ], + "im_livechat.conversation.tag": [ + {"id": self.conversation_tag.id, "name": "Support", "color": self.conversation_tag.color}, + ], + "mail.guest": [ + self._expected_result_for_persona(guest=True), + ], + "mail.message": self._filter_messages_fields( + self._expected_result_for_message(self.channel_general), + self._expected_result_for_message(self.channel_channel_public_1), + self._expected_result_for_message(self.channel_channel_public_2), + self._expected_result_for_message(self.channel_channel_group_1), + self._expected_result_for_message(self.channel_channel_group_2), + self._expected_result_for_message(self.channel_livechat_1), + self._expected_result_for_message(self.channel_livechat_2), + ), + "mail.notification": [ + self._expected_result_for_notification(self.channel_channel_public_1), + ], + "mail.message.subtype": [ + {"description": False, "id": self.env.ref("mail.mt_note").id}, + {"description": False, "id": self.env.ref("mail.mt_comment").id}, + ], + "mail.thread": self._filter_threads_fields( + self._expected_result_for_thread(self.channel_general), + self._expected_result_for_thread(self.channel_channel_public_1), + self._expected_result_for_thread(self.channel_channel_public_2), + self._expected_result_for_thread(self.channel_channel_group_1), + self._expected_result_for_thread(self.channel_channel_group_2), + self._expected_result_for_thread(self.channel_livechat_1), + self._expected_result_for_thread(self.channel_livechat_2), + ), + "MessageReactions": [ + *self._expected_result_for_message_reactions(self.channel_general), + *self._expected_result_for_message_reactions(self.channel_channel_public_1), + ], + "res.country": [ + {"code": "IN", "id": self.env.ref("base.in").id, "name": "India"}, + {"code": "BE", "id": self.env.ref("base.be").id, "name": "Belgium"}, + ], + "res.groups": [{"full_name": "Role / User", "id": self.env.ref("base.group_user").id}], + "res.partner": self._filter_partners_fields( + self._expected_result_for_persona( + self.users[0], + also_livechat=True, + also_notification=True, + ), + self._expected_result_for_persona(self.users[2]), + self._expected_result_for_persona(self.users[12]), + self._expected_result_for_persona(self.users[14]), + self._expected_result_for_persona(self.users[15]), + self._expected_result_for_persona(self.users[3]), + self._expected_result_for_persona(self.users[1], also_livechat=True), + self._expected_result_for_persona(self.user_root), + ), + "res.users": self._filter_users_fields( + self._res_for_user(self.users[0]), + self._res_for_user(self.users[12]), + self._res_for_user(self.users[14]), + self._res_for_user(self.users[15]), + self._res_for_user(self.users[2]), + self._res_for_user(self.users[3]), + self._res_for_user(self.user_root), + self._res_for_user(self.users[1]), + ), + "hr.employee": [ + self._res_for_employee(self.users[0].employee_ids[0]), + self._res_for_employee(self.users[12].employee_ids[0]), + self._res_for_employee(self.users[14].employee_ids[0]), + self._res_for_employee(self.users[15].employee_ids[0]), + self._res_for_employee(self.users[2].employee_ids[0]), + self._res_for_employee(self.users[3].employee_ids[0]), + ], + } + + def _expected_result_for_channel(self, channel): + # sudo: bus.bus: reading non-sensitive last id + bus_last_id = self.env["bus.bus"].sudo()._bus_last_id() + members = channel.channel_member_ids + member_0 = members.filtered(lambda m: m.partner_id == self.users[0].partner_id) + member_2 = members.filtered(lambda m: m.partner_id == self.users[2].partner_id) + member_12 = members.filtered(lambda m: m.partner_id == self.users[12].partner_id) + last_interest_dt = fields.Datetime.to_string(channel.last_interest_dt) + if channel == self.channel_general: + return { + "avatar_cache_key": channel.avatar_cache_key, + "channel_type": "channel", + "create_uid": self.user_root.id, + "default_display_mode": False, + "description": "General announcements for all employees.", + "fetchChannelInfoState": "fetched", + "from_message_id": False, + "group_ids": channel.group_ids.ids, + "group_public_id": self.env.ref("base.group_user").id, + "id": channel.id, + "invited_member_ids": [["ADD", []]], + "is_editable": True, + "last_interest_dt": last_interest_dt, + "member_count": len(self.group_user.all_user_ids), + "message_needaction_counter_bus_id": bus_last_id, + "message_needaction_counter": 0, + "name": "general", + "parent_channel_id": False, + "rtc_session_ids": [["ADD", []]], + "uuid": channel.uuid, + } + if channel == self.channel_channel_public_1: + return { + "avatar_cache_key": channel.avatar_cache_key, + "channel_type": "channel", + "create_uid": self.env.user.id, + "default_display_mode": False, + "description": False, + "fetchChannelInfoState": "fetched", + "from_message_id": False, + "group_ids": [], + "group_public_id": False, + "id": channel.id, + "invited_member_ids": [["ADD", []]], + "is_editable": True, + "last_interest_dt": last_interest_dt, + "member_count": 5, + "message_needaction_counter_bus_id": bus_last_id, + "message_needaction_counter": 1, + "name": "public channel 1", + "parent_channel_id": False, + "rtc_session_ids": [["ADD", []]], + "uuid": channel.uuid, + } + if channel == self.channel_channel_public_2: + return { + "avatar_cache_key": channel.avatar_cache_key, + "channel_type": "channel", + "create_uid": self.env.user.id, + "default_display_mode": False, + "description": False, + "fetchChannelInfoState": "fetched", + "from_message_id": False, + "group_ids": [], + "group_public_id": False, + "id": channel.id, + "invited_member_ids": [["ADD", []]], + "is_editable": True, + "last_interest_dt": last_interest_dt, + "member_count": 5, + "message_needaction_counter_bus_id": bus_last_id, + "message_needaction_counter": 0, + "name": "public channel 2", + "parent_channel_id": False, + "rtc_session_ids": [["ADD", []]], + "uuid": channel.uuid, + } + if channel == self.channel_channel_group_1: + return { + "avatar_cache_key": channel.avatar_cache_key, + "channel_type": "channel", + "create_uid": self.env.user.id, + "default_display_mode": False, + "description": False, + "fetchChannelInfoState": "fetched", + "from_message_id": False, + "group_ids": [], + "group_public_id": self.env.ref("base.group_user").id, + "id": channel.id, + "invited_member_ids": [["ADD", [member_0.id]]], + "is_editable": True, + "last_interest_dt": last_interest_dt, + "member_count": 5, + "message_needaction_counter_bus_id": bus_last_id, + "message_needaction_counter": 0, + "name": "group restricted channel 1", + "parent_channel_id": False, + # sudo: discuss.channel.rtc.session - reading a session in a test file + "rtc_session_ids": [["ADD", [member_2.sudo().rtc_session_ids.id]]], + "uuid": channel.uuid, + } + if channel == self.channel_channel_group_2: + return { + "avatar_cache_key": channel.avatar_cache_key, + "channel_type": "channel", + "create_uid": self.env.user.id, + "default_display_mode": False, + "description": False, + "fetchChannelInfoState": "fetched", + "from_message_id": False, + "group_ids": [], + "group_public_id": self.env.ref("base.group_user").id, + "id": channel.id, + "invited_member_ids": [["ADD", []]], + "is_editable": True, + "last_interest_dt": last_interest_dt, + "member_count": 5, + "message_needaction_counter_bus_id": bus_last_id, + "message_needaction_counter": 0, + "name": "group restricted channel 2", + "parent_channel_id": False, + "rtc_session_ids": [["ADD", []]], + "uuid": channel.uuid, + } + if channel == self.channel_group_1: + return { + "avatar_cache_key": channel.avatar_cache_key, + "channel_name_member_ids": [member_0.id, member_12.id], + "channel_type": "group", + "create_uid": self.env.user.id, + "default_display_mode": False, + "description": False, + "fetchChannelInfoState": "fetched", + "from_message_id": False, + "id": channel.id, + "invited_member_ids": [["ADD", []]], + "is_editable": True, + "last_interest_dt": last_interest_dt, + "member_count": 2, + "message_needaction_counter_bus_id": bus_last_id, + "message_needaction_counter": 0, + "name": "", + "parent_channel_id": False, + "rtc_session_ids": [["ADD", []]], + "uuid": channel.uuid, + } + if channel == self.channel_chat_1: + return { + "channel_type": "chat", + "create_uid": self.env.user.id, + "default_display_mode": False, + "fetchChannelInfoState": "fetched", + "id": channel.id, + "invited_member_ids": [["ADD", []]], + "is_editable": True, + "last_interest_dt": last_interest_dt, + "member_count": 2, + "message_needaction_counter_bus_id": bus_last_id, + "message_needaction_counter": 0, + "name": "Ernest Employee, test14", + "rtc_session_ids": [["ADD", []]], + "uuid": channel.uuid, + } + if channel == self.channel_chat_2: + return { + "channel_type": "chat", + "create_uid": self.env.user.id, + "default_display_mode": False, + "fetchChannelInfoState": "fetched", + "id": channel.id, + "invited_member_ids": [["ADD", []]], + "is_editable": True, + "last_interest_dt": last_interest_dt, + "member_count": 2, + "message_needaction_counter_bus_id": bus_last_id, + "message_needaction_counter": 0, + "name": "Ernest Employee, test15", + "rtc_session_ids": [["ADD", []]], + "uuid": channel.uuid, + } + if channel == self.channel_chat_3: + return { + "channel_type": "chat", + "create_uid": self.env.user.id, + "default_display_mode": False, + "fetchChannelInfoState": "fetched", + "id": channel.id, + "invited_member_ids": [["ADD", []]], + "is_editable": True, + "last_interest_dt": last_interest_dt, + "member_count": 2, + "message_needaction_counter_bus_id": bus_last_id, + "message_needaction_counter": 0, + "name": "Ernest Employee, test2", + "rtc_session_ids": [["ADD", []]], + "uuid": channel.uuid, + } + if channel == self.channel_chat_4: + return { + "channel_type": "chat", + "create_uid": self.env.user.id, + "default_display_mode": False, + "fetchChannelInfoState": "fetched", + "id": channel.id, + "invited_member_ids": [["ADD", []]], + "is_editable": True, + "last_interest_dt": last_interest_dt, + "member_count": 2, + "message_needaction_counter_bus_id": bus_last_id, + "message_needaction_counter": 0, + "name": "Ernest Employee, test3", + "rtc_session_ids": [["ADD", []]], + "uuid": channel.uuid, + } + if channel == self.channel_livechat_1: + return { + "ai_agent_id": False, + "channel_type": "livechat", + "country_id": self.env.ref("base.in").id, + "create_uid": self.users[1].id, + "default_display_mode": False, + "description": False, + "fetchChannelInfoState": "fetched", + "id": channel.id, + "invited_member_ids": [["ADD", []]], + "is_editable": True, + "last_interest_dt": last_interest_dt, + "livechat_end_dt": False, + "livechat_channel_id": self.im_livechat_channel.id, + "livechat_conversation_tag_ids": [self.conversation_tag.id], + "livechat_note": False, + "livechat_outcome": "no_answer", + "livechat_status": "in_progress", + "livechat_lang_id": False, + "livechat_visitor_id": False, + "livechat_expertise_ids": [], + "livechat_operator_id": self.users[0].partner_id.id, + "member_count": 2, + "message_needaction_counter_bus_id": bus_last_id, + "message_needaction_counter": 0, + "name": "test1 Ernest Employee", + "requested_by_operator": False, + "rtc_session_ids": [["ADD", []]], + "uuid": channel.uuid, + } + if channel == self.channel_livechat_2: + return { + "ai_agent_id": False, + "channel_type": "livechat", + "country_id": self.env.ref("base.be").id, + "create_uid": self.env.ref("base.public_user").id, + "default_display_mode": False, + "description": False, + "fetchChannelInfoState": "fetched", + "id": channel.id, + "invited_member_ids": [["ADD", []]], + "is_editable": True, + "last_interest_dt": last_interest_dt, + "livechat_end_dt": False, + "livechat_channel_id": self.im_livechat_channel.id, + "livechat_conversation_tag_ids": [], + "livechat_note": False, + "livechat_outcome": "no_answer", + "livechat_status": "in_progress", + "livechat_lang_id": False, + "livechat_visitor_id": False, + "livechat_expertise_ids": [], + "livechat_operator_id": self.users[0].partner_id.id, + "member_count": 2, + "message_needaction_counter_bus_id": bus_last_id, + "message_needaction_counter": 0, + "name": "Visitor Ernest Employee", + "requested_by_operator": False, + "rtc_session_ids": [["ADD", []]], + "uuid": channel.uuid, + } + return {} + + def _res_for_member(self, channel, partner=None, guest=None): + members = channel.channel_member_ids + member_0 = members.filtered(lambda m: m.partner_id == self.users[0].partner_id) + member_0_last_interest_dt = fields.Datetime.to_string(member_0.last_interest_dt) + member_0_last_seen_dt = fields.Datetime.to_string(member_0.last_seen_dt) + member_0_create_date = fields.Datetime.to_string(member_0.create_date) + member_1 = members.filtered(lambda m: m.partner_id == self.users[1].partner_id) + member_2 = members.filtered(lambda m: m.partner_id == self.users[2].partner_id) + member_3 = members.filtered(lambda m: m.partner_id == self.users[3].partner_id) + member_12 = members.filtered(lambda m: m.partner_id == self.users[12].partner_id) + member_14 = members.filtered(lambda m: m.partner_id == self.users[14].partner_id) + member_15 = members.filtered(lambda m: m.partner_id == self.users[15].partner_id) + last_message = channel._get_last_messages() + last_message_of_partner_0 = self.env["mail.message"].search( + Domain("author_id", "=", member_0.partner_id.id) + & Domain("model", "=", "discuss.channel") + & Domain("res_id", "=", channel.id), + order="id desc", + limit=1, + ) + member_g = members.filtered(lambda m: m.guest_id) + guest = member_g.guest_id + # sudo: bus.bus: reading non-sensitive last id + bus_last_id = self.env["bus.bus"].sudo()._bus_last_id() + if channel == self.channel_general and partner == self.users[0].partner_id: + return { + "create_date": member_0_create_date, + "custom_channel_name": False, + "custom_notifications": False, + "fetched_message_id": False, + "id": member_0.id, + "last_interest_dt": member_0_last_interest_dt, + "message_unread_counter": 1, + "message_unread_counter_bus_id": bus_last_id, + "mute_until_dt": False, + "last_seen_dt": member_0_last_seen_dt, + "new_message_separator": 0, + "partner_id": self.users[0].partner_id.id, + "rtc_inviting_session_id": False, + "seen_message_id": False, + "unpin_dt": False, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_channel_public_1 and partner == self.users[0].partner_id: + return { + "create_date": member_0_create_date, + "custom_channel_name": False, + "custom_notifications": False, + "fetched_message_id": last_message.id, + "id": member_0.id, + "last_interest_dt": member_0_last_interest_dt, + "message_unread_counter": 0, + "message_unread_counter_bus_id": bus_last_id, + "mute_until_dt": False, + "last_seen_dt": member_0_last_seen_dt, + "new_message_separator": last_message.id + 1, + "partner_id": self.users[0].partner_id.id, + "rtc_inviting_session_id": False, + "seen_message_id": last_message.id, + "unpin_dt": False, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_channel_public_2 and partner == self.users[0].partner_id: + return { + "create_date": member_0_create_date, + "custom_channel_name": False, + "custom_notifications": False, + "fetched_message_id": last_message.id, + "id": member_0.id, + "last_interest_dt": member_0_last_interest_dt, + "message_unread_counter": 0, + "message_unread_counter_bus_id": bus_last_id, + "mute_until_dt": False, + "last_seen_dt": member_0_last_seen_dt, + "new_message_separator": last_message.id + 1, + "partner_id": self.users[0].partner_id.id, + "rtc_inviting_session_id": False, + "seen_message_id": last_message.id, + "unpin_dt": False, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_channel_group_1 and partner == self.users[0].partner_id: + return { + "create_date": member_0_create_date, + "custom_channel_name": False, + "custom_notifications": False, + "fetched_message_id": last_message_of_partner_0.id, + "id": member_0.id, + "last_interest_dt": member_0_last_interest_dt, + "message_unread_counter": 0, + "message_unread_counter_bus_id": bus_last_id, + "mute_until_dt": False, + "last_seen_dt": member_0_last_seen_dt, + "new_message_separator": last_message_of_partner_0.id + 1, + "partner_id": self.users[0].partner_id.id, + "rtc_inviting_session_id": member_0.rtc_inviting_session_id.id, + "seen_message_id": last_message_of_partner_0.id, + "unpin_dt": False, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_channel_group_1 and partner == self.users[2].partner_id: + return { + "id": member_2.id, + "partner_id": self.users[2].partner_id.id, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_channel_group_2 and partner == self.users[0].partner_id: + return { + "create_date": member_0_create_date, + "custom_channel_name": False, + "custom_notifications": False, + "fetched_message_id": last_message.id, + "id": member_0.id, + "last_interest_dt": member_0_last_interest_dt, + "message_unread_counter": 0, + "message_unread_counter_bus_id": bus_last_id, + "mute_until_dt": False, + "last_seen_dt": member_0_last_seen_dt, + "new_message_separator": last_message.id + 1, + "partner_id": self.users[0].partner_id.id, + "rtc_inviting_session_id": False, + "seen_message_id": last_message.id, + "unpin_dt": False, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_group_1 and partner == self.users[0].partner_id: + return { + "create_date": member_0_create_date, + "custom_channel_name": False, + "custom_notifications": False, + "fetched_message_id": False, + "id": member_0.id, + "last_interest_dt": member_0_last_interest_dt, + "message_unread_counter": 0, + "message_unread_counter_bus_id": bus_last_id, + "mute_until_dt": False, + "last_seen_dt": member_0_last_seen_dt, + "new_message_separator": 0, + "partner_id": self.users[0].partner_id.id, + "rtc_inviting_session_id": False, + "seen_message_id": False, + "unpin_dt": False, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_group_1 and partner == self.users[12].partner_id: + return { + "create_date": fields.Datetime.to_string(member_12.create_date), + "last_seen_dt": False, + "fetched_message_id": False, + "id": member_12.id, + "partner_id": self.users[12].partner_id.id, + "seen_message_id": False, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_chat_1 and partner == self.users[0].partner_id: + return { + "create_date": member_0_create_date, + "custom_channel_name": False, + "custom_notifications": False, + "fetched_message_id": False, + "id": member_0.id, + "last_interest_dt": member_0_last_interest_dt, + "message_unread_counter": 0, + "message_unread_counter_bus_id": bus_last_id, + "mute_until_dt": False, + "last_seen_dt": member_0_last_seen_dt, + "new_message_separator": 0, + "partner_id": self.users[0].partner_id.id, + "rtc_inviting_session_id": False, + "seen_message_id": False, + "unpin_dt": False, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_chat_1 and partner == self.users[14].partner_id: + return { + "create_date": fields.Datetime.to_string(member_14.create_date), + "last_seen_dt": False, + "fetched_message_id": False, + "id": member_14.id, + "partner_id": self.users[14].partner_id.id, + "seen_message_id": False, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_chat_2 and partner == self.users[0].partner_id: + return { + "create_date": member_0_create_date, + "custom_channel_name": False, + "custom_notifications": False, + "fetched_message_id": False, + "id": member_0.id, + "last_interest_dt": member_0_last_interest_dt, + "message_unread_counter": 0, + "message_unread_counter_bus_id": bus_last_id, + "mute_until_dt": False, + "last_seen_dt": member_0_last_seen_dt, + "new_message_separator": 0, + "partner_id": self.users[0].partner_id.id, + "rtc_inviting_session_id": False, + "seen_message_id": False, + "unpin_dt": False, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_chat_2 and partner == self.users[15].partner_id: + return { + "create_date": fields.Datetime.to_string(member_15.create_date), + "last_seen_dt": False, + "fetched_message_id": False, + "id": member_15.id, + "partner_id": self.users[15].partner_id.id, + "seen_message_id": False, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_chat_3 and partner == self.users[0].partner_id: + return { + "create_date": member_0_create_date, + "custom_channel_name": False, + "custom_notifications": False, + "fetched_message_id": False, + "id": member_0.id, + "last_interest_dt": member_0_last_interest_dt, + "message_unread_counter": 0, + "message_unread_counter_bus_id": bus_last_id, + "mute_until_dt": False, + "last_seen_dt": member_0_last_seen_dt, + "new_message_separator": 0, + "partner_id": self.users[0].partner_id.id, + "rtc_inviting_session_id": False, + "seen_message_id": False, + "unpin_dt": False, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_chat_3 and partner == self.users[2].partner_id: + return { + "create_date": fields.Datetime.to_string(member_2.create_date), + "last_seen_dt": False, + "fetched_message_id": False, + "id": member_2.id, + "partner_id": self.users[2].partner_id.id, + "seen_message_id": False, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_chat_4 and partner == self.users[0].partner_id: + return { + "create_date": member_0_create_date, + "custom_channel_name": False, + "custom_notifications": False, + "fetched_message_id": False, + "id": member_0.id, + "last_interest_dt": member_0_last_interest_dt, + "message_unread_counter": 0, + "message_unread_counter_bus_id": bus_last_id, + "mute_until_dt": False, + "last_seen_dt": member_0_last_seen_dt, + "new_message_separator": 0, + "partner_id": self.users[0].partner_id.id, + "rtc_inviting_session_id": False, + "seen_message_id": False, + "unpin_dt": False, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_chat_4 and partner == self.users[3].partner_id: + return { + "create_date": fields.Datetime.to_string(member_3.create_date), + "last_seen_dt": False, + "fetched_message_id": False, + "id": member_3.id, + "partner_id": self.users[3].partner_id.id, + "seen_message_id": False, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_livechat_1 and partner == self.users[0].partner_id: + return { + "create_date": member_0_create_date, + "custom_channel_name": False, + "custom_notifications": False, + "fetched_message_id": False, + "id": member_0.id, + "livechat_member_type": "agent", + "last_interest_dt": member_0_last_interest_dt, + "message_unread_counter": 1, + "message_unread_counter_bus_id": bus_last_id, + "mute_until_dt": False, + "last_seen_dt": member_0_last_seen_dt, + "new_message_separator": 0, + "partner_id": self.users[0].partner_id.id, + "rtc_inviting_session_id": False, + "seen_message_id": False, + "unpin_dt": fields.Datetime.to_string(member_0.unpin_dt), + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_livechat_1 and partner == self.users[1].partner_id: + return { + "create_date": fields.Datetime.to_string(member_1.create_date), + "last_seen_dt": fields.Datetime.to_string(member_1.last_seen_dt), + "fetched_message_id": last_message.id, + "id": member_1.id, + "livechat_member_type": "visitor", + "partner_id": self.users[1].partner_id.id, + "seen_message_id": last_message.id, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_livechat_2 and partner == self.users[0].partner_id: + return { + "create_date": member_0_create_date, + "custom_channel_name": False, + "custom_notifications": False, + "fetched_message_id": False, + "id": member_0.id, + "livechat_member_type": "agent", + "last_interest_dt": member_0_last_interest_dt, + "message_unread_counter": 1, + "message_unread_counter_bus_id": bus_last_id, + "mute_until_dt": False, + "last_seen_dt": member_0_last_seen_dt, + "new_message_separator": 0, + "partner_id": self.users[0].partner_id.id, + "rtc_inviting_session_id": False, + "seen_message_id": False, + "unpin_dt": fields.Datetime.to_string(member_0.unpin_dt), + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + if channel == self.channel_livechat_2 and guest: + return { + "create_date": fields.Datetime.to_string(member_g.create_date), + "last_seen_dt": fields.Datetime.to_string(member_g.last_seen_dt), + "fetched_message_id": last_message.id, + "id": member_g.id, + "livechat_member_type": "visitor", + "guest_id": guest.id, + "seen_message_id": last_message.id, + "channel_id": {"id": channel.id, "model": "discuss.channel"}, + } + return {} + + def _expected_result_for_livechat_channel(self): + return {"id": self.im_livechat_channel.id, "name": "support"} + + def _expected_result_for_message(self, channel): + last_message = channel._get_last_messages() + create_date = fields.Datetime.to_string(last_message.create_date) + date = fields.Datetime.to_string(last_message.date) + write_date = fields.Datetime.to_string(last_message.write_date) + user_0 = self.users[0] + user_1 = self.users[1] + user_2 = self.users[2] + members = channel.channel_member_ids + member_g = members.filtered(lambda m: m.guest_id) + guest = member_g.guest_id + if channel == self.channel_general: + return { + "attachment_ids": [], + "author_guest_id": False, + "author_id": user_0.partner_id.id, + "body": ["markup", "

test

"], + "create_date": create_date, + "date": date, + "default_subject": "general", + "email_from": '"Ernest Employee" ', + "id": last_message.id, + "incoming_email_cc": False, + "incoming_email_to": False, + "message_link_preview_ids": [], + "message_type": "comment", + "model": "discuss.channel", + "needaction": False, + "notification_ids": [], + "parent_id": False, + "partner_ids": [], + "pinned_at": False, + "rating_id": False, + "reactions": [ + {"content": "👍", "message": last_message.id}, + {"content": "😁", "message": last_message.id}, + {"content": "😊", "message": last_message.id}, + ], + "record_name": "general", + "res_id": 1, + "scheduledDatetime": False, + "starred": False, + "subject": False, + "subtype_id": self.env.ref("mail.mt_note").id, + "thread": {"id": channel.id, "model": "discuss.channel"}, + "trackingValues": [], + "write_date": write_date, + } + if channel == self.channel_channel_public_1: + return { + "attachment_ids": [], + "author_guest_id": False, + "author_id": user_2.partner_id.id, + "body": ["markup", "

test

"], + "create_date": create_date, + "date": date, + "default_subject": "public channel 1", + "email_from": '"test2" ', + "id": last_message.id, + "incoming_email_cc": False, + "incoming_email_to": False, + "message_link_preview_ids": [], + "message_type": "comment", + "model": "discuss.channel", + "needaction": True, + "notification_ids": [last_message.notification_ids.id], + "thread": {"id": channel.id, "model": "discuss.channel"}, + "parent_id": False, + "partner_ids": [self.users[0].partner_id.id], + "pinned_at": False, + "rating_id": False, + "reactions": [ + {"content": "😁", "message": last_message.id}, + {"content": "😊", "message": last_message.id}, + {"content": "😏", "message": last_message.id}, + ], + "record_name": "public channel 1", + "res_id": channel.id, + "scheduledDatetime": False, + "starred": True, + "subject": False, + "subtype_id": self.env.ref("mail.mt_note").id, + "trackingValues": [], + "write_date": write_date, + } + if channel == self.channel_channel_public_2: + return { + "attachment_ids": [], + "author_guest_id": False, + "author_id": user_0.partner_id.id, + "body": [ + "markup", + '
created this channel.
', + ], + "create_date": create_date, + "date": date, + "default_subject": "public channel 2", + "email_from": '"Ernest Employee" ', + "id": last_message.id, + "incoming_email_cc": False, + "incoming_email_to": False, + "message_link_preview_ids": [], + "message_type": "notification", + "model": "discuss.channel", + "needaction": False, + "notification_ids": [], + "thread": {"id": channel.id, "model": "discuss.channel"}, + "parent_id": False, + "partner_ids": [], + "pinned_at": False, + "rating_id": False, + "reactions": [], + "record_name": "public channel 2", + "res_id": channel.id, + "scheduledDatetime": False, + "starred": False, + "subject": False, + "subtype_id": self.env.ref("mail.mt_comment").id, + "trackingValues": [], + "write_date": write_date, + } + if channel == self.channel_channel_group_1: + return { + "attachment_ids": [], + "author_guest_id": False, + "author_id": self.user_root.partner_id.id, + "body": [ + "markup", + '
', + ], + "call_history_ids": [channel.call_history_ids[0].id], + "create_date": create_date, + "date": date, + "default_subject": "group restricted channel 1", + "email_from": '"OdooBot" ', + "id": last_message.id, + "incoming_email_cc": False, + "incoming_email_to": False, + "message_link_preview_ids": [], + "message_type": "notification", + "model": "discuss.channel", + "needaction": False, + "notification_ids": [], + "thread": {"id": channel.id, "model": "discuss.channel"}, + "parent_id": False, + "partner_ids": [], + "pinned_at": False, + "rating_id": False, + "reactions": [], + "record_name": "group restricted channel 1", + "res_id": channel.id, + "scheduledDatetime": False, + "starred": False, + "subject": False, + "subtype_id": self.env.ref("mail.mt_note").id, + "trackingValues": [], + "write_date": write_date, + } + if channel == self.channel_channel_group_2: + return { + "attachment_ids": [], + "author_guest_id": False, + "author_id": user_0.partner_id.id, + "body": [ + "markup", + '
created this channel.
', + ], + "create_date": create_date, + "date": date, + "default_subject": "group restricted channel 2", + "email_from": '"Ernest Employee" ', + "id": last_message.id, + "incoming_email_cc": False, + "incoming_email_to": False, + "message_link_preview_ids": [], + "message_type": "notification", + "model": "discuss.channel", + "needaction": False, + "notification_ids": [], + "thread": {"id": channel.id, "model": "discuss.channel"}, + "parent_id": False, + "partner_ids": [], + "pinned_at": False, + "rating_id": False, + "reactions": [], + "record_name": "group restricted channel 2", + "res_id": channel.id, + "scheduledDatetime": False, + "starred": False, + "subject": False, + "subtype_id": self.env.ref("mail.mt_comment").id, + "trackingValues": [], + "write_date": write_date, + } + if channel == self.channel_livechat_1: + return { + "attachment_ids": [], + "author_guest_id": False, + "author_id": user_1.partner_id.id, + "body": ["markup", "

test

"], + "create_date": create_date, + "date": date, + "default_subject": "test1 Ernest Employee", + "email_from": '"test1" ', + "id": last_message.id, + "incoming_email_cc": False, + "incoming_email_to": False, + "message_link_preview_ids": [], + "message_type": "comment", + "model": "discuss.channel", + "needaction": False, + "notification_ids": [], + "thread": {"id": channel.id, "model": "discuss.channel"}, + "parent_id": False, + "partner_ids": [], + "pinned_at": False, + "rating_id": False, + "reactions": [], + "record_name": "test1 Ernest Employee", + "res_id": channel.id, + "scheduledDatetime": False, + "starred": False, + "subject": False, + "subtype_id": self.env.ref("mail.mt_note").id, + "trackingValues": [], + "write_date": write_date, + } + if channel == self.channel_livechat_2: + return { + "attachment_ids": [], + "author_guest_id": guest.id, + "author_id": False, + "body": ["markup", "

test

"], + "create_date": create_date, + "date": date, + "default_subject": "Visitor Ernest Employee", + "email_from": False, + "id": last_message.id, + "incoming_email_cc": False, + "incoming_email_to": False, + "message_link_preview_ids": [], + "message_type": "comment", + "model": "discuss.channel", + "needaction": False, + "notification_ids": [], + "thread": {"id": channel.id, "model": "discuss.channel"}, + "parent_id": False, + "partner_ids": [], + "pinned_at": False, + "rating_id": False, + "reactions": [], + "record_name": "Visitor Ernest Employee", + "res_id": channel.id, + "scheduledDatetime": False, + "starred": False, + "subject": False, + "subtype_id": self.env.ref("mail.mt_note").id, + "trackingValues": [], + "write_date": write_date, + } + return {} + + def _expected_result_for_message_reactions(self, channel): + last_message = channel._get_last_messages() + partner_0 = self.users[0].partner_id.id + partner_1 = self.users[1].partner_id.id + partner_2 = self.users[2].partner_id.id + reactions_0 = last_message.sudo().reaction_ids.filtered(lambda r: r.content == "👍") + reactions_1 = last_message.sudo().reaction_ids.filtered(lambda r: r.content == "😁") + reactions_2 = last_message.sudo().reaction_ids.filtered(lambda r: r.content == "😊") + reactions_3 = last_message.sudo().reaction_ids.filtered(lambda r: r.content == "😏") + if channel == self.channel_general: + return [ + { + "content": "👍", + "count": 1, + "guests": [], + "message": last_message.id, + "partners": [partner_2], + "sequence": min(reactions_0.ids), + }, + { + "content": "😁", + "count": 2, + "guests": [], + "message": last_message.id, + "partners": [partner_2, partner_1], + "sequence": min(reactions_1.ids), + }, + { + "content": "😊", + "count": 3, + "guests": [], + "message": last_message.id, + "partners": [partner_2, partner_1, partner_0], + "sequence": min(reactions_2.ids), + }, + ] + if channel == self.channel_channel_public_1: + return [ + { + "content": "😁", + "count": 1, + "guests": [], + "message": last_message.id, + "partners": [partner_2], + "sequence": min(reactions_1.ids), + }, + { + "content": "😊", + "count": 3, + "guests": [], + "message": last_message.id, + "partners": [partner_2, partner_1, partner_0], + "sequence": min(reactions_2.ids), + }, + { + "content": "😏", + "count": 2, + "guests": [], + "message": last_message.id, + "partners": [partner_1, partner_0], + "sequence": min(reactions_3.ids), + }, + ] + return [] + + def _expected_result_for_notification(self, channel): + last_message = channel._get_last_messages() + if channel == self.channel_channel_public_1: + return { + "mail_email_address": False, + "failure_type": False, + "id": last_message.notification_ids.id, + "mail_message_id": last_message.id, + "notification_status": "sent", + "notification_type": "inbox", + "res_partner_id": self.users[0].partner_id.id, + } + return {} + + def _expected_result_for_persona( + self, + user=None, + guest=None, + only_inviting=False, + also_livechat=False, + also_notification=False, + ): + if user == self.users[0]: + res = { + "active": True, + "avatar_128_access_token": user.partner_id._get_avatar_128_access_token(), + "email": "e.e@example.com", + "id": user.partner_id.id, + "im_status": "online", + "im_status_access_token": user.partner_id._get_im_status_access_token(), + "is_company": False, + "main_user_id": user.id, + "mention_token": user.partner_id._get_mention_token(), + "name": "Ernest Employee", + "write_date": fields.Datetime.to_string(user.partner_id.write_date), + } + if also_livechat: + res.update( + { + "country_id": False, + "is_public": False, + "user_livechat_username": False, + } + ) + if also_notification: + res["name"] = "Ernest Employee" + return res + if user == self.users[1]: + res = { + "active": True, + "avatar_128_access_token": user.partner_id._get_avatar_128_access_token(), + "country_id": self.env.ref("base.in").id, + "id": user.partner_id.id, + "im_status": "offline", + "im_status_access_token": user.partner_id._get_im_status_access_token(), + "is_company": False, + "is_public": False, + "main_user_id": user.id, + "name": "test1", + "mention_token": user.partner_id._get_mention_token(), + "write_date": fields.Datetime.to_string(user.partner_id.write_date), + } + if also_livechat: + res["offline_since"] = False + res["user_livechat_username"] = False + res["email"] = user.email + return res + if user == self.users[2]: + if only_inviting: + return { + "avatar_128_access_token": user.partner_id._get_avatar_128_access_token(), + "id": user.partner_id.id, + "im_status": "offline", + "im_status_access_token": user.partner_id._get_im_status_access_token(), + "name": "test2", + "mention_token": user.partner_id._get_mention_token(), + "write_date": fields.Datetime.to_string(user.partner_id.write_date), + } + return { + "active": True, + "avatar_128_access_token": user.partner_id._get_avatar_128_access_token(), + "email": "test2@example.com", + "id": user.partner_id.id, + "im_status": "offline", + "im_status_access_token": user.partner_id._get_im_status_access_token(), + "is_company": False, + "main_user_id": user.id, + "mention_token": user.partner_id._get_mention_token(), + "name": "test2", + "write_date": fields.Datetime.to_string(user.partner_id.write_date), + } + if user == self.users[3]: + return { + "active": True, + "avatar_128_access_token": user.partner_id._get_avatar_128_access_token(), + "email": False, + "id": user.partner_id.id, + "im_status": "offline", + "im_status_access_token": user.partner_id._get_im_status_access_token(), + "is_company": False, + "main_user_id": user.id, + "mention_token": user.partner_id._get_mention_token(), + "name": "test3", + "write_date": fields.Datetime.to_string(self.users[3].partner_id.write_date), + } + if user == self.users[12]: + return { + "active": True, + "avatar_128_access_token": user.partner_id._get_avatar_128_access_token(), + "email": False, + "id": user.partner_id.id, + "im_status": "offline", + "im_status_access_token": user.partner_id._get_im_status_access_token(), + "is_company": False, + "main_user_id": user.id, + "mention_token": user.partner_id._get_mention_token(), + "name": "test12", + "write_date": fields.Datetime.to_string(user.partner_id.write_date), + } + if user == self.users[14]: + return { + "active": True, + "avatar_128_access_token": user.partner_id._get_avatar_128_access_token(), + "email": False, + "id": user.partner_id.id, + "im_status": "offline", + "im_status_access_token": user.partner_id._get_im_status_access_token(), + "is_company": False, + "main_user_id": user.id, + "mention_token": user.partner_id._get_mention_token(), + "name": "test14", + "write_date": fields.Datetime.to_string(user.partner_id.write_date), + } + if user == self.users[15]: + return { + "active": True, + "avatar_128_access_token": user.partner_id._get_avatar_128_access_token(), + "email": False, + "id": user.partner_id.id, + "im_status": "offline", + "im_status_access_token": user.partner_id._get_im_status_access_token(), + "is_company": False, + "main_user_id": user.id, + "mention_token": user.partner_id._get_mention_token(), + "name": "test15", + "write_date": fields.Datetime.to_string(user.partner_id.write_date), + } + if user == self.user_root: + return { + "avatar_128_access_token": user.partner_id._get_avatar_128_access_token(), + "id": user.partner_id.id, + "is_company": False, + "main_user_id": user.id, + "name": "OdooBot", + "write_date": fields.Datetime.to_string(user.partner_id.write_date), + } + if guest: + return { + "avatar_128_access_token": self.guest._get_avatar_128_access_token(), + "country_id": self.guest.country_id.id, + "id": self.guest.id, + "im_status": "offline", + "im_status_access_token": self.guest._get_im_status_access_token(), + "name": "Visitor", + "offline_since": False, + "write_date": fields.Datetime.to_string(self.guest.write_date), + } + return {} + + def _expected_result_for_rtc_session(self, channel, user): + members = channel.channel_member_ids + member_2 = members.filtered(lambda m: m.partner_id == self.users[2].partner_id) + if channel == self.channel_channel_group_1 and user == self.users[2]: + return { + # sudo: discuss.channel.rtc.session - reading a session in a test file + "channel_member_id": member_2.id, + "id": member_2.sudo().rtc_session_ids.id, + "is_camera_on": False, + "is_deaf": False, + "is_screen_sharing_on": False, + "is_muted": False, + } + return {} + + def _expected_result_for_thread(self, channel): + common_data = { + "id": channel.id, + "model": "discuss.channel", + "module_icon": "/mail/static/description/icon.png", + "rating_avg": 0.0, + "rating_count": 0, + } + if channel == self.channel_general: + return {**common_data, "display_name": "general"} + if channel == self.channel_channel_public_1: + return {**common_data, "display_name": "public channel 1"} + if channel == self.channel_channel_public_2: + return {**common_data, "display_name": "public channel 2"} + if channel == self.channel_channel_group_1: + return {**common_data, "display_name": "group restricted channel 1"} + if channel == self.channel_channel_group_2: + return {**common_data, "display_name": "group restricted channel 2"} + if channel == self.channel_livechat_1: + return {**common_data, "display_name": "test1 Ernest Employee"} + if channel == self.channel_livechat_2: + return {**common_data, "display_name": "Visitor Ernest Employee"} + return {} + + def _res_for_user(self, user): + partner = user.partner_id + if user == self.users[0]: + return { + "id": user.id, + "employee_ids": user.employee_ids.ids, + "partner_id": partner.id, + "share": False, + } + if user == self.users[1]: + return { + "id": user.id, + "partner_id": partner.id, + "share": False, + } + if user == self.users[2]: + return { + "id": user.id, + "employee_ids": user.employee_ids.ids, + "partner_id": partner.id, + "share": False, + } + if user == self.users[3]: + return { + "id": user.id, + "employee_ids": user.employee_ids.ids, + "partner_id": partner.id, + "share": False, + } + if user == self.users[12]: + return { + "id": user.id, + "employee_ids": user.employee_ids.ids, + "partner_id": partner.id, + "share": False, + } + if user == self.users[14]: + return { + "id": user.id, + "employee_ids": user.employee_ids.ids, + "partner_id": partner.id, + "share": False, + } + if user == self.users[15]: + return { + "id": user.id, + "employee_ids": user.employee_ids.ids, + "partner_id": partner.id, + "share": False, + } + if user == self.user_root: + return { + "id": user.id, + "partner_id": partner.id, + "share": False, + } + return {} + + def _res_for_employee(self, employee): + return { + "id": employee.id, + "leave_date_to": False, + } diff --git a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_performance_inbox.py b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_performance_inbox.py new file mode 100644 index 0000000..a810f56 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_performance_inbox.py @@ -0,0 +1,82 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from itertools import chain + +from odoo.addons.mail.tests.common import MailCommon +from odoo.tests.common import HttpCase, tagged, warmup + + +@tagged("post_install", "-at_install", "is_query_count") +class TestInboxPerformance(HttpCase, MailCommon): + @warmup + def test_fetch_with_rating_stats_enabled(self): + """ + Computation of rating_stats should run a single query per model with rating_stats enabled. + """ + # Queries (in order): + # - search website (get_current_website by domain) + # - search website (get_current_website default) + # - search website_rewrite (_get_rewrites) sometimes occurs depending on the routing cache + # - insert res_device_log + # - _xmlid_lookup (_get_public_users) + # - fetch website (_get_cached_values) + # - get_param ir_config_parameter (_pre_dispatch website_sale) + # 4 _message_fetch: + # 2 _search_needaction: + # - fetch res_users (current user) + # - search ir_rule (_get_rules for mail.notification) + # - search ir_rule (_get_rules) + # - search mail_message + # 30 message _to_store: + # - search mail_message_schedule + # - fetch mail_message + # - search mail_followers + # 2 thread _to_store: + # - fetch slide_channel + # - fetch product_template + # - search mail_message_res_partner_starred_rel (_compute_starred) + # - search message_attachment_rel + # - search mail_link_preview + # - search mail_message_reaction + # - search mail_message_res_partner_rel + # - fetch mail_message_subtype + # - search mail_notification + # 7 _filtered_for_web_client: + # - fetch mail_notification + # 4 _compute_domain: + # - search ir_rule (_get_rules for res.partner) + # - search res_groups_users_rel + # - search rule_group_rel + # - fetch ir_rule + # - fetch res_company + # - fetch res_partner + # 2 _compute_rating_id: + # - search rating_rating + # - fetch rating_rating + # - search mail_tracking_value + # 3 _author_to_store: + # - fetch res_partner + # - search res_users + # - fetch res_users + # - search ir_rule (_get_rules for rating.rating) + # - read group rating_rating (_rating_get_stats_per_record for slide.channel) + # - read group rating_rating (_compute_rating_stats for slide.channel) + # - read group rating_rating (_rating_get_stats_per_record for product.template) + # - read group rating_rating (_compute_rating_stats for product.template) + # - get_param ir_config_parameter (_save_session) + first_model_records = self.env["product.template"].create( + [{"name": "Product A1"}, {"name": "Product A2"}] + ) + second_model_records = self.env["slide.channel"].create( + [{"name": "Course B1"}, {"name": "Course B2"}] + ) + for record in chain(first_model_records, second_model_records): + record.message_post( + body=f"

Test message for {record.name}

", + message_type="comment", + partner_ids=[self.user_employee.partner_id.id], + rating_value="4", + ) + self.authenticate(self.user_employee.login, self.user_employee.password) + with self.assertQueryCount(43): + self.make_jsonrpc_request("/mail/inbox/messages") diff --git a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_res_partner.py b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_res_partner.py new file mode 100644 index 0000000..12a9f1d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_res_partner.py @@ -0,0 +1,12 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.mail.tests.common import MailCommon +from odoo.addons.mail.tools.discuss import Store + + +class TestResPartner(MailCommon): + + def test_portal_user_store_data_access(self): + portal_user = mail_new_test_user(self.env, login="portal-user", groups="base.group_portal") + Store().add(portal_user.partner_id.with_user(self.user_employee_c2)) diff --git a/odoo-bringout-oca-ocb-test_event_full/README.md b/odoo-bringout-oca-ocb-test_event_full/README.md index 4f537b2..3a2806d 100644 --- a/odoo-bringout-oca-ocb-test_event_full/README.md +++ b/odoo-bringout-oca-ocb-test_event_full/README.md @@ -14,7 +14,6 @@ pip install odoo-bringout-oca-ocb-test_event_full ## Dependencies -This addon depends on: - event - event_booth - event_crm @@ -23,43 +22,18 @@ This addon depends on: - event_sms - payment_demo - website_event_booth_sale_exhibitor -- website_event_crm_questions - website_event_exhibitor -- website_event_questions -- website_event_meet - website_event_sale - website_event_track - website_event_track_live - website_event_track_quiz -## Manifest Information - -- **Name**: Test Full Event Flow -- **Version**: 1.0 -- **Category**: Hidden/Tests -- **License**: LGPL-3 -- **Installable**: False - ## Source -Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `test_event_full`. +- Repository: https://github.com/OCA/OCB +- Branch: 19.0 +- Path: addons/test_event_full ## License -This package maintains the original LGPL-3 license from the upstream Odoo project. - -## Documentation - -- Overview: doc/OVERVIEW.md -- Architecture: doc/ARCHITECTURE.md -- Models: doc/MODELS.md -- Controllers: doc/CONTROLLERS.md -- Wizards: doc/WIZARDS.md -- Reports: doc/REPORTS.md -- Security: doc/SECURITY.md -- Install: doc/INSTALL.md -- Usage: doc/USAGE.md -- Configuration: doc/CONFIGURATION.md -- Dependencies: doc/DEPENDENCIES.md -- Troubleshooting: doc/TROUBLESHOOTING.md -- FAQ: doc/FAQ.md +This package preserves the original LGPL-3 license. diff --git a/odoo-bringout-oca-ocb-test_event_full/pyproject.toml b/odoo-bringout-oca-ocb-test_event_full/pyproject.toml index b12e81e..ddbccfb 100644 --- a/odoo-bringout-oca-ocb-test_event_full/pyproject.toml +++ b/odoo-bringout-oca-ocb-test_event_full/pyproject.toml @@ -1,27 +1,26 @@ [project] name = "odoo-bringout-oca-ocb-test_event_full" version = "16.0.0" -description = "Test Full Event Flow - Odoo addon" +description = "Test Full Event Flow - + Odoo addon + " authors = [ { name = "Ernad Husremovic", email = "hernad@bring.out.ba" } ] dependencies = [ - "odoo-bringout-oca-ocb-event>=16.0.0", - "odoo-bringout-oca-ocb-event_booth>=16.0.0", - "odoo-bringout-oca-ocb-event_crm>=16.0.0", - "odoo-bringout-oca-ocb-event_crm_sale>=16.0.0", - "odoo-bringout-oca-ocb-event_sale>=16.0.0", - "odoo-bringout-oca-ocb-event_sms>=16.0.0", - "odoo-bringout-oca-ocb-payment_demo>=16.0.0", - "odoo-bringout-oca-ocb-website_event_booth_sale_exhibitor>=16.0.0", - "odoo-bringout-oca-ocb-website_event_crm_questions>=16.0.0", - "odoo-bringout-oca-ocb-website_event_exhibitor>=16.0.0", - "odoo-bringout-oca-ocb-website_event_questions>=16.0.0", - "odoo-bringout-oca-ocb-website_event_meet>=16.0.0", - "odoo-bringout-oca-ocb-website_event_sale>=16.0.0", - "odoo-bringout-oca-ocb-website_event_track>=16.0.0", - "odoo-bringout-oca-ocb-website_event_track_live>=16.0.0", - "odoo-bringout-oca-ocb-website_event_track_quiz>=16.0.0", + "odoo-bringout-oca-ocb-event>=19.0.0", + "odoo-bringout-oca-ocb-event_booth>=19.0.0", + "odoo-bringout-oca-ocb-event_crm>=19.0.0", + "odoo-bringout-oca-ocb-event_crm_sale>=19.0.0", + "odoo-bringout-oca-ocb-event_sale>=19.0.0", + "odoo-bringout-oca-ocb-event_sms>=19.0.0", + "TODO_MAP-payment_demo>=19.0.0", + "odoo-bringout-oca-ocb-website_event_booth_sale_exhibitor>=19.0.0", + "odoo-bringout-oca-ocb-website_event_exhibitor>=19.0.0", + "odoo-bringout-oca-ocb-website_event_sale>=19.0.0", + "odoo-bringout-oca-ocb-website_event_track>=19.0.0", + "odoo-bringout-oca-ocb-website_event_track_live>=19.0.0", + "odoo-bringout-oca-ocb-website_event_track_quiz>=19.0.0", "requests>=2.25.1" ] readme = "README.md" @@ -31,7 +30,7 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Office/Business", ] diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/__manifest__.py b/odoo-bringout-oca-ocb-test_event_full/test_event_full/__manifest__.py index 82d233c..fe828fc 100644 --- a/odoo-bringout-oca-ocb-test_event_full/test_event_full/__manifest__.py +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/__manifest__.py @@ -19,10 +19,7 @@ automatic lead generation, full Online support, ... 'event_sms', 'payment_demo', 'website_event_booth_sale_exhibitor', - 'website_event_crm_questions', 'website_event_exhibitor', - 'website_event_questions', - 'website_event_meet', 'website_event_sale', 'website_event_track', 'website_event_track_live', @@ -35,8 +32,12 @@ automatic lead generation, full Online support, ... ], 'assets': { 'web.assets_tests': [ - 'test_event_full/static/**/*', + 'test_event_full/static/src/js/tours/*', + ], + 'web.assets_unit_tests': [ + 'test_event_full/static/src/js/tests/*', ], }, + 'author': 'Odoo S.A.', 'license': 'LGPL-3', } diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/data/event_type_data.xml b/odoo-bringout-oca-ocb-test_event_full/test_event_full/data/event_type_data.xml deleted file mode 100644 index 4ebf4a2..0000000 --- a/odoo-bringout-oca-ocb-test_event_full/test_event_full/data/event_type_data.xml +++ /dev/null @@ -1,122 +0,0 @@ - - - - -

Standard

- Standard - -
- -

Premium

- Premium - - 90 -
- - - - Europe/Paris - - - - - Test Type -

Template note

- - 30 - -

Ticket Instructions

- -
- - - simple_choice - - - Question1 - - - Q1-Answer1 - 1 - - - - Q1-Answer2 - 2 - - - - simple_choice - - - Question2 - - - Q2-Answer1 - 1 - - - - Q2-Answer2 - 2 - - - - text_box - - - Question3 - - -
diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/static/src/js/tests/test_template_reference_field_widget.test.js b/odoo-bringout-oca-ocb-test_event_full/test_event_full/static/src/js/tests/test_template_reference_field_widget.test.js new file mode 100644 index 0000000..c6b85bd --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/static/src/js/tests/test_template_reference_field_widget.test.js @@ -0,0 +1,124 @@ +import { defineMailModels } from "@mail/../tests/mail_test_helpers"; +import { describe, expect, test } from "@odoo/hoot"; +import { click, select } from "@odoo/hoot-dom"; +import { animationFrame } from "@odoo/hoot-mock"; +import { defineModels, fields, models, mountView, onRpc } from "@web/../tests/web_test_helpers"; + +class EventMail extends models.Model { + _name = "event.mail"; + + template_ref = fields.Reference({ + selection: [ + ["mail.template", "Mail Template"], + ["sms.template", "SMS Template"], + ["some.template", "Some Template"], + ], + }); + + _records = [ + { + id: 1, + template_ref: "mail.template,1", + }, + { + id: 2, + template_ref: "sms.template,1", + }, + { + id: 3, + template_ref: "some.template,1", + }, + ]; +} +class MailTemplate extends models.Model { + _name = "mail.template"; + + name = fields.Char(); + + _records = [{ id: 1, name: "Mail Template 1" }]; +} +class SmsTemplate extends models.Model { + _name = "sms.template"; + + name = fields.Char(); + + _records = [{ id: 1, name: "SMS template 1" }]; +} +class SomeTemplate extends models.Model { + _name = "some.template"; + + name = fields.Char(); + + _records = [{ id: 1, name: "Some Template 1" }]; +} +defineMailModels(); +defineModels([EventMail, MailTemplate, SmsTemplate, SomeTemplate]); + +describe.current.tags("desktop"); + +test("Reference field displays right icons", async () => { + // bypass list controller check + onRpc("has_group", () => true); + + await mountView({ + type: "list", + resModel: "event.mail", + arch: ` + + + `, + }); + + // each field cell will be the field of a different record (1 field/line) + expect(".o_field_cell").toHaveCount(3); + expect(".o_field_cell.o_EventMailTemplateReferenceField_cell").toHaveCount(3); + expect(".o_field_cell:eq(0) .fa-envelope").toHaveCount(1); + expect(".o_field_cell:eq(1) .fa-mobile").toHaveCount(1); + expect(".o_field_cell:eq(2) .fa-envelope").toHaveCount(0); + expect(".o_field_cell:eq(2) .fa-mobile").toHaveCount(0); + + // select a sms.template instead of mail.template + + await click(".o_field_cell:eq(0)"); + await animationFrame(); + await click(".o_field_cell:eq(0) select.o_input"); + await select("sms.template"); + await animationFrame(); + await click(".o_field_cell:eq(0) .o_field_many2one_selection input"); + await animationFrame(); + await click(".o_field_cell:eq(0) .o-autocomplete--dropdown-item"); + // click out + await click(".o_list_renderer"); + await animationFrame(); + + expect(".o_field_cell:eq(0) .fa-mobile").toHaveCount(1); + expect(".o_field_cell:eq(0) .fa-envelope").toHaveCount(0); + + // select a some other model to check it has no icon + + await click(".o_field_cell:eq(0)"); + await animationFrame(); + await click(".o_field_cell:eq(0) select.o_input"); + await select("some.template"); + await animationFrame(); + await click(".o_field_cell:eq(0) .o_field_many2one_selection input"); + await animationFrame(); + await click(".o_field_cell:eq(0) .o-autocomplete--dropdown-item"); + await click(".o_list_renderer"); + await animationFrame(); + + expect(".o_field_cell:eq(0) .fa-mobile").toHaveCount(0); + expect(".o_field_cell:eq(0) .fa-envelope").toHaveCount(0); + + // select no record for the model + + await click(".o_field_cell:eq(1)"); + await animationFrame(); + await click(".o_field_cell:eq(1) select.o_input"); + await select("mail.template"); + await click(".o_list_renderer"); + await animationFrame(); + + expect(".o_field_cell:eq(1) .fa-mobile").toHaveCount(0); + expect(".o_field_cell:eq(1) .fa-envelope").toHaveCount(0); +}); diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/static/src/js/tours/wevent_performance_tour.js b/odoo-bringout-oca-ocb-test_event_full/test_event_full/static/src/js/tours/wevent_performance_tour.js index 290d581..d48e813 100644 --- a/odoo-bringout-oca-ocb-test_event_full/test_event_full/static/src/js/tours/wevent_performance_tour.js +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/static/src/js/tours/wevent_performance_tour.js @@ -1,85 +1,76 @@ -odoo.define('test_event_full.tour.performance', function (require) { -"use strict"; - -var tour = require('web_tour.tour'); +import { registry } from "@web/core/registry"; +import * as wsTourUtils from '@website_sale/js/tours/tour_utils'; var registerSteps = [{ - content: "Select 2 units of 'Ticket1' ticket type", - trigger: '#o_wevent_tickets_collapse .row.o_wevent_ticket_selector[name="Ticket1"] select', - run: 'text 2', + content: "Open ticket modal", + trigger: 'button.btn-primary:contains("Register")', + run: "click", }, { - content: "Select 1 unit of 'Ticket2' ticket type", - trigger: '#o_wevent_tickets_collapse .row.o_wevent_ticket_selector[name="Ticket2"] select', - run: 'text 1', + content: "Add 2 units of 'Ticket1' ticket type by clicking the '+' button", + trigger: 'button[data-increment-type*="plus"]', + run: "dblclick", +}, { + content: "Edit 1 unit of 'Ticket2' ticket type", + trigger: '.modal input:eq(2)', + run: "edit 1", }, { content: "Click on 'Register' button", trigger: '#o_wevent_tickets .btn-primary:contains("Register"):not(:disabled)', run: 'click', }, { - content: "Fill attendees details", - trigger: 'form[id="attendee_registration"] .btn:contains("Continue")', - run: function () { - $("input[name='1-name']").val("Raoulette Poiluchette"); - $("input[name='1-phone']").val("0456112233"); - $("input[name='1-email']").val("raoulette@example.com"); - $("div[name*='Question1'] select[name*='question_answer-1']").val($("select[name*='question_answer-1'] option:contains('Q1-Answer2')").val()); - $("div[name*='Question2'] select[name*='question_answer-1']").val($("select[name*='question_answer-1'] option:contains('Q2-Answer1')").val()); - $("input[name='2-name']").val("Michel Tractopelle"); - $("input[name='2-phone']").val("0456332211"); - $("input[name='2-email']").val("michel@example.com"); - $("div[name*='Question1'] select[name*='question_answer-2']").val($("select[name*='question_answer-2'] option:contains('Q1-Answer1')").val()); - $("div[name*='Question2'] select[name*='question_answer-2']").val($("select[name*='question_answer-2'] option:contains('Q2-Answer2')").val()); - $("input[name='3-name']").val("Hubert Boitaclous"); - $("input[name='3-phone']").val("0456995511"); - $("input[name='3-email']").val("hubert@example.com"); - $("div[name*='Question1'] select[name*='question_answer-3']").val($("select[name*='question_answer-3'] option:contains('Q1-Answer2')").val()); - $("div[name*='Question2'] select[name*='question_answer-3']").val($("select[name*='question_answer-3'] option:contains('Q2-Answer2')").val()); - $("textarea[name*='question_answer']").text("Random answer from random guy"); - }, + content: "Choose the 'Q1-Answer2' answer of the 'Question1' for the first ticket", + trigger: 'select[name*="1-simple_choice"]', + run: "selectByIndex 2", +}, { + content: "Choose the 'Q2-Answer1' answer of the 'Question2' for the first ticket", + trigger: 'select[name*="1-simple_choice"]:last', + run: "selectByIndex 1", +}, { + content: "Choose the 'Q1-Answer1' answer of the 'Question1' for the second ticket", + trigger: 'select[name*="2-simple_choice"]', + run: "selectByIndex 1", +}, { + content: "Choose the 'Q2-Answer2' answer of the 'Question2' for the second ticket", + trigger: 'select[name*="2-simple_choice"]:last', + run: "selectByIndex 2", +}, { + content: "Choose the 'Q1-Answer2' answer of the 'Question1' for the third ticket", + trigger: 'select[name*="3-simple_choice"]', + run: "selectByIndex 2", +}, { + content: "Choose the 'Q2-Answer2' answer of the 'Question2' for the third ticket", + trigger: 'select[name*="3-simple_choice"]:last', + run: "selectByIndex 2", +}, { + content: "Fill the text content of the 'Question3' for the third ticket", + trigger: 'textarea[name*="text_box"]', + run: "edit Random answer from random guy", }, { content: "Validate attendees details", - extra_trigger: "input[name='1-name'], input[name='2-name'], input[name='3-name']", - trigger: 'button:contains("Continue")', + trigger: 'button[type=submit]:last', run: 'click', -}, { - content: "Address filling", - trigger: 'select[name="country_id"]', - run: function () { - $('input[name="name"]').val('Raoulette Poiluchette'); - $('input[name="phone"]').val('0456112233'); - $('input[name="email"]').val('raoulette@example.com'); - $('input[name="street"]').val('Cheesy Crust Street, 42'); - $('input[name="city"]').val('CheeseCity'); - $('input[name="zip"]').val('8888'); - $('#country_id option:eq(1)').attr('selected', true); - }, -}, { - content: "Next", - trigger: '.oe_cart .btn:contains("Next")', -}, { - content: 'Select Test payment provider', - trigger: '.o_payment_option_card:contains("Demo")' -}, { - content: 'Add card number', - trigger: 'input[name="customer_input"]', - run: 'text 4242424242424242' -}, { - content: "Pay now", - extra_trigger: "#cart_products:contains(Ticket1):contains(Ticket2)", - trigger: 'button:contains(Pay Now)', - run: 'click', -}, { - content: 'Payment is successful', - trigger: '.oe_website_sale_tx_status:contains("Your payment has been successfully processed.")', - run: function () {} -}]; + expectUnloadPage: true, +}, +...wsTourUtils.fillAdressForm({ + name: "Raoulette Poiluchette", + phone: "0456112233", + email: "raoulette@example.com", + street: "Cheesy Crust Street, 42", + city: "CheeseCity", + zip: "8888", +}), +{ + content: "Confirm address", + trigger: 'a[name="website_sale_main_button"]', + run: "click", + expectUnloadPage: true, +}, +...wsTourUtils.payWithDemo(), +]; -tour.register('wevent_performance_register', { - test: true -}, [].concat( +registry.category("web_tour.tours").add('wevent_performance_register', { + steps: () => [].concat( registerSteps, ) -); - }); diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/static/src/js/tours/wevent_register_tour.js b/odoo-bringout-oca-ocb-test_event_full/test_event_full/static/src/js/tours/wevent_register_tour.js index ee7a1ad..a8e9e83 100644 --- a/odoo-bringout-oca-ocb-test_event_full/test_event_full/static/src/js/tours/wevent_register_tour.js +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/static/src/js/tours/wevent_register_tour.js @@ -1,18 +1,57 @@ -odoo.define('test_event_full.tour.register', function (require) { -"use strict"; - -var tour = require('web_tour.tour'); +import { registry } from "@web/core/registry"; +import { session } from "@web/session"; /** * TALKS STEPS */ +const reminderToggleSteps = function (talkName, reminderOn, toggleReminder) { + let steps = []; + if (reminderOn) { + steps = steps.concat([{ + content: `Check Favorite for ${talkName} was already on`, + trigger: "div.o_wetrack_js_reminder i.fa-bell", + }]); + } + else { + steps = steps.concat([{ + content: `Check Favorite for ${talkName} was off`, + trigger: "div.o_wetrack_js_reminder i.fa-bell-o", + }]); + if (toggleReminder) { + steps = steps.concat([{ + content: "Set Favorite", + trigger: "i[title='Set Favorite']", + run: "click", + }]); + if (session.is_public){ + steps = steps.concat([{ + content: "The form of the email reminder modal is filled", + trigger: "#o_wetrack_email_reminder_form input[name='email']", + run: "fill visitor@odoo.com", + }, + { + content: "The form is submit", + trigger: "#o_wetrack_email_reminder_form button[type='submit']", + run: "click", + }]); + } + steps = steps.concat([{ + content: `Check Favorite for ${talkName} is now on`, + trigger: "div.o_wetrack_js_reminder i.fa-bell", + }]); + } + } + return steps; +}; -var discoverTalkSteps = function (talkName, fromList, reminderOn, toggleReminder) { +const discoverTalkSteps = function (talkName, fromList, checkToggleReminder, reminderOn, toggleReminder) { var steps; if (fromList) { steps = [{ content: 'Go on "' + talkName + '" talk in List', trigger: 'a:contains("' + talkName + '")', + run: "click", + expectUnloadPage: true, }]; } else { @@ -20,109 +59,98 @@ var discoverTalkSteps = function (talkName, fromList, reminderOn, toggleReminder content: 'Click on Live Track', trigger: 'article span:contains("' + talkName + '")', run: 'click', + expectUnloadPage: true, }]; } steps = steps.concat([{ content: `Check we are on the "${talkName}" talk page`, trigger: 'div.o_wesession_track_main', - run: function () {}, // it's a check }]); - - if (reminderOn) { - steps = steps.concat([{ - content: `Check Favorite for ${talkName} was already on`, - trigger: 'div.o_wetrack_js_reminder i.fa-bell', - extra_trigger: 'span.o_wetrack_js_reminder_text:contains("Favorite On")', - run: function () {}, // it's a check - }]); - } - else { - steps = steps.concat([{ - content: `Check Favorite for ${talkName} was off`, - trigger: 'span.o_wetrack_js_reminder_text:contains("Set Favorite")', - run: function () {}, // it's a check - }]); - if (toggleReminder) { - steps = steps.concat([{ - content: "Set Favorite", - trigger: 'span.o_wetrack_js_reminder_text', - run: 'click', - }, { - content: `Check Favorite for ${talkName} is now on`, - trigger: 'div.o_wetrack_js_reminder i.fa-bell', - extra_trigger: 'span.o_wetrack_js_reminder_text:contains("Favorite On")', - run: function () {}, // it's a check - }]); - } + if (checkToggleReminder){ + steps = steps.concat(reminderToggleSteps(talkName, reminderOn, toggleReminder)); } return steps; }; - -/** - * ROOMS STEPS - */ - -var discoverRoomSteps = function (roomName) { - var steps = [{ - content: 'Go on "' + roomName + '" room in List', - trigger: 'a.o_wevent_meeting_room_card h4:contains("' + roomName + '")', - run: function() { - // can't click on it, it will try to launch Jitsi and fail on chrome headless - }, - }]; - return steps; -}; - - /** * REGISTER STEPS */ -var registerSteps = [{ - content: 'Go on Register', - trigger: 'a.btn-primary:contains("Register")', -}, { - content: "Select 2 units of 'Standard' ticket type", - trigger: '#o_wevent_tickets_collapse .row:has(.o_wevent_registration_multi_select:contains("Free")) select', - run: 'text 2', -}, { - content: "Click on 'Register' button", - trigger: '#o_wevent_tickets .btn-primary:contains("Register"):not(:disabled)', - run: 'click', -}, { - content: "Fill attendees details", - trigger: 'form[id="attendee_registration"] .btn:contains("Continue")', - run: function () { - $("input[name='1-name']").val("Raoulette Poiluchette"); - $("input[name='1-phone']").val("0456112233"); - $("input[name='1-email']").val("raoulette@example.com"); - $("select[name*='question_answer-1']").val($("select[name*='question_answer-1'] option:contains('Consumers')").val()); - $("input[name='2-name']").val("Michel Tractopelle"); - $("input[name='2-phone']").val("0456332211"); - $("input[name='2-email']").val("michel@example.com"); - $("select[name*='question_answer-2']").val($("select[name*='question_answer-1'] option:contains('Research')").val()); - $("textarea[name*='question_answer']").text("An unicorn told me about you. I ate it afterwards."); +const registerSteps = [ + { + content: "Open ticket modal", + trigger: "button.btn-primary:contains(Register):enabled", + run: "click", }, -}, { - content: "Validate attendees details", - extra_trigger: "input[name='1-name'], input[name='2-name'], input[name='3-name']", - trigger: 'button:contains("Continue")', - run: 'click', -}, { - trigger: 'div.o_wereg_confirmed_attendees span:contains("Raoulette Poiluchette")', - run: function () {} // check -}, { - trigger: 'div.o_wereg_confirmed_attendees span:contains("Michel Tractopelle")', - run: function () {} // check -}, { - content: "Click on 'register favorites talks' button", - trigger: 'a:contains("register to your favorites talks now")', - run: 'click', -}, { - trigger: 'h1:contains("Book your talks")', - run: function() {}, -}]; + { + content: "Edit 2 units of 'Standard' ticket type", + trigger: ".modal .o_wevent_ticket_selector input", + run: "edit 2", + }, + { + content: "Click on 'Register' button", + trigger: ".modal #o_wevent_tickets .btn-primary:contains(Register):enabled", + run: "click", + }, + { + content: "Wait the modal is shown before continue", + trigger: ".modal.modal_shown.show form[id=attendee_registration]", + }, + { + trigger: ".modal input[name*='1-name']", + run: "edit Raoulette Poiluchette", + }, + { + trigger: ".modal input[name*='1-phone']", + run: "edit 0456112233", + }, + { + trigger: ".modal input[name*='1-email']", + run: "edit raoulette@example.com", + }, + { + trigger: ".modal select[name*='1-simple_choice']", + run: "selectByLabel Consumers", + }, + { + trigger: ".modal input[name*='2-name']", + run: "edit Michel Tractopelle", + }, + { + trigger: ".modal input[name*='2-phone']", + run: "edit 0456332211", + }, + { + trigger: ".modal input[name*='2-email']", + run: "edit michel@example.com", + }, + { + trigger: ".modal select[name*='2-simple_choice']", + run: "selectByLabel Research", + }, + { + trigger: ".modal textarea[name*='text_box']", + run: "edit An unicorn told me about you. I ate it afterwards.", + }, + { + trigger: ".modal input[name*='1-name'], input[name*='2-name'], input[name*='3-name']", + }, + { + content: "Validate attendees details", + trigger: ".modal button[type=submit]:enabled", + run: "click", + expectUnloadPage: true, + }, + { + content: "Click on 'register favorites talks' button", + trigger: "a:contains(register to your favorites talks now)", + run: "click", + expectUnloadPage: true, + }, + { + trigger: "h5:contains(Book your talks)", + }, +]; /** * MAIN STEPS @@ -132,43 +160,46 @@ var initTourSteps = function (eventName) { return [{ content: 'Go on "' + eventName + '" page', trigger: 'a[href*="/event"]:contains("' + eventName + '"):first', + run: "click", + expectUnloadPage: true, }]; }; var browseTalksSteps = [{ - content: 'Browse Talks', - trigger: 'a:contains("Talks")', + content: 'Browse Talks Menu', + trigger: 'a[href*="#"]:contains("Talks")', + run: "click", +}, { + content: 'Browse Talks Submenu', + trigger: 'a.dropdown-item span:contains("Talks")', + run: "click", + expectUnloadPage: true, }, { content: 'Check we are on the talk list page', - trigger: 'h1:contains("Book your talks")', - run: function () {} // check + trigger: 'h5:contains("Book your talks")', }]; -var browseMeetSteps = [{ - content: 'Browse Meet', - trigger: 'a:contains("Community")', +var browseBackSteps = [{ + content: 'Browse Back', + trigger: 'a:contains("All Talks")', + run: "click", + expectUnloadPage: true, }, { - content: 'Check we are on the community page', - trigger: 'span:contains("Join a room")', - run: function () {} // check + content: 'Check we are back on the talk list page', + trigger: 'h5:contains("Book your talks")', }]; - -tour.register('wevent_register', { +registry.category("web_tour.tours").add('wevent_register', { url: '/event', - test: true -}, [].concat( + steps: () => [].concat( initTourSteps('Online Reveal'), browseTalksSteps, - discoverTalkSteps('What This Event Is All About', true, true), - browseTalksSteps, - discoverTalkSteps('Live Testimonial', false, false, false), - browseTalksSteps, - discoverTalkSteps('Our Last Day Together !', true, false, true), - browseMeetSteps, - discoverRoomSteps('Best wood for furniture'), + discoverTalkSteps('What This Event Is All About', true, true, true), + browseBackSteps, + discoverTalkSteps('Live Testimonial', false, false, false, false), + browseBackSteps, + discoverTalkSteps('Our Last Day Together!', true, true, false, true), + browseBackSteps, registerSteps, ) -); - }); diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/__init__.py b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/__init__.py index 94b6abc..8469ebf 100644 --- a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/__init__.py +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/__init__.py @@ -7,5 +7,5 @@ from . import test_event_event from . import test_event_mail from . import test_event_security from . import test_performance +from . import test_wevent_menu from . import test_wevent_register -from . import test_event_discount diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/common.py b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/common.py index 9107fb5..be6d551 100644 --- a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/common.py +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/common.py @@ -3,10 +3,14 @@ from datetime import datetime, timedelta, time +from odoo import Command from odoo.addons.base.tests.common import HttpCaseWithUserDemo, HttpCaseWithUserPortal +from odoo.addons.base.tests.test_ir_cron import CronMixinCase +from odoo.addons.event.tests.common import EventCase from odoo.addons.event_crm.tests.common import EventCrmCase -from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.mail.tests.common import mail_new_test_user, MailCase from odoo.addons.sales_team.tests.common import TestSalesCommon +from odoo.addons.sms.tests.common import SMSCase from odoo.addons.website.tests.test_website_visitor import MockVisitor @@ -15,7 +19,6 @@ class TestEventFullCommon(EventCrmCase, TestSalesCommon, MockVisitor): @classmethod def setUpClass(cls): super(TestEventFullCommon, cls).setUpClass() - cls._init_mail_gateway() # Context data: dates # ------------------------------------------------------------ @@ -37,6 +40,7 @@ class TestEventFullCommon(EventCrmCase, TestSalesCommon, MockVisitor): # set country in order to format Belgian numbers cls.company_admin.write({ 'country_id': cls.env.ref('base.be').id, + 'email': 'info@yourcompany.com', }) cls.event_user = mail_new_test_user( cls.env, @@ -55,7 +59,6 @@ class TestEventFullCommon(EventCrmCase, TestSalesCommon, MockVisitor): 'country_id': cls.env.ref('base.be').id, 'email': 'customer.test@example.com', 'name': 'Test Customer', - 'mobile': '0456123456', 'phone': '0456123456', }) # make a SO for a customer, selling some tickets @@ -68,14 +71,16 @@ class TestEventFullCommon(EventCrmCase, TestSalesCommon, MockVisitor): cls.ticket_product = cls.env['product.product'].create({ 'description_sale': 'Ticket Product Description', - 'detailed_type': 'event', + 'type': 'service', + 'service_tracking': 'event', 'list_price': 10, 'name': 'Test Registration Product', 'standard_price': 30.0, }) cls.booth_product = cls.env['product.product'].create({ 'description_sale': 'Booth Product Description', - 'detailed_type': 'event_booth', + 'type': 'service', + 'service_tracking': 'event_booth', 'list_price': 20, 'name': 'Test Booth Product', 'standard_price': 60.0, @@ -120,9 +125,8 @@ class TestEventFullCommon(EventCrmCase, TestSalesCommon, MockVisitor): # ------------------------------------------------------------ test_registration_report = cls.env.ref('test_event_full.event_registration_report_test') subscription_template = cls.env.ref('event.event_subscription') - subscription_template.write({'report_template': test_registration_report.id}) + subscription_template.write({'report_template_ids': [(6, 0, test_registration_report.ids)]}) cls.test_event_type = cls.env['event.type'].create({ - 'auto_confirm': True, 'default_timezone': 'Europe/Paris', 'event_type_booth_ids': [ (0, 0, {'booth_category_id': cls.event_booth_categories[0].id, @@ -145,21 +149,18 @@ class TestEventFullCommon(EventCrmCase, TestSalesCommon, MockVisitor): 'event_type_mail_ids': [ (0, 0, {'interval_unit': 'now', # right at subscription 'interval_type': 'after_sub', - 'notification_type': 'mail', 'template_ref': 'mail.template,%i' % subscription_template.id, } ), (0, 0, {'interval_nbr': 1, # 1 days before event 'interval_unit': 'days', 'interval_type': 'before_event', - 'notification_type': 'mail', 'template_ref': 'mail.template,%i' % cls.env['ir.model.data']._xmlid_to_res_id('event.event_reminder'), } ), (0, 0, {'interval_nbr': 1, # 1 days after event 'interval_unit': 'days', 'interval_type': 'after_event', - 'notification_type': 'sms', 'template_ref': 'sms.template,%i' % cls.env['ir.model.data']._xmlid_to_res_id('event_sms.sms_template_data_event_reminder'), } ), @@ -228,15 +229,15 @@ class TestEventFullCommon(EventCrmCase, TestSalesCommon, MockVisitor): 'is_published': True, } - cls.test_event = cls.env['event.event'].create({ - 'name': 'Test Event', - 'auto_confirm': True, - 'date_begin': datetime.now() + timedelta(days=1), - 'date_end': datetime.now() + timedelta(days=5), - 'date_tz': 'Europe/Brussels', - 'event_type_id': cls.test_event_type.id, - 'is_published': True, - }) + with cls.mock_datetime_and_now(cls, cls.reference_now): + cls.test_event = cls.env['event.event'].create({ + 'name': 'Test Event', + 'date_begin': datetime.now() + timedelta(days=1), + 'date_end': datetime.now() + timedelta(days=5), + 'date_tz': 'Europe/Brussels', + 'event_type_id': cls.test_event_type.id, + 'is_published': True, + }) # update post-synchronize data ticket_1 = cls.test_event.event_ticket_ids.filtered(lambda t: t.name == 'Ticket1') ticket_2 = cls.test_event.event_ticket_ids.filtered(lambda t: t.name == 'Ticket2') @@ -251,39 +252,36 @@ class TestEventFullCommon(EventCrmCase, TestSalesCommon, MockVisitor): ], limit=1) cls.customer_data = [ - {'email': 'customer.email.%02d@test.example.com' % x, - 'name': 'My Customer %02d' % x, - 'mobile': '04569999%02d' % x, + {'email': f'customer.email.{idx:02d}@test.example.com', + 'name': f'My Customer {idx:02d}', 'partner_id': False, - 'phone': '04560000%02d' % x, - } for x in range(0, 10) + 'phone': f'04560000{idx:02d}', + } for idx in range(0, 10) ] cls.website_customer_data = [ - {'email': 'website.email.%02d@test.example.com' % x, - 'name': 'My Customer %02d' % x, - 'mobile': '04569999%02d' % x, + {'email': f'website.email.{idx:02d}@test.example.com', + 'name': f'My Customer {idx:02d}', 'partner_id': cls.env.ref('base.public_partner').id, - 'phone': '04560000%02d' % x, + 'phone': f'04560000{idx:02d}', 'registration_answer_ids': [ (0, 0, { 'question_id': cls.test_event.question_ids[0].id, - 'value_answer_id': cls.test_event.question_ids[0].answer_ids[(x % 2)].id, + 'value_answer_id': cls.test_event.question_ids[0].answer_ids[(idx % 2)].id, }), (0, 0, { 'question_id': cls.test_event.question_ids[1].id, - 'value_answer_id': cls.test_event.question_ids[1].answer_ids[(x % 2)].id, + 'value_answer_id': cls.test_event.question_ids[1].answer_ids[(idx % 2)].id, }), (0, 0, { 'question_id': cls.test_event.question_ids[2].id, - 'value_text_box': 'CustomerAnswer%s' % x, + 'value_text_box': f'CustomerAnswer{idx}', }) ], - } for x in range(0, 10) + } for idx in range(0, 10) ] cls.partners = cls.env['res.partner'].create([ - {'email': 'partner.email.%02d@test.example.com' % x, - 'name': 'PartnerCustomer', - 'mobile': '04569999%02d' % x, - 'phone': '04560000%02d' % x, - } for x in range(0, 10) + {'email': f'partner.email.{idx:02d}@test.example.com', + 'name': f'PartnerCustomer {idx:02d}', + 'phone': f'04560000{idx:02d}', + } for idx in range(0, 10) ]) def assertLeadConvertion(self, rule, registrations, partner=None, **expected): @@ -304,6 +302,98 @@ class TestEventFullCommon(EventCrmCase, TestSalesCommon, MockVisitor): self.assertIn(answer.value_text_box, lead.description) # better: check multi line +class TestEventMailCommon(EventCase, SMSCase, MailCase, CronMixinCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.event_cron_id = cls.env.ref('event.event_mail_scheduler') + # deactivate other schedulers to avoid messing with crons + cls.env['event.mail'].search([]).unlink() + # consider asynchronous sending as default sending + cls.env["ir.config_parameter"].set_param("event.event_mail_async", False) + + cls.env.company.write({ + 'email': 'info@yourcompany.example.com', + 'name': 'YourCompany', + }) + + # prepare SMS templates + cls.sms_template_sub = cls.env['sms.template'].create({ + 'name': 'Test SMS Subscription', + 'model_id': cls.env.ref('event.model_event_registration').id, + 'body': '{{ object.event_id.organizer_id.name }} registration confirmation.', + 'lang': '{{ object.partner_id.lang }}' + }) + cls.sms_template_rem = cls.env['sms.template'].create({ + 'name': 'Test SMS Reminder', + 'model_id': cls.env.ref('event.model_event_registration').id, + 'body': '{{ object.event_id.organizer_id.name }} reminder', + 'lang': '{{ object.partner_id.lang }}' + }) + + # freeze some datetimes, and ensure more than 1D+1H before event starts + # to ease time-based scheduler check + # Since `now` is used to set the `create_date` of an event and create_date + # has often microseconds, we set it to ensure that the scheduler we still be + # launched if scheduled_date == create_date - microseconds + cls.reference_now = datetime(2021, 3, 20, 14, 30, 15, 123456) + cls.event_date_begin = datetime(2021, 3, 25, 8, 0, 0) + cls.event_date_end = datetime(2021, 3, 28, 18, 0, 0) + + cls._setup_test_reports() + with cls.mock_datetime_and_now(cls, cls.reference_now): + cls.test_event = cls.env['event.event'].create({ + 'name': 'TestEventMail', + 'user_id': cls.user_eventmanager.id, + 'date_begin': cls.event_date_begin, + 'date_end': cls.event_date_end, + 'event_mail_ids': [ + (0, 0, { # right at subscription: mail + 'interval_unit': 'now', + 'interval_type': 'after_sub', + 'notification_type': 'mail', + 'template_ref': f'mail.template,{cls.template_subscription.id}', + }), + (0, 0, { # right at subscription: sms + 'interval_unit': 'now', + 'interval_type': 'after_sub', + 'notification_type': 'sms', + 'template_ref': f'sms.template,{cls.sms_template_sub.id}', + }), + (0, 0, { # 3 days before event: mail + 'interval_nbr': 3, + 'interval_unit': 'days', + 'interval_type': 'before_event', + 'notification_type': 'mail', + 'template_ref': f'mail.template,{cls.template_reminder.id}', + }), + (0, 0, { # 3 days before event: SMS + 'interval_nbr': 3, + 'interval_unit': 'days', + 'interval_type': 'before_event', + 'notification_type': 'sms', + 'template_ref': f'sms.template,{cls.sms_template_rem.id}', + }), + (0, 0, { # 1h after event: mail + 'interval_nbr': 1, + 'interval_unit': 'hours', + 'interval_type': 'after_event', + 'notification_type': 'mail', + 'template_ref': f'mail.template,{cls.template_reminder.id}', + }), + (0, 0, { # 1h after event: SMS + 'interval_nbr': 1, + 'interval_unit': 'hours', + 'interval_type': 'after_event', + 'notification_type': 'sms', + 'template_ref': f'sms.template,{cls.sms_template_rem.id}', + }), + ], + }) + + class TestWEventCommon(HttpCaseWithUserDemo, HttpCaseWithUserPortal, MockVisitor): def setUp(self): @@ -322,7 +412,8 @@ class TestWEventCommon(HttpCaseWithUserDemo, HttpCaseWithUserPortal, MockVisitor 'description_sale': 'Mighty Description', 'list_price': 10, 'standard_price': 30.0, - 'detailed_type': 'event', + 'type': 'service', + 'service_tracking': 'event', }) self.event_tag_category_1 = self.env['event.tag.category'].create({ @@ -336,13 +427,12 @@ class TestWEventCommon(HttpCaseWithUserDemo, HttpCaseWithUserPortal, MockVisitor 'color': 8, }) self.env['event.event'].search( - [('name', 'like', '%Online Reveal%')] + [('name', 'like', 'Online Reveal')] ).write( {'name': 'Do not click on me'} ) self.event = self.env['event.event'].create({ 'name': 'Online Reveal TestEvent', - 'auto_confirm': True, 'stage_id': self.env.ref('event.event_stage_booked').id, 'address_id': False, 'user_id': self.user_demo.id, @@ -377,7 +467,6 @@ class TestWEventCommon(HttpCaseWithUserDemo, HttpCaseWithUserPortal, MockVisitor 'email': 'constantin@test.example.com', 'country_id': self.env.ref('base.be').id, 'phone': '0485112233', - 'mobile': False, }) self.event_speaker = self.env['res.partner'].create({ 'name': 'Brandon Freeman', @@ -392,7 +481,7 @@ class TestWEventCommon(HttpCaseWithUserDemo, HttpCaseWithUserPortal, MockVisitor self.event_question_1 = self.env['event.question'].create({ 'title': 'Which field are you working in', 'question_type': 'simple_choice', - 'event_id': self.event.id, + 'event_ids': [Command.set(self.event.ids)], 'once_per_order': False, 'answer_ids': [ (0, 0, {'name': 'Consumers'}), @@ -403,7 +492,7 @@ class TestWEventCommon(HttpCaseWithUserDemo, HttpCaseWithUserPortal, MockVisitor self.event_question_2 = self.env['event.question'].create({ 'title': 'How did you hear about us ?', 'question_type': 'text_box', - 'event_id': self.event.id, + 'event_ids': [Command.set(self.event.ids)], 'once_per_order': True, }) @@ -421,6 +510,7 @@ class TestWEventCommon(HttpCaseWithUserDemo, HttpCaseWithUserPortal, MockVisitor 'wishlisted_by_default': True, 'user_id': self.user_admin.id, 'partner_id': self.event_speaker.id, + 'description': 'Performance of Raoul Grosbedon.' }) self.track_1 = self.env['event.track'].create({ 'name': 'Live Testimonial', @@ -431,9 +521,10 @@ class TestWEventCommon(HttpCaseWithUserDemo, HttpCaseWithUserPortal, MockVisitor 'is_published': True, 'user_id': self.user_admin.id, 'partner_id': self.event_speaker.id, + 'description': 'Description of the live.' }) self.track_2 = self.env['event.track'].create({ - 'name': 'Our Last Day Together !', + 'name': 'Our Last Day Together!', 'event_id': self.event.id, 'stage_id': self.env.ref('website_event_track.event_track_stage3').id, 'date': self.reference_now + timedelta(days=1), @@ -441,22 +532,7 @@ class TestWEventCommon(HttpCaseWithUserDemo, HttpCaseWithUserPortal, MockVisitor 'is_published': True, 'user_id': self.user_admin.id, 'partner_id': self.event_speaker.id, - }) - - # ------------------------------------------------------------ - # MEETING ROOMS - # ---------------------------------------------------------- - - self.env['event.meeting.room'].create({ - 'name': 'Best wood for furniture', - 'summary': 'Let\'s talk about wood types for furniture', - 'target_audience': 'wood expert(s)', - 'is_pinned': True, - 'website_published': True, - 'event_id': self.event.id, - 'room_lang_id': self.env.ref('base.lang_en').id, - 'room_max_capacity': '12', - 'room_participant_count': 9, + 'description': 'Description of our last day together.' }) self.env.flush_all() diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_crm.py b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_crm.py index e0cecb8..e509cbf 100644 --- a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_crm.py +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_crm.py @@ -2,9 +2,10 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.addons.test_event_full.tests.common import TestEventFullCommon -from odoo.tests import users +from odoo.tests import tagged, users +@tagged("event_crm") class TestEventCrm(TestEventFullCommon): @classmethod diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_discount.py b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_discount.py deleted file mode 100644 index e6e04ad..0000000 --- a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_discount.py +++ /dev/null @@ -1,78 +0,0 @@ -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -import time - -from odoo.tests import tagged -from odoo.fields import Command - -from odoo.addons.test_event_full.tests.common import TestEventFullCommon - - -@tagged('post_install', '-at_install') -class TestEventTicketPriceRounding(TestEventFullCommon): - - @classmethod - def setUpClass(cls): - super().setUpClass() - - cls.ticket_product.write({ - 'lst_price': 1.0 - }) - - cls.currency_jpy = cls.env['res.currency'].create({ - 'name': 'JPX', - 'symbol': '¥', - 'rounding': 1.0, - 'rate_ids': [Command.create({'rate': 133.6200, 'name': time.strftime('%Y-%m-%d')})], - }) - - cls.currency_cad = cls.env['res.currency'].create({ - 'name': 'CXD', - 'symbol': '$', - 'rounding': 0.01, - 'rate_ids': [Command.create({'rate': 1.338800, 'name': time.strftime('%Y-%m-%d')})], - }) - - cls.pricelist_usd = cls.env['product.pricelist'].create({ - 'name': 'Pricelist USD', - 'currency_id': cls.env.ref('base.USD').id, - }) - - cls.pricelist_jpy = cls.env['product.pricelist'].create({ - 'name': 'Pricelist JPY', - 'currency_id': cls.currency_jpy.id, - }) - - cls.pricelist_cad = cls.env['product.pricelist'].create({ - 'name': 'Pricelist CAD', - 'currency_id': cls.currency_cad.id, - }) - - cls.event_type = cls.env['event.type'].create({ - 'name': 'Test Event Type', - 'auto_confirm': True, - 'event_type_ticket_ids': [ - (0, 0, { - 'name': 'Test Event Ticket', - 'product_id': cls.ticket_product.id, - 'price': 30.0, - }) - ], - }) - - cls.event_ticket = cls.event_type.event_type_ticket_ids[0] - - def test_no_discount_usd(self): - ticket = self.event_ticket.with_context(pricelist=self.pricelist_usd.id) - ticket._compute_price_reduce() - self.assertAlmostEqual(ticket.price_reduce, 30.0, places=6, msg="No discount should be applied for the USD pricelist.") - - def test_no_discount_jpy(self): - ticket = self.event_ticket.with_context(pricelist=self.pricelist_jpy.id) - ticket._compute_price_reduce() - self.assertAlmostEqual(ticket.price_reduce, 30.0, places=6, msg="No discount should be applied for the JPY pricelist.") - - def test_no_discount_cad(self): - ticket = self.event_ticket.with_context(pricelist=self.pricelist_cad.id) - ticket._compute_price_reduce() - self.assertAlmostEqual(ticket.price_reduce, 30.0, places=6, msg="No discount should be applied for the CAD pricelist.") diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_event.py b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_event.py index 8bb0fdb..a065259 100644 --- a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_event.py +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_event.py @@ -36,16 +36,13 @@ class TestEventEvent(TestEventFullCommon): # check result self.assertEqual(event.address_id, self.env.user.company_id.partner_id) - self.assertTrue(event.auto_confirm) self.assertEqual(event.country_id, self.env.user.company_id.country_id) self.assertEqual(event.date_tz, 'Europe/Paris') self.assertEqual(event.event_booth_count, 4) self.assertEqual(len(event.event_mail_ids), 3) self.assertEqual(len(event.event_ticket_ids), 2) self.assertTrue(event.introduction_menu) - self.assertTrue(event.location_menu) - self.assertTrue(event.menu_register_cta) - self.assertEqual(event.message_partner_ids, self.env.user.partner_id + self.env.user.company_id.partner_id) + self.assertEqual(event.message_partner_ids, self.env.user.partner_id) self.assertEqual(event.note, '

Template note

') self.assertTrue(event.register_menu) self.assertEqual(len(event.question_ids), 3) @@ -75,12 +72,23 @@ class TestEventEvent(TestEventFullCommon): self.assertTrue(event.is_ongoing) self.assertTrue(event.event_registrations_started) + def test_event_kanban_state_on_stage_change(self): + """Test that kanban_state updates correctly when stage is changed.""" + test_event_1 = self.env['event.event'].browse(self.test_event.ids) + test_event_2 = test_event_1.copy() + + test_event_1.kanban_state = 'done' + test_event_2.kanban_state = 'cancel' # Event Cancelled + + new_stage = self.env['event.stage'].create({'name': 'New Stage', 'sequence': 1}) + (test_event_1 | test_event_2).stage_id = new_stage.id # Change event stage + + self.assertEqual(test_event_1.kanban_state, 'normal', 'kanban state should reset to "normal" on stage change') + self.assertEqual(test_event_2.kanban_state, 'cancel', 'kanban state should not reset on stage change') + @freeze_time('2021-12-01 11:00:00') @users('event_user') def test_event_seats_and_schedulers(self): - now = datetime.now() # used to force create_date, as sql is not wrapped by freeze gun - self.env.cr._now = now - test_event = self.env['event.event'].browse(self.test_event.ids) ticket_1 = test_event.event_ticket_ids.filtered(lambda ticket: ticket.name == 'Ticket1') ticket_2 = test_event.event_ticket_ids.filtered(lambda ticket: ticket.name == 'Ticket2') @@ -94,14 +102,15 @@ class TestEventEvent(TestEventFullCommon): self.assertFalse(ticket_2.sale_available) # make 9 registrations (let 1 on ticket) - with self.mock_mail_gateway(): + with self.mock_datetime_and_now(self.reference_now), \ + self.mock_mail_gateway(): self.env['event.registration'].create([ - {'create_date': now, - 'email': 'test.customer.%02d@test.example.com' % x, - 'phone': '04560011%02d' % x, - 'event_id': test_event.id, - 'event_ticket_id': ticket_1.id, - 'name': 'Customer %d' % x, + { + 'email': 'test.customer.%02d@test.example.com' % x, + 'phone': '04560011%02d' % x, + 'event_id': test_event.id, + 'event_ticket_id': ticket_1.id, + 'name': 'Customer %d' % x, } for x in range(0, 9) ]) @@ -114,27 +123,29 @@ class TestEventEvent(TestEventFullCommon): self.assertEqual(ticket_2.seats_available, 0) # prevent registration due to ticket limit - with self.assertRaises(exceptions.ValidationError): + with self.mock_datetime_and_now(self.reference_now), \ + self.assertRaises(exceptions.ValidationError): self.env['event.registration'].create([ - {'create_date': now, - 'email': 'additional.customer.%02d@test.example.com' % x, - 'phone': '04560011%02d' % x, - 'event_id': test_event.id, - 'event_ticket_id': ticket_1.id, - 'name': 'Additional Customer %d' % x, + { + 'email': 'additional.customer.%02d@test.example.com' % x, + 'phone': '04560011%02d' % x, + 'event_id': test_event.id, + 'event_ticket_id': ticket_1.id, + 'name': 'Additional Customer %d' % x, } for x in range(0, 2) ]) # make 20 registrations (on free ticket) - with self.mock_mail_gateway(): + with self.mock_datetime_and_now(self.reference_now), \ + self.mock_mail_gateway(): self.env['event.registration'].create([ - {'create_date': now, - 'email': 'other.customer.%02d@test.example.com' % x, - 'phone': '04560011%02d' % x, - 'event_id': test_event.id, - 'event_ticket_id': ticket_2.id, - 'name': 'Other Customer %d' % x, + { + 'email': 'other.customer.%02d@test.example.com' % x, + 'phone': '04560011%02d' % x, + 'event_id': test_event.id, + 'event_ticket_id': ticket_2.id, + 'name': 'Other Customer %d' % x, } for x in range(0, 20) ]) @@ -145,14 +156,15 @@ class TestEventEvent(TestEventFullCommon): self.assertEqual(ticket_2.seats_available, 0) # prevent registration due to event limit - with self.assertRaises(exceptions.ValidationError): + with self.mock_datetime_and_now(self.reference_now), \ + self.assertRaises(exceptions.ValidationError): self.env['event.registration'].create([ - {'create_date': now, - 'email': 'additional.customer.%02d@test.example.com' % x, - 'phone': '04560011%02d' % x, - 'event_id': test_event.id, - 'event_ticket_id': ticket_2.id, - 'name': 'Additional Customer %d' % x, + { + 'email': 'additional.customer.%02d@test.example.com' % x, + 'phone': '04560011%02d' % x, + 'event_id': test_event.id, + 'event_ticket_id': ticket_2.id, + 'name': 'Additional Customer %d' % x, } for x in range(0, 2) ]) diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_mail.py b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_mail.py index d3bd0c4..ba829f6 100644 --- a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_mail.py +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_mail.py @@ -2,15 +2,15 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import datetime, timedelta -from freezegun import freeze_time +from unittest.mock import patch -from odoo.addons.mail.tests.common import MockEmail -from odoo.addons.sms.tests.common import MockSMS -from odoo.addons.test_event_full.tests.common import TestWEventCommon -from odoo.exceptions import ValidationError -from odoo.tools import mute_logger +from odoo.addons.test_event_full.tests.common import TestEventFullCommon, TestEventMailCommon +from odoo.tests import tagged, users +from odoo.tools import formataddr -class TestTemplateRefModel(TestWEventCommon): + +@tagged('event_mail', 'post_install', '-at_install') +class TestEventMailInternals(TestEventMailCommon): def test_template_ref_delete_lines(self): """ When deleting a template, related lines should be deleted too """ @@ -51,117 +51,303 @@ class TestTemplateRefModel(TestWEventCommon): self.assertEqual(len(event_type.event_type_mail_ids.exists()), 0) self.assertEqual(len(event.event_mail_ids.exists()), 0) - def test_template_ref_model_constraint(self): - test_cases = [ - ('mail', 'mail.template', True), - ('mail', 'sms.template', False), - ('sms', 'sms.template', True), - ('sms', 'mail.template', False), - ] +@tagged('event_mail', 'post_install', '-at_install') +class TestEventMailSchedule(TestEventMailCommon): - for notification_type, template_type, valid in test_cases: - with self.subTest(notification_type=notification_type, template_type=template_type): - if template_type == 'mail.template': - template = self.env[template_type].create({ - 'name': 'test template', - 'model_id': self.env['ir.model']._get_id('event.registration'), - }) - else: - template = self.env[template_type].create({ - 'name': 'test template', - 'body': 'Body Test', - 'model_id': self.env['ir.model']._get_id('event.registration'), - }) - if not valid: - with self.assertRaises(ValidationError) as cm: - self.env['event.mail'].create({ - 'event_id': self.event.id, - 'notification_type': notification_type, - 'interval_unit': 'now', - 'interval_type': 'before_event', - 'template_ref': template, - }) - if notification_type == 'mail': - self.assertEqual(str(cm.exception), 'The template which is referenced should be coming from mail.template model.') - else: - self.assertEqual(str(cm.exception), 'The template which is referenced should be coming from sms.template model.') - -class TestEventSmsMailSchedule(TestWEventCommon, MockEmail, MockSMS): - - @freeze_time('2020-07-06 12:00:00') - @mute_logger('odoo.addons.base.models.ir_model', 'odoo.models') def test_event_mail_before_trigger_sent_count(self): - """ Emails are sent to both confirmed and unconfirmed attendees. - This test checks that the count of sent emails includes the emails sent to unconfirmed ones - - Time in the test is frozen to simulate the following state: - - NOW Event Start Event End - 12:00 13:00 14:00 - | | | - ──────────────────────────────────────► - | | time - ◄─────────────────► - 3 hours - Trigger before event - """ - self.sms_template_rem = self.env['sms.template'].create({ - 'name': 'Test reminder', - 'model_id': self.env.ref('event.model_event_registration').id, - 'body': '{{ object.event_id.organizer_id.name }} reminder', - 'lang': '{{ object.partner_id.lang }}' - }) - test_event = self.env['event.event'].create({ - 'name': 'TestEventMail', - # 'user_id': self.env.ref('base.user_admin').id, - 'auto_confirm': False, - 'date_begin': datetime.now() + timedelta(hours=1), - 'date_end': datetime.now() + timedelta(hours=2), - 'event_mail_ids': [ - (0, 0, { # email 3 hours before event - 'interval_nbr': 3, - 'interval_unit': 'hours', - 'interval_type': 'before_event', - 'template_ref': 'mail.template,%i' % self.env['ir.model.data']._xmlid_to_res_id('event.event_reminder')}), - (0, 0, { # sms 3 hours before event - 'interval_nbr': 3, - 'interval_unit': 'hours', - 'interval_type': 'before_event', - 'notification_type': 'sms', - 'template_ref': 'sms.template,%i' % self.sms_template_rem.id}), - ] - }) - mail_scheduler = test_event.event_mail_ids - self.assertEqual(len(mail_scheduler), 2, 'There should be two mail schedulers. One for mail one for sms. Cannot perform test') + """ Emails are only sent to confirmed attendees. """ + test_event = self.test_event + mail_schedulers = test_event.event_mail_ids + self.assertEqual(len(mail_schedulers), 6) + before = mail_schedulers.filtered(lambda m: m.interval_type == "before_event" and m.interval_unit == "days") + self.assertEqual(len(before), 2) # Add registrations - self.env['event.registration'].create([{ + _dummy, _dummy, open_reg, done_reg = self.env['event.registration'].create([{ 'event_id': test_event.id, 'name': 'RegistrationUnconfirmed', 'email': 'Registration@Unconfirmed.com', + 'phone': '1', 'state': 'draft', }, { 'event_id': test_event.id, 'name': 'RegistrationCanceled', 'email': 'Registration@Canceled.com', + 'phone': '2', 'state': 'cancel', }, { 'event_id': test_event.id, 'name': 'RegistrationConfirmed', 'email': 'Registration@Confirmed.com', + 'phone': '3', 'state': 'open', + }, { + 'event_id': test_event.id, + 'name': 'RegistrationDone', + 'email': 'Registration@Done.com', + 'phone': '4', + 'state': 'done', }]) - with self.mock_mail_gateway(), self.mockSMSGateway(): - mail_scheduler.execute() + with self.mock_datetime_and_now(self.event_date_begin - timedelta(days=2)), \ + self.mock_mail_gateway(), \ + self.mockSMSGateway(): + before.execute() - self.assertEqual(len(self._new_mails), 2, 'Mails were not created') - self.assertEqual(len(self._new_sms), 2, 'SMS were not created') + for registration in open_reg, done_reg: + with self.subTest(registration_state=registration.state, medium='mail'): + self.assertMailMailWEmails( + [formataddr((registration.name, registration.email.lower()))], + 'outgoing', + ) + with self.subTest(registration_state=registration.state, medium='sms'): + self.assertSMS( + self.env['res.partner'], + registration.phone, + None, + ) + self.assertEqual(len(self._new_mails), 2, 'Mails should not be sent to draft or cancel registrations') + self.assertEqual(len(self._new_sms), 2, 'SMS should not be sent to draft or cancel registrations') - self.assertEqual(test_event.seats_expected, 2, 'Wrong number of expected seats (attendees)') + self.assertEqual(test_event.seats_taken, 2, 'Wrong number of seats_taken') - self.assertEqual(mail_scheduler.filtered(lambda r: r.notification_type == 'mail').mail_count_done, 2, - 'Wrong Emails Sent Count! Probably emails sent to unconfirmed attendees were not included into the Sent Count') - self.assertEqual(mail_scheduler.filtered(lambda r: r.notification_type == 'sms').mail_count_done, 2, - 'Wrong SMS Sent Count! Probably SMS sent to unconfirmed attendees were not included into the Sent Count') + for scheduler in before: + self.assertEqual( + scheduler.mail_count_done, 2, + 'Wrong Emails Sent Count! Probably emails sent to unconfirmed attendees were not included into the Sent Count' + ) + + @users('user_eventmanager') + def test_schedule_event_scalability(self): + """ Test scalability / iterative work on event-based schedulers """ + test_event = self.env['event.event'].browse(self.test_event.ids) + registrations = self._create_registrations(test_event, 30) + registrations = registrations.sorted("id") + + # check event-based schedulers + after_mail = test_event.event_mail_ids.filtered(lambda s: s.interval_type == "after_event" and s.notification_type == "mail") + self.assertEqual(len(after_mail), 1) + self.assertEqual(after_mail.mail_count_done, 0) + self.assertFalse(after_mail.mail_done) + after_sms = test_event.event_mail_ids.filtered(lambda s: s.interval_type == "after_event" and s.notification_type == "sms") + self.assertEqual(len(after_sms), 1) + self.assertEqual(after_sms.mail_count_done, 0) + self.assertFalse(after_sms.mail_done) + before_mail = test_event.event_mail_ids.filtered(lambda s: s.interval_type == "before_event" and s.notification_type == "mail") + self.assertEqual(len(before_mail), 1) + self.assertEqual(before_mail.mail_count_done, 0) + self.assertFalse(before_mail.mail_done) + before_sms = test_event.event_mail_ids.filtered(lambda s: s.interval_type == "before_event" and s.notification_type == "sms") + self.assertEqual(len(before_sms), 1) + self.assertEqual(before_sms.mail_count_done, 0) + self.assertFalse(before_sms.mail_done) + + # setup batch and cron limit sizes to check iterative behavior + batch_size, cron_limit = 5, 20 + self.env["ir.config_parameter"].sudo().set_param("mail.batch_size", batch_size) + self.env["ir.config_parameter"].sudo().set_param("mail.render.cron.limit", cron_limit) + + # launch before event schedulers -> all communications are sent + current_now = self.event_date_begin - timedelta(days=1) + EventMail = type(self.env['event.mail']) + exec_origin = EventMail._execute_event_based_for_registrations + with ( + patch.object( + EventMail, '_execute_event_based_for_registrations', autospec=True, wraps=EventMail, side_effect=exec_origin, + ) as mock_exec, + self.mock_datetime_and_now(current_now), + self.mockSMSGateway(), + self.mock_mail_gateway(), + self.capture_triggers('event.event_mail_scheduler') as capture, + ): + self.event_cron_id.method_direct_trigger() + + self.assertFalse(after_mail.last_registration_id) + self.assertEqual(after_mail.mail_count_done, 0) + self.assertFalse(after_mail.mail_done) + self.assertFalse(after_sms.last_registration_id) + self.assertEqual(after_sms.mail_count_done, 0) + self.assertFalse(after_sms.mail_done) + # iterative work on registrations: only 20 (cron limit) are taken into account + self.assertEqual(before_mail.last_registration_id, registrations[19]) + self.assertEqual(before_mail.mail_count_done, 20) + self.assertFalse(before_mail.mail_done) + self.assertEqual(before_sms.last_registration_id, registrations[19]) + self.assertEqual(before_sms.mail_count_done, 20) + self.assertFalse(before_sms.mail_done) + self.assertEqual(mock_exec.call_count, 8, "Batch of 5 to make 20 registrations: 4 calls / scheduler") + # cron should have been triggered for the remaining registrations + self.assertSchedulerCronTriggers(capture, [current_now] * 2) + + # relaunch to close scheduler + with ( + self.mock_datetime_and_now(current_now), + self.mockSMSGateway(), + self.mock_mail_gateway(), + self.capture_triggers('event.event_mail_scheduler') as capture, + ): + self.event_cron_id.method_direct_trigger() + self.assertEqual(before_mail.last_registration_id, registrations[-1]) + self.assertEqual(before_mail.mail_count_done, 30) + self.assertTrue(before_mail.mail_done) + self.assertEqual(before_sms.last_registration_id, registrations[-1]) + self.assertEqual(before_sms.mail_count_done, 30) + self.assertTrue(before_sms.mail_done) + self.assertFalse(capture.records) + + # launch after event schedulers -> all communications are sent + current_now = self.event_date_end + timedelta(hours=1) + with ( + self.mock_datetime_and_now(current_now), + self.mockSMSGateway(), + self.mock_mail_gateway(), + self.capture_triggers('event.event_mail_scheduler') as capture, + ): + self.event_cron_id.method_direct_trigger() + + # iterative work on registrations: only 20 (cron limit) are taken into account + self.assertEqual(after_mail.last_registration_id, registrations[19]) + self.assertEqual(after_mail.mail_count_done, 20) + self.assertFalse(after_mail.mail_done) + self.assertEqual(after_sms.last_registration_id, registrations[19]) + self.assertEqual(after_sms.mail_count_done, 20) + self.assertFalse(after_sms.mail_done) + self.assertEqual(mock_exec.call_count, 8, "Batch of 5 to make 20 registrations: 4 calls / scheduler") + # cron should have been triggered for the remaining registrations + self.assertSchedulerCronTriggers(capture, [current_now] * 2) + + # relaunch to close scheduler + with ( + self.mock_datetime_and_now(current_now), + self.mockSMSGateway(), + self.mock_mail_gateway(), + self.capture_triggers('event.event_mail_scheduler') as capture, + ): + self.event_cron_id.method_direct_trigger() + self.assertEqual(after_mail.last_registration_id, registrations[-1]) + self.assertEqual(after_mail.mail_count_done, 30) + self.assertTrue(after_mail.mail_done) + self.assertEqual(after_sms.last_registration_id, registrations[-1]) + self.assertEqual(after_sms.mail_count_done, 30) + self.assertTrue(after_sms.mail_done) + self.assertFalse(capture.records) + + @users('user_eventmanager') + def test_schedule_subscription_scalability(self): + """ Test scalability / iterative work on subscription-based schedulers """ + test_event = self.env['event.event'].browse(self.test_event.ids) + + sub_mail = test_event.event_mail_ids.filtered(lambda s: s.interval_type == "after_sub" and s.interval_unit == "now" and s.notification_type == "mail") + self.assertEqual(len(sub_mail), 1) + self.assertEqual(sub_mail.mail_count_done, 0) + sub_sms = test_event.event_mail_ids.filtered(lambda s: s.interval_type == "after_sub" and s.interval_unit == "now" and s.notification_type == "sms") + self.assertEqual(len(sub_sms), 1) + self.assertEqual(sub_sms.mail_count_done, 0) + + # setup batch and cron limit sizes to check iterative behavior + batch_size, cron_limit = 5, 20 + self.env["ir.config_parameter"].sudo().set_param("mail.batch_size", batch_size) + self.env["ir.config_parameter"].sudo().set_param("mail.render.cron.limit", cron_limit) + + # create registrations -> each one receives its on subscribe communication + EventMailRegistration = type(self.env['event.mail.registration']) + exec_origin = EventMailRegistration._execute_on_registrations + with patch.object( + EventMailRegistration, '_execute_on_registrations', autospec=True, wraps=EventMailRegistration, side_effect=exec_origin, + ) as mock_exec, \ + self.mock_datetime_and_now(self.reference_now + timedelta(hours=1)), \ + self.mockSMSGateway(), \ + self.mock_mail_gateway(), \ + self.capture_triggers('event.event_mail_scheduler') as capture: + self._create_registrations(test_event, 30) + + # iterative work on registrations: only 20 (cron limit) are taken into account + self.assertEqual(sub_mail.mail_count_done, 20) + self.assertEqual(sub_sms.mail_count_done, 20) + self.assertEqual(mock_exec.call_count, 8, "Batch of 5 to make 20 registrations: 4 calls / scheduler") + # cron should have been triggered for the remaining registrations + self.assertSchedulerCronTriggers(capture, [self.reference_now + timedelta(hours=1)] * 2) + + # iterative work on registrations, force cron to close those + with ( + patch.object( + EventMailRegistration, '_execute_on_registrations', autospec=True, wraps=EventMailRegistration, side_effect=exec_origin, + ) as mock_exec, + self.mock_datetime_and_now(self.reference_now + timedelta(hours=1)), + self.mockSMSGateway(), + self.mock_mail_gateway(), + self.capture_triggers('event.event_mail_scheduler') as capture, + ): + self.event_cron_id.method_direct_trigger() + + # finished sending communications + self.assertEqual(sub_mail.mail_count_done, 30) + self.assertEqual(sub_sms.mail_count_done, 30) + self.assertFalse(capture.records) + self.assertEqual(mock_exec.call_count, 4, "Batch of 5 to make 10 remaining registrations: 2 calls / scheduler") + + +@tagged('event_mail', 'post_install', '-at_install') +class TestEventSaleMail(TestEventFullCommon): + + def test_event_mail_on_sale_confirmation(self): + """Test that a mail is sent to the customer when a sale order is confirmed.""" + ticket = self.test_event.event_ticket_ids[0] + self.test_event.env.company.partner_id.email = 'test.email@test.example.com' + order_line_vals = { + "event_id": self.test_event.id, + "event_ticket_id": ticket.id, + "product_id": ticket.product_id.id, + "product_uom_qty": 1, + } + self.customer_so.write({"order_line": [(0, 0, order_line_vals)]}) + + # check sale mail configuration + aftersub = self.test_event.event_mail_ids.filtered( + lambda m: m.interval_type == "after_sub" + ) + self.assertTrue(aftersub) + aftersub.template_ref.email_from = "{{ (object.event_id.organizer_id.email_formatted or object.event_id.user_id.email_formatted or '') }}" + self.assertEqual(self.test_event.organizer_id, self.test_event.env.company.partner_id) + + registration = self.env["event.registration"].create( + { + **self.website_customer_data[0], + "partner_id": self.event_customer.id, + "sale_order_line_id": self.customer_so.order_line[0].id, + } + ) + self.assertEqual(self.test_event.registration_ids, registration) + self.assertEqual(self.customer_so.state, "draft") + self.assertEqual(registration.state, "draft") + + with self.mock_mail_gateway(): + self.customer_so.action_confirm() + # mail send is done when writing state value, hence flushing for the test + registration.flush_recordset() + self.assertEqual(self.customer_so.state, "sale") + self.assertEqual(registration.state, "open") + + # Ensure mails are sent to customers right after subscription + self.assertMailMailWRecord( + registration, + [self.event_customer.id], + "outgoing", + author=self.test_event.organizer_id, + fields_values={ + "email_from": self.test_event.organizer_id.email_formatted, + }, + ) + + def test_registration_template_body_translation(self): + self.env['res.lang']._activate_lang('fr_BE') + test_event = self.test_event + self.partners[0].lang = 'fr_BE' + self.env.ref('event.event_subscription').with_context(lang='fr_BE').body_html = 'Bonjour' + with self.mock_mail_gateway(mail_unlink_sent=False): + self.env['event.registration'].create({ + 'event_id': test_event.id, + 'partner_id': self.partners[0].id + }) + self.assertEqual(self._new_mails[0].body_html, "

Bonjour

") diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_security.py b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_security.py index e6adc58..63fb0c4 100644 --- a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_security.py +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_security.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from odoo.addons.test_event_full.tests.common import TestEventFullCommon from odoo.exceptions import AccessError +from odoo.fields import Command from odoo.tests import tagged from odoo.tests.common import users from odoo.tools import mute_logger @@ -55,7 +56,7 @@ class TestEventSecurity(TestEventFullCommon): def test_event_access_event_registration(self): # Event: read ok event = self.test_event.with_user(self.env.user) - event.read(['name', 'user_id', 'kanban_state_label']) + event.read(['name', 'user_id', 'kanban_state']) # Event: read only with self.assertRaises(AccessError): @@ -77,7 +78,7 @@ class TestEventSecurity(TestEventFullCommon): def test_event_access_event_user(self): # Event event = self.test_event.with_user(self.env.user) - event.read(['name', 'user_id', 'kanban_state_label']) + event.read(['name', 'user_id', 'kanban_state']) event.write({'name': 'New name'}) self.env['event.event'].create({ 'name': 'Event', @@ -133,7 +134,7 @@ class TestEventSecurity(TestEventFullCommon): event_type.unlink() # Settings access rights required to enable some features - self.user_eventmanager.write({'groups_id': [ + self.user_eventmanager.write({'group_ids': [ (3, self.env.ref('base.group_system').id), (4, self.env.ref('base.group_erp_manager').id) ]}) @@ -142,6 +143,45 @@ class TestEventSecurity(TestEventFullCommon): }) event_config.execute() + def test_event_question_access(self): + """ Check that some user groups have access to questions and answers only if they are linked to at + least one published event. """ + question = self.env['event.question'].create({ + "title": "Question", + "event_ids": [Command.create({ + 'name': 'Unpublished Event', + 'is_published': False, + })] + }) + answer = self.env['event.question.answer'].create({ + "name": "Answer", + "question_id": question.id, + }) + restricted_users = [self.user_employee, self.user_portal, self.user_public] + unrestricted_users = [self.user_eventmanager, self.user_eventuser] + + for user in restricted_users: + with self.assertRaises(AccessError, msg=f'{user.name} should not have access to questions of unpublished events'): + question.with_user(user).read(['title']) + with self.assertRaises(AccessError, msg=f'{user.name} should not have access to answers of unpublished events'): + answer.with_user(user).read(['name']) + + for user in unrestricted_users: + question.with_user(user).read(['title']) + answer.with_user(user).read(['name']) + + # To check the access of user groups to questions and answers linked to at least one published event. + self.env['event.event'].create({ + 'name': 'Published Event', + 'is_published': True, + 'question_ids': [Command.set(question.ids)], + }) + + # Check that all user groups have access to questions and answers linked to at least one published event. + for user in restricted_users + unrestricted_users: + question.with_user(user).read(['title']) + answer.with_user(user).read(['name']) + def test_implied_groups(self): """Test that the implied groups are correctly set. diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_performance.py b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_performance.py index 6d4bee5..cd91e40 100644 --- a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_performance.py +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_performance.py @@ -6,8 +6,7 @@ from freezegun import freeze_time from odoo.addons.test_event_full.tests.common import TestEventFullCommon from odoo.addons.website.tests.test_performance import UtilPerf -from odoo.tests.common import users, warmup, Form -from odoo.tests import tagged +from odoo.tests import Form, users, warmup, tagged @tagged('event_performance', 'post_install', '-at_install', '-standard') @@ -17,6 +16,9 @@ class EventPerformanceCase(TestEventFullCommon): super(EventPerformanceCase, self).setUp() # patch registry to simulate a ready environment self.patch(self.env.registry, 'ready', True) + # we don't use mock_mail_gateway thus want to mock smtp to test the stack + self._mock_smtplib_connection() + self._flush_tracking() def _flush_tracking(self): @@ -52,7 +54,7 @@ class TestEventPerformance(EventPerformanceCase): batch_size = 20 # simple without type involved + website - with freeze_time(self.reference_now), self.assertQueryCount(event_user=5368): # tef 4944 / com 4943 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=3418): # tef 3316 / com 3315 self.env.cr._now = self.reference_now # force create_date to check schedulers event_values = [ dict(self.event_base_vals, @@ -60,7 +62,7 @@ class TestEventPerformance(EventPerformanceCase): ) for x in range(batch_size) ] - self.env['event.event'].create(event_values) + self.env['event.event'].with_context(lang='en_US').create(event_values) @users('event_user') @warmup @@ -70,7 +72,7 @@ class TestEventPerformance(EventPerformanceCase): event_type = self.env['event.type'].browse(self.test_event_type.ids) # complex with type - with freeze_time(self.reference_now), self.assertQueryCount(event_user=439): # 439 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=432): # tef 432 self.env.cr._now = self.reference_now # force create_date to check schedulers event_values = [ dict(self.event_base_vals, @@ -89,7 +91,7 @@ class TestEventPerformance(EventPerformanceCase): event_type = self.env['event.type'].browse(self.test_event_type.ids) # complex with type + website - with freeze_time(self.reference_now), self.assertQueryCount(event_user=5480): # tef 5056 / com 5055 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=3522): # tef 3420 / com 3419 self.env.cr._now = self.reference_now # force create_date to check schedulers event_values = [ dict(self.event_base_vals, @@ -97,7 +99,7 @@ class TestEventPerformance(EventPerformanceCase): ) for x in range(batch_size) ] - self.env['event.event'].create(event_values) + self.env['event.event'].with_context(lang='en_US').create(event_values) @users('event_user') @@ -107,7 +109,7 @@ class TestEventPerformance(EventPerformanceCase): has_social = 'social_menu' in self.env['event.event'] # otherwise view may crash in enterprise # no type, no website - with freeze_time(self.reference_now), self.assertQueryCount(event_user=206): # tef 160 / com 160 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=108): # tef 103 / com 103 self.env.cr._now = self.reference_now # force create_date to check schedulers # Require for `website_menu` to be visible #
@@ -128,7 +130,7 @@ class TestEventPerformance(EventPerformanceCase): has_social = 'social_menu' in self.env['event.event'] # otherwise view may crash in enterprise # no type, website - with freeze_time(self.reference_now), self.assertQueryCount(event_user=666): # tef 565 / com 566 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=429): # tef 379 / com 380 self.env.cr._now = self.reference_now # force create_date to check schedulers # Require for `website_menu` to be visible #
@@ -150,7 +152,7 @@ class TestEventPerformance(EventPerformanceCase): has_social = 'social_menu' in self.env['event.event'] # otherwise view may crash in enterprise # type and website - with freeze_time(self.reference_now), self.assertQueryCount(event_user=692): # tef 593 / com 596 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=472): # tef 426 / com 428 self.env.cr._now = self.reference_now # force create_date to check schedulers # Require for `website_menu` to be visible #
@@ -168,7 +170,7 @@ class TestEventPerformance(EventPerformanceCase): def test_event_create_single_notype(self): """ Test a single event creation """ # simple without type involved - with freeze_time(self.reference_now), self.assertQueryCount(event_user=31): # 31 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=31): # tef 31 self.env.cr._now = self.reference_now # force create_date to check schedulers event_values = dict( self.event_base_vals, @@ -181,13 +183,13 @@ class TestEventPerformance(EventPerformanceCase): def test_event_create_single_notype_website(self): """ Test a single event creation """ # simple without type involved + website - with freeze_time(self.reference_now), self.assertQueryCount(event_user=352): # tef 327 / com 326 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=242): # tef 228 / com 234 self.env.cr._now = self.reference_now # force create_date to check schedulers event_values = dict( self.event_base_vals, website_menu=True ) - self.env['event.event'].create([event_values]) + self.env['event.event'].with_context(lang='en_US').create([event_values]) @users('event_user') @warmup @@ -196,7 +198,7 @@ class TestEventPerformance(EventPerformanceCase): event_type = self.env['event.type'].browse(self.test_event_type.ids) # complex with type - with freeze_time(self.reference_now), self.assertQueryCount(event_user=58): # 58 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=52): # tef 52 self.env.cr._now = self.reference_now # force create_date to check schedulers event_values = dict( self.event_base_vals, @@ -212,13 +214,13 @@ class TestEventPerformance(EventPerformanceCase): event_type = self.env['event.type'].browse(self.test_event_type.ids) # complex with type + website - with freeze_time(self.reference_now), self.assertQueryCount(event_user=387): # tef 362 / com 361 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=274): # tef 266 / com 265 self.env.cr._now = self.reference_now # force create_date to check schedulers event_values = dict( self.event_base_vals, event_type_id=event_type.id, ) - self.env['event.event'].create([event_values]) + self.env['event.event'].with_context(lang='en_US').create([event_values]) @tagged('event_performance', 'registration_performance', 'post_install', '-at_install', '-standard') @@ -234,7 +236,7 @@ class TestRegistrationPerformance(EventPerformanceCase): """ event = self.env['event.event'].browse(self.test_event.ids) - with freeze_time(self.reference_now), self.assertQueryCount(event_user=720): # tef only: 674? - com runbot 716 - ent runbot 719 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=638): # tef 633 / com 636 self.env.cr._now = self.reference_now # force create_date to check schedulers registration_values = [ dict(reg_data, @@ -258,7 +260,7 @@ class TestRegistrationPerformance(EventPerformanceCase): """ event = self.env['event.event'].browse(self.test_event.ids) - with freeze_time(self.reference_now), self.assertQueryCount(event_user=210): # tef 167 / com runbot 206 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=165): # tef 164 / com runbot 163 self.env.cr._now = self.reference_now # force create_date to check schedulers registration_values = [ dict(reg_data, @@ -280,7 +282,7 @@ class TestRegistrationPerformance(EventPerformanceCase): form like) """ event = self.env['event.event'].browse(self.test_event.ids) - with freeze_time(self.reference_now), self.assertQueryCount(event_user=731): # tef only: 685? - com runbot 727 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=651): # tef 647 - com 649 self.env.cr._now = self.reference_now # force create_date to check schedulers registration_values = [ dict(reg_data, @@ -301,12 +303,11 @@ class TestRegistrationPerformance(EventPerformanceCase): """ Test a single registration creation using Form """ event = self.env['event.event'].browse(self.test_event.ids) - with freeze_time(self.reference_now), self.assertQueryCount(event_user=231): # tef only: 210? - com runbot 216 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=145): # tef 140 / com 143 self.env.cr._now = self.reference_now # force create_date to check schedulers with Form(self.env['event.registration']) as reg_form: reg_form.event_id = event reg_form.email = 'email.00@test.example.com' - reg_form.mobile = '0456999999' reg_form.name = 'My Customer' reg_form.phone = '0456000000' _registration = reg_form.save() @@ -317,7 +318,7 @@ class TestRegistrationPerformance(EventPerformanceCase): """ Test a single registration creation using Form """ event = self.env['event.event'].browse(self.test_event.ids) - with freeze_time(self.reference_now), self.assertQueryCount(event_user=233): # tef only: 213? - com runbot 217 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=146): # tef 141 / com 144 self.env.cr._now = self.reference_now # force create_date to check schedulers with Form(self.env['event.registration']) as reg_form: reg_form.event_id = event @@ -330,7 +331,7 @@ class TestRegistrationPerformance(EventPerformanceCase): """ Test a single registration creation using Form """ event = self.env['event.event'].browse(self.test_event.ids) - with freeze_time(self.reference_now), self.assertQueryCount(event_user=124): # tef 107 / com 109 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=63): # tef 61 / com 61 self.env.cr._now = self.reference_now # force create_date to check schedulers with Form(self.env['event.registration'].with_context(event_lead_rule_skip=True)) as reg_form: reg_form.event_id = event @@ -344,7 +345,7 @@ class TestRegistrationPerformance(EventPerformanceCase): event = self.env['event.event'].browse(self.test_event.ids) # simple customer data - with freeze_time(self.reference_now), self.assertQueryCount(event_user=143): # tef only: 135? - com runbot 140 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=122): # tef 118 / com 120 self.env.cr._now = self.reference_now # force create_date to check schedulers registration_values = dict( self.customer_data[0], @@ -358,7 +359,7 @@ class TestRegistrationPerformance(EventPerformanceCase): event = self.env['event.event'].browse(self.test_event.ids) # partner-based customer - with freeze_time(self.reference_now), self.assertQueryCount(event_user=149): # tef only: 142? - com runbot 146 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=121): # tef 117 / com 120 self.env.cr._now = self.reference_now # force create_date to check schedulers registration_values = { 'event_id': event.id, @@ -373,7 +374,7 @@ class TestRegistrationPerformance(EventPerformanceCase): event = self.env['event.event'].browse(self.test_event.ids) # partner-based customer - with freeze_time(self.reference_now), self.assertQueryCount(event_user=46): # tef 41 / com 43 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=39): # tef 38 / com 38 self.env.cr._now = self.reference_now # force create_date to check schedulers registration_values = { 'event_id': event.id, @@ -388,7 +389,7 @@ class TestRegistrationPerformance(EventPerformanceCase): event = self.env['event.event'].browse(self.test_event.ids) # website customer data - with freeze_time(self.reference_now), self.assertQueryCount(event_user=151): # tef only: 142? - com runbot 146 + with freeze_time(self.reference_now), self.assertQueryCount(event_user=126): # tef 122 / com 124 self.env.cr._now = self.reference_now # force create_date to check schedulers registration_values = dict( self.website_customer_data[0], @@ -429,7 +430,6 @@ class TestOnlineEventPerformance(EventPerformanceCase, UtilPerf): ]) def _test_url_open(self, url): - url += ('?' not in url and '?' or '') + '&debug=disable-t-cache' return self.url_open(url) @warmup @@ -437,7 +437,7 @@ class TestOnlineEventPerformance(EventPerformanceCase, UtilPerf): # website customer data with freeze_time(self.reference_now): self.authenticate('user_eventmanager', 'user_eventmanager') - with self.assertQueryCount(default=36): # tef 35 + with self.assertQueryCount(default=35): # tef 34 self._test_url_open('/event/%i' % self.test_event.id) @warmup @@ -445,7 +445,7 @@ class TestOnlineEventPerformance(EventPerformanceCase, UtilPerf): # website customer data with freeze_time(self.reference_now): self.authenticate(None, None) - with self.assertQueryCount(default=27): + with self.assertQueryCount(default=25): self._test_url_open('/event/%i' % self.test_event.id) @warmup @@ -453,7 +453,7 @@ class TestOnlineEventPerformance(EventPerformanceCase, UtilPerf): # website customer data with freeze_time(self.reference_now): self.authenticate('user_eventmanager', 'user_eventmanager') - with self.assertQueryCount(default=39): # tef 38 + with self.assertQueryCount(default=43): # tef 42 self._test_url_open('/event') @warmup @@ -461,23 +461,22 @@ class TestOnlineEventPerformance(EventPerformanceCase, UtilPerf): # website customer data with freeze_time(self.reference_now): self.authenticate(None, None) - with self.assertQueryCount(default=28): + with self.assertQueryCount(default=39): self._test_url_open('/event') - # @warmup - # def test_register_public(self): - # with freeze_time(self.reference_now + timedelta(hours=3)): # be sure sales has started - # self.assertTrue(self.test_event.event_registrations_started) - # self.authenticate(None, None) - # with self.assertQueryCount(default=99999): # tef only: 1110 - # self.browser_js( - # '/event/%i/register' % self.test_event.id, - # 'odoo.__DEBUG__.services["web_tour.tour"].run("wevent_performance_register")', - # 'odoo.__DEBUG__.services["web_tour.tour"].tours.wevent_performance_register.ready', - # login=None, - # timeout=200, - # ) + @warmup + def test_register_public(self): + with freeze_time(self.reference_now + timedelta(hours=3)): # be sure sales has started + self.assertTrue(self.test_event.event_registrations_started) + self.authenticate(None, None) + with self.assertQueryCount(default=1197): # tef: 1197 + self.start_tour( + '/event/%i/register' % self.test_event.id, + 'wevent_performance_register', + login=None, + timeout=200, + ) - # # minimal checkup, to be improved in future tests independently from performance - # self.assertEqual(len(self.test_event.registration_ids), 3) - # self.assertEqual(len(self.test_event.registration_ids.visitor_id), 1) + # minimal checkup, to be improved in future tests independently from performance + self.assertEqual(len(self.test_event.registration_ids), 3) + self.assertEqual(len(self.test_event.registration_ids.visitor_id), 1) diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_wevent_menu.py b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_wevent_menu.py new file mode 100644 index 0000000..ff76318 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_wevent_menu.py @@ -0,0 +1,41 @@ +from odoo.addons.test_event_full.tests.common import TestWEventCommon +from odoo.tests import tagged +from odoo.tests.common import users + + +@tagged('event_online', 'post_install', '-at_install') +class TestWEventMenu(TestWEventCommon): + + @users('admin') + def test_seo_data(self): + """Test SEO data for submenus on event website page""" + + self.assertFalse(self.event.website_meta_title, 'Event should initially have no meta title') + self.event.write({ + 'website_meta_title': 'info', + }) + self.assertTrue(self.event.website_meta_title, 'Event should have a meta title after writing') + + menus = [ + ('booth_menu_ids', 'Get a Booth'), + ('exhibitor_menu_ids', 'Exhibitor'), + ('community_menu_ids', 'Leaderboard'), + ('track_menu_ids', 'Talks'), + ('track_menu_ids', 'Agenda'), + ('track_proposal_menu_ids', 'Talk Proposal'), + ] + + for menu_field, menu_name in menus: + menu = self.event[menu_field] + + if menu_field == 'track_menu_ids': + menu_url = '/track' if menu_name == 'Talks' else '/agenda' + menu = self.event[menu_field].filtered(lambda menu: menu.menu_id.url.endswith(menu_url)) + + self.assertFalse(menu.website_meta_title, f"{menu_name} page should initially have no meta title") + menu.write({'website_meta_title': menu_name}) + + web_page = self.url_open(menu.menu_id.url) + + self.assertTrue(menu.website_meta_title, f"{menu_name} page should have a meta title after writing") + self.assertIn(f"{menu.website_meta_title}", web_page.text) diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_wevent_register.py b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_wevent_register.py index cc38388..58dc8fc 100644 --- a/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_wevent_register.py +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_wevent_register.py @@ -4,6 +4,7 @@ from freezegun import freeze_time from odoo import tests +from odoo.addons.mail.tests.common import mail_new_test_user from odoo.addons.test_event_full.tests.common import TestWEventCommon @@ -11,13 +12,9 @@ from odoo.addons.test_event_full.tests.common import TestWEventCommon class TestWEventRegister(TestWEventCommon): def test_register(self): + self.env.company.country_id = self.env.ref('base.us') with freeze_time(self.reference_now, tick=True): - self.browser_js( - '/event', - 'odoo.__DEBUG__.services["web_tour.tour"].run("wevent_register")', - 'odoo.__DEBUG__.services["web_tour.tour"].tours.wevent_register.ready', - login=None - ) + self.start_tour('/event', 'wevent_register', login=None) new_registrations = self.event.registration_ids visitor = new_registrations.visitor_id @@ -40,5 +37,15 @@ class TestWEventRegister(TestWEventCommon): self.assertEqual(visitor.display_name, "Raoulette Poiluchette") self.assertEqual(visitor.event_registration_ids, new_registrations) self.assertEqual(visitor.partner_id, self.env['res.partner']) - self.assertEqual(visitor.mobile, "0456112233") self.assertEqual(visitor.email, "raoulette@example.com") + + def test_internal_user_register(self): + mail_new_test_user( + self.env, + name='User Internal', + login='user_internal', + email='user_internal@example.com', + groups='base.group_user', + ) + with freeze_time(self.reference_now, tick=True): + self.start_tour('/event', 'wevent_register', login='user_internal') diff --git a/odoo-bringout-oca-ocb-test_mail/README.md b/odoo-bringout-oca-ocb-test_mail/README.md index 0ad6c7f..1e95367 100644 --- a/odoo-bringout-oca-ocb-test_mail/README.md +++ b/odoo-bringout-oca-ocb-test_mail/README.md @@ -12,38 +12,15 @@ pip install odoo-bringout-oca-ocb-test_mail ## Dependencies -This addon depends on: - mail -- test_performance - -## Manifest Information - -- **Name**: Mail Tests -- **Version**: 1.0 -- **Category**: Hidden -- **License**: LGPL-3 -- **Installable**: True +- test_orm ## Source -Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `test_mail`. +- Repository: https://github.com/OCA/OCB +- Branch: 19.0 +- Path: addons/test_mail ## License -This package maintains the original LGPL-3 license from the upstream Odoo project. - -## Documentation - -- Overview: doc/OVERVIEW.md -- Architecture: doc/ARCHITECTURE.md -- Models: doc/MODELS.md -- Controllers: doc/CONTROLLERS.md -- Wizards: doc/WIZARDS.md -- Reports: doc/REPORTS.md -- Security: doc/SECURITY.md -- Install: doc/INSTALL.md -- Usage: doc/USAGE.md -- Configuration: doc/CONFIGURATION.md -- Dependencies: doc/DEPENDENCIES.md -- Troubleshooting: doc/TROUBLESHOOTING.md -- FAQ: doc/FAQ.md +This package preserves the original LGPL-3 license. diff --git a/odoo-bringout-oca-ocb-test_mail/pyproject.toml b/odoo-bringout-oca-ocb-test_mail/pyproject.toml index 902e522..5a94cfa 100644 --- a/odoo-bringout-oca-ocb-test_mail/pyproject.toml +++ b/odoo-bringout-oca-ocb-test_mail/pyproject.toml @@ -1,13 +1,14 @@ [project] name = "odoo-bringout-oca-ocb-test_mail" version = "16.0.0" -description = "Mail Tests - Mail Tests: performances and tests specific to mail" +description = "Mail Tests - + Mail Tests: performances and tests specific to mail + " authors = [ { name = "Ernad Husremovic", email = "hernad@bring.out.ba" } ] dependencies = [ - "odoo-bringout-oca-ocb-mail>=16.0.0", - "odoo-bringout-oca-ocb-test_performance>=16.0.0", + "odoo-bringout-oca-ocb-mail>=19.0.0", "requests>=2.25.1" ] readme = "README.md" @@ -17,7 +18,7 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Office/Business", ] diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/__manifest__.py b/odoo-bringout-oca-ocb-test_mail/test_mail/__manifest__.py index 2a8f154..aa7e914 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/__manifest__.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/__manifest__.py @@ -11,7 +11,7 @@ present in a separate module as it contains models used only to perform tests independently to functional aspects of other models. """, 'depends': [ 'mail', - 'test_performance', + 'test_orm', ], 'data': [ 'security/ir.model.access.csv', @@ -21,16 +21,14 @@ tests independently to functional aspects of other models. """, 'data/subtype_data.xml', ], 'assets': { - 'web.qunit_suite_tests': [ - 'test_mail/static/tests/*', + 'web.assets_unit_tests': [ + 'test_mail/static/tests/**/*', ], - 'web.qunit_mobile_suite_tests': [ - 'test_mail/static/tests/mobile/activity_tests.js', - ], - 'web.tests_assets': [ - 'test_mail/static/tests/helpers/*', + 'web.assets_tests': [ + 'test_mail/static/tests/tours/*', ], }, 'installable': True, + 'author': 'Odoo S.A.', 'license': 'LGPL-3', } diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/data/data.xml b/odoo-bringout-oca-ocb-test_mail/test_mail/data/data.xml index 1e4c55f..2336076 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/data/data.xml +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/data/data.xml @@ -36,5 +36,18 @@ trigger + + Document + Document + 5 + upload_file + mail.test.activity + + + + Do Stuff + Hey Zoidberg! Get in here! + default + diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/data/mail_template_data.xml b/odoo-bringout-oca-ocb-test_mail/test_mail/data/mail_template_data.xml index 944a1b7..5f8aab7 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/data/mail_template_data.xml +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/data/mail_template_data.xml @@ -4,6 +4,7 @@ Mail Test Full: Tracking Template Test Template {{ object.customer_id.id }} +

Hello

@@ -13,6 +14,7 @@ Mail Test: Template Post on {{ object.name }} {{ object.customer_id.id }} +

Adding stuff on

@@ -33,6 +35,30 @@ + + + + diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/data/subtype_data.xml b/odoo-bringout-oca-ocb-test_mail/test_mail/data/subtype_data.xml index 2880100..f3702a8 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/data/subtype_data.xml +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/data/subtype_data.xml @@ -52,4 +52,13 @@ + + + New ticket + New Ticket + mail.test.ticket.partner + + + + diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/data/test_mail_data.py b/odoo-bringout-oca-ocb-test_mail/test_mail/data/test_mail_data.py index 0c5d787..2bb5fa6 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/data/test_mail_data.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/data/test_mail_data.py @@ -11,7 +11,7 @@ Subject: {subject} MIME-Version: 1.0 Content-Type: multipart/alternative; boundary="----=_Part_4200734_24778174.1344608186754" -Date: Fri, 10 Aug 2012 14:16:26 +0000 +Date: {date} Message-ID: {msg_id} {extra} ------=_Part_4200734_24778174.1344608186754 @@ -135,6 +135,38 @@ Message-ID: {msg_id} """ +MAIL_TEMPLATE_SHORT = """Return-Path: {return_path} +To: {to} +cc: {cc} +Received: by mail1.openerp.com (Postfix, from userid 10002) + id 5DF9ABFB2A; Fri, 10 Aug 2012 16:16:39 +0200 (CEST) +From: {email_from} +Subject: {subject} +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="----=_Part_4200734_24778174.1344608186754" +Date: Fri, 10 Aug 2012 14:16:26 +0000 +Message-ID: {msg_id} +{extra} +------=_Part_4200734_24778174.1344608186754 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +Eli alla à l'eau + +-- +Signature +------=_Part_4200734_24778174.1344608186754 +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: quoted-printable +
Eli alla à l'eau
+--
+Sylvie +
+------=_Part_4200734_24778174.1344608186754-- +""" + + MAIL_MULTIPART_MIXED = """Return-Path: X-Original-To: raoul@grosbedon.fr Delivered-To: raoul@grosbedon.fr @@ -249,7 +281,7 @@ Date: Sun, 26 Mar 2023 05:23:22 +0200 Message-ID: {msg_id} Subject: {subject} From: "Sylvie Lelitre" -To: groups@test.com +To: groups@test.mycompany.com Content-Type: multipart/mixed; boundary="000000000000b951de05f7c47a9e" --000000000000b951de05f7c47a9e @@ -648,11 +680,11 @@ AAAAACwAAAAAAgACAAAEA3DJFQA7 --001a11416b9e9b229a05272b7052-- """ -MAIL_EML_ATTACHMENT = """Subject: Re: test attac +MAIL_EML_ATTACHMENT = """Subject: {subject} From: {email_from} To: {to} -References: -Message-ID: +References: {references} +Message-ID: {msg_id} Date: Wed, 14 Mar 2018 14:26:58 +0100 User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:52.0) Gecko/20100101 Thunderbird/52.6.0 @@ -1395,6 +1427,7 @@ Date: Fri, 10 Aug 2012 14:16:26 +0000 ------=_Part_4200734_24778174.1344608186754 Content-Type: {pdf_mime}; name="scan_soraya.lernout_1691652648.pdf" +Content-Disposition: attachment; filename="scan_soraya.lernout_1691652648.pdf" Content-Transfer-Encoding: base64 JVBERi0xLjEKJcKlwrHDqwoKMSAwIG9iagogIDw8IC9UeXBlIC9DYXRhbG9nCiAgICAgL1BhZ2VzIDIgMCBSCiAgPj4KZW5kb2JqCgoyIDAgb2JqCiAgPDwgL1R5cGUgL1BhZ2VzCiAgICAgL0tpZHMgWzMgMCBSXQogICAgIC9Db3VudCAxCiAgICAgL01lZGlhQm94IFswIDAgMzAwIDE0NF0KICA+PgplbmRvYmoKCjMgMCBvYmoKICA8PCAgL1R5cGUgL1BhZ2UKICAgICAgL1BhcmVudCAyIDAgUgogICAgICAvUmVzb3VyY2VzCiAgICAgICA8PCAvRm9udAogICAgICAgICAgIDw8IC9GMQogICAgICAgICAgICAgICA8PCAvVHlwZSAvRm9udAogICAgICAgICAgICAgICAgICAvU3VidHlwZSAvVHlwZTEKICAgICAgICAgICAgICAgICAgL0Jhc2VGb250IC9UaW1lcy1Sb21hbgogICAgICAgICAgICAgICA+PgogICAgICAgICAgID4+CiAgICAgICA+PgogICAgICAvQ29udGVudHMgNCAwIFIKICA+PgplbmRvYmoKCjQgMCBvYmoKICA8PCAvTGVuZ3RoIDU1ID4+CnN0cmVhbQogIEJUCiAgICAvRjEgMTggVGYKICAgIDAgMCBUZAogICAgKEhlbGxvIFdvcmxkKSBUagogIEVUCmVuZHN0cmVhbQplbmRvYmoKCnhyZWYKMCA1CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAxOCAwMDAwMCBuIAowMDAwMDAwMDc3IDAwMDAwIG4gCjAwMDAwMDAxNzggMDAwMDAgbiAKMDAwMDAwMDQ1NyAwMDAwMCBuIAp0cmFpbGVyCiAgPDwgIC9Sb290IDEgMCBSCiAgICAgIC9TaXplIDUKICA+PgpzdGFydHhyZWYKNTY1CiUlRU9GCg== diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/models/__init__.py b/odoo-bringout-oca-ocb-test_mail/test_mail/models/__init__.py index fc2f562..97035a8 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/models/__init__.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/models/__init__.py @@ -1,7 +1,7 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - from . import mail_test_access +from . import mail_test_lead +from . import mail_test_ticket from . import test_mail_corner_case_models +from . import test_mail_feature_models from . import test_mail_models from . import test_mail_thread_models diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/models/mail_test_access.py b/odoo-bringout-oca-ocb-test_mail/test_mail/models/mail_test_access.py index 10c93dd..a729311 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/models/mail_test_access.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/models/mail_test_access.py @@ -1,9 +1,11 @@ -from odoo import exceptions, fields, models +from odoo import fields, models, tools class MailTestAccess(models.Model): """ Test access on mail models without depending on real models like channel - or partner which have their own set of ACLs. """ + or partner which have their own set of ACLs. Public, portal and internal + have access to this model depending on 'access' field, allowing to check + ir.rule usage. """ _description = 'Mail Access Test' _name = 'mail.test.access' _inherit = ['mail.thread.blacklist'] @@ -27,7 +29,7 @@ class MailTestAccess(models.Model): ], name='Access', default='public') - def _mail_get_partner_fields(self): + def _mail_get_partner_fields(self, introspect_fields=False): return ['customer_id'] @@ -36,7 +38,7 @@ class MailTestAccessCusto(models.Model): or partner which have their own set of ACLs. """ _description = 'Mail Access Test with Custo' _name = 'mail.test.access.custo' - _inherit = ['mail.thread.blacklist'] + _inherit = ['mail.thread.blacklist', 'mail.activity.mixin'] _mail_post_access = 'write' # default value but ease mock _order = 'id DESC' _primary_email = 'email_from' @@ -46,15 +48,47 @@ class MailTestAccessCusto(models.Model): phone = fields.Char() customer_id = fields.Many2one('res.partner', 'Customer') is_locked = fields.Boolean() + is_readonly = fields.Boolean() - def _mail_get_partner_fields(self): + def _mail_get_partner_fields(self, introspect_fields=False): return ['customer_id'] - def _get_mail_message_access(self, res_ids, operation, model_name=None): - # customize message creation - if operation == "create": - if any(record.is_locked for record in self.browse(res_ids)): - raise exceptions.AccessError('Cannot post on locked records') - else: - return "read" - return super()._get_mail_message_access(res_ids, operation, model_name=model_name) + def _mail_get_operation_for_mail_message_operation(self, message_operation): + # customize message creation: only unlocked, except admins + if message_operation == "create" and not self.env.user._is_admin(): + return dict.fromkeys(self.filtered(lambda r: not r.is_locked), 'read') + # customize read: read access on unlocked, write access on locked + elif message_operation == "read": + return { + record: 'write' if record.is_locked else 'read' + for record in self + } + return super()._mail_get_operation_for_mail_message_operation(message_operation) + + +class MailTestAccessPublic(models.Model): + """A model inheriting from mail.thread with public read and write access + to test some public and guest interactions.""" + _description = "Access Test Public" + _name = "mail.test.access.public" + _inherit = ["mail.thread"] + + name = fields.Char("Name") + customer_id = fields.Many2one('res.partner', 'Customer') + email = fields.Char('Email') + mobile = fields.Char('Mobile') + is_locked = fields.Boolean() + + def _mail_get_partner_fields(self, introspect_fields=False): + return ['customer_id'] + + def _get_customer_information(self): + email_key_to_values = super()._get_customer_information() + for record in self.filtered('email'): + # do not fill Falsy with random data, unless monorecord (= always correct) + if not tools.email_normalize(record.email) and len(self) > 1: + continue + values = email_key_to_values.setdefault(record.email, {}) + if not values.get('phone'): + values['phone'] = record.mobile + return email_key_to_values diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/models/mail_test_lead.py b/odoo-bringout-oca-ocb-test_mail/test_mail/models/mail_test_lead.py new file mode 100644 index 0000000..20900be --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/models/mail_test_lead.py @@ -0,0 +1,55 @@ +from odoo import fields, models, _ +from odoo.tools.mail import parse_contact_from_email + + +class MailTestTLead(models.Model): + """ Lead-like model for business flows testing """ + _name = "mail.test.lead" + _description = 'Lead-like model' + _inherit = [ + 'mail.thread.blacklist', + 'mail.thread.cc', + 'mail.activity.mixin', + ] + _mail_defaults_to_email = True + _primary_email = 'email_from' + + name = fields.Char() + company_id = fields.Many2one('res.company') + user_id = fields.Many2one('res.users', tracking=1) + email_from = fields.Char() + customer_name = fields.Char() + partner_id = fields.Many2one('res.partner', tracking=2) + lang_code = fields.Char() + phone = fields.Char() + + def _creation_message(self): + self.ensure_one() + return _('A new lead has been created and is assigned to %(user_name)s.', user_name=self.user_id.name or _('nobody')) + + def _get_customer_information(self): + email_normalized_to_values = super()._get_customer_information() + + for lead in self: + email_key = lead.email_normalized or lead.email_from + values = email_normalized_to_values.setdefault(email_key, {}) + values['lang'] = values.get('lang') or lead.lang_code + values['name'] = values.get('name') or lead.customer_name or parse_contact_from_email(lead.email_from)[0] or lead.email_from + values['phone'] = values.get('phone') or lead.phone + return email_normalized_to_values + + def _message_post_after_hook(self, message, msg_vals): + if self.email_from and not self.partner_id: + # we consider that posting a message with a specified recipient (not a follower, a specific one) + # on a document without customer means that it was created through the chatter using + # suggested recipients. This heuristic allows to avoid ugly hacks in JS. + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == self.email_from or (self.email_normalized and partner.email_normalized == self.email_normalized) + ) + if new_partner: + if new_partner[0].email_normalized: + email_domain = ('email_normalized', '=', new_partner[0].email_normalized) + else: + email_domain = ('email_from', '=', new_partner[0].email) + self.search([('partner_id', '=', False), email_domain]).write({'partner_id': new_partner[0].id}) + return super()._message_post_after_hook(message, msg_vals) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/models/mail_test_ticket.py b/odoo-bringout-oca-ocb-test_mail/test_mail/models/mail_test_ticket.py new file mode 100644 index 0000000..f0ae9b6 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/models/mail_test_ticket.py @@ -0,0 +1,266 @@ +import ast + +from odoo import api, fields, models, _ +from odoo.tools import email_normalize + + +class MailTestTicket(models.Model): + """ This model can be used in tests when complex chatter features are + required like modeling tasks or tickets. """ + _description = 'Ticket-like model' + _name = "mail.test.ticket" + _inherit = ['mail.thread'] + _primary_email = 'email_from' + + name = fields.Char() + email_from = fields.Char(tracking=True) + phone_number = fields.Char() + count = fields.Integer(default=1) + datetime = fields.Datetime(default=fields.Datetime.now) + mail_template = fields.Many2one('mail.template', 'Template') + customer_id = fields.Many2one('res.partner', 'Customer', tracking=2) + user_id = fields.Many2one('res.users', 'Responsible', tracking=1) + container_id = fields.Many2one('mail.test.container', tracking=True) + + def _mail_get_partner_fields(self, introspect_fields=False): + return ['customer_id'] + + def _message_compute_subject(self): + self.ensure_one() + return f"Ticket for {self.name} on {self.datetime.strftime('%m/%d/%Y, %H:%M:%S')}" + + def _notify_get_recipients_groups(self, message, model_description, msg_vals=False): + # Activate more groups to test query counters notably (and be backward compatible for tests) + groups = super()._notify_get_recipients_groups( + message, model_description, msg_vals=msg_vals + ) + for group_name, _group_method, group_data in groups: + if group_name == 'portal': + group_data['active'] = True + elif group_name == 'customer': + group_data['active'] = True + group_data['has_button_access'] = True + + return groups + + def _track_template(self, changes): + res = super()._track_template(changes) + record = self[0] + if 'customer_id' in changes and record.mail_template: + res['customer_id'] = ( + record.mail_template, + { + 'composition_mode': 'mass_mail', + 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + } + ) + elif 'datetime' in changes: + res['datetime'] = ( + 'test_mail.mail_test_ticket_tracking_view', + { + 'composition_mode': 'mass_mail', + 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + } + ) + return res + + def _creation_subtype(self): + if self.container_id: + return self.env.ref('test_mail.st_mail_test_ticket_container_upd') + return super(MailTestTicket, self)._creation_subtype() + + def _track_subtype(self, init_values): + self.ensure_one() + if 'container_id' in init_values and self.container_id: + return self.env.ref('test_mail.st_mail_test_ticket_container_upd') + return super(MailTestTicket, self)._track_subtype(init_values) + + def _get_customer_information(self): + email_keys_to_values = super()._get_customer_information() + + for ticket in self: + email_key = email_normalize(ticket.email_from) or ticket.email_from + # do not fill Falsy with random data, unless monorecord (= always correct) + if not email_key and len(self) > 1: + continue + values = email_keys_to_values.setdefault(email_key, {}) + if not values.get('phone'): + values['phone'] = ticket.phone_number + return email_keys_to_values + + +class MailTestTicketEl(models.Model): + """ Just mail.test.ticket, but exclusion-list enabled. Kept as different + model to avoid messing with existing tests, notably performance, and ease + backward comparison. """ + _description = 'Ticket-like model with exclusion list' + _name = "mail.test.ticket.el" + _inherit = [ + 'mail.test.ticket', + 'mail.thread.blacklist', + ] + _primary_email = 'email_from' + + email_from = fields.Char( + 'Email', + compute='_compute_email_from', readonly=False, store=True) + + @api.depends('customer_id') + def _compute_email_from(self): + for ticket in self.filtered(lambda r: r.customer_id and not r.email_from): + ticket.email_from = ticket.customer_id.email_formatted + + +class MailTestTicketMc(models.Model): + """ Just mail.test.ticket, but multi company. Kept as different model to + avoid messing with existing tests, notably performance, and ease backward + comparison. """ + _description = 'Ticket-like model' + _name = "mail.test.ticket.mc" + _inherit = ['mail.test.ticket'] + _primary_email = 'email_from' + + company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.company) + container_id = fields.Many2one('mail.test.container.mc', tracking=True) + + def _get_customer_information(self): + email_keys_to_values = super()._get_customer_information() + + for ticket in self: + email_key = email_normalize(ticket.email_from) or ticket.email_from + # do not fill Falsy with random data, unless monorecord (= always correct) + if not email_key and len(self) > 1: + continue + values = email_keys_to_values.setdefault(email_key, {}) + if not values.get('company_id'): + values['company_id'] = ticket.company_id.id + return email_keys_to_values + + def _notify_get_reply_to(self, default=None, author_id=False): + # Override to use alias of the parent container + aliases = self.sudo().mapped('container_id')._notify_get_reply_to(default=default, author_id=author_id) + res = {ticket.id: aliases.get(ticket.container_id.id) for ticket in self} + leftover = self.filtered(lambda rec: not rec.container_id) + if leftover: + res.update(super()._notify_get_reply_to(default=default, author_id=author_id)) + return res + + def _creation_subtype(self): + if self.container_id: + return self.env.ref('test_mail.st_mail_test_ticket_container_mc_upd') + return super()._creation_subtype() + + def _track_subtype(self, init_values): + self.ensure_one() + if 'container_id' in init_values and self.container_id: + return self.env.ref('test_mail.st_mail_test_ticket_container_mc_upd') + return super()._track_subtype(init_values) + + +class MailTestTicketPartner(models.Model): + """ Mail.test.ticket.mc, with complete partner support. More functional + and therefore done in a separate model to avoid breaking other tests. """ + _description = 'MC ticket-like model with partner support' + _name = "mail.test.ticket.partner" + _inherit = [ + 'mail.test.ticket.mc', + 'mail.thread.blacklist', + ] + _primary_email = 'email_from' + + # fields to mimic stage-based tracing + state = fields.Selection( + [('new', 'New'), ('open', 'Open'), ('close', 'Close'),], + default='open', tracking=10) + state_template_id = fields.Many2one('mail.template') + + def _message_post_after_hook(self, message, msg_vals): + if self.email_from and not self.customer_id: + # we consider that posting a message with a specified recipient (not a follower, a specific one) + # on a document without customer means that it was created through the chatter using + # suggested recipients. This heuristic allows to avoid ugly hacks in JS. + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == self.email_from or (self.email_normalized and partner.email_normalized == self.email_normalized) + ) + if new_partner: + if new_partner[0].email_normalized: + email_domain = ('email_normalized', '=', new_partner[0].email_normalized) + else: + email_domain = ('email_from', '=', new_partner[0].email) + self.search([ + ('customer_id', '=', False), email_domain, + ]).write({'customer_id': new_partner[0].id}) + return super()._message_post_after_hook(message, msg_vals) + + def _creation_subtype(self): + if self.state == 'new': + return self.env.ref('test_mail.st_mail_test_ticket_partner_new') + return super(MailTestTicket, self)._creation_subtype() + + def _track_template(self, changes): + res = super()._track_template(changes) + record = self[0] + # acknowledgement-like email, like in project/helpdesk + if 'state' in changes and record.state == 'new' and record.state_template_id: + res['state'] = ( + record.state_template_id, + { + 'auto_delete_keep_log': False, + 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + 'email_layout_xmlid': 'mail.mail_notification_light' + }, + ) + return res + + +class MailTestContainer(models.Model): + """ This model can be used in tests when container records like projects + or teams are required. """ + _description = 'Project-like model with alias' + _name = "mail.test.container" + _mail_post_access = 'read' + _inherit = ['mail.thread', 'mail.alias.mixin'] + + name = fields.Char() + description = fields.Text() + customer_id = fields.Many2one('res.partner', 'Customer') + + def _mail_get_partner_fields(self, introspect_fields=False): + return ['customer_id'] + + def _notify_get_recipients_groups(self, message, model_description, msg_vals=False): + # Activate more groups to test query counters notably (and be backward compatible for tests) + groups = super()._notify_get_recipients_groups( + message, model_description, msg_vals=msg_vals + ) + for group_name, _group_method, group_data in groups: + if group_name == 'portal': + group_data['active'] = True + + return groups + + def _alias_get_creation_values(self): + values = super()._alias_get_creation_values() + values['alias_model_id'] = self.env['ir.model']._get('mail.test.ticket').id + values['alias_force_thread_id'] = False + if self.id: + values['alias_defaults'] = defaults = ast.literal_eval(self.alias_defaults or "{}") + defaults['container_id'] = self.id + return values + + +class MailTestContainerMc(models.Model): + """ Just mail.test.container, but multi company. Kept as different model to + avoid messing with existing tests, notably performance, and ease backward + comparison. """ + _description = 'Project-like model with alias (MC)' + _name = "mail.test.container.mc" + _mail_post_access = 'read' + _inherit = ['mail.test.container'] + + company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.company) + + def _alias_get_creation_values(self): + values = super()._alias_get_creation_values() + values['alias_model_id'] = self.env['ir.model']._get('mail.test.ticket.mc').id + return values diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_corner_case_models.py b/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_corner_case_models.py index f093e3e..14613cc 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_corner_case_models.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_corner_case_models.py @@ -21,6 +21,19 @@ class MailPerformanceThread(models.Model): record.value_pc = float(record.value) / 100 +class MailPerformanceThreadRecipients(models.Model): + _name = 'mail.performance.thread.recipients' + _description = 'Performance: mail.thread, for recipients' + _inherit = ['mail.thread'] + _primary_email = 'email_from' + + name = fields.Char() + value = fields.Integer() + email_from = fields.Char('Email From') + partner_id = fields.Many2one('res.partner', string='Customer') + user_id = fields.Many2one('res.users', 'Responsible', tracking=1) + + class MailPerformanceTracking(models.Model): _name = 'mail.performance.tracking' _description = 'Performance: multi tracking' @@ -36,7 +49,7 @@ class MailTestFieldType(models.Model): """ Test default values, notably type, messing through models during gateway processing (i.e. lead.type versus attachment.type). """ _description = 'Test Field Type' - _name = 'mail.test.field.type' + _name = "mail.test.field.type" _inherit = ['mail.thread'] name = fields.Char() @@ -49,11 +62,11 @@ class MailTestFieldType(models.Model): @api.model_create_multi def create(self, vals_list): # Emulate an addon that alters the creation context, such as `crm` - if not self._context.get('default_type'): + if not self.env.context.get('default_type'): self = self.with_context(default_type='first') return super(MailTestFieldType, self).create(vals_list) - def _mail_get_partner_fields(self): + def _mail_get_partner_fields(self, introspect_fields=False): return ['customer_id'] @@ -61,7 +74,7 @@ class MailTestLang(models.Model): """ A simple chatter model with lang-based capabilities, allowing to test translations. """ _description = 'Lang Chatter Model' - _name = 'mail.test.lang' + _name = "mail.test.lang" _inherit = ['mail.thread'] name = fields.Char() @@ -69,30 +82,85 @@ class MailTestLang(models.Model): customer_id = fields.Many2one('res.partner') lang = fields.Char('Lang') - def _mail_get_partner_fields(self): + def _mail_get_partner_fields(self, introspect_fields=False): return ['customer_id'] - def _notify_get_recipients_groups(self, msg_vals=None): - groups = super(MailTestLang, self)._notify_get_recipients_groups(msg_vals=msg_vals) - - local_msg_vals = dict(msg_vals or {}) - + def _notify_get_recipients_groups(self, message, model_description, msg_vals=False): + groups = super()._notify_get_recipients_groups( + message, model_description, msg_vals=msg_vals + ) for group in [g for g in groups if g[0] in('follower', 'customer')]: group_options = group[2] group_options['has_button_access'] = True - group_options['actions'] = [ - {'url': self._notify_get_action_link('controller', controller='/test_mail/do_stuff', **local_msg_vals), - 'title': _('NotificationButtonTitle')} - ] return groups # ------------------------------------------------------------ # TRACKING MODELS # ------------------------------------------------------------ + +class MailTestTrackAllM2m(models.Model): + _description = 'Sub-model: pseudo tags for tracking' + _name = "mail.test.track.all.m2m" + _inherit = ['mail.thread'] + + name = fields.Char('Name') + + +class MailTestTrackAllO2m(models.Model): + _description = 'Sub-model: pseudo tags for tracking' + _name = "mail.test.track.all.o2m" + _inherit = ['mail.thread'] + + name = fields.Char('Name') + mail_track_all_id = fields.Many2one('mail.test.track.all') + + +class MailTestTrackAllPropertiesParent(models.Model): + _description = 'Properties Parent' + _name = "mail.test.track.all.properties.parent" + + definition_properties = fields.PropertiesDefinition() + + +class MailTestTrackAll(models.Model): + _description = 'Test tracking on all field types' + _name = "mail.test.track.all" + _inherit = ['mail.thread'] + + boolean_field = fields.Boolean('Boolean', tracking=1) + char_field = fields.Char('Char', tracking=2) + company_id = fields.Many2one('res.company') + currency_id = fields.Many2one('res.currency', related='company_id.currency_id') + date_field = fields.Date('Date', tracking=3) + datetime_field = fields.Datetime('Datetime', tracking=4) + float_field = fields.Float('Float', tracking=5) + float_field_with_digits = fields.Float('Precise Float', digits=(10, 8), tracking=5) + html_field = fields.Html('Html', tracking=False) + integer_field = fields.Integer('Integer', tracking=7) + many2many_field = fields.Many2many( + 'mail.test.track.all.m2m', string='Many2Many', + tracking=8) + many2one_field_id = fields.Many2one('res.partner', string='Many2one', tracking=9) + monetary_field = fields.Monetary('Monetary', tracking=10) + one2many_field = fields.One2many( + 'mail.test.track.all.o2m', 'mail_track_all_id', + string='One2Many', + tracking=11) + properties_parent_id = fields.Many2one('mail.test.track.all.properties.parent', tracking=True) + properties = fields.Properties('Properties', definition='properties_parent_id.definition_properties') + selection_field = fields.Selection( + string='Selection', + selection=[('first', 'FIRST'), ('second', 'SECOND')], + tracking=12) + text_field = fields.Text('Text', tracking=13) + + name = fields.Char('Name') + + class MailTestTrackCompute(models.Model): - _name = 'mail.test.track.compute' _description = "Test tracking with computed fields" + _name = "mail.test.track.compute" _inherit = ['mail.thread'] partner_id = fields.Many2one('res.partner', tracking=True) @@ -101,63 +169,60 @@ class MailTestTrackCompute(models.Model): partner_phone = fields.Char(related='partner_id.phone', tracking=True) +class MailTestTrackDurationMixin(models.Model): + _description = 'Fake model to test the mixin mail.tracking.duration.mixin' + _name = "mail.test.track.duration.mixin" + _track_duration_field = 'customer_id' + _inherit = ['mail.tracking.duration.mixin'] + + name = fields.Char() + customer_id = fields.Many2one('res.partner', 'Customer', tracking=True) + + def _mail_get_partner_fields(self, introspect_fields=False): + return ['customer_id'] + + +class MailTestTrackGroups(models.Model): + _description = "Test tracking with groups" + _name = "mail.test.track.groups" + _inherit = ['mail.thread'] + + name = fields.Char(tracking=1) + partner_id = fields.Many2one('res.partner', tracking=2, groups="base.group_user") + secret = fields.Char(tracking=3, groups="base.group_user") + + class MailTestTrackMonetary(models.Model): - _name = 'mail.test.track.monetary' _description = 'Test tracking monetary field' + _name = "mail.test.track.monetary" _inherit = ['mail.thread'] company_id = fields.Many2one('res.company') company_currency = fields.Many2one("res.currency", string='Currency', related='company_id.currency_id', readonly=True, tracking=True) revenue = fields.Monetary('Revenue', currency_field='company_currency', tracking=True) -class MailTestMultiCompanyWithActivity(models.Model): - """ This model can be used in multi company tests with activity""" - _name = "mail.test.multi.company.with.activity" - _description = "Test Multi Company Mail With Activity" - _inherit = ["mail.thread", "mail.activity.mixin"] - name = fields.Char() - company_id = fields.Many2one("res.company") - - -class MailTestSelectionTracking(models.Model): +class MailTestTrackSelection(models.Model): """ Test tracking for selection fields """ _description = 'Test Selection Tracking' - _name = 'mail.test.track.selection' + _name = "mail.test.track.selection" _inherit = ['mail.thread'] name = fields.Char() selection_type = fields.Selection([('first', 'First'), ('second', 'Second')], tracking=True) -class MailTestTrackAll(models.Model): - _name = 'mail.test.track.all' - _description = 'Test tracking on all field types' - _inherit = ['mail.thread'] - - boolean_field = fields.Boolean('Boolean', tracking=True) - char_field = fields.Char('Char', tracking=True) - company_id = fields.Many2one('res.company') - currency_id = fields.Many2one('res.currency', related='company_id.currency_id') - date_field = fields.Date('Date', tracking=True) - datetime_field = fields.Datetime('Datetime', tracking=True) - float_field = fields.Float('Float', tracking=True) - html_field = fields.Html('Html', tracking=True) - integer_field = fields.Integer('Integer', tracking=True) - many2one_field_id = fields.Many2one('res.partner', string='Many2one', tracking=True) - monetary_field = fields.Monetary('Monetary', tracking=True) - selection_field = fields.Selection(string='Selection', selection=[['first', 'FIRST']], tracking=True) - text_field = fields.Text('Text', tracking=True) - # ------------------------------------------------------------ # OTHER # ------------------------------------------------------------ + class MailTestMultiCompany(models.Model): - """ This model can be used in multi company tests""" - _name = 'mail.test.multi.company' + """ This model can be used in multi company tests, with attachments support + for checking record update in MC """ _description = "Test Multi Company Mail" - _inherit = 'mail.thread' + _name = "mail.test.multi.company" + _inherit = ['mail.thread.main.attachment'] name = fields.Char() company_id = fields.Many2one('res.company') @@ -168,16 +233,29 @@ class MailTestMultiCompanyRead(models.Model): even if the user has no write access. """ _description = 'Simple Chatter Model ' _name = 'mail.test.multi.company.read' - _inherit = ['mail.test.multi.company'] + _inherit = ['mail.test.multi.company', 'mail.activity.mixin'] _mail_post_access = 'read' -class MailTestNotMailThread(models.Model): +class MailTestMultiCompanyWithActivity(models.Model): + """ This model can be used in multi company tests with activity""" + _description = "Test Multi Company Mail With Activity" + _name = "mail.test.multi.company.with.activity" + _inherit = ["mail.thread", "mail.activity.mixin"] + + name = fields.Char() + company_id = fields.Many2one("res.company") + + +class MailTestNothread(models.Model): """ Models not inheriting from mail.thread but using some cross models capabilities of mail. """ - _name = 'mail.test.nothread' _description = "NoThread Model" + _name = "mail.test.nothread" name = fields.Char() company_id = fields.Many2one('res.company') customer_id = fields.Many2one('res.partner') + + def _mail_get_partner_fields(self, introspect_fields=False): + return ['customer_id'] diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_feature_models.py b/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_feature_models.py new file mode 100644 index 0000000..f4a347d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_feature_models.py @@ -0,0 +1,95 @@ +from odoo import api, fields, models +from odoo.fields import Domain + +# ------------------------------------------------------------ +# RECIPIENTS +# ------------------------------------------------------------ + + +class MailTestRecipients(models.Model): + _name = 'mail.test.recipients' + _description = "Test Recipients Computation" + _inherit = ['mail.thread.cc'] + _primary_email = 'customer_email' + + company_id = fields.Many2one('res.company') + contact_ids = fields.Many2many('res.partner') + customer_id = fields.Many2one('res.partner') + customer_email = fields.Char('Customer Email', compute='_compute_customer_email', readonly=False, store=True) + customer_phone = fields.Char('Customer Phone', compute='_compute_customer_phone', readonly=False, store=True) + name = fields.Char() + + @api.depends('customer_id') + def _compute_customer_email(self): + for source in self.filtered(lambda r: r.customer_id and not r.customer_email): + source.customer_email = source.customer_id.email_formatted + + @api.depends('customer_id') + def _compute_customer_phone(self): + for source in self.filtered(lambda r: r.customer_id and not r.customer_phone): + source.customer_phone = source.customer_id.phone + + def _mail_get_partner_fields(self, introspect_fields=False): + return ['customer_id', 'contact_ids'] + + +class MailTestThreadCustomer(models.Model): + _name = 'mail.test.thread.customer' + _description = "Test Customer Thread Model" + _inherit = ['mail.test.recipients'] + _mail_thread_customer = True + _primary_email = 'customer_email' + +# ------------------------------------------------------------ +# PROPERTIES +# ------------------------------------------------------------ + + +class MailTestProperties(models.Model): + _name = 'mail.test.properties' + _description = 'Mail Test Properties' + _inherit = ['mail.thread'] + + name = fields.Char('Name') + parent_id = fields.Many2one('mail.test.properties', string='Parent') + properties = fields.Properties('Properties', definition='parent_id.definition_properties') + definition_properties = fields.PropertiesDefinition('Definitions') + +# ------------------------------------------------------------ +# ROTTING RESOURCES +# ------------------------------------------------------------ + + +class MailTestStageField(models.Model): + _description = 'Fake model to be a stage to help test rotting implementation' + _name = 'mail.test.rotting.stage' + + name = fields.Char() + rotting_threshold_days = fields.Integer(default=3) + no_rot = fields.Boolean(default=False) + + +class MailTestRottingMixin(models.Model): + _description = 'Fake model to test the rotting part of the mixin mail.thread.tracking.duration.mixin' + _name = 'mail.test.rotting.resource' + _track_duration_field = 'stage_id' + _inherit = ['mail.tracking.duration.mixin'] + + name = fields.Char() + date_last_stage_update = fields.Datetime( + 'Last Stage Update', compute='_compute_date_last_stage_update', index=True, readonly=True, store=True) + stage_id = fields.Many2one('mail.test.rotting.stage', 'Stage') + done = fields.Boolean(default=False) + + def _get_rotting_depends_fields(self): + return super()._get_rotting_depends_fields() + ['done', 'stage_id.no_rot'] + + def _get_rotting_domain(self): + return super()._get_rotting_domain() & Domain([ + ('done', '=', False), + ('stage_id.no_rot', '=', False), + ]) + + @api.depends('stage_id') + def _compute_date_last_stage_update(self): + self.date_last_stage_update = fields.Datetime.now() diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_models.py b/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_models.py index 7770208..37a7ab0 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_models.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_models.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - from odoo import api, fields, models, _ @@ -8,29 +5,93 @@ class MailTestSimple(models.Model): """ A very simple model only inheriting from mail.thread when only communication history is necessary. """ _description = 'Simple Chatter Model' - _name = 'mail.test.simple' + _name = "mail.test.simple" _inherit = ['mail.thread'] name = fields.Char() email_from = fields.Char() + def _message_compute_subject(self): + """ To ease mocks """ + _a = super()._message_compute_subject() + return _a + + def _notify_by_email_get_final_mail_values(self, *args, **kwargs): + """ To ease mocks """ + _a = super()._notify_by_email_get_final_mail_values(*args, **kwargs) + return _a + + def _notify_by_email_get_headers(self, headers=None): + headers = super()._notify_by_email_get_headers(headers=headers) + headers['X-Custom'] = 'Done' + return headers + +class MailTestSimpleUnnamed(models.Model): + """ A very simple model only inheriting from mail.thread when only + communication history is necessary, and has no 'name' field """ + _description = 'Simple Chatter Model without "name" field' + _name = 'mail.test.simple.unnamed' + _inherit = ['mail.thread'] + _rec_name = "description" + + description = fields.Char() + +class MailTestSimpleMainAttachment(models.Model): + _description = 'Simple Chatter Model With Main Attachment Management' + _name = "mail.test.simple.main.attachment" + _inherit = ['mail.test.simple', 'mail.thread.main.attachment'] + + +class MailTestSimpleUnfollow(models.Model): + """ A very simple model only inheriting from mail.thread when only + communication history is necessary with unfollow link enabled in + notification emails even for non-internal user. """ + _description = 'Simple Chatter Model' + _name = "mail.test.simple.unfollow" + _inherit = ['mail.thread'] + _partner_unfollow_enabled = True + + name = fields.Char() + company_id = fields.Many2one('res.company') + email_from = fields.Char() + + +class MailTestAliasOptional(models.Model): + """ A chatter model inheriting from the alias mixin using optional alias_id + field, hence no inherits. """ + _description = 'Chatter Model using Optional Alias Mixin' + _name = "mail.test.alias.optional" + _inherit = ['mail.alias.mixin.optional'] + + name = fields.Char() + company_id = fields.Many2one('res.company', default=lambda self: self.env.company) + email_from = fields.Char() + + def _alias_get_creation_values(self): + """ Updates itself """ + values = super()._alias_get_creation_values() + values['alias_model_id'] = self.env['ir.model']._get_id('mail.test.alias.optional') + if self.id: + values['alias_force_thread_id'] = self.id + values['alias_defaults'] = {'company_id': self.company_id.id} + return values + class MailTestGateway(models.Model): """ A very simple model only inheriting from mail.thread to test pure mass mailing features and base performances. """ _description = 'Simple Chatter Model for Mail Gateway' - _name = 'mail.test.gateway' + _name = "mail.test.gateway" _inherit = ['mail.thread.blacklist'] _primary_email = 'email_from' name = fields.Char() email_from = fields.Char() custom_field = fields.Char() + user_id = fields.Many2one('res.users', 'Responsible') @api.model def message_new(self, msg_dict, custom_values=None): - """ Check override of 'message_new' allowing to update record values - base on incoming email. """ defaults = { 'email_from': msg_dict.get('from'), } @@ -38,11 +99,32 @@ class MailTestGateway(models.Model): return super().message_new(msg_dict, custom_values=defaults) +class MailTestGatewayCompany(models.Model): + """ A very simple model only inheriting from mail.thread to test pure mass + mailing features and base performances, with a company field. """ + _description = 'Simple Chatter Model for Mail Gateway with company' + _name = "mail.test.gateway.company" + _inherit = ['mail.test.gateway'] + + company_id = fields.Many2one('res.company', 'Company') + + +class MailTestGatewayMainAttachment(models.Model): + """ A very simple model only inheriting from mail.thread to test pure mass + mailing features and base performances, with a company field and main + attachment management. """ + _description = 'Simple Chatter Model for Mail Gateway with company' + _name = "mail.test.gateway.main.attachment" + _inherit = ['mail.test.gateway', 'mail.thread.main.attachment'] + + company_id = fields.Many2one('res.company', 'Company') + + class MailTestGatewayGroups(models.Model): """ A model looking like discussion channels / groups (flat thread and alias). Used notably for advanced gatewxay tests. """ _description = 'Channel/Group-like Chatter Model for Mail Gateway' - _name = 'mail.test.gateway.groups' + _name = "mail.test.gateway.groups" _inherit = ['mail.thread.blacklist', 'mail.alias.mixin'] _mail_flat_thread = False _primary_email = 'email_from' @@ -60,25 +142,15 @@ class MailTestGatewayGroups(models.Model): values['alias_parent_thread_id'] = self.id return values - def _mail_get_partner_fields(self): + def _mail_get_partner_fields(self, introspect_fields=False): return ['customer_id'] - def _message_get_default_recipients(self): - return dict( - (record.id, { - 'email_cc': False, - 'email_to': record.email_from if not record.customer_id.ids else False, - 'partner_ids': record.customer_id.ids, - }) - for record in self - ) - -class MailTestStandard(models.Model): +class MailTestTrack(models.Model): """ This model can be used in tests when automatic subscription and simple tracking is necessary. Most features are present in a simple way. """ _description = 'Standard Chatter Model' - _name = 'mail.test.track' + _name = "mail.test.track" _inherit = ['mail.thread'] name = fields.Char() @@ -86,24 +158,40 @@ class MailTestStandard(models.Model): user_id = fields.Many2one('res.users', 'Responsible', tracking=True) container_id = fields.Many2one('mail.test.container', tracking=True) company_id = fields.Many2one('res.company') + track_fields_tofilter = fields.Char() # comma-separated list of field names + track_enable_default_log = fields.Boolean(default=False) + parent_id = fields.Many2one('mail.test.track', string='Parent') + + def _track_filter_for_display(self, tracking_values): + values = super()._track_filter_for_display(tracking_values) + filtered_fields = set(self.track_fields_tofilter.split(',') if self.track_fields_tofilter else '') + return values.filtered(lambda val: val.field_id.name not in filtered_fields) + + def _track_get_default_log_message(self, changes): + filtered_fields = set(self.track_fields_tofilter.split(',') if self.track_fields_tofilter else '') + if self.track_enable_default_log and not all(change in filtered_fields for change in changes): + return f'There was a change on {self.name} for fields "{",".join(changes)}"' + return super()._track_get_default_log_message(changes) class MailTestActivity(models.Model): """ This model can be used to test activities in addition to simple chatter features. """ _description = 'Activity Model' - _name = 'mail.test.activity' + _name = "mail.test.activity" _inherit = ['mail.thread', 'mail.activity.mixin'] name = fields.Char() date = fields.Date() email_from = fields.Char() active = fields.Boolean(default=True) + company_id = fields.Many2one('res.company') def action_start(self, action_summary): return self.activity_schedule( 'test_mail.mail_act_test_todo', - summary=action_summary + summary=action_summary, + user_id=self.env.uid, ) def action_close(self, action_feedback, attachment_ids=None): @@ -112,196 +200,18 @@ class MailTestActivity(models.Model): attachment_ids=attachment_ids) -class MailTestTicket(models.Model): - """ This model can be used in tests when complex chatter features are - required like modeling tasks or tickets. """ - _description = 'Ticket-like model' - _name = 'mail.test.ticket' - _inherit = ['mail.thread'] - _primary_email = 'email_from' - - name = fields.Char() - email_from = fields.Char(tracking=True) - count = fields.Integer(default=1) - datetime = fields.Datetime(default=fields.Datetime.now) - mail_template = fields.Many2one('mail.template', 'Template') - customer_id = fields.Many2one('res.partner', 'Customer', tracking=2) - user_id = fields.Many2one('res.users', 'Responsible', tracking=1) - container_id = fields.Many2one('mail.test.container', tracking=True) - - def _mail_get_partner_fields(self): - return ['customer_id'] - - def _message_get_default_recipients(self): - return dict( - (record.id, { - 'email_cc': False, - 'email_to': record.email_from if not record.customer_id.ids else False, - 'partner_ids': record.customer_id.ids, - }) - for record in self - ) - - def _notify_get_recipients_groups(self, msg_vals=None): - """ Activate more groups to test query counters notably (and be backward - compatible for tests). """ - local_msg_vals = dict(msg_vals or {}) - groups = super()._notify_get_recipients_groups(msg_vals=msg_vals) - for group_name, _group_method, group_data in groups: - if group_name == 'portal': - group_data['active'] = True - elif group_name == 'customer': - group_data['active'] = True - group_data['has_button_access'] = True - group_data['actions'] = [{ - 'url': self._notify_get_action_link( - 'controller', - controller='/test_mail/do_stuff', - **local_msg_vals - ), - 'title': _('NotificationButtonTitle') - }] - - return groups - - def _track_template(self, changes): - res = super(MailTestTicket, self)._track_template(changes) - record = self[0] - if 'customer_id' in changes and record.mail_template: - res['customer_id'] = (record.mail_template, {'composition_mode': 'mass_mail'}) - elif 'datetime' in changes: - res['datetime'] = ('test_mail.mail_test_ticket_tracking_view', {'composition_mode': 'mass_mail'}) - return res - - def _creation_subtype(self): - if self.container_id: - return self.env.ref('test_mail.st_mail_test_ticket_container_upd') - return super(MailTestTicket, self)._creation_subtype() - - def _track_subtype(self, init_values): - self.ensure_one() - if 'container_id' in init_values and self.container_id: - return self.env.ref('test_mail.st_mail_test_ticket_container_upd') - return super(MailTestTicket, self)._track_subtype(init_values) - - - -class MailTestTicketEL(models.Model): - """ Just mail.test.ticket, but exclusion-list enabled. Kept as different - model to avoid messing with existing tests, notably performance, and ease - backward comparison. """ - _description = 'Ticket-like model with exclusion list' - _name = 'mail.test.ticket.el' - _inherit = [ - 'mail.test.ticket', - 'mail.thread.blacklist', - ] - _primary_email = 'email_from' - - email_from = fields.Char( - 'Email', - compute='_compute_email_from', readonly=False, store=True) - - @api.depends('customer_id') - def _compute_email_from(self): - for ticket in self.filtered(lambda r: r.customer_id and not r.email_from): - ticket.email_from = ticket.customer_id.email_formatted - - -class MailTestTicketMC(models.Model): - """ Just mail.test.ticket, but multi company. Kept as different model to - avoid messing with existing tests, notably performance, and ease backward - comparison. """ - _description = 'Ticket-like model' - _name = 'mail.test.ticket.mc' - _inherit = ['mail.test.ticket'] - _primary_email = 'email_from' - - company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.company) - container_id = fields.Many2one('mail.test.container.mc', tracking=True) - - def _creation_subtype(self): - if self.container_id: - return self.env.ref('test_mail.st_mail_test_ticket_container_mc_upd') - return super()._creation_subtype() - - def _track_subtype(self, init_values): - self.ensure_one() - if 'container_id' in init_values and self.container_id: - return self.env.ref('test_mail.st_mail_test_ticket_container_mc_upd') - return super()._track_subtype(init_values) - - -class MailTestContainer(models.Model): - """ This model can be used in tests when container records like projects - or teams are required. """ - _description = 'Project-like model with alias' - _name = 'mail.test.container' - _mail_post_access = 'read' - _inherit = ['mail.thread', 'mail.alias.mixin'] - - name = fields.Char() - description = fields.Text() - customer_id = fields.Many2one('res.partner', 'Customer') - alias_id = fields.Many2one( - 'mail.alias', 'Alias', - delegate=True) - - def _mail_get_partner_fields(self): - return ['customer_id'] - - def _message_get_default_recipients(self): - return dict( - (record.id, { - 'email_cc': False, - 'email_to': False, - 'partner_ids': record.customer_id.ids, - }) - for record in self - ) - - def _notify_get_recipients_groups(self, msg_vals=None): - """ Activate more groups to test query counters notably (and be backward - compatible for tests). """ - groups = super(MailTestContainer, self)._notify_get_recipients_groups(msg_vals=msg_vals) - for group_name, _group_method, group_data in groups: - if group_name == 'portal': - group_data['active'] = True - - return groups - - def _alias_get_creation_values(self): - values = super(MailTestContainer, self)._alias_get_creation_values() - values['alias_model_id'] = self.env['ir.model']._get('mail.test.container').id - if self.id: - values['alias_force_thread_id'] = self.id - values['alias_parent_thread_id'] = self.id - return values - -class MailTestContainerMC(models.Model): - """ Just mail.test.container, but multi company. Kept as different model to - avoid messing with existing tests, notably performance, and ease backward - comparison. """ - _description = 'Project-like model with alias (MC)' - _name = 'mail.test.container.mc' - _mail_post_access = 'read' - _inherit = ['mail.test.container'] - - company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.company) - - class MailTestComposerMixin(models.Model): """ A simple invite-like wizard using the composer mixin, rendering on composer source test model. Purpose is to have a minimal composer which runs on other records and check notably dynamic template support and translations. """ _description = 'Invite-like Wizard' - _name = 'mail.test.composer.mixin' + _name = "mail.test.composer.mixin" _inherit = ['mail.composer.mixin'] name = fields.Char('Name') author_id = fields.Many2one('res.partner') - description = fields.Html('Description', render_engine="qweb", render_options={"post_process": True}, sanitize=False) + description = fields.Html('Description', render_engine="qweb", render_options={"post_process": True}, sanitize='email_outgoing') source_ids = fields.Many2many('mail.test.composer.source', string='Invite source') def _compute_render_model(self): @@ -310,8 +220,8 @@ class MailTestComposerMixin(models.Model): class MailTestComposerSource(models.Model): """ A simple model on which invites are sent. """ - _description = 'Invite-like Wizard' - _name = 'mail.test.composer.source' + _description = 'Invite-like Source' + _name = "mail.test.composer.source" _inherit = ['mail.thread.blacklist'] _primary_email = 'email_from' @@ -326,5 +236,5 @@ class MailTestComposerSource(models.Model): for source in self.filtered(lambda r: r.customer_id and not r.email_from): source.email_from = source.customer_id.email_formatted - def _mail_get_partner_fields(self): + def _mail_get_partner_fields(self, introspect_fields=False): return ['customer_id'] diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_thread_models.py b/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_thread_models.py index 29bbe72..cfa40f0 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_thread_models.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_thread_models.py @@ -4,7 +4,7 @@ from odoo import api, fields, models -class MailTestCC(models.Model): +class MailTestCc(models.Model): _name = 'mail.test.cc' _description = "Test Email CC Thread" _inherit = ['mail.thread.cc'] diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/security/ir.model.access.csv b/odoo-bringout-oca-ocb-test_mail/test_mail/security/ir.model.access.csv index 2dca354..73bcba6 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/security/ir.model.access.csv +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/security/ir.model.access.csv @@ -1,15 +1,29 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_mail_performance_thread,access_mail_performance_thread,model_mail_performance_thread,,1,1,1,1 +access_mail_performance_thread,access_mail_performance_thread,model_mail_performance_thread,base.group_user,1,1,1,1 +access_mail_performance_thread_recipients,access_mail_performance_thread_recipients,model_mail_performance_thread_recipients,base.group_user,1,1,1,1 access_mail_performance_tracking_user,mail.performance.tracking,model_mail_performance_tracking,base.group_user,1,1,1,1 access_mail_test_access_portal,mail.access.portal.portal,model_mail_test_access,base.group_portal,1,1,0,0 access_mail_test_access_public,mail.access.portal.public,model_mail_test_access,base.group_public,1,0,0,0 access_mail_test_access_user,mail.access.portal.user,model_mail_test_access,base.group_user,1,1,1,1 access_mail_test_access_custo_portal,mail.access.portal.portal,model_mail_test_access_custo,base.group_portal,1,0,0,0 access_mail_test_access_custo_user,mail.access.portal.user,model_mail_test_access_custo,base.group_user,1,1,1,1 +access_mail_test_access_public_public,mail.test.access.public.public,model_mail_test_access_public,base.group_public,1,1,0,0 +access_mail_test_access_public_portal,mail.test.access.public.portal,model_mail_test_access_public,base.group_portal,1,1,0,0 +access_mail_test_access_public_user,mail.test.access.public.user,model_mail_test_access_public,base.group_user,1,1,1,1 +access_mail_test_alias_optional_portal,mail.test.alias.optional.portal,model_mail_test_alias_optional,base.group_portal,1,0,0,0 +access_mail_test_alias_optional_user,mail.test.alias.optional.user,model_mail_test_alias_optional,base.group_user,1,1,1,1 access_mail_test_simple_portal,mail.test.simple.portal,model_mail_test_simple,base.group_portal,1,0,0,0 access_mail_test_simple_user,mail.test.simple.user,model_mail_test_simple,base.group_user,1,1,1,1 +access_mail_test_simple_unnamed_portal,mail.test.simple.unnamed.portal,model_mail_test_simple_unnamed,base.group_portal,1,0,0,0 +access_mail_test_simple_unnamed_user,mail.test.simple.unnamed.user,model_mail_test_simple_unnamed,base.group_user,1,1,1,1 +access_mail_test_simple_unfollow_portal,mail.test.simple.unfollow.portal,model_mail_test_simple_unfollow,base.group_portal,0,0,0,0 +access_mail_test_simple_unfollow_user,mail.test.simple.unfollow.user,model_mail_test_simple_unfollow,base.group_user,1,1,1,1 +access_mail_test_simple_main_attachment_portal,mail.test.simple.main.attachment.portal,model_mail_test_simple_main_attachment,base.group_portal,1,0,0,0 +access_mail_test_simple_main_attachment_user,mail.test.simple.main.attachment.user,model_mail_test_simple_main_attachment,base.group_user,1,1,1,1 access_mail_test_gateway_portal,mail.test.gateway.portal,model_mail_test_gateway,base.group_portal,1,0,0,0 access_mail_test_gateway_user,mail.test.gateway.user,model_mail_test_gateway,base.group_user,1,1,1,1 +access_mail_test_gateway_company_user,mail.test.gateway.company.user,model_mail_test_gateway_company,base.group_user,1,1,1,1 +access_mail_test_gateway_main_attachment_user,mail.test.gateway.main.attachment.user,model_mail_test_gateway_main_attachment,base.group_user,1,1,1,1 access_mail_test_gateway_groups_portal,mail.test.gateway.groups.portal,model_mail_test_gateway_groups,base.group_portal,1,0,0,0 access_mail_test_gateway_groups_user,mail.test.gateway.groups.user,model_mail_test_gateway_groups,base.group_user,1,1,1,1 access_mail_test_track_portal,mail.test.track.portal,model_mail_test_track,base.group_portal,0,0,0,0 @@ -18,15 +32,18 @@ access_mail_test_activity_portal,mail.test.activity.portal,model_mail_test_activ access_mail_test_activity_user,mail.test.activity.user,model_mail_test_activity,base.group_user,1,1,1,1 access_mail_test_field_type_portal,mail.test.field.type.portal,model_mail_test_field_type,base.group_portal,0,0,0,0 access_mail_test_field_type_user,mail.test.field.type.user,model_mail_test_field_type,base.group_user,1,1,1,1 +access_mail_test_lead_user,mail.test.lead.user,model_mail_test_lead,base.group_user,1,1,1,1 access_mail_test_ticket_portal,mail.test.ticket.portal,model_mail_test_ticket,base.group_portal,1,0,0,0 access_mail_test_ticket_user,mail.test.ticket.user,model_mail_test_ticket,base.group_user,1,1,1,1 access_mail_test_ticket_el_portal,mail.test.ticket.el.portal,model_mail_test_ticket_el,base.group_portal,1,0,0,0 access_mail_test_ticket_el_user,mail.test.ticket.el.user,model_mail_test_ticket_el,base.group_user,1,1,1,1 access_mail_test_ticket_mc_portal,mail.test.ticket.mc.portal,model_mail_test_ticket_mc,base.group_portal,1,0,0,0 access_mail_test_ticket_mc_user,mail.test.ticket.mc.user,model_mail_test_ticket_mc,base.group_user,1,1,1,1 +access_mail_test_ticket_partner_portal,mail.test.ticket.partner.portal,model_mail_test_ticket_partner,base.group_portal,1,0,0,0 +access_mail_test_ticket_partner_user,mail.test.ticket.partner.user,model_mail_test_ticket_partner,base.group_user,1,1,1,1 access_mail_test_composer_mixin_all,mail.test.composer.mixin.all,model_mail_test_composer_mixin,,0,0,0,0 access_mail_test_composer_mixin_user,mail.test.composer.mixin.user,model_mail_test_composer_mixin,base.group_user,1,1,1,1 -access_mail_test_composer_source_all,mail.test.composer.source.all,model_mail_test_composer_source,,1,0,0,0 +access_mail_test_composer_source_all,mail.test.composer.source.all,model_mail_test_composer_source,base.group_user,1,0,0,0 access_mail_test_composer_source_user,mail.test.composer.source.user,model_mail_test_composer_source,base.group_user,1,1,1,1 access_mail_test_container_portal,mail.test.container_portal,model_mail_test_container,base.group_portal,1,0,0,0 access_mail_test_container_user,mail.test.container.user,model_mail_test_container,base.group_user,1,1,1,1 @@ -44,8 +61,18 @@ access_mail_test_multi_company_with_activity_user,mail.test.multi.company.with.a access_mail_test_multi_company_with_activity_portal,mail.test.multi.company.with.activity.portal,model_mail_test_multi_company_with_activity,base.group_portal,1,0,0,0 access_mail_test_nothread_user,mail.test.nothread.user,model_mail_test_nothread,base.group_user,1,1,1,1 access_mail_test_nothread_portal,mail.test.nothread.portal,model_mail_test_nothread,base.group_portal,1,0,0,0 +access_mail_test_recipients_user,mail.test.recipients.user,model_mail_test_recipients,base.group_user,1,1,1,1 +access_mail_test_rotting_resource,mail.test.rotting.resource,model_mail_test_rotting_resource,base.group_user,1,1,1,1 +access_mail_test_rotting_stage,mail.test.rotting.stage,model_mail_test_rotting_stage,base.group_user,1,1,1,1 +access_mail_test_thread_customer_user,mail.test.thread.customer.user,model_mail_test_thread_customer,base.group_user,1,1,1,1 access_mail_test_track_all,mail.test.track.all,model_mail_test_track_all,base.group_user,1,1,1,1 +access_mail_test_track_all_properties_parent,access_mail_test_track_all_properties_parent,model_mail_test_track_all_properties_parent,base.group_user,1,0,0,0 +access_mail_test_track_all_m2m,mail.test.track.all.m2m,model_mail_test_track_all_m2m,base.group_user,1,1,1,1 +access_mail_test_track_all_o2m,mail.test.track.all.o2m,model_mail_test_track_all_o2m,base.group_user,1,1,1,1 access_mail_test_track_compute,mail.test.track.compute,model_mail_test_track_compute,base.group_user,1,1,1,1 +access_mail_test_track_groups,mail.test.track.groups,model_mail_test_track_groups,base.group_user,1,1,1,1 access_mail_test_track_monetary,mail.test.track.monetary,model_mail_test_track_monetary,base.group_user,1,1,1,1 access_mail_test_track_selection_portal,mail.test.track.selection.portal,model_mail_test_track_selection,base.group_portal,0,0,0,0 access_mail_test_track_selection_user,mail.test.track.selection.user,model_mail_test_track_selection,base.group_user,1,1,1,1 +access_mail_test_properties_user,mail.test.properties.user,model_mail_test_properties,base.group_user,1,1,1,1 +access_mail_test_track_duration_mixin,mail.test.track.duration.mixin,model_mail_test_track_duration_mixin,base.group_user,1,1,1,1 diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/security/test_mail_security.xml b/odoo-bringout-oca-ocb-test_mail/test_mail/security/test_mail_security.xml index 293777d..d7c0db4 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/security/test_mail_security.xml +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/security/test_mail_security.xml @@ -1,6 +1,16 @@ + + + Dummy rule, just to enable rule evaluation, shows some specific errors + + [('email_from', '!=', 'donotsetmewiththisvalue')] + + + Public: public only @@ -55,21 +65,45 @@ - - - Mail Test Multi Company - - - ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + Portal: read unlocked + + [('is_locked', '=', False)] + + + + + + + + Internal: create/write/unlink unlocked and not readonly + + [('is_readonly', '=', False), ('is_locked', '=', False)] + + + + + + + + Admin: all + + [(1, '=', 1)] + + + + + + Mail Test Multi Company [('company_id', 'in', company_ids + [False])] - MC Readonly Rule @@ -77,7 +111,6 @@ [('company_id', 'in', company_ids + [False])] - Mail Test Multi Company With Activity @@ -85,15 +118,13 @@ [('company_id', 'in', company_ids + [False])] - + Portal Mail Test Ticket [('message_partner_ids', 'in', [user.partner_id.id])] - - Mail Test Ticket Multi Company @@ -106,16 +137,26 @@ [('message_partner_ids', 'in', [user.partner_id.id])] + + Mail Test Ticket Multi Company Partner + + + [('company_id', 'in', company_ids + [False])] + + + Portal Mail Test Ticket Multi Company Partner + + [('message_partner_ids', 'in', [user.partner_id.id])] + + - + Portal Mail Test Container [('message_partner_ids', 'in', [user.partner_id.id])] - - Mail Test Container Multi Company diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/activity.test.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/activity.test.js new file mode 100644 index 0000000..cbaaeb3 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/activity.test.js @@ -0,0 +1,1199 @@ +import { + click, + contains, + insertText, + openFormView, + openView, + registerArchs, + start, + startServer, +} from "@mail/../tests/mail_test_helpers"; +import { MailActivitySchedule } from "@mail/../tests/mock_server/mock_models/mail_activity_schedule"; +import { ActivityController } from "@mail/views/web/activity/activity_controller"; +import { ActivityModel } from "@mail/views/web/activity/activity_model"; +import { ActivityRenderer } from "@mail/views/web/activity/activity_renderer"; +import { beforeEach, describe, expect, test } from "@odoo/hoot"; +import { keyDown, waitFor } from "@odoo/hoot-dom"; +import { animationFrame, disableAnimations, mockDate } from "@odoo/hoot-mock"; +import { onMounted, onWillUnmount } from "@odoo/owl"; +import { MailTestActivity } from "@test_mail/../tests/mock_server/models/mail_test_activity"; +import { defineTestMailModels } from "@test_mail/../tests/test_mail_test_helpers"; +import { + asyncStep, + mockService, + onRpc, + patchWithCleanup, + serverState, + waitForSteps, + contains as webContains, +} from "@web/../tests/web_test_helpers"; +import { Domain } from "@web/core/domain"; +import { formatDate, serializeDate } from "@web/core/l10n/dates"; +import { deepEqual, omit } from "@web/core/utils/objects"; +import { getOrigin } from "@web/core/utils/urls"; +import { DynamicList } from "@web/model/relational_model/dynamic_list"; +import { RelationalModel } from "@web/model/relational_model/relational_model"; + +const { DateTime } = luxon; + +let pyEnv; +const archs = { + "mail.test.activity,false,activity": ` + + +
+ +
+
+
+ `, + "mail.test.activity,1,activity": ` + +
+ + + + + Test invisible + +
+
+ `, +}; + +function patchActivityDomain(load, params) { + if (params.domain) { + // Remove domain term used to filter record having "done" activities (not understood by the getRecords mock) + const domain = new Domain(params.domain); + const newDomain = Domain.removeDomainLeaves(domain.toList(), ["activity_ids.active"]); + if (!deepEqual(domain.toList(), newDomain.toList())) { + return load({ + ...params, + domain: newDomain.toList(), + context: params.context + ? { ...params.context, active_test: false } + : { active_test: false }, + }); + } + } + return load(params); +} + +describe.current.tags("desktop"); +defineTestMailModels(); +beforeEach(async () => { + // Because tests implicitly use Popover + // and that it uses HTMLElement.animate() + disableAnimations(); + mockDate("2023-04-08 10:00:00", 0); + patchWithCleanup(DynamicList.prototype, { + async load(params) { + return patchActivityDomain(super.load.bind(this), params); + }, + }); + patchWithCleanup(RelationalModel.prototype, { + async load(params) { + return patchActivityDomain(super.load.bind(this), params); + }, + }); + pyEnv = await startServer(); + const mailTemplateIds = pyEnv["mail.template"].create([ + { name: "Template1" }, + { name: "Template2" }, + ]); + // reset incompatible setup + pyEnv["mail.activity.type"].unlink(pyEnv["mail.activity.type"].search([])); + const mailActivityTypeIds = pyEnv["mail.activity.type"].create([ + { name: "Email", mail_template_ids: mailTemplateIds }, + { name: "Call" }, + { name: "Call for Demo" }, + { name: "To Do" }, + ]); + const resUsersId1 = pyEnv["res.users"].create({ + partner_id: pyEnv["res.partner"].create({ name: "first partner" }), + }); + const mailActivityIds = pyEnv["mail.activity"].create([ + { + display_name: "An activity", + date_deadline: serializeDate(DateTime.now().plus({ days: 3 })), + can_write: true, + state: "planned", + activity_type_id: mailActivityTypeIds[0], + user_id: resUsersId1, + }, + { + display_name: "An activity", + date_deadline: serializeDate(DateTime.now()), + can_write: true, + state: "today", + activity_type_id: mailActivityTypeIds[0], + user_id: resUsersId1, + }, + { + res_model: "mail.test.activity", + display_name: "An activity", + date_deadline: serializeDate(DateTime.now().minus({ days: 2 })), + can_write: true, + state: "overdue", + activity_type_id: mailActivityTypeIds[1], + user_id: resUsersId1, + }, + ]); + pyEnv["mail.test.activity"].create([ + { name: "Meeting Room Furnitures", activity_ids: [mailActivityIds[0]] }, + { name: "Office planning", activity_ids: [mailActivityIds[1], mailActivityIds[2]] }, + ]); +}); + +test("activity view: simple activity rendering", async () => { + const mailTestActivityIds = pyEnv["mail.test.activity"].search([]); + const mailActivityTypeIds = pyEnv["mail.activity.type"].search([]); + await start(); + registerArchs(archs); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + await contains(".o_activity_view_table th", { text: "Email" }); + await contains(".progress-bar[data-tooltip='1 Planned']", { + parent: [".o_activity_view_table th", { text: "Email" }], + }); + await contains(".progress-bar[data-tooltip='1 Today']", { + parent: [".o_activity_view_table th", { text: "Email" }], + }); + await contains(".o_activity_view_table th", { + text: "Call", + after: [".o_activity_view_table th", { text: "Email" }], + }); + await contains(".progress-bar[data-tooltip='1 Overdue']", { + parent: [".o_activity_view_table th", { text: "Call" }], + }); + await contains(".o_activity_view_table th", { + text: "Call for Demo", + after: [".o_activity_view_table th", { text: "Call" }], + }); + await contains(".progress-bar", { + count: 0, + parent: [".o_activity_view_table th", { text: "Call for Demo" }], + }); + await contains(".o_activity_view_table tr:nth-child(1) .o_activity_record", { + text: "Office planning", + }); + await contains(".o_activity_view_table tr:nth-child(2) .o_activity_record", { + text: "Meeting Room Furnitures", + }); + const today = formatDate(DateTime.now()); + await contains(":nth-child(1 of .o_activity_summary_cell)", { + text: today, + parent: [ + "tr", + { + contains: [".o_activity_record", { text: "Office planning" }], + }, + ], + }); + await contains(".o_activity_empty_cell", { + count: 2, + parent: [ + "tr", + { + contains: [".o_activity_record", { text: "Office planning" }], + }, + ], + }); + mockService("action", { + doAction(action) { + asyncStep("doAction"); + expect(action).toEqual({ + context: { + default_res_id: mailTestActivityIds[1], + default_res_model: "mail.test.activity", + default_activity_type_id: mailActivityTypeIds[2], + dialog_size: "large", + }, + res_id: false, + res_model: "mail.activity", + target: "new", + type: "ir.actions.act_window", + view_mode: "form", + view_type: "form", + views: [[false, "form"]], + }); + }, + }); + await click(":nth-child(1 of .o_activity_empty_cell)", { + parent: [ + "tr", + { + contains: [".o_activity_record", { text: "Office planning" }], + }, + ], + }); + await waitForSteps(["doAction"]); + await contains(".o_activity_view_table tfoot .o_record_selector"); +}); + +test("activity view: Activity rendering with done activities", async () => { + const activityTypeUpload = pyEnv["mail.activity.type"].create({ + category: "upload_file", + name: "Test Upload document", + }); + pyEnv["mail.activity"].create( + Object.entries(["done", "done", "done", "done", "planned", "planned", "planned"]).map( + ([idx, state]) => { + const userId = pyEnv["res.users"].create({ + partner_id: pyEnv["res.partner"].create({ name: `Partner ${idx}` }), + }); + // issue with compute/related, `display_name` is wrong until next write. + pyEnv["res.users"].write([userId], {}); + return { + active: state !== "done", + activity_type_id: activityTypeUpload, + attachment_ids: + state === "done" + ? [ + pyEnv["ir.attachment"].create({ + name: `attachment ${idx}`, + create_date: serializeDate( + DateTime.now().minus({ days: idx }) + ), + create_uid: serverState.userId, + }), + ] + : [], + can_write: true, + date_deadline: serializeDate(DateTime.now().plus({ days: idx })), + date_done: + state === "done" + ? serializeDate(DateTime.now().minus({ days: idx })) + : false, + display_name: `Upload folders ${idx}`, + state: state, + user_id: userId, + }; + } + ) + ); + const [meetingRecord, officeRecord] = pyEnv["mail.test.activity"].search([]); + const uploadDoneActs = pyEnv["mail.activity"].search_read([ + ["activity_type_id", "=", activityTypeUpload], + ["active", "=", false], + ]); + const uploadPlannedActs = pyEnv["mail.activity"].search_read([ + ["activity_type_id", "=", activityTypeUpload], + ]); + pyEnv["mail.test.activity"].write([meetingRecord], { + activity_ids: [ + uploadPlannedActs[0].id, + uploadPlannedActs[1].id, + uploadPlannedActs[2].id, + uploadDoneActs[0].id, + ], + }); + pyEnv["mail.test.activity"].write([officeRecord], { + activity_ids: [uploadDoneActs[1].id, uploadDoneActs[2].id, uploadDoneActs[3].id], + }); + await start(); + registerArchs(archs); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + const domActivity = document.querySelector(".o_activity_view"); + const domHeaderUpload = domActivity.querySelector("table thead tr:first-child th:nth-child(6)"); + const selRowMeetingCellUpload = "table tbody tr:first-child td:nth-child(6)"; + const domRowMeetingCellUpload = domActivity.querySelector(selRowMeetingCellUpload); + const selRowOfficeCellUpload = "table tbody tr:nth-child(2) td:nth-child(6)"; + const domRowOfficeCellUpload = domActivity.querySelector(selRowOfficeCellUpload); + + // Headers + await contains(".o_column_progress .progress-bar:first-child[data-tooltip='3 Planned']", { + target: domHeaderUpload, + }); + await contains(".o_animated_number", { + target: domHeaderUpload, + text: "3", + }); + await contains(".o_column_progress_aggregated_on", { + target: domHeaderUpload, + text: "7", + }); + // Cells avatars + await contains( + `.o-mail-Avatar img[data-src='/web/image/res.users/${uploadPlannedActs[0].user_id[0]}/avatar_128'`, + { target: domRowMeetingCellUpload } + ); + await contains( + `.o-mail-Avatar img[data-src='/web/image/res.users/${uploadPlannedActs[1].user_id[0]}/avatar_128'`, + { target: domRowMeetingCellUpload } + ); + await contains( + `.o-mail-Avatar img[data-src='/web/image/res.users/${uploadPlannedActs[2].user_id[0]}/avatar_128'`, + { target: domRowMeetingCellUpload, count: 0 } + ); + await contains(`.o-mail-Avatar`, { target: domRowOfficeCellUpload, count: 0 }); // all activity are done + // Cells counters + await contains(".o-mail-ActivityCell-counter", { + target: domRowMeetingCellUpload, + text: "3 / 4", + }); + await contains(".o-mail-ActivityCell-counter", { + text: "3", + target: domRowOfficeCellUpload, + }); + // Cells dates + await contains(".o-mail-ActivityCell-deadline", { + text: formatDate(luxon.DateTime.fromISO(uploadPlannedActs[0].date_deadline)), + target: domRowMeetingCellUpload, + }); + await contains(".o-mail-ActivityCell-deadline", { + text: formatDate(luxon.DateTime.fromISO(uploadDoneActs[1].date_done)), + target: domRowOfficeCellUpload, + }); + // Activity list popovers content + await click(`${selRowMeetingCellUpload} > div`, { + target: domActivity, + }); + await contains(".o-mail-ActivityListPopover .badge.text-bg-success", { text: "3" }); // 3 planned + for (const actIdx of [0, 1, 2]) { + await contains(".o-mail-ActivityListPopoverItem", { + text: uploadPlannedActs[actIdx].user_id[1], + }); + } + await contains(".o-mail-ActivityListPopoverItem", { text: "Due in 4 days" }); + await contains(".o-mail-ActivityListPopoverItem", { text: "Due in 5 days" }); + await contains(".o-mail-ActivityListPopoverItem", { text: "Due in 6 days" }); + await contains(".o-mail-ActivityListPopover .badge.text-bg-secondary", { text: "1" }); // 1 done + await contains(".o-mail-ActivityListPopoverItem", { text: uploadDoneActs[0].user_id[1] }); + await contains(".o-mail-ActivityListPopoverItem", { + text: formatDate(luxon.DateTime.fromISO(uploadDoneActs[0].date_done)), + }); + await click(`${selRowOfficeCellUpload} > div`, { + target: domActivity, + }); + await contains(".o-mail-ActivityListPopover .badge.text-bg-secondary", { text: "3" }); // 3 done + for (const actIdx of [1, 2, 3]) { + await contains(".o-mail-ActivityListPopoverItem", { + text: formatDate(luxon.DateTime.fromISO(uploadDoneActs[actIdx].date_done)), + }); + await contains(".o-mail-ActivityListPopoverItem", { + text: uploadDoneActs[actIdx].user_id[1], + }); + } +}); + +test("activity view: a pager can be used when there are more than the limit of 100 activities to display", async () => { + const mailActivityTypeIds = pyEnv["mail.activity.type"].search([]); + + const recordsToCreate = []; + const activityToCreate = []; + + for (let i = 0; i < 101; i++) { + activityToCreate.push({ + display_name: "An activity " + i * 2, + date_deadline: serializeDate(DateTime.now().plus({ days: 3 })), + can_write: true, + state: "planned", + activity_type_id: mailActivityTypeIds[0], + }); + activityToCreate.push({ + display_name: "An activity " + (i * 2 + 1), + date_deadline: serializeDate(DateTime.now().plus({ days: 2 })), + can_write: true, + state: "planned", + activity_type_id: mailActivityTypeIds[1], + }); + } + const createdActivity = pyEnv["mail.activity"].create(activityToCreate); + for (let i = 0; i < 101; i++) { + recordsToCreate.push({ + name: "pagerTestRecord" + i, + activity_ids: [createdActivity[i * 2], createdActivity[i * 2 + 1]], + }); + } + pyEnv["mail.test.activity"].create(recordsToCreate); + + await start(); + registerArchs(archs); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + domain: [["name", "like", "pagerTestRecord"]], + }); + await contains(".o_activity_record", { count: 100 }); + await contains(".o_activity_summary_cell.planned", { count: 200 }); + await click(".o_pager_next"); + await contains(".o_activity_record"); + await contains(".o_activity_summary_cell.planned", { count: 2 }); + await click(".o_pager_previous"); + await contains(".o_activity_record", { count: 100 }); + await contains(".o_activity_summary_cell.planned", { count: 200 }); +}); + +test("activity view: no content rendering", async () => { + await start(); + // reset incompatible setup + pyEnv["mail.activity.type"].unlink(pyEnv["mail.activity.type"].search([])); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + await contains(".o_view_nocontent"); + await contains(".o_view_nocontent .o_view_nocontent_empty_folder", { + text: "No data to display", + }); +}); + +test("activity view: batch send mail on activity", async () => { + const mailTestActivityIds = pyEnv["mail.test.activity"].search([]); + const mailTemplateIds = pyEnv["mail.template"].search([]); + onRpc("activity_send_mail", ({ args }) => { + asyncStep(args); + return true; + }); + await start(); + registerArchs(archs); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + await click("[data-bs-toggle=dropdown]", { + parent: [".o_activity_view_table th", { text: "Email" }], + }); + await contains(".dropdown-menu.show .o_send_mail_template", { count: 2 }); + await click(".o_send_mail_template", { text: "Template1" }); + await waitForSteps([ + [[mailTestActivityIds[0], mailTestActivityIds[1]], mailTemplateIds[0]], // template 1 sendt on activity 1 and 2 + ]); + await click(".o_send_mail_template", { text: "Template2" }); + await waitForSteps([ + [[mailTestActivityIds[0], mailTestActivityIds[1]], mailTemplateIds[1]], // template 2 sendt on activity 1 and 2 + ]); +}); + +test("activity view: activity_ids condition in domain", async () => { + onRpc("get_activity_data", ({ kwargs }) => asyncStep(kwargs.domain)); + onRpc("web_search_read", ({ kwargs }) => asyncStep(kwargs.domain)); + await start(); + registerArchs(archs); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + + await click(".o_pager_value"); + await contains(".o_pager_value:focus"); + await keyDown("Enter"); + + await waitForSteps([ + // load view requests + [["activity_ids.active", "in", [true, false]]], + [[1, "=", 1]], // Due to the relational model patch above that removes it + // pager requests + [["activity_ids.active", "in", [true, false]]], + [[1, "=", 1]], // Due to the dynamic list patch above that removes it + ]); +}); + +test("activity view: activity widget", async () => { + const mailActivityTypeIds = pyEnv["mail.activity.type"].search([]); + const [mailTestActivityId2] = pyEnv["mail.test.activity"].search([ + ["name", "=", "Office planning"], + ]); + const [mailTemplateId1] = pyEnv["mail.template"].search([["name", "=", "Template1"]]); + onRpc("activity_send_mail", (args) => { + expect(args.args).toEqual([[mailTestActivityId2], mailTemplateId1]); + asyncStep("activity_send_mail"); + return true; + }); + onRpc("action_feedback_schedule_next", (args) => { + expect(args.args).toEqual([pyEnv["mail.activity"].search([["state", "=", "overdue"]])]); + expect(args.kwargs.feedback).toBe("feedback2"); + asyncStep("action_feedback_schedule_next"); + return { serverGeneratedAction: true }; + }); + await start(); + registerArchs(archs); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + mockService("action", { + doAction(action) { + if (action.serverGeneratedAction) { + asyncStep("serverGeneratedAction"); + } else if (action.res_model === "mail.compose.message") { + expect(action.context).toEqual({ + default_model: "mail.test.activity", + default_res_ids: [mailTestActivityId2], + default_subtype_xmlid: "mail.mt_comment", + default_template_id: mailTemplateId1, + force_email: true, + }); + asyncStep("do_action_compose"); + } else if (action.res_model === "mail.activity.schedule") { + expect(action.context).toEqual({ + default_activity_type_id: mailActivityTypeIds[1], + active_ids: [mailTestActivityId2], + active_id: mailTestActivityId2, + active_model: "mail.test.activity", + }); + asyncStep("do_action_activity"); + } else { + asyncStep("Unexpected action" + action.res_model); + } + }, + }); + await click(".today .o-mail-ActivityCell-deadline"); + await contains(".o-mail-ActivityListPopover"); + await contains(".o-mail-ActivityListPopover-todayTitle", { text: "Today" }); + await contains(".o-mail-ActivityMailTemplate-name", { text: "Template1" }); + await contains(".o-mail-ActivityMailTemplate-name", { text: "Template2" }); + await click(".o-mail-ActivityMailTemplate-preview[data-mail-template-id='1']"); + await waitForSteps(["do_action_compose"]); + await click(".today .o-mail-ActivityCell-deadline"); + await click(".o-mail-ActivityMailTemplate-send[data-mail-template-id='1']"); + await waitForSteps(["activity_send_mail"]); + await click(".overdue .o-mail-ActivityCell-deadline"); + await contains(".o-mail-ActivityMailTemplate-name", { count: 0 }); + await click(".o-mail-ActivityListPopover button", { text: "Schedule an activity" }); + await waitForSteps(["do_action_activity"]); + await contains(".o-mail-ActivityListPopover", { count: 0 }); + await click(".overdue .o-mail-ActivityCell-deadline"); + await click(".o-mail-ActivityListPopoverItem-markAsDone"); + await insertText( + ".o-mail-ActivityMarkAsDone textarea[placeholder='Write Feedback']", + "feedback2" + ); + await click(".o-mail-ActivityMarkAsDone button[aria-label='Done and Schedule Next']"); + await waitForSteps(["action_feedback_schedule_next", "serverGeneratedAction"]); +}); + +test("activity widget: cancel an activity from the widget", async () => { + const [mailActivityId] = pyEnv["mail.activity"].search([["state", "=", "planned"]]); + const [mailActivityTypeId] = pyEnv["mail.activity.type"].search([["name", "=", "Email"]]); + pyEnv["res.users"].write([serverState.userId], { + activity_ids: [mailActivityId], + activity_type_id: mailActivityTypeId, + }); + onRpc("mail.activity", "unlink", ({ args, route }) => { + expect(args).toEqual([[mailActivityId]]); + expect(route).toInclude("mail.activity"); + expect(route).toInclude("unlink"); + asyncStep("unlink"); + }); + await start(); + registerArchs(archs); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + + // Check if activity is scheduled + await click(".planned .o-mail-ActivityCell-deadline"); + await contains(".o-mail-ActivityListPopover"); + + const activityListPopoverButtons = document.querySelectorAll( + ".overflow-auto.d-flex.align-items-baseline button" + ); + // ensure the buttons are in the same order as in the chatter. + expect(activityListPopoverButtons[0]).toHaveClass("o-mail-ActivityListPopoverItem-markAsDone"); + expect(activityListPopoverButtons[1]).toHaveClass("o-mail-ActivityListPopoverItem-editbtn"); + expect(activityListPopoverButtons[2]).toHaveClass("o-mail-ActivityListPopoverItem-cancel btn"); + + // Cancel the activity + await click(".o-mail-ActivityListPopoverItem .o-mail-ActivityListPopoverItem-cancel"); + await waitForSteps(["unlink"]); + + // Verify no activity is scheduled + await contains(".planned", { count: 0 }); +}); + +test("activity view: Mark as done with keep done enabled", async () => { + await start(); + registerArchs(archs); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + await contains(".o_activity_view:not(.o_action)"); + const domActivity = document.querySelector(".o_activity_view:not(.o_action)"); + const domHeaderEmail = domActivity.querySelector("table thead tr:first-child th:nth-child(2)"); + const selRowOfficeCellEmail = "table tbody tr:nth-child(2) td:nth-child(2)"; + + await contains(".o_animated_number", { + target: domHeaderEmail, + text: "2", + }); + await contains(".o_column_progress_aggregated_on", { + target: domHeaderEmail, + text: "2", + }); + await click(`${selRowOfficeCellEmail} > div`, { + target: domActivity, + }); + await click(".o-mail-ActivityListPopoverItem .o-mail-ActivityListPopoverItem-markAsDone"); + await click(".o-mail-ActivityMarkAsDone button[aria-label='Done']"); + await contains(".o_animated_number", { + target: domHeaderEmail, + text: "1", + }); + await contains(".o_column_progress_aggregated_on", { + target: domHeaderEmail, + text: "2", + }); +}); + +test("activity view: no group_by_menu and no comparison_menu", async () => { + await start(); + registerArchs(archs); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + await click(".o_searchview_dropdown_toggler"); + await contains(".o-dropdown--menu .o_dropdown_container", { count: 2 }); + await contains(".o-dropdown--menu .o_filter_menu"); + await contains(".o-dropdown--menu .o_favorite_menu"); +}); + +test("activity view: group_by in the action has no effect", async () => { + patchWithCleanup(ActivityModel.prototype, { + async load(params) { + // force params to have a groupBy set, the model should ignore this value during the load + params.groupBy = ["user_id"]; + await super.load(params); + }, + }); + onRpc("get_activity_data", ({ kwargs }) => { + expect(kwargs.groupby).toBe(undefined); + asyncStep("get_activity_data"); + }); + await start(); + registerArchs(archs); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + await waitForSteps(["get_activity_data"]); +}); + +test("activity view: search more to schedule an activity for a record of a respecting model", async () => { + const mailTestActivityId1 = pyEnv["mail.test.activity"].create({ + name: "MailTestActivity 3", + }); + registerArchs(archs); + MailTestActivity._views.list = ''; + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + mockService("action", { + doAction(action, options) { + asyncStep("doAction"); + const expectedAction = { + context: { + active_ids: [mailTestActivityId1], + active_id: mailTestActivityId1, + active_model: "mail.test.activity", + }, + name: "Schedule Activity", + res_model: "mail.activity.schedule", + target: "new", + type: "ir.actions.act_window", + view_mode: "form", + views: [[false, "form"]], + }; + expect(action).toEqual(expectedAction); + options.onClose(); + }, + }); + await click(".o_activity_view tfoot tr .o_record_selector"); + await contains(".o_data_row .o_data_cell", { + count: 3, + parent: [".modal-dialog", { text: "Search: MailTestActivity" }], + }); + await click(".o_data_row .o_data_cell", { + text: "MailTestActivity 3", + parent: [".modal-dialog", { text: "Search: MailTestActivity" }], + }); + await waitForSteps(["doAction"]); +}); + +test("activity view: Domain should not reset on load", async () => { + registerArchs(archs); + MailTestActivity._views.list = ''; + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + domain: [["id", "=", 1]], + }); + mockService("action", { + doAction(action, options) { + asyncStep("doAction"); + options.onClose(); + }, + }); + + await click(".o_activity_view .o_record_selector"); + // search create dialog + await click(".modal-lg .o_data_row .o_data_cell"); + await waitForSteps(["doAction"]); + await click(".o_activity_view .o_record_selector"); + // again open search create dialog + await contains(".modal-lg .o_data_row"); +}); + +test("activity view: 'scheduleActivity' does not add activity_ids condition as selectCreateDialog domain", async () => { + patchWithCleanup(ActivityController.prototype, { + scheduleActivity() { + super.scheduleActivity(); + asyncStep(this.getSearchProps().domain); + }, + }); + registerArchs(archs); + MailTestActivity._views.list = ''; + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + mockService("action", { + doAction(action, options) { + options.onClose?.(); + }, + }); + // open search create dialog and schedule an activity + await click(".o_activity_view .o_record_selector"); + await click(".modal-lg .o_data_row .o_data_cell", { + text: "Meeting Room Furnitures", + }); + + // again open search create dialog + await click(".o_activity_view .o_record_selector"); + await waitForSteps([[], []]); +}); + +test("activity view: 'onClose' of 'openActivityFormView' does not add activity_ids condition as selectCreateDialog domain", async () => { + patchWithCleanup(ActivityController.prototype, { + openActivityFormView(resId, activityTypeId) { + super.openActivityFormView(resId, activityTypeId); + asyncStep(this.getSearchProps().domain); + }, + }); + registerArchs(archs); + MailTestActivity._views.list = ''; + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + mockService("action", { + doAction(action, options) { + options.onClose?.(); + }, + }); + //schedule an activity on an empty activity cell + await click( + ".o_activity_view :nth-child(1 of .o_data_row) :nth-child(1 of .o_activity_empty_cell)" + ); + await waitForSteps([[]]); +}); + +test("activity view: 'onReloadData' does not add activity_ids condition as selectCreateDialog domain", async () => { + patchWithCleanup(ActivityController.prototype, { + get rendererProps() { + const rendererProps = { ...super.rendererProps }; + asyncStep(this.getSearchProps().domain); + return rendererProps; + }, + }); + registerArchs(archs); + MailTestActivity._views.list = ''; + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + mockService("action", { + doAction(action, options) { + options.onClose?.(); + }, + }); + + //schedule another activity on an activity cell with a scheduled activity + await click(".today .o-mail-ActivityCell-deadline"); + await click(".o-mail-ActivityListPopover button:contains(Schedule an activity)"); + await waitForSteps([[], [], []]); +}); + +test("Activity view: discard an activity creation dialog", async () => { + registerArchs(archs); + onRpc("has_access", () => true); + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + await click( + ".o_activity_view :nth-child(1 of .o_data_row) :nth-child(1 of .o_activity_empty_cell)" + ); + await contains(".modal.o_technical_modal"); + await click(".modal.o_technical_modal .o_form_button_cancel"); + await contains(".modal.o_technical_modal", { count: 0 }); +}); + +test("Activity view: many2one_avatar_user widget in activity view", async () => { + const [mailTestActivityId1] = pyEnv["mail.test.activity"].search([ + ["name", "=", "Meeting Room Furnitures"], + ]); + const resUsersId1 = pyEnv["res.users"].create({ + display_name: "first user", + avatar_128: "Atmaram Bhide", + }); + pyEnv["mail.test.activity"].write([mailTestActivityId1], { activity_user_id: resUsersId1 }); + registerArchs({ + "mail.test.activity,false,activity": ` + +
+ + +
+
+
`, + }); + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + await contains(".o_m2o_avatar", { count: 1 }); + await contains( + `tr:nth-child(2) .o_m2o_avatar > img[data-src="/web/image/res.users/${resUsersId1}/avatar_128"]` + ); + // "should not have text on many2one_avatar_user if onlyImage node option is passed" + await contains(".o_m2o_avatar > span", { count: 0 }); +}); + +test("Activity view: on_destroy_callback doesn't crash", async () => { + patchWithCleanup(ActivityRenderer.prototype, { + setup() { + super.setup(); + onMounted(() => { + asyncStep("mounted"); + }); + onWillUnmount(() => { + asyncStep("willUnmount"); + }); + }, + }); + registerArchs(archs); + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + // force the unmounting of the activity view by opening another one + await openFormView("mail.test.activity"); + await waitForSteps(["mounted", "willUnmount"]); +}); + +test("Schedule activity dialog uses the same search view as activity view", async () => { + pyEnv["mail.test.activity"].unlink(pyEnv["mail.test.activity"].search([])); + MailTestActivity._views.list = ``; + registerArchs(archs); + onRpc("get_views", ({ kwargs }) => asyncStep(kwargs.views)); + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + viewId: 18, + searchViewId: 19, + }); + await waitForSteps([ + [ + [18, "activity"], + [19, "search"], + ], + ]); + // click on "Schedule activity" + await click(".o_activity_view .o_record_selector"); + await waitForSteps([ + [ + [false, "list"], + [19, "search"], + ], + ]); + // open an activity view (with search arch 1) + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + viewId: 15, + searchViewId: 16, + }); + await waitForSteps([ + [ + [15, "activity"], + [16, "search"], + ], + ]); + // click on "Schedule activity" + await click(".o_activity_view .o_record_selector"); + await waitForSteps([ + [ + [false, "list"], + [16, "search"], + ], + ]); +}); + +test("Activity view: apply progressbar filter", async () => { + const mailActivityTypeIds = pyEnv["mail.activity.type"].search([]); + const mailTemplateIds = pyEnv["mail.template"].search([]); + const [resUsersId1] = pyEnv["res.users"].search([]); + pyEnv["mail.activity"].create([ + { + display_name: "An activity", + date_deadline: serializeDate(DateTime.now().plus({ days: 3 })), + can_write: true, + state: "planned", + activity_type_id: mailActivityTypeIds[2], + mail_template_ids: mailTemplateIds, + user_id: resUsersId1, + }, + ]); + const mailActivityIds = pyEnv["mail.activity"].create([ + { + display_name: "An activity", + date_deadline: serializeDate(DateTime.now().plus({ days: 3 })), + can_write: true, + state: "planned", + activity_type_id: mailActivityTypeIds[0], + user_id: resUsersId1, + }, + { + display_name: "An activity", + date_deadline: serializeDate(DateTime.now().plus({ days: 3 })), + can_write: true, + state: "planned", + activity_type_id: mailActivityTypeIds[2], + mail_template_ids: mailTemplateIds, + user_id: resUsersId1, + }, + ]); + const [mailTestActivityId1] = pyEnv["mail.test.activity"].search([ + ["name", "=", "Meeting Room Furnitures"], + ]); + pyEnv["mail.test.activity"].write([mailTestActivityId1], { + activity_ids: mailActivityIds, + }); + registerArchs(archs); + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + await contains(".o_activity_record", { + text: "Office planning", + parent: [".o_activity_view tbody tr:first-of-type"], + }); + await contains(".o_activity_view .planned", { count: 2 }); + await click(".progress-bar[data-tooltip='1 Planned']", { + parent: [".o_activity_view_table th", { text: "Email" }], + }); + await contains(".o_activity_view thead .o_activity_filter_planned"); + await contains(".progress-bar-striped"); + await contains(".progress-bar-animated.progress-bar-striped[data-tooltip='1 Planned']", { + parent: [".o_activity_view_table th", { text: "Email" }], + }); + await contains(".o_activity_view tbody .o_activity_filter_planned", { count: 5 }); + const tr = document.querySelectorAll(".o_activity_view tbody tr")[1]; + expect(tr.querySelectorAll("td")[2]).toHaveClass("o_activity_empty_cell"); +}); + +test("Activity view: hide/show columns", async () => { + registerArchs(archs); + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + + for (const [index, column] of ["Email", "Call", "Call for Demo", "To Do"].entries()) { + await contains(`.o_activity_view th:nth-child(${index + 2}) div span:first-child`, { + text: column, + }); + } + await contains(".o_activity_view th:last-child button.dropdown-toggle"); + await click("th:last-child button.dropdown-toggle"); + await click("input[name='Email']"); + for (const [index, column] of ["Call", "Call for Demo", "To Do"].entries()) { + await contains(`.o_activity_view th:nth-child(${index + 2}) div span:first-child`, { + text: column, + }); + } + await click("input[name='Call for Demo']"); + for (const [index, column] of ["Call", "To Do"].entries()) { + await contains(`.o_activity_view th:nth-child(${index + 2}) div span:first-child`, { + text: column, + }); + } + + await click("input[name='Email']"); + for (const [index, column] of ["Email", "Call", "To Do"].entries()) { + await contains(`.o_activity_view th:nth-child(${index + 2}) div span:first-child`, { + text: column, + }); + } +}); + +test("Activity view: luxon in renderingContext", async () => { + registerArchs({ + "mail.test.activity,false,activity": ` + + +
+ + luxon + +
+
+
+ `, + }); + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + await contains(".luxon", { count: 2 }); +}); + +test("test displaying image (write_date field)", async () => { + // the presence of write_date field ensures that the image is reloaded when necessary + registerArchs({ + "mail.test.activity,false,activity": ` + + +
+ + +
+
+
`, + }); + onRpc("web.search_read", (route, args) => { + expect(Object.keys(args.specification)).toEqual(["write_date", "id"]); + return { length: 2, records: [{ id: 1 }, { id: 2 }] }; + }); + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + await contains(`.o_activity_record img[src='${getOrigin()}/web/image/partner/2/image']`); +}); + +test("test node visibility depends on invisible attribute on the node and in the context", async () => { + registerArchs(archs); + MailTestActivity._views["activity,1"] = ` + +
+ + + + + Test invisible + +
+
`; + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[1, "activity"]], + }); + await contains(".invisible_node", { count: 2 }); + await openView({ + res_model: "mail.test.activity", + views: [[1, "activity"]], + context: { invisible: true }, + }); + await contains(".invisible_node", { count: 0 }); +}); + +test("update activity view after creating multiple activities", async () => { + registerArchs(archs); + MailTestActivity._views.list = + ''; + MailActivitySchedule._views.form = "
"; + + const Activity = pyEnv["mail.activity"]; + const activityToCreate = omit(Activity[0], "id"); + Activity.unlink(Activity.search([])); + + onRpc(({ method, model }) => { + if (method === "web_save" && model === "mail.activity.schedule") { + Activity.create(activityToCreate); + } + }); + + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + expect(".o_activity_summary_cell").toHaveCount(0); + await click("table tfoot tr .o_record_selector"); + await click( + ".o_list_renderer table tbody tr:nth-child(2) td:nth-child(2) .o-mail-ActivityButton" + ); + await webContains(".o-mail-ActivityListPopover > button.btn-secondary").click(); + const modalSchedule = await waitFor(".modal:has(.o_form_view)"); + await insertText(`.o_form_view .o_field_widget[name='summary'] input`, "test1", { + target: modalSchedule, + }); + await click(".modal-footer button.o_form_button_save", { target: modalSchedule }); + await click(".modal-footer button.o_form_button_cancel"); + await waitFor(".o_activity_summary_cell:not(.o_activity_empty_cell)"); + expect(".o_activity_summary_cell:not(.o_activity_empty_cell)").toHaveCount(1); +}); + +test("Activity view: context given to the rpc to fetch data", async () => { + registerArchs(archs); + const context = { custom_context: true }; + onRpc("get_activity_data", ({ kwargs }) => { + const customContext = kwargs.context?.custom_context; + expect(customContext).toBe(true); + asyncStep("get_activity_data"); + }); + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + context, + }); + await waitForSteps(["get_activity_data"]); +}); + +test("Activity View: Hide 'New' button in SelectCreateDialog based on action context", async () => { + registerArchs(archs); + MailTestActivity._views.list = ` + + + + `; + + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + context: { create: false }, + }); + await click("table tfoot tr .o_record_selector"); + await animationFrame(); + expect(".o_create_button").toHaveCount(0, { + message: "'New' button should be hidden", + }); +}); diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/activity_mobile.test.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/activity_mobile.test.js new file mode 100644 index 0000000..770eb8e --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/activity_mobile.test.js @@ -0,0 +1,35 @@ +import { describe, expect, test } from "@odoo/hoot"; +import { defineTestMailModels } from "@test_mail/../tests/test_mail_test_helpers"; +import { openView, start, startServer } from "@mail/../tests/mail_test_helpers"; + +describe.current.tags("mobile"); +defineTestMailModels(); + +test("horizontal scroll applies only to the content, not to the whole controller", async () => { + const pyEnv = await startServer(); + pyEnv["mail.activity.type"].create([ + { name: "Email" }, + { name: "Call" }, + { name: "Upload document" }, + ]); + await start(); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + const o_view_controller = document.querySelector(".o_view_controller"); + const o_content = o_view_controller.querySelector(".o_content"); + const o_cp_item = document.querySelector(".o_breadcrumb .active"); + const initialXCpItem = o_cp_item.getBoundingClientRect().x; + const o_header_cell = o_content.querySelector(".o_activity_type_cell"); + const initialXHeaderCell = o_header_cell.getBoundingClientRect().x; + expect(o_view_controller).toHaveClass("o_action_delegate_scroll"); + expect(o_view_controller).toHaveStyle({ overflow: "hidden" }); + expect(o_content).toHaveStyle({ overflow: "auto" }); + expect(o_content.scrollLeft).toBe(0); + + o_content.scrollLeft = 100; + expect(o_content.scrollLeft).toBe(100); + expect(o_header_cell.getBoundingClientRect().x).toBeLessThan(initialXHeaderCell); + expect(o_cp_item).toHaveRect({ x: initialXCpItem }); +}); diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/activity_tests.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/activity_tests.js deleted file mode 100644 index 081dd0c..0000000 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/activity_tests.js +++ /dev/null @@ -1,676 +0,0 @@ -/** @odoo-module **/ - -import ActivityRenderer from '@mail/js/views/activity/activity_renderer'; -import { start, startServer } from '@mail/../tests/helpers/test_utils'; - -import testUtils from 'web.test_utils'; -import { click, insertText } from "@web/../tests/utils"; -import { legacyExtraNextTick, patchWithCleanup} from "@web/../tests/helpers/utils"; -import { doAction } from "@web/../tests/webclient/helpers"; -import { session } from '@web/session'; - -let serverData; -let pyEnv; - -QUnit.module('test_mail', {}, function () { -QUnit.module('activity view', { - async beforeEach() { - pyEnv = await startServer(); - const mailTemplateIds = pyEnv['mail.template'].create([{ name: "Template1" }, { name: "Template2" }]); - // reset incompatible setup - pyEnv['mail.activity.type'].unlink(pyEnv['mail.activity.type'].search([])); - const mailActivityTypeIds = pyEnv['mail.activity.type'].create([ - { name: "Email", mail_template_ids: mailTemplateIds }, - { name: "Call" }, - { name: "Call for Demo" }, - { name: "To Do" }, - ]); - const resUsersId1 = pyEnv['res.users'].create({ display_name: 'first user' }); - const mailActivityIds = pyEnv['mail.activity'].create([ - { - display_name: "An activity", - date_deadline: moment().add(3, "days").format("YYYY-MM-DD"), // now - can_write: true, - state: "planned", - activity_type_id: mailActivityTypeIds[0], - mail_template_ids: mailTemplateIds, - user_id: resUsersId1, - }, - { - display_name: "An activity", - date_deadline: moment().format("YYYY-MM-DD"), // now - can_write: true, - state: "today", - activity_type_id: mailActivityTypeIds[0], - mail_template_ids: mailTemplateIds, - user_id: resUsersId1, - }, - { - res_model: 'mail.test.activity', - display_name: "An activity", - date_deadline: moment().subtract(2, "days").format("YYYY-MM-DD"), // now - can_write: true, - state: "overdue", - activity_type_id: mailActivityTypeIds[1], - user_id: resUsersId1, - }, - ]); - pyEnv['mail.test.activity'].create([ - { name: 'Meeting Room Furnitures', activity_ids: [mailActivityIds[0]] }, - { name: 'Office planning', activity_ids: [mailActivityIds[1], mailActivityIds[2]] }, - ]); - serverData = { - views: { - 'mail.test.activity,false,activity': - '' + - '' + - '
' + - '' + - '
' + - '
' + - '
', - 'mail.test.activity,false,form': - '
' + - '' + - '', - }, - }; - } -}); - -var activityDateFormat = function (date) { - return date.toLocaleDateString(moment().locale(), { day: 'numeric', month: 'short' }); -}; - -QUnit.test('activity view: simple activity rendering', async function (assert) { - assert.expect(15); - const mailTestActivityIds = pyEnv['mail.test.activity'].search([]); - const mailActivityTypeIds = pyEnv['mail.activity.type'].search([]); - - const { click , env, openView } = await start({ - serverData, - }); - await openView({ - res_model: "mail.test.activity", - views: [[false, "activity"], [false, "form"]], - }); - patchWithCleanup(env.services.action, { - doAction(action, options) { - assert.deepEqual(action, { - context: { - default_res_id: mailTestActivityIds[1], - default_res_model: "mail.test.activity", - default_activity_type_id: mailActivityTypeIds[2], - }, - res_id: false, - res_model: "mail.activity", - target: "new", - type: "ir.actions.act_window", - view_mode: "form", - view_type: "form", - views: [[false, "form"]] - }, - "should do a do_action with correct parameters"); - options.onClose(); - return Promise.resolve(); - }, - }); - - const $activity = $(document.querySelector('.o_activity_view')); - assert.containsOnce($activity, 'table', - 'should have a table'); - var $th1 = $activity.find('table thead tr:first th:nth-child(2)'); - assert.containsOnce($th1, 'span:first:contains(Email)', 'should contain "Email" in header of first column'); - assert.containsOnce($th1, '.o_legacy_kanban_counter', 'should contain a progressbar in header of first column'); - assert.hasAttrValue($th1.find('.o_kanban_counter_progress .progress-bar:first'), 'data-bs-original-title', '1 Planned', - 'the counter progressbars should be correctly displayed'); - assert.hasAttrValue($th1.find('.o_kanban_counter_progress .progress-bar:nth-child(2)'), 'data-bs-original-title', '1 Today', - 'the counter progressbars should be correctly displayed'); - var $th2 = $activity.find('table thead tr:first th:nth-child(3)'); - assert.containsOnce($th2, 'span:first:contains(Call)', 'should contain "Call" in header of second column'); - assert.hasAttrValue($th2.find('.o_kanban_counter_progress .progress-bar:nth-child(3)'), 'data-bs-original-title', '1 Overdue', - 'the counter progressbars should be correctly displayed'); - assert.containsNone($activity, 'table thead tr:first th:nth-child(4) .o_kanban_counter', - 'should not contain a progressbar in header of 3rd column'); - assert.ok($activity.find('table tbody tr:first td:first:contains(Office planning)').length, - 'should contain "Office planning" in first colum of first row'); - assert.ok($activity.find('table tbody tr:nth-child(2) td:first:contains(Meeting Room Furnitures)').length, - 'should contain "Meeting Room Furnitures" in first colum of second row'); - - var today = activityDateFormat(new Date()); - - assert.ok($activity.find('table tbody tr:first td:nth-child(2).today .o_closest_deadline:contains(' + today + ')').length, - 'should contain an activity for today in second cell of first line ' + today); - var td = 'table tbody tr:nth-child(1) td.o_activity_empty_cell'; - assert.containsN($activity, td, 2, 'should contain an empty cell as no activity scheduled yet.'); - - // schedule an activity (this triggers a do_action) - await testUtils.fields.editAndTrigger($activity.find(td + ':first'), null, ['mouseenter', 'click']); - assert.containsOnce($activity, 'table tfoot tr .o_record_selector', - 'should contain search more selector to choose the record to schedule an activity for it'); - - // Ensure that the form view is opened in edit mode - await click(document.querySelector(".o_activity_record")); - const $form = $(document.querySelector('.o_form_view')); - assert.containsOnce($form, '.o_form_editable', - 'Form view should be opened in edit mode'); -}); - -QUnit.test('activity view: no content rendering', async function (assert) { - assert.expect(2); - - const { openView, pyEnv } = await start({ - serverData, - }); - // reset incompatible setup - pyEnv['mail.activity.type'].unlink(pyEnv['mail.activity.type'].search([])); - await openView({ - res_model: "mail.test.activity", - views: [[false, "activity"]], - }); - const $activity = $(document); - - assert.containsOnce($activity, '.o_view_nocontent', - "should display the no content helper"); - assert.strictEqual($activity.find('.o_view_nocontent .o_view_nocontent_empty_folder').text().trim(), - "No data to display", - "should display the no content helper text"); -}); - -QUnit.test('activity view: batch send mail on activity', async function (assert) { - assert.expect(6); - - const mailTestActivityIds = pyEnv['mail.test.activity'].search([]); - const mailTemplateIds = pyEnv['mail.template'].search([]); - const { openView } = await start({ - serverData, - mockRPC: function(route, args) { - if (args.method === 'activity_send_mail') { - assert.step(JSON.stringify(args.args)); - return Promise.resolve(true); - } - }, - }); - await openView({ - res_model: "mail.test.activity", - views: [[false, "activity"]], - }); - const $activity = $(document); - assert.notOk($activity.find('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show').length, - 'dropdown shouldn\'t be displayed'); - - testUtils.dom.click($activity.find('table thead tr:first th:nth-child(2) span:nth-child(2) i.fa-ellipsis-v')); - assert.ok($activity.find('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show').length, - 'dropdown should have appeared'); - - testUtils.dom.click($activity.find('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show .o_send_mail_template:contains(Template2)')); - assert.notOk($activity.find('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show').length, - 'dropdown shouldn\'t be displayed'); - - testUtils.dom.click($activity.find('table thead tr:first th:nth-child(2) span:nth-child(2) i.fa-ellipsis-v')); - testUtils.dom.click($activity.find('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show .o_send_mail_template:contains(Template1)')); - assert.verifySteps([ - `[[${mailTestActivityIds[0]},${mailTestActivityIds[1]}],${mailTemplateIds[1]}]`, // send mail template 1 on mail.test.activity 1 and 2 - `[[${mailTestActivityIds[0]},${mailTestActivityIds[1]}],${mailTemplateIds[0]}]`, // send mail template 2 on mail.test.activity 1 and 2 - ]); -}); - -QUnit.test('activity view: activity widget', async function (assert) { - assert.expect(16); - - const mailActivityTypeIds = pyEnv['mail.activity.type'].search([]); - const [mailTestActivityId2] = pyEnv['mail.test.activity'].search([['name', '=', 'Office planning']]); - const [mailTemplateId1] = pyEnv['mail.template'].search([['name', '=', 'Template1']]); - const { env, openView } = await start({ - mockRPC: function (route, args) { - if (args.method === 'activity_send_mail') { - assert.deepEqual([[mailTestActivityId2], mailTemplateId1], args.args, "Should send template related to mailTestActivity2"); - assert.step('activity_send_mail'); - // random value returned in order for the mock server to know that this route is implemented. - return true; - } - if (args.method === 'action_feedback_schedule_next') { - assert.deepEqual( - [pyEnv['mail.activity'].search([['state', '=', 'overdue']])], - args.args, - "Should execute action_feedback_schedule_next only on the overude activity" - ); - assert.equal(args.kwargs.feedback, "feedback2"); - assert.step('action_feedback_schedule_next'); - return Promise.resolve({ serverGeneratedAction: true }); - } - }, - serverData, - }); - await openView({ - res_model: 'mail.test.activity', - views: [[false, 'activity']], - }); - patchWithCleanup(env.services.action, { - doAction(action) { - if (action.serverGeneratedAction) { - assert.step('serverGeneratedAction'); - } else if (action.res_model === 'mail.compose.message') { - assert.deepEqual({ - default_model: 'mail.test.activity', - default_res_id: mailTestActivityId2, - default_template_id: mailTemplateId1, - default_use_template: true, - force_email: true - }, action.context); - assert.step("do_action_compose"); - } else if (action.res_model === 'mail.activity') { - assert.deepEqual({ - "default_activity_type_id": mailActivityTypeIds[1], - "default_res_id": mailTestActivityId2, - "default_res_model": 'mail.test.activity', - }, action.context); - assert.step("do_action_activity"); - } else { - assert.step("Unexpected action"); - } - return Promise.resolve(); - }, - }); - - await testUtils.dom.click(document.querySelector('.today .o_closest_deadline')); - assert.hasClass(document.querySelector('.today .dropdown-menu.o_activity'), 'show', "dropdown should be displayed"); - assert.ok(document.querySelector('.o_activity_color_today').textContent.includes('Today'), "Title should be today"); - assert.ok([...document.querySelectorAll('.today .o_activity_title_entry')].filter(el => el.textContent.includes('Template1')).length, - "Template1 should be available"); - assert.ok([...document.querySelectorAll('.today .o_activity_title_entry')].filter(el => el.textContent.includes('Template2')).length, - "Template2 should be available"); - - await testUtils.dom.click(document.querySelector('.o_activity_title_entry[data-activity-id="2"] .o_activity_template_preview')); - await testUtils.dom.click(document.querySelector('.o_activity_title_entry[data-activity-id="2"] .o_activity_template_send')); - await testUtils.dom.click(document.querySelector('.overdue .o_closest_deadline')); - assert.notOk(document.querySelector('.overdue .o_activity_template_preview'), - "No template should be available"); - - await testUtils.dom.click(document.querySelector('.overdue .o_schedule_activity')); - await testUtils.dom.click(document.querySelector('.overdue .o_closest_deadline')); - await testUtils.dom.click(document.querySelector('.overdue .o_mark_as_done')); - document.querySelector('.overdue #activity_feedback').value = "feedback2"; - - await testUtils.dom.click(document.querySelector('.overdue .o_activity_popover_done_next')); - assert.verifySteps([ - "do_action_compose", - "activity_send_mail", - "do_action_activity", - "action_feedback_schedule_next", - "serverGeneratedAction" - ]); - -}); - -QUnit.test("activity view: no group_by_menu and no comparison_menu", async function (assert) { - assert.expect(4); - - serverData.actions = { - 1: { - id: 1, - name: "MailTestActivity Action", - res_model: "mail.test.activity", - type: "ir.actions.act_window", - views: [[false, "activity"]], - }, - }; - - const mockRPC = (route, args) => { - if (args.method === "get_activity_data") { - assert.strictEqual( - args.kwargs.context.lang, - "zz_ZZ", - "The context should have been passed" - ); - } - }; - - patchWithCleanup(session.user_context, { lang: "zz_ZZ" }); - - const { webClient } = await start({ serverData, mockRPC }); - - await doAction(webClient, 1); - - assert.containsN( - document.body, - ".o_search_options .dropdown button:visible", - 2, - "only two elements should be available in view search" - ); - assert.isVisible( - document.querySelector(".o_search_options .dropdown.o_filter_menu > button"), - "filter should be available in view search" - ); - assert.isVisible( - document.querySelector(".o_search_options .dropdown.o_favorite_menu > button"), - "favorites should be available in view search" - ); -}); - -QUnit.test('activity view: search more to schedule an activity for a record of a respecting model', async function (assert) { - assert.expect(5); - const mailTestActivityId1 = pyEnv['mail.test.activity'].create({ name: 'MailTestActivity 3' }); - Object.assign(serverData.views, { - 'mail.test.activity,false,list': '', - }); - const { env, openView } = await start({ - mockRPC(route, args) { - if (args.method === 'name_search') { - args.kwargs.name = "MailTestActivity"; - } - }, - serverData, - }); - await openView({ - res_model: 'mail.test.activity', - views: [[false, 'activity']], - }); - patchWithCleanup(env.services.action, { - doAction(action, options) { - assert.step('doAction'); - var expectedAction = { - context: { - default_res_id: mailTestActivityId1, - default_res_model: "mail.test.activity", - }, - name: "Schedule Activity", - res_id: false, - res_model: "mail.activity", - target: "new", - type: "ir.actions.act_window", - view_mode: "form", - views: [[false, "form"]], - }; - assert.deepEqual(action, expectedAction, - "should execute an action with correct params"); - options.onClose(); - return Promise.resolve(); - }, - }); - - const activity = $(document); - assert.containsOnce(activity, 'table tfoot tr .o_record_selector', - 'should contain search more selector to choose the record to schedule an activity for it'); - await testUtils.dom.click(activity.find('table tfoot tr .o_record_selector')); - // search create dialog - var $modal = $('.modal-lg'); - assert.strictEqual($modal.find('.o_data_row').length, 3, "all mail.test.activity should be available to select"); - // select a record to schedule an activity for it (this triggers a do_action) - await testUtils.dom.click($modal.find('.o_data_row:last .o_data_cell')); - assert.verifySteps(['doAction']); -}); - -QUnit.test("Activity view: discard an activity creation dialog", async function (assert) { - assert.expect(2); - - serverData.actions = { - 1: { - id: 1, - name: "MailTestActivity Action", - res_model: "mail.test.activity", - type: "ir.actions.act_window", - views: [[false, "activity"]], - }, - }; - - Object.assign(serverData.views, { - 'mail.activity,false,form': - `
- -
-
- `, - }); - - const mockRPC = (route, args) => { - if (args.method === "check_access_rights") { - return true; - } - }; - - const { webClient } = await start({ serverData, mockRPC }); - await doAction(webClient, 1); - - await testUtils.dom.click( - document.querySelector(".o_activity_view .o_data_row .o_activity_empty_cell") - ); - await legacyExtraNextTick(); - assert.containsOnce($, ".modal.o_technical_modal", "Activity Modal should be opened"); - - await testUtils.dom.click($('.modal.o_technical_modal button[special="cancel"]')); - await legacyExtraNextTick(); - assert.containsNone($, ".modal.o_technical_modal", "Activity Modal should be closed"); -}); - -QUnit.test('Activity view: many2one_avatar_user widget in activity view', async function (assert) { - assert.expect(3); - - const [mailTestActivityId1] = pyEnv['mail.test.activity'].search([['name', '=', 'Meeting Room Furnitures']]); - const resUsersId1 = pyEnv['res.users'].create({ - display_name: "first user", - avatar_128: "Atmaram Bhide", - }); - pyEnv['mail.test.activity'].write([mailTestActivityId1], { activity_user_id: resUsersId1 }); - Object.assign(serverData.views, { - 'mail.test.activity,false,activity': - ` - -
- - -
-
-
`, - }); - serverData.actions = { - 1: { - id: 1, - name: 'MailTestActivity Action', - res_model: 'mail.test.activity', - type: 'ir.actions.act_window', - views: [[false, 'activity']], - } - }; - - const { webClient } = await start({ serverData }); - await doAction(webClient, 1); - - await legacyExtraNextTick(); - assert.containsN(document.body, '.o_m2o_avatar', 2); - assert.containsOnce(document.body, `tr[data-res-id=${mailTestActivityId1}] .o_m2o_avatar > img[data-src="/web/image/res.users/${resUsersId1}/avatar_128"]`, - "should have m2o avatar image"); - assert.containsNone(document.body, '.o_m2o_avatar > span', - "should not have text on many2one_avatar_user if onlyImage node option is passed"); -}); - -QUnit.test("Activity view: on_destroy_callback doesn't crash", async function (assert) { - assert.expect(3); - - patchWithCleanup(ActivityRenderer.prototype, { - setup() { - this._super(); - owl.onMounted(() => { - assert.step('mounted'); - }); - owl.onWillUnmount(() => { - assert.step('willUnmount'); - }); - } - }); - - const { openView } = await start({ - serverData, - }); - await openView({ - res_model: 'mail.test.activity', - views: [[false, 'activity']], - }); - // force the unmounting of the activity view by opening another one - await openView({ - res_model: 'mail.test.activity', - views: [[false, 'form']], - }); - - assert.verifySteps([ - 'mounted', - 'willUnmount' - ]); -}); - -QUnit.test("Schedule activity dialog uses the same search view as activity view", async function (assert) { - assert.expect(8); - pyEnv['mail.test.activity'].unlink(pyEnv['mail.test.activity'].search([])); - Object.assign(serverData.views, { - "mail.test.activity,false,list": ``, - "mail.test.activity,false,search": ``, - 'mail.test.activity,1,search': ``, - }); - - function mockRPC(route, args) { - if (args.method === "get_views") { - assert.step(JSON.stringify(args.kwargs.views)); - } - } - - const { webClient , click } = await start({ serverData, mockRPC }); - - // open an activity view (with default search arch) - await doAction(webClient, { - name: 'Dashboard', - res_model: 'mail.test.activity', - type: 'ir.actions.act_window', - views: [[false, 'activity']], - }); - - assert.verifySteps([ - '[[false,"activity"],[false,"search"]]', - ]) - - // click on "Schedule activity" - await click(document.querySelector(".o_activity_view .o_record_selector")); - - assert.verifySteps([ - '[[false,"list"],[false,"search"]]', - ]) - - // open an activity view (with search arch 1) - await doAction(webClient, { - name: 'Dashboard', - res_model: 'mail.test.activity', - type: 'ir.actions.act_window', - views: [[false, 'activity']], - search_view_id: [1,"search"], - }); - - assert.verifySteps([ - '[[false,"activity"],[1,"search"]]', - ]) - - // click on "Schedule activity" - await click(document.querySelector(".o_activity_view .o_record_selector")); - - assert.verifySteps([ - '[[false,"list"],[1,"search"]]', - ]); -}); - -QUnit.test('Activity view: apply progressbar filter', async function (assert) { - assert.expect(9); - - serverData.actions = { - 1: { - id: 1, - name: 'MailTestActivity Action', - res_model: 'mail.test.activity', - type: 'ir.actions.act_window', - views: [[false, 'activity']], - } - }; - - const { webClient } = await start({ serverData }); - - await doAction(webClient, 1); - - assert.containsNone(document.querySelector('.o_activity_view thead'), - '.o_activity_filter_planned,.o_activity_filter_today,.o_activity_filter_overdue,.o_activity_filter___false', - "should not have active filter"); - assert.containsNone(document.querySelector('.o_activity_view tbody'), - '.o_activity_filter_planned,.o_activity_filter_today,.o_activity_filter_overdue,.o_activity_filter___false', - "should not have active filter"); - assert.strictEqual(document.querySelector('.o_activity_view tbody .o_activity_record').textContent, - 'Office planning', "'Office planning' should be first record"); - assert.containsOnce(document.querySelector('.o_activity_view tbody'), '.planned', - "other records should be available"); - - await testUtils.dom.click(document.querySelector('.o_kanban_counter_progress .progress-bar[data-filter="planned"]')); - assert.containsOnce(document.querySelector('.o_activity_view thead'), '.o_activity_filter_planned', - "planned should be active filter"); - assert.containsN(document.querySelector('.o_activity_view tbody'), '.o_activity_filter_planned', 5, - "planned should be active filter"); - assert.strictEqual(document.querySelector('.o_activity_view tbody .o_activity_record').textContent, - 'Meeting Room Furnitures', "'Office planning' should be first record"); - const tr = document.querySelectorAll('.o_activity_view tbody tr')[1]; - assert.hasClass(tr.querySelectorAll('td')[1], 'o_activity_empty_cell', - "other records should be hidden"); - assert.containsNone(document.querySelector('.o_activity_view tbody'), 'planned', - "other records should be hidden"); -}); - -QUnit.test("Activity view: luxon in renderingContext", async function (assert) { - Object.assign(serverData.views, { - "mail.test.activity,false,activity": ` - - -
- - luxon - -
-
-
`, - }); - const { openView } = await start({ - serverData, - }); - await openView({ - res_model: "mail.test.activity", - views: [[false, "activity"]], - }); - assert.containsN(document.body, ".luxon", 2); -}); - -QUnit.test('update activity view after creating multiple activities', async function (assert) { - assert.expect(9); - pyEnv['mail.test.activity'].create({ name: 'MailTestActivity 3' }); - Object.assign(serverData.views, { - 'mail.test.activity,false,list': '', - 'mail.activity,false,form': '
' - }); - - const { openView } = await start({ - mockRPC(route, args) { - if (args.method === 'name_search') { - args.kwargs.name = "MailTestActivity"; - } - }, - serverData, - }); - await openView({ - res_model: 'mail.test.activity', - views: [[false, 'activity']], - }); - - await click("table tfoot tr .o_record_selector"); - await click(".o_list_renderer table tbody tr:nth-child(2) td:nth-child(2) .o_ActivityButtonView") - await click(".o-main-components-container .o_PopoverManager .o_ActivityListView .o_ActivityListView_addActivityButton"); - await insertText('.o_field_many2one_selection .o_input_dropdown .dropdown input[id=activity_type_id]', "test1"); - await click(".o_field_many2one_selection .o_input_dropdown .dropdown input[id=activity_type_id]"); - await click('.o-autocomplete--dropdown-menu li:nth-child(1) .dropdown-item'); - await click(".modal-footer .o_cp_buttons .o_form_buttons_edit .btn-primary"); - await click(".modal-footer .o_form_button_cancel"); - await click("table tbody tr:nth-child(1) td:nth-child(6) .o_mail_activity .o_activity_btn .o_closest_deadline"); -}); - -}); diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/attachment_view.test.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/attachment_view.test.js new file mode 100644 index 0000000..e10ba4c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/attachment_view.test.js @@ -0,0 +1,252 @@ +import { defineTestMailModels } from "@test_mail/../tests/test_mail_test_helpers"; +import { beforeEach, describe, test, expect } from "@odoo/hoot"; +import { queryOne, waitUntil } from "@odoo/hoot-dom"; +import { animationFrame } from "@odoo/hoot-mock"; +import { + click, + contains, + openFormView, + registerArchs, + start, + startServer, + patchUiSize, + SIZES, + dragenterFiles, + dropFiles, +} from "@mail/../tests/mail_test_helpers"; +import { browser } from "@web/core/browser/browser"; +import { patchWithCleanup } from "@web/../tests/web_test_helpers"; + +describe.current.tags("desktop"); +defineTestMailModels(); + +let popoutIframe, popoutWindow; + +beforeEach(() => { + popoutIframe = document.createElement("iframe"); + popoutWindow = { + closed: false, + get document() { + const doc = popoutIframe.contentDocument; + if (!doc) { + return undefined; + } + const originalWrite = doc.write; + doc.write = (content) => { + // This avoids duplicating the test script in the popoutWindow + const sanitizedContent = content.replace( + /)<[^<]*)*<\/script>/gi, + "" + ); + originalWrite.call(doc, sanitizedContent); + }; + return doc; + }, + close: () => { + popoutWindow.closed = true; + popoutIframe.remove(popoutAttachmentViewBody()); + }, + }; +}); + +patchWithCleanup(browser, { + open: () => { + popoutWindow.closed = false; + queryOne(".o_popout_holder").append(popoutIframe); + return popoutWindow; + }, +}); + +function popoutAttachmentViewBody() { + return popoutWindow.document.querySelector(".o-mail-PopoutAttachmentView"); +} +async function popoutIsEmpty() { + await animationFrame(); + expect(popoutAttachmentViewBody()).toBe(null); +} +async function popoutContains(selector) { + await animationFrame(); + await waitUntil(() => popoutAttachmentViewBody()); + const target = popoutAttachmentViewBody().querySelector(selector); + expect(target).toBeDisplayed(); + return target; +} +async function popoutClick(selector) { + const target = await popoutContains(selector); + click(target); +} + +test("Attachment view popout controls test", async () => { + /* + * This test makes sure that the attachment view controls are working in the following cases: + * - Inside the popout window + * - After closing the popout window + */ + const pyEnv = await startServer(); + const recordId = pyEnv["mail.test.simple.main.attachment"].create({ + display_name: "first partner", + message_attachment_count: 2, + }); + pyEnv["ir.attachment"].create([ + { + mimetype: "image/jpeg", + res_id: recordId, + res_model: "mail.test.simple.main.attachment", + }, + { + mimetype: "application/pdf", + res_id: recordId, + res_model: "mail.test.simple.main.attachment", + }, + ]); + registerArchs({ + "mail.test.simple.main.attachment,false,form": ` +
+
+ + + +
+ + `, + }); + + patchUiSize({ size: SIZES.XXL }); + await start(); + await openFormView("mail.test.simple.main.attachment", recordId); + await click(".o_attachment_preview .o_attachment_control"); + await animationFrame(); + expect(".o_attachment_preview").not.toBeVisible(); + await popoutClick(".o_move_next"); + await popoutContains("img"); + await popoutClick(".o_move_previous"); + await popoutContains("iframe"); + popoutWindow.close(); + await contains(".o_attachment_preview:not(.d-none)"); + expect(".o_attachment_preview").toBeVisible(); + await click(".o_attachment_preview .o_move_next"); + await contains(".o_attachment_preview img"); + await click(".o_attachment_preview .o_move_previous"); + await contains(".o_attachment_preview iframe"); + await click(".o_attachment_preview .o_attachment_control"); + await animationFrame(); + expect(".o_attachment_preview").not.toBeVisible(); +}); + +test("Chatter main attachment: can change from non-viewable to viewable", async () => { + const pyEnv = await startServer(); + const recordId = pyEnv['mail.test.simple.main.attachment'].create({}); + const irAttachmentId = pyEnv['ir.attachment'].create({ + mimetype: 'text/plain', + name: "Blah.txt", + res_id: recordId, + res_model: 'mail.test.simple.main.attachment', + }); + pyEnv['mail.message'].create({ + attachment_ids: [irAttachmentId], + model: 'mail.test.simple.main.attachment', + res_id: recordId, + }); + pyEnv['mail.test.simple.main.attachment'].write([recordId], {message_main_attachment_id : irAttachmentId}); + + registerArchs({ + "mail.test.simple.main.attachment,false,form": ` +
+ + + +
+ + `, + }); + patchUiSize({ size: SIZES.XXL }); + await start(); + await openFormView("mail.test.simple.main.attachment", recordId); + + // Add a PDF file + const pdfFile = new File([new Uint8Array(1)], "text.pdf", { type: "application/pdf" }); + await dragenterFiles(".o-mail-Chatter", [pdfFile]); + await dropFiles(".o-Dropzone", [pdfFile]); + await contains(".o_attachment_preview"); + await contains(".o-mail-Attachment > iframe", { count: 0 }); // The viewer tries to display the text file not the PDF + + // Switch to the PDF file in the viewer + await click(".o_move_next"); + await contains(".o-mail-Attachment > iframe"); // There should be iframe for PDF viewer +}); + +test.skip("Attachment view / chatter popout across multiple records test", async () => { + // skip because test has race conditions: https://runbot.odoo.com/odoo/runbot.build.error/109795 + const pyEnv = await startServer(); + const recordIds = pyEnv["mail.test.simple.main.attachment"].create([ + { + display_name: "first partner", + message_attachment_count: 1, + }, + { + display_name: "second partner", + message_attachment_count: 0, + }, + { + display_name: "third partner", + message_attachment_count: 1, + }, + ]); + pyEnv["ir.attachment"].create([ + { + mimetype: "image/jpeg", + res_id: recordIds[0], + res_model: "mail.test.simple.main.attachment", + }, + { + mimetype: "application/pdf", + res_id: recordIds[2], + res_model: "mail.test.simple.main.attachment", + }, + ]); + registerArchs({ + "mail.test.simple.main.attachment,false,form": ` +
+
+ + + +
+ + `, + }); + + async function navigateRecords() { + /** + * It should be called on the first record of recordIds + * The popout window should be open + * It navigates recordIds as 0 -> 1 -> 2 -> 0 -> 2 + */ + await animationFrame(); + expect(".o_attachment_preview").not.toBeVisible(); + await popoutContains("img"); + await click(".o_pager_next"); + await popoutIsEmpty(); + await click(".o_pager_next"); + await popoutContains("iframe"); + await click(".o_pager_next"); + await popoutContains("img"); + await click(".o_pager_previous"); + await popoutContains("iframe"); + popoutWindow.close(); + await contains(".o_attachment_preview:not(.d-none)"); + } + + patchUiSize({ size: SIZES.XXL }); + await start(); + await openFormView("mail.test.simple.main.attachment", recordIds[0], { + resIds: recordIds, + }); + await click(".o_attachment_preview .o_attachment_control"); + await navigateRecords(); + await openFormView("mail.test.simple.main.attachment", recordIds[0], { + resIds: recordIds, + }); + await click("button i[title='Pop out Attachments']"); + await navigateRecords(); +}); diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/chatter.test.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/chatter.test.js new file mode 100644 index 0000000..3709b80 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/chatter.test.js @@ -0,0 +1,197 @@ +import { + click, + contains, + inputFiles, + insertText, + listenStoreFetch, + openFormView, + patchUiSize, + registerArchs, + SIZES, + start, + startServer, + triggerHotkey, + waitStoreFetch, +} from "@mail/../tests/mail_test_helpers"; +import { describe, test } from "@odoo/hoot"; +import { defineTestMailModels } from "@test_mail/../tests/test_mail_test_helpers"; +import { MockServer, onRpc } from "@web/../tests/web_test_helpers"; +import { mail_data } from "@mail/../tests/mock_server/mail_mock_server"; + +describe.current.tags("desktop"); +defineTestMailModels(); + +test("Send message button activation (access rights dependent)", async () => { + const pyEnv = await startServer(); + registerArchs({ + "mail.test.multi.company,false,form": ` +
+ + + + + + `, + "mail.test.multi.company.read,false,form": ` +
+ + + + + + `, + }); + let userAccess = {}; + listenStoreFetch("mail.thread", { + async onRpc(request) { + const { params } = await request.json(); + if (params.fetch_params.some((fetchParam) => fetchParam[0] === "mail.thread")) { + const res = await mail_data.bind(MockServer.current)(request); + res["mail.thread"][0].hasWriteAccess = userAccess.hasWriteAccess; + res["mail.thread"][0].hasReadAccess = userAccess.hasReadAccess; + return res; + } + }, + }); + await start(); + const simpleId = pyEnv["mail.test.multi.company"].create({ name: "Test MC Simple" }); + const simpleMcId = pyEnv["mail.test.multi.company.read"].create({ + name: "Test MC Readonly with Activities", + }); + async function assertSendButton( + enabled, + activities, + msg, + model = null, + resId = null, + hasReadAccess = false, + hasWriteAccess = false + ) { + userAccess = { hasReadAccess, hasWriteAccess }; + await openFormView(model, resId); + if (resId) { + await waitStoreFetch("mail.thread"); + } + if (enabled) { + await contains(".o-mail-Chatter-topbar button:enabled", { text: "Send message" }); + await contains(".o-mail-Chatter-topbar button:enabled", { text: "Log note" }); + if (activities) { + await contains(".o-mail-Chatter-topbar button:enabled", { text: "Activity" }); + + } + } else { + await contains(".o-mail-Chatter-topbar button:disabled", { text: "Send message" }); + await contains(".o-mail-Chatter-topbar button:disabled", { text: "Log note" }); + if (activities) { + await contains(".o-mail-Chatter-topbar button:disabled", { text: "Activity" }); + + } + } + } + await assertSendButton( + true, + false, + "Record, all rights", + "mail.test.multi.company", + simpleId, + true, + true + ); + await assertSendButton( + true, + true, + "Record, all rights", + "mail.test.multi.company.read", + simpleId, + true, + true + ); + await assertSendButton( + false, + false, + "Record, no write access", + "mail.test.multi.company", + simpleId, + true + ); + await assertSendButton( + true, + true, + "Record, read access but model accept post with read only access", + "mail.test.multi.company.read", + simpleMcId, + true + ); + await assertSendButton(false, false, "Record, no rights", "mail.test.multi.company", simpleId); + await assertSendButton(false, true, "Record, no rights", "mail.test.multi.company.read", simpleMcId); + // Note that rights have no impact on send button for draft record (chatter.isTemporary=true) + await assertSendButton(true, false, "Draft record", "mail.test.multi.company"); + await assertSendButton(true, true, "Draft record", "mail.test.multi.company.read"); +}); + +test("basic chatter rendering with a model without activities", async () => { + const pyEnv = await startServer(); + const recordId = pyEnv["mail.test.simple"].create({ name: "new record" }); + registerArchs({ + "mail.test.simple,false,form": ` +
+ + + + + + `, + }); + await start(); + await openFormView("mail.test.simple", recordId); + await contains(".o-mail-Chatter"); + await contains(".o-mail-Chatter-topbar"); + await contains("button[aria-label='Attach files']"); + await contains("button", { count: 0, text: "Activities" }); + await contains(".o-mail-Followers"); + await contains(".o-mail-Thread"); +}); + +test("opened attachment box should remain open after adding a new attachment", async (assert) => { + const pyEnv = await startServer(); + const recordId = pyEnv["mail.test.simple.main.attachment"].create({}); + const attachmentId = pyEnv["ir.attachment"].create({ + mimetype: "image/jpeg", + res_id: recordId, + res_model: "mail.test.simple.main.attachment", + }); + pyEnv["mail.message"].create({ + attachment_ids: [attachmentId], + model: "mail.test.simple.main.attachment", + res_id: recordId, + }); + onRpc("/mail/thread/data", async (request) => { + await new Promise((resolve) => setTimeout(resolve, 1)); // need extra time for useEffect + }); + patchUiSize({ size: SIZES.XXL }); + await start(); + await openFormView("mail.test.simple.main.attachment", recordId, { + arch: ` +
+ + + +
+ + `, + }); + await contains(".o_attachment_preview"); + await click(".o-mail-Chatter-attachFiles"); + await contains(".o-mail-AttachmentBox"); + await click("button", { text: "Send message" }); + await inputFiles(".o-mail-Composer .o_input_file", [ + new File(["image"], "testing.jpeg", { type: "image/jpeg" }), + ]); + await click(".o-mail-Composer-send:enabled"); + await contains(".o_move_next"); + await click("button", { text: "Send message" }); + await insertText(".o-mail-Composer-input", "test"); + triggerHotkey("control+Enter"); + await contains(".o-mail-Message-body", { text: "test" }); + await contains(".o-mail-AttachmentBox .o-mail-AttachmentImage", { count: 2 }); +}); diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/chatter_tests.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/chatter_tests.js deleted file mode 100644 index f6aab64..0000000 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/chatter_tests.js +++ /dev/null @@ -1,77 +0,0 @@ -/** @odoo-module **/ - -import { - start, - startServer, -} from '@mail/../tests/helpers/test_utils'; - - -QUnit.module('mail', {}, function () { -QUnit.module('Chatter'); - -QUnit.test('Send message button activation (access rights dependent)', async function (assert) { - const pyEnv = await startServer(); - const view = `
- - - -
- -
-
`; - - let userAccess = {}; - const { openView } = await start({ - serverData: { - views: { - 'mail.test.multi.company,false,form': view, - 'mail.test.multi.company.read,false,form': view, - } - }, - async mockRPC(route, args, performRPC) { - const res = await performRPC(route, args); - if (route === '/mail/thread/data') { - // mimic user with custom access defined in userAccess variable - const { thread_model } = args; - Object.assign(res, userAccess); - res['canPostOnReadonly'] = thread_model === 'mail.test.multi.company.read'; - } - return res; - }, - }); - const resSimpleId1 = pyEnv['mail.test.multi.company'].create({ name: 'Test MC Simple' }); - const resSimpleMCId1 = pyEnv['mail.test.multi.company.read'].create({ name: 'Test MC Readonly' }); - async function assertSendButton(enabled, msg, - model = null, resId = null, - hasReadAccess = false, hasWriteAccess = false) { - userAccess = { hasReadAccess, hasWriteAccess }; - await openView({ - res_id: resId, - res_model: model, - views: [[false, 'form']], - }); - const details = `hasReadAccess: ${hasReadAccess}, hasWriteAccess: ${hasWriteAccess}, model: ${model}, resId: ${resId}`; - if (enabled) { - assert.containsNone(document.body, '.o_ChatterTopbar_buttonSendMessage:disabled', - `${msg}: send message button must not be disabled (${details}`); - } else { - assert.containsOnce(document.body, '.o_ChatterTopbar_buttonSendMessage:disabled', - `${msg}: send message button must be disabled (${details})`); - } - } - const enabled = true, disabled = false; - - await assertSendButton(enabled, 'Record, all rights', 'mail.test.multi.company', resSimpleId1, true, true); - await assertSendButton(enabled, 'Record, all rights', 'mail.test.multi.company.read', resSimpleId1, true, true); - await assertSendButton(disabled, 'Record, no write access', 'mail.test.multi.company', resSimpleId1, true); - await assertSendButton(enabled, 'Record, read access but model accept post with read only access', - 'mail.test.multi.company.read', resSimpleMCId1, true); - await assertSendButton(disabled, 'Record, no rights', 'mail.test.multi.company', resSimpleId1); - await assertSendButton(disabled, 'Record, no rights', 'mail.test.multi.company.read', resSimpleMCId1); - - // Note that rights have no impact on send button for draft record (chatter.isTemporary=true) - await assertSendButton(enabled, 'Draft record', 'mail.test.multi.company'); - await assertSendButton(enabled, 'Draft record', 'mail.test.multi.company.read'); -}); - -}); diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/helpers/model_definitions_setup.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/helpers/model_definitions_setup.js deleted file mode 100644 index dba01ff..0000000 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/helpers/model_definitions_setup.js +++ /dev/null @@ -1,5 +0,0 @@ -/** @odoo-module **/ - -import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers'; - -addModelNamesToFetch(['mail.test.track.all', 'mail.test.activity', 'mail.test.multi.company', 'mail.test.multi.company.read']); diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mobile/activity_tests.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mobile/activity_tests.js deleted file mode 100644 index 2d7dc26..0000000 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mobile/activity_tests.js +++ /dev/null @@ -1,48 +0,0 @@ -/** @odoo-module **/ - -import { start } from "@mail/../tests/helpers/test_utils"; -import { prepareTarget } from "web.test_utils"; - -QUnit.module("test_mail", () => { -QUnit.module("activity view mobile"); - -QUnit.test('horizontal scroll applies only to the content, not to the whole controller', async (assert) => { - const viewPort = prepareTarget(); - viewPort.style.position = "initial"; - viewPort.style.width = "initial"; - - const { openView } = await start(); - await openView({ - res_model: "mail.test.activity", - views: [[false, "activity"]], - }); - const o_view_controller = document.querySelector(".o_view_controller"); - const o_content = o_view_controller.querySelector(".o_content"); - - const o_cp_buttons = o_view_controller.querySelector(".o_control_panel .o_cp_buttons"); - const initialXCpBtn = o_cp_buttons.getBoundingClientRect().x; - - const o_header_cell = o_content.querySelector(".o_activity_type_cell"); - const initialXHeaderCell = o_header_cell.getBoundingClientRect().x; - - assert.hasClass(o_view_controller, "o_action_delegate_scroll", - "the 'o_view_controller' should be have the 'o_action_delegate_scroll'."); - assert.strictEqual(window.getComputedStyle(o_view_controller).overflow,"hidden", - "the view controller should have overflow hidden"); - assert.strictEqual(window.getComputedStyle(o_content).overflow,"auto", - "the view content should have the overflow auto"); - assert.strictEqual(o_content.scrollLeft, 0, "the o_content should not have scroll value"); - - // Horizontal scroll - o_content.scrollLeft = 100; - - assert.strictEqual(o_content.scrollLeft, 100, "the o_content should be 100 due to the overflow auto"); - assert.ok(o_header_cell.getBoundingClientRect().x < initialXHeaderCell, - "the gantt header cell x position value should be lower after the scroll"); - assert.strictEqual(o_cp_buttons.getBoundingClientRect().x, initialXCpBtn, - "the btn x position of the control panel button should be the same after the scroll"); - viewPort.style.position = ""; - viewPort.style.width = ""; -}); - -}); diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_activity.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_activity.js new file mode 100644 index 0000000..d16bb30 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_activity.js @@ -0,0 +1,6 @@ +import { models } from "@web/../tests/web_test_helpers"; + +export class MailTestActivity extends models.ServerModel { + _name = "mail.test.activity"; + _inherit = ["mail.thread"]; +} diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_multi_company.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_multi_company.js new file mode 100644 index 0000000..10e5b14 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_multi_company.js @@ -0,0 +1,5 @@ +import { models } from "@web/../tests/web_test_helpers"; + +export class MailTestMultiCompany extends models.ServerModel { + _name = "mail.test.multi.company"; +} diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_multi_company_read.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_multi_company_read.js new file mode 100644 index 0000000..c02586b --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_multi_company_read.js @@ -0,0 +1,6 @@ +import { models } from "@web/../tests/web_test_helpers"; + +export class MailTestMultiCompanyRead extends models.ServerModel { + _name = "mail.test.multi.company.read"; + _mail_post_access = "read"; +} diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_properties.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_properties.js new file mode 100644 index 0000000..c13b1c2 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_properties.js @@ -0,0 +1,5 @@ +import { models } from "@web/../tests/web_test_helpers"; + +export class MailTestProperties extends models.ServerModel { + _name = "mail.test.properties"; +} diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_simple.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_simple.js new file mode 100644 index 0000000..0ae2da4 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_simple.js @@ -0,0 +1,5 @@ +import { models } from "@web/../tests/web_test_helpers"; + +export class MailTestSimple extends models.ServerModel { + _name = "mail.test.simple"; +} diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_simple_main_attachment.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_simple_main_attachment.js new file mode 100644 index 0000000..44d2a62 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_simple_main_attachment.js @@ -0,0 +1,5 @@ +import { models } from "@web/../tests/web_test_helpers"; + +export class MailTestSimpleMainAttachment extends models.ServerModel { + _name = "mail.test.simple.main.attachment"; +} diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_track_all.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_track_all.js new file mode 100644 index 0000000..27a0e05 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/mail_test_track_all.js @@ -0,0 +1,10 @@ +import { fields, models } from "@web/../tests/web_test_helpers"; + +export class MailTestTrackAll extends models.ServerModel { + _name = "mail.test.track.all"; + _inherit = ["mail.thread"]; + + float_field_with_digits = fields.Float({ + digits: [10, 8], + }); +} diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/res_currency.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/res_currency.js new file mode 100644 index 0000000..904159b --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mock_server/models/res_currency.js @@ -0,0 +1,5 @@ +import { models } from "@web/../tests/web_test_helpers"; + +export class ResCurrency extends models.ServerModel { + _name = "res.currency"; +} diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/properties_field.test.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/properties_field.test.js new file mode 100644 index 0000000..235e453 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/properties_field.test.js @@ -0,0 +1,73 @@ +import { + click, + contains, + openFormView, + registerArchs, + start, + startServer, +} from "@mail/../tests/mail_test_helpers"; +import { describe, test } from "@odoo/hoot"; +import { defineTestMailModels } from "@test_mail/../tests/test_mail_test_helpers"; +import { asyncStep, onRpc, waitForSteps } from "@web/../tests/web_test_helpers"; + +/** + * Open a chat window when clicking on an avatar many2one / many2many properties. + */ +async function testPropertyFieldAvatarOpenChat(propertyType) { + const pyEnv = await startServer(); + registerArchs({ + "mail.test.properties,false,form": ` +
+ + + + + + + + `, + }); + onRpc("mail.test.properties", "has_access", () => true); + onRpc("res.users", "read", () => { + asyncStep("read res.users"); + return [{ id: userId, partner_id: [partnerId, "Partner Test"] }]; + }); + onRpc("res.users", "search_read", () => [{ id: userId, name: "User Test" }]); + await start(); + const partnerId = pyEnv["res.partner"].create({ name: "Partner Test" }); + const userId = pyEnv["res.users"].create({ partner_id: partnerId }); + const propertyDefinition = { + type: propertyType, + comodel: "res.users", + name: "user", + string: "user", + }; + const parentId = pyEnv["mail.test.properties"].create({ + name: "Parent", + definition_properties: [propertyDefinition], + }); + const childId = pyEnv["mail.test.properties"].create({ + name: "Test", + parent_id: parentId, + properties: [{ ...propertyDefinition, value: [userId] }], + }); + + await openFormView("mail.test.properties", childId); + await waitForSteps([]); + await click( + propertyType === "many2one" ? ".o_field_property_many2one_value img" : ".o_m2m_avatar" + ); + await waitForSteps(["read res.users"]); + await contains(".o-mail-ChatWindow", { text: "Partner Test" }); +} + +describe.current.tags("desktop"); +defineTestMailModels(); + +test("Properties fields: many2one avatar open chat on click", async () => { + await testPropertyFieldAvatarOpenChat("many2one"); +}); + +test("Properties fields: m2m avatar list open chat on click", async () => { + await testPropertyFieldAvatarOpenChat("many2many"); +}); diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/systray_activity_menu.test.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/systray_activity_menu.test.js new file mode 100644 index 0000000..5636335 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/systray_activity_menu.test.js @@ -0,0 +1,129 @@ +import { start, startServer } from "@mail/../tests/mail_test_helpers"; +import { click, contains } from "@mail/../tests/mail_test_helpers_contains"; +import { beforeEach, describe, expect, test } from "@odoo/hoot"; +import { mockDate } from "@odoo/hoot-mock"; +import { defineTestMailModels } from "@test_mail/../tests/test_mail_test_helpers"; +import { asyncStep, mockService, waitForSteps } from "@web/../tests/web_test_helpers"; +import { serializeDate, today } from "@web/core/l10n/dates"; + +describe.current.tags("desktop"); +defineTestMailModels(); +// Avoid problem around midnight (Ex.: tomorrow activities become today activities when reaching midnight) +beforeEach(() => mockDate("2023-04-08 10:00:00", 0)); + +test("menu with no records", async () => { + await start(); + await click(".o_menu_systray .dropdown-toggle:has(i[aria-label='Activities'])"); + await contains(".o-mail-ActivityMenu", { + text: "Congratulations, you're done with your activities.", + }); +}); + +test("do not show empty text when at least some future activities", async () => { + const tomorrow = today().plus({ days: 1 }); + const pyEnv = await startServer(); + const activityId = pyEnv["mail.test.activity"].create({}); + pyEnv["mail.activity"].create([ + { + date_deadline: serializeDate(tomorrow), + res_id: activityId, + res_model: "mail.test.activity", + }, + ]); + await start(); + await click(".o_menu_systray .dropdown-toggle:has(i[aria-label='Activities'])"); + await contains(".o-mail-ActivityMenu", { + count: 0, + text: "Congratulations, you're done with your activities.", + }); +}); + +test("activity menu widget: activity menu with 2 models", async () => { + const tomorrow = today().plus({ days: 1 }); + const yesterday = today().plus({ days: -1 }); + const pyEnv = await startServer(); + const partnerId = pyEnv["res.partner"].create({}); + const activityIds = pyEnv["mail.test.activity"].create([{}, {}, {}, {}]); + pyEnv["mail.activity"].create([ + { res_id: partnerId, res_model: "res.partner", date_deadline: serializeDate(today()) }, + { + res_id: activityIds[0], + res_model: "mail.test.activity", + date_deadline: serializeDate(today()), + }, + { + date_deadline: serializeDate(tomorrow), + res_id: activityIds[1], + res_model: "mail.test.activity", + }, + { + date_deadline: serializeDate(tomorrow), + res_id: activityIds[2], + res_model: "mail.test.activity", + }, + { + date_deadline: serializeDate(yesterday), + res_id: activityIds[3], + res_model: "mail.test.activity", + }, + ]); + await start(); + await contains(".o_menu_systray i[aria-label='Activities']"); + await contains(".o-mail-ActivityMenu-counter"); + await contains(".o-mail-ActivityMenu-counter", { text: "5" }); + const actionChecks = { + context: { + force_search_count: 1, + search_default_filter_activities_my: 1, + search_default_activities_overdue: 1, + search_default_activities_today: 1, + }, + domain: [["active", "in", [true, false]]], + }; + mockService("action", { + doAction(action) { + Object.entries(actionChecks).forEach(([key, value]) => { + if (Array.isArray(value) || typeof value === "object") { + expect(action[key]).toEqual(value); + } else { + expect(action[key]).toBe(value); + } + }); + asyncStep("do_action:" + action.name); + }, + }); + await click(".o_menu_systray i[aria-label='Activities']"); + await contains(".o-mail-ActivityMenu"); + await contains(".o-mail-ActivityMenu .o-mail-ActivityGroup", { count: 2 }); + await contains(".o-mail-ActivityMenu .o-mail-ActivityGroup", { + contains: [ + ["div[name='activityTitle']", { text: "res.partner" }], + ["span", { text: "0 Late" }], + ["span", { text: "1 Today" }], + ["span", { text: "0 Future" }], + ], + }); + await contains(".o-mail-ActivityMenu .o-mail-ActivityGroup", { + contains: [ + ["div[name='activityTitle']", { text: "mail.test.activity" }], + ["span", { text: "1 Late" }], + ["span", { text: "1 Today" }], + ["span", { text: "2 Future" }], + ], + }); + actionChecks.res_model = "res.partner"; + await click(".o-mail-ActivityMenu .o-mail-ActivityGroup", { text: "res.partner" }); + await contains(".o-mail-ActivityMenu", { count: 0 }); + await click(".o_menu_systray i[aria-label='Activities']"); + actionChecks.res_model = "mail.test.activity"; + await click(".o-mail-ActivityMenu .o-mail-ActivityGroup", { text: "mail.test.activity" }); + await waitForSteps(["do_action:res.partner", "do_action:mail.test.activity"]); +}); + +test("activity menu widget: close on messaging menu click", async () => { + await start(); + await click(".o_menu_systray i[aria-label='Activities']"); + await contains(".o-mail-ActivityMenu"); + await click(".o_menu_systray i[aria-label='Messages']"); + await contains(".o-mail-ActivityMenu", { count: 0 }); +}); diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/systray_activity_menu_tests.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/systray_activity_menu_tests.js deleted file mode 100644 index 7315746..0000000 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/systray_activity_menu_tests.js +++ /dev/null @@ -1,165 +0,0 @@ -/** @odoo-module **/ - -import { - start, - startServer, -} from '@mail/../tests/helpers/test_utils'; - -import session from 'web.session'; -import { date_to_str } from 'web.time'; -import { patchWithCleanup } from '@web/../tests/helpers/utils'; - - -QUnit.module('test_mail', {}, function () { -QUnit.module('systray_activity_menu_tests.js', { - async beforeEach() { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - const pyEnv = await startServer(); - const resPartnerId1 = pyEnv['res.partner'].create({}); - const mailTestActivityIds = pyEnv['mail.test.activity'].create([{}, {}, {}, {}]); - pyEnv['mail.activity'].create([ - { res_id: resPartnerId1, res_model: 'res.partner' }, - { res_id: mailTestActivityIds[0], res_model: 'mail.test.activity' }, - { date_deadline: date_to_str(tomorrow), res_id: mailTestActivityIds[1], res_model: 'mail.test.activity' }, - { date_deadline: date_to_str(tomorrow), res_id: mailTestActivityIds[2], res_model: 'mail.test.activity' }, - { date_deadline: date_to_str(yesterday), res_id: mailTestActivityIds[3], res_model: 'mail.test.activity' }, - ]); - }, -}); - -QUnit.test('activity menu widget: menu with no records', async function (assert) { - assert.expect(1); - - const { click } = await start({ - mockRPC: function (route, args) { - if (args.method === 'systray_get_activities') { - return Promise.resolve([]); - } - }, - }); - await click('.o_ActivityMenuView_dropdownToggle'); - assert.containsOnce(document.body, '.o_ActivityMenuView_noActivity'); -}); - -QUnit.test('activity menu widget: activity menu with 2 models', async function (assert) { - assert.expect(10); - - const { click, env } = await start(); - - await click('.o_ActivityMenuView_dropdownToggle'); - assert.containsOnce(document.body, '.o_ActivityMenuView', 'should contain an instance of widget'); - assert.ok(document.querySelectorAll('.o_ActivityMenuView_activityGroup').length); - assert.containsOnce(document.body, '.o_ActivityMenuView_counter', "widget should have notification counter"); - assert.strictEqual(parseInt(document.querySelector('.o_ActivityMenuView_counter').innerText), 5, "widget should have 5 notification counter"); - - var context = {}; - patchWithCleanup(env.services.action, { - doAction(action) { - assert.deepEqual(action.context, context, "wrong context value"); - }, - }); - - // case 1: click on "late" - context = { - force_search_count: 1, - search_default_activities_overdue: 1, - }; - assert.containsOnce(document.body, '.o_ActivityMenuView_dropdownMenu.show', 'ActivityMenu should be open'); - await click('.o_ActivityMenuView_activityGroupFilterButton[data-model_name="mail.test.activity"][data-filter="overdue"]'); - assert.containsNone(document.body, '.show', 'ActivityMenu should be closed'); - // case 2: click on "today" - context = { - force_search_count: 1, - search_default_activities_today: 1, - }; - await click('.dropdown-toggle[title="Activities"]'); - await click('.o_ActivityMenuView_activityGroupFilterButton[data-model_name="mail.test.activity"][data-filter="today"]'); - // case 3: click on "future" - context = { - force_search_count: 1, - search_default_activities_upcoming_all: 1, - }; - await click('.dropdown-toggle[title="Activities"]'); - await click('.o_ActivityMenuView_activityGroupFilterButton[data-model_name="mail.test.activity"][data-filter="upcoming_all"]'); - // case 4: click anywere else - context = { - force_search_count: 1, - search_default_activities_overdue: 1, - search_default_activities_today: 1, - }; - await click('.dropdown-toggle[title="Activities"]'); - await click('.o_ActivityMenuView_activityGroups > div[data-model_name="mail.test.activity"]'); -}); - -QUnit.test('activity menu widget: activity view icon', async function (assert) { - assert.expect(14); - - patchWithCleanup(session, { uid: 10 }); - const { click, env } = await start(); - - await click('.o_ActivityMenuView_dropdownToggle'); - assert.containsN(document.body, '.o_ActivityMenuView_activityGroupActionButton', 2, - "widget should have 2 activity view icons"); - - var first = document.querySelector('.o_ActivityMenuView_activityGroupActionButton[data-model_name="res.partner"]'); - var second = document.querySelector('.o_ActivityMenuView_activityGroupActionButton[data-model_name="mail.test.activity"]'); - assert.ok(first, "should have activity action linked to 'res.partner'"); - assert.hasClass(first, 'fa-clock-o', "should display the activity action icon"); - - assert.ok(second, "should have activity action linked to 'mail.test.activity'"); - assert.hasClass(second, 'fa-clock-o', "should display the activity action icon"); - - patchWithCleanup(env.services.action, { - doAction(action) { - if (action.name) { - assert.ok(action.domain, "should define a domain on the action"); - assert.deepEqual(action.domain, [["activity_ids.user_id", "=", 10]], - "should set domain to user's activity only"); - assert.step('do_action:' + action.name); - } else { - assert.step('do_action:' + action); - } - }, - }); - - assert.hasClass(document.querySelector('.o-dropdown-menu'), 'show', - "dropdown should be expanded"); - - await click('.o_ActivityMenuView_activityGroupActionButton[data-model_name="mail.test.activity"]'); - assert.containsNone(document.body, '.o-dropdown-menu', - "dropdown should be collapsed"); - - // click on the "res.partner" activity icon - await click('.dropdown-toggle[title="Activities"]'); - await click('.o_ActivityMenuView_activityGroupActionButton[data-model_name="res.partner"]'); - - assert.verifySteps([ - 'do_action:mail.test.activity', - 'do_action:res.partner' - ]); -}); - -QUnit.test('activity menu widget: close on messaging menu click', async function (assert) { - assert.expect(2); - - const { click } = await start(); - - await click('.dropdown-toggle[title="Activities"]'); - assert.hasClass( - document.querySelector('.o_ActivityMenuView_dropdownMenu'), - 'show', - "activity menu should be shown after click on itself" - ); - - await click(`.o_MessagingMenu_toggler`); - assert.containsNone( - document.body, - '.o_ActivityMenuView_dropdownMenu', - "activity menu should be hidden after click on messaging menu" - ); -}); - -}); diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/test_mail_test_helpers.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/test_mail_test_helpers.js new file mode 100644 index 0000000..4753061 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/test_mail_test_helpers.js @@ -0,0 +1,32 @@ +import { contains, mailModels } from "@mail/../tests/mail_test_helpers"; +import { MailTestActivity } from "@test_mail/../tests/mock_server/models/mail_test_activity"; +import { MailTestMultiCompany } from "@test_mail/../tests/mock_server/models/mail_test_multi_company"; +import { MailTestMultiCompanyRead } from "@test_mail/../tests/mock_server/models/mail_test_multi_company_read"; +import { MailTestProperties } from "@test_mail/../tests/mock_server/models/mail_test_properties"; +import { MailTestSimpleMainAttachment } from "./mock_server/models/mail_test_simple_main_attachment"; +import { MailTestSimple } from "@test_mail/../tests/mock_server/models/mail_test_simple"; +import { MailTestTrackAll } from "@test_mail/../tests/mock_server/models/mail_test_track_all"; +import { defineModels, defineParams } from "@web/../tests/web_test_helpers"; + +export const testMailModels = { + ...mailModels, + MailTestActivity, + MailTestMultiCompany, + MailTestMultiCompanyRead, + MailTestProperties, + MailTestSimpleMainAttachment, + MailTestSimple, + MailTestTrackAll, +}; + +export function defineTestMailModels() { + defineParams({ suite: "test_mail" }, "replace"); + defineModels(testMailModels); +} + +export async function editSelect(selector, value) { + await contains(selector); + const el = document.querySelector(selector); + el.value = value; + el.dispatchEvent(new Event("change")); +} diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/tours/mail_activity_view_tour.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/tours/mail_activity_view_tour.js new file mode 100644 index 0000000..ebe5426 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/tours/mail_activity_view_tour.js @@ -0,0 +1,69 @@ +import { registry } from "@web/core/registry"; + +const setPager = value => [ + { + content: "Click Pager", + trigger: ".o_pager_value:first()", + run: "click", + }, + { + content: "Change pager to display lines " + value, + trigger: "input.o_pager_value", + run: `edit ${value} && click body`, + }, + { + trigger: `.o_pager_value:contains('${value}')`, + }, +] + + +const checkRows = values => { + return { + trigger: '.o_activity_view', + run: () => { + const dataRow = document.querySelectorAll('.o_activity_view tbody .o_data_row .o_activity_record'); + if (dataRow.length !== values.length) { + throw Error(`There should be ${values.length} activities`); + } + values.forEach((value, index) => { + if (dataRow[index].textContent !== value) { + throw Error(`Record does not match ${value} != ${dataRow[index]}`); + } + }); + } + } +} + +registry.category("web_tour.tours").add("mail_activity_view", { + steps: () => [ + { + content: "Open the debug menu", + trigger: ".o_debug_manager button", + run: "click", + }, + { + content: "Click the Set Defaults menu", + trigger: ".o-dropdown-item:contains(Open View)", + run: "click", + }, + { + trigger: ".o_searchview_input", + run: "edit Test Activity View" + }, + { + trigger: ".o_searchview_autocomplete .o-dropdown-item.focus", + content: "Validate search", + run: "click", + }, + { + content: "Select Test Activity View", + trigger: `.o_data_row td:contains("Test Activity View")`, + run: "click", + }, + checkRows(["Task 1", "Task 2", "Task 3"]), + ...setPager("1-2"), + checkRows(["Task 2", "Task 3"]), + ...setPager("3"), + checkRows(["Task 1"]), + ], +}) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/tracking_value.test.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/tracking_value.test.js new file mode 100644 index 0000000..b7c2208 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/tracking_value.test.js @@ -0,0 +1,377 @@ +import { + contains, + click, + insertText, + openFormView, + registerArchs, + start, + startServer, +} from "@mail/../tests/mail_test_helpers"; +import { beforeEach, describe, expect, test } from "@odoo/hoot"; +import { mockDate, mockTimeZone } from "@odoo/hoot-mock"; +import { defineTestMailModels } from "@test_mail/../tests/test_mail_test_helpers"; +import { editSelectMenu, patchWithCleanup } from "@web/../tests/web_test_helpers"; +import { currencies } from "@web/core/currency"; + +const archs = { + "mail.test.track.all,false,form": ` +
+ + + + + + + + + + + + + + + + `, +}; + +describe.current.tags("desktop"); +defineTestMailModels(); +beforeEach(() => mockTimeZone(0)); + +test("basic rendering of tracking value (float type)", async () => { + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ float_field: 12.3 }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await insertText("div[name=float_field] input", "45.67", { replace: true }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking"); + await contains(".o-mail-Message-trackingField"); + await contains(".o-mail-Message-trackingField", { text: "(Float)" }); + await contains(".o-mail-Message-trackingOld"); + await contains(".o-mail-Message-trackingOld", { text: "12.30" }); + await contains(".o-mail-Message-trackingSeparator"); + await contains(".o-mail-Message-trackingNew"); + await contains(".o-mail-Message-trackingNew", { text: "45.67" }); +}); + +test("rendering of tracked field of type float: from non-0 to 0", async () => { + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + float_field: 1, + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await insertText("div[name=float_field] input", "0", { replace: true }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "1.000.00(Float)" }); +}); + +test("rendering of tracked field of type float: from 0 to non-0", async () => { + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + float_field: 0, + float_field_with_digits: 0, + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await insertText("div[name=float_field] input", "1.01", { replace: true }); + await insertText("div[name=float_field_with_digits] input", "1.0001", { replace: true }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { count: 2 }); + const [increasedPrecisionLine, defaultPrecisionLine] = + document.getElementsByClassName("o-mail-Message-tracking"); + const expectedText = [ + [defaultPrecisionLine, ["0.00", "1.01", "(Float)"]], + [increasedPrecisionLine, ["0.00000000", "1.00010000", "(Float)"]], + ]; + for (const [targetLine, [oldText, newText, fieldName]] of expectedText) { + await contains(".o-mail-Message-trackingOld", { target: targetLine, text: oldText }); + await contains(".o-mail-Message-trackingNew", { target: targetLine, text: newText }); + await contains(".o-mail-Message-trackingField", { target: targetLine, text: fieldName }); + } +}); + +test("rendering of tracked field of type integer: from non-0 to 0", async () => { + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + integer_field: 1, + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await insertText("div[name=integer_field] input", "0", { replace: true }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "10(Integer)" }); +}); + +test("rendering of tracked field of type integer: from 0 to non-0", async () => { + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + integer_field: 0, + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await insertText("div[name=integer_field] input", "1", { replace: true }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "01(Integer)" }); +}); + +test("rendering of tracked field of type monetary: from non-0 to 0", async () => { + const pyEnv = await startServer(); + + const testCurrencyId = pyEnv["res.currency"].create({ name: "ECU", symbol: "§" }); + // need to patch currencies as they're passed via cookies, not through the orm + patchWithCleanup(currencies, { + [testCurrencyId]: { digits: [69, 2], position: "after", symbol: "§" }, + }); + + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + currency_id: testCurrencyId, + monetary_field: 1, + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await insertText("div[name=monetary_field] input", "0", { replace: true }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "1.00 §0.00 §(Monetary)" }); +}); + +test("rendering of tracked field of type monetary: from 0 to non-0", async () => { + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + monetary_field: 0, + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await insertText("div[name=monetary_field] input", "1", { replace: true }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "0.001.00(Monetary)" }); +}); + +test("rendering of tracked field of type boolean: from true to false", async () => { + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + boolean_field: true, + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await click(".o_field_boolean input"); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "YesNo(Boolean)" }); +}); + +test("rendering of tracked field of type boolean: from false to true", async () => { + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({}); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await click(".o_field_boolean input"); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "NoYes(Boolean)" }); +}); + +test("rendering of tracked field of type char: from a string to empty string", async () => { + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + char_field: "Marc", + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await insertText("div[name=char_field] input", "", { replace: true }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "MarcNone(Char)" }); +}); + +test("rendering of tracked field of type char: from empty string to a string", async () => { + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + char_field: "", + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await insertText("div[name=char_field] input", "Marc", { replace: true }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "NoneMarc(Char)" }); +}); + +test("rendering of tracked field of type date: from no date to a set date", async () => { + mockDate("2018-12-01"); + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + date_field: false, + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await click("div[name=date_field] input"); + await click(".o_datetime_button", { text: "14" }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "None12/14/2018(Date)" }); +}); + +test("rendering of tracked field of type date: from a set date to no date", async () => { + mockDate("2018-12-01"); + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + date_field: "2018-12-14", + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await click("div[name=date_field] button"); + await insertText("div[name=date_field] input", "", { replace: true }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "12/14/2018None(Date)" }); +}); + +test("rendering of tracked field of type datetime: from no date and time to a set date and time", async function () { + mockDate("2018-12-01", 3); + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + datetime_field: false, + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await click("div[name=datetime_field] input"); + await click(".o_datetime_button", { text: "14" }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "None12/14/2018 12:00:00(Datetime)" }); + const [savedRecord] = pyEnv["mail.test.track.all"].search_read([ + ["id", "=", mailTestTrackAllId1], + ]); + expect(savedRecord.datetime_field).toBe("2018-12-14 09:00:00"); +}); + +test("rendering of tracked field of type datetime: from a set date and time to no date and time", async () => { + mockTimeZone(3); + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + datetime_field: "2018-12-14 13:42:28 ", + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await click("div[name=datetime_field] button"); + await insertText("div[name=datetime_field] input", "", { replace: true }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "12/14/2018 16:42:28None(Datetime)" }); +}); + +test("rendering of tracked field of type text: from some text to empty", async () => { + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + text_field: "Marc", + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await insertText("div[name=text_field] textarea", "", { replace: true }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "MarcNone(Text)" }); +}); + +test("rendering of tracked field of type text: from empty to some text", async () => { + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + text_field: "", + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await insertText("div[name=text_field] textarea", "Marc", { replace: true }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "NoneMarc(Text)" }); +}); + +test("rendering of tracked field of type selection: from a selection to no selection", async () => { + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + selection_field: "first", + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await editSelectMenu("div[name=selection_field] input", { value: "" }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "firstNone(Selection)" }); +}); + +test("rendering of tracked field of type selection: from no selection to a selection", async () => { + const pyEnv = await startServer(); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({}); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await editSelectMenu("div[name=selection_field] input", { value: "First" }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "Nonefirst(Selection)" }); +}); + +test("rendering of tracked field of type many2one: from having a related record to no related record", async () => { + const pyEnv = await startServer(); + const resPartnerId1 = pyEnv["res.partner"].create({ name: "Marc" }); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({ + many2one_field_id: resPartnerId1, + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await insertText(".o_field_many2one_selection input", "", { replace: true }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "MarcNone(Many2one)" }); +}); + +test("rendering of tracked field of type many2one: from no related record to having a related record", async () => { + const pyEnv = await startServer(); + pyEnv["res.partner"].create({ name: "Marc" }); + const mailTestTrackAllId1 = pyEnv["mail.test.track.all"].create({}); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId1); + await click("[name=many2one_field_id] input"); + await click("[name=many2one_field_id] .o-autocomplete--dropdown-item", { text: "Marc" }); + await click(".o_form_button_save"); + await contains(".o-mail-Message-tracking", { text: "NoneMarc(Many2one)" }); +}); + +test("Search message with filter in chatter", async () => { + const pyEnv = await startServer(); + const mailTestTrackAllId = pyEnv["mail.test.track.all"].create({}); + pyEnv["mail.message"].create({ + body: "Hermit", + model: "mail.test.track.all", + res_id: mailTestTrackAllId, + }); + await start(); + registerArchs(archs); + await openFormView("mail.test.track.all", mailTestTrackAllId); + await click("[name=many2one_field_id] input"); + await click("[name=many2one_field_id] .o-autocomplete--dropdown-item", { text: "Hermit" }); + await click(".o_form_button_save"); + // Search message with filter + await click("[title='Search Messages']"); + await insertText(".o_searchview_input", "Hermit"); + await click("button[title='Filter Messages']"); + await click("span", { text: "Conversations" }); + await contains(".o-mail-SearchMessageResult .o-mail-Message", { text: "Hermit" }); + + await click("button[title='Filter Messages']"); + await click("span", { text: "Tracked Changes" }); + await contains(".o-mail-SearchMessageResult .o-mail-Message", { text: "Hermit" }); + + await click("button[title='Filter Messages']"); + await click("span", { text: "All" }); + await contains(".o-mail-SearchMessageResult .o-mail-Message", { count: 2 }); +}); diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/tracking_value_tests.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/tracking_value_tests.js deleted file mode 100644 index c3d04ea..0000000 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/tracking_value_tests.js +++ /dev/null @@ -1,438 +0,0 @@ -/** @odoo-module **/ - -import { - start, - startServer, -} from '@mail/../tests/helpers/test_utils'; - -import { editInput, editSelect, selectDropdownItem, patchWithCleanup, patchTimeZone } from "@web/../tests/helpers/utils"; - -import session from 'web.session'; -import testUtils from 'web.test_utils'; - -QUnit.module('test_mail', {}, function () { -QUnit.module('tracking_value_tests.js', { - beforeEach() { - const views = { - 'mail.test.track.all,false,form': - `
- - - - - - - - - - - - -
- -
-
`, - }; - this.start = async ({ res_id }) => { - const { openFormView, ...remainder } = await start({ - serverData: { views }, - }); - await openFormView( - { - res_model: 'mail.test.track.all', - res_id, - }, - { - props: { mode: 'edit' }, - }, - ); - return remainder; - }; - - patchWithCleanup(session, { - getTZOffset() { - return 0; - }, - }); - }, -}); - -QUnit.test('basic rendering of tracking value (float type)', async function (assert) { - assert.expect(8); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ float_field: 12.30 }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await editInput(document.body, 'div[name=float_field] input', 45.67); - await click('.o_form_button_save'); - assert.containsOnce( - document.body, - '.o_TrackingValue', - "should display a tracking value" - ); - assert.containsOnce( - document.body, - '.o_TrackingValue_fieldName', - "should display the name of the tracked field" - ); - assert.strictEqual( - document.querySelector('.o_TrackingValue_fieldName').textContent, - "(Float)", - "should display the correct tracked field name (Float)", - ); - assert.containsOnce( - document.body, - '.o_TrackingValue_oldValue', - "should display the old value" - ); - assert.strictEqual( - document.querySelector('.o_TrackingValue_oldValue').textContent, - "12.30", - "should display the correct old value (12.30)", - ); - assert.containsOnce( - document.body, - '.o_TrackingValue_separator', - "should display the separator" - ); - assert.containsOnce( - document.body, - '.o_TrackingValue_newValue', - "should display the new value" - ); - assert.strictEqual( - document.querySelector('.o_TrackingValue_newValue').textContent, - "45.67", - "should display the correct new value (45.67)", - ); -}); - -QUnit.test('rendering of tracked field of type float: from non-0 to 0', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ float_field: 1 }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await editInput(document.body, 'div[name=float_field] input', 0); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "1.000.00(Float)", - "should display the correct content of tracked field of type float: from non-0 to 0 (1.00 -> 0.00 (Float))" - ); -}); - -QUnit.test('rendering of tracked field of type float: from 0 to non-0', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ float_field: 0 }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await editInput(document.body, 'div[name=float_field] input', 1); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "0.001.00(Float)", - "should display the correct content of tracked field of type float: from 0 to non-0 (0.00 -> 1.00 (Float))" - ); -}); - -QUnit.test('rendering of tracked field of type integer: from non-0 to 0', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ integer_field: 1 }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await editInput(document.body, 'div[name=integer_field] input', 0); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "10(Integer)", - "should display the correct content of tracked field of type integer: from non-0 to 0 (1 -> 0 (Integer))" - ); -}); - -QUnit.test('rendering of tracked field of type integer: from 0 to non-0', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ integer_field: 0 }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await editInput(document.body, 'div[name=integer_field] input', 1); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "01(Integer)", - "should display the correct content of tracked field of type integer: from 0 to non-0 (0 -> 1 (Integer))" - ); -}); - -QUnit.test('rendering of tracked field of type monetary: from non-0 to 0', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ monetary_field: 1 }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await editInput(document.body, 'div[name=monetary_field] input', 0); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "1.000.00(Monetary)", - "should display the correct content of tracked field of type monetary: from non-0 to 0 (1.00 -> 0.00 (Monetary))" - ); -}); - -QUnit.test('rendering of tracked field of type monetary: from 0 to non-0', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ monetary_field: 0 }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await editInput(document.body, 'div[name=monetary_field] input', 1); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "0.001.00(Monetary)", - "should display the correct content of tracked field of type monetary: from 0 to non-0 (0.00 -> 1.00 (Monetary))" - ); -}); - -QUnit.test('rendering of tracked field of type boolean: from true to false', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ boolean_field: true }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - document.querySelector('.o_field_boolean input').click(); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "YesNo(Boolean)", - "should display the correct content of tracked field of type boolean: from true to false (True -> False (Boolean))" - ); -}); - -QUnit.test('rendering of tracked field of type boolean: from false to true', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({}); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - document.querySelector('.o_field_boolean input').click(); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "NoYes(Boolean)", - "should display the correct content of tracked field of type boolean: from false to true (False -> True (Boolean))" - ); -}); - -QUnit.test('rendering of tracked field of type char: from a string to empty string', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ char_field: 'Marc' }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await editInput(document.body, 'div[name=char_field] input', ''); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "MarcNone(Char)", - "should display the correct content of tracked field of type char: from a string to empty string (Marc -> None (Char))" - ); -}); - -QUnit.test('rendering of tracked field of type char: from empty string to a string', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ char_field: '' }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await editInput(document.body, 'div[name=char_field] input', 'Marc'); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "NoneMarc(Char)", - "should display the correct content of tracked field of type char: from empty string to a string (None -> Marc (Char))" - ); -}); - -QUnit.test('rendering of tracked field of type date: from no date to a set date', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ date_field: false }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await testUtils.fields.editAndTrigger(document.querySelector('div[name=date_field] .o_datepicker .o_datepicker_input'), '12/14/2018', ['change']); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "None12/14/2018(Date)", - "should display the correct content of tracked field of type date: from no date to a set date (None -> 12/14/2018 (Date))" - ); -}); - -QUnit.test('rendering of tracked field of type date: from a set date to no date', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ date_field: '2018-12-14' }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await testUtils.fields.editAndTrigger(document.querySelector('div[name=date_field] .o_datepicker .o_datepicker_input'), '', ['change']); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "12/14/2018None(Date)", - "should display the correct content of tracked field of type date: from a set date to no date (12/14/2018 -> None (Date))" - ); -}); - -QUnit.test('rendering of tracked field of type datetime: from no date and time to a set date and time', async function (assert) { - assert.expect(2); - - patchTimeZone(180); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ datetime_field: false }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await testUtils.fields.editAndTrigger(document.querySelector('div[name=datetime_field] .o_datepicker .o_datepicker_input'), '12/14/2018 13:42:28', ['change']); - await click('.o_form_button_save'); - const savedRecord = pyEnv.getData()["mail.test.track.all"].records.find(({id}) => id === mailTestTrackAllId1); - assert.strictEqual(savedRecord.datetime_field, '2018-12-14 10:42:28'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "None12/14/2018 13:42:28(Datetime)", - "should display the correct content of tracked field of type datetime: from no date and time to a set date and time (None -> 12/14/2018 13:42:28 (Datetime))" - ); -}); - -QUnit.test('rendering of tracked field of type datetime: from a set date and time to no date and time', async function (assert) { - assert.expect(1); - - patchTimeZone(180) - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ datetime_field: '2018-12-14 13:42:28 ' }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await testUtils.fields.editAndTrigger(document.querySelector('div[name=datetime_field] .o_datepicker .o_datepicker_input'), '', ['change']); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "12/14/2018 16:42:28None(Datetime)", - "should display the correct content of tracked field of type datetime: from a set date and time to no date and time (12/14/2018 13:42:28 -> None (Datetime))" - ); -}); - -QUnit.test('rendering of tracked field of type text: from some text to empty', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ text_field: 'Marc' }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await editInput(document.body, 'div[name=text_field] textarea', ''); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "MarcNone(Text)", - "should display the correct content of tracked field of type text: from some text to empty (Marc -> None (Text))" - ); -}); - -QUnit.test('rendering of tracked field of type text: from empty to some text', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ text_field: '' }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await editInput(document.body, 'div[name=text_field] textarea', 'Marc'); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "NoneMarc(Text)", - "should display the correct content of tracked field of type text: from empty to some text (None -> Marc (Text))" - ); -}); - -QUnit.test('rendering of tracked field of type selection: from a selection to no selection', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ selection_field: 'first' }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await editSelect(document.body, 'div[name=selection_field] select', false); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "firstNone(Selection)", - "should display the correct content of tracked field of type selection: from a selection to no selection (first -> None (Selection))" - ); -}); - -QUnit.test('rendering of tracked field of type selection: from no selection to a selection', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({}); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await editSelect(document.body, 'div[name=selection_field] select', '"first"'); - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "Nonefirst(Selection)", - "should display the correct content of tracked field of type selection: from no selection to a selection (None -> first (Selection))" - ); -}); - -QUnit.test('rendering of tracked field of type many2one: from having a related record to no related record', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - const resPartnerId1 = pyEnv['res.partner'].create({ display_name: 'Marc' }); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({ many2one_field_id: resPartnerId1 }); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await editInput(document.body, ".o_field_many2one_selection input", '') - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "MarcNone(Many2one)", - "should display the correct content of tracked field of type many2one: from having a related record to no related record (Marc -> None (Many2one))" - ); -}); - -QUnit.test('rendering of tracked field of type many2one: from no related record to having a related record', async function (assert) { - assert.expect(1); - - const pyEnv = await startServer(); - pyEnv['res.partner'].create({ display_name: 'Marc' }); - const mailTestTrackAllId1 = pyEnv['mail.test.track.all'].create({}); - const { click } = await this.start({ res_id: mailTestTrackAllId1 }); - - await selectDropdownItem(document.body, "many2one_field_id", "Marc") - await click('.o_form_button_save'); - assert.strictEqual( - document.querySelector('.o_TrackingValue').textContent, - "NoneMarc(Many2one)", - "should display the correct content of tracked field of type many2one: from no related record to having a related record (None -> Marc (Many2one))" - ); -}); -}); diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/__init__.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/__init__.py index 15b05ee..9b420f0 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/__init__.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/__init__.py @@ -1,24 +1,32 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import test_controller_attachment +from . import test_controller_binary +from . import test_controller_thread from . import test_invite from . import test_ir_actions +from . import test_ir_attachment from . import test_mail_activity +from . import test_mail_activity_mixin +from . import test_mail_activity_plan +from . import test_mail_alias from . import test_mail_composer from . import test_mail_composer_mixin from . import test_mail_followers +from . import test_mail_gateway +from . import test_mail_flow +from . import test_mail_mail +from . import test_mail_management from . import test_mail_message from . import test_mail_message_security -from . import test_mail_mail -from . import test_mail_gateway from . import test_mail_multicompany +from . import test_mail_push +from . import test_mail_scheduled_message +from . import test_mail_security from . import test_mail_thread_internals from . import test_mail_thread_mixins from . import test_mail_template from . import test_mail_template_preview -from . import test_message_management from . import test_message_post from . import test_message_track from . import test_performance -from . import test_ui -from . import test_mail_management -from . import test_mail_security diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/common.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/common.py index e928cbb..1c88257 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/common.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/common.py @@ -1,15 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.addons.mail.tests.common import MailCommon from odoo.tests.common import TransactionCase -class TestMailCommon(MailCommon): - """ Main entry point for functional tests. Kept to ease backward - compatibility. """ - - class TestRecipients(TransactionCase): @classmethod @@ -25,13 +19,11 @@ class TestRecipients(TransactionCase): 'name': 'Valid Lelitre', 'email': 'valid.lelitre@agrolait.com', 'country_id': cls.env.ref('base.be').id, - 'mobile': '0456001122', - 'phone': False, + 'phone': '0456001122', }) cls.partner_2 = Partner.create({ 'name': 'Valid Poilvache', 'email': 'valid.other@gmail.com', 'country_id': cls.env.ref('base.be').id, - 'mobile': '+32 456 22 11 00', - 'phone': False, + 'phone': '+32 456 22 11 00', }) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_controller_attachment.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_controller_attachment.py new file mode 100644 index 0000000..6a12ae7 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_controller_attachment.py @@ -0,0 +1,35 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import odoo +from odoo.addons.mail.tests.common_controllers import MailControllerAttachmentCommon + + +@odoo.tests.tagged("-at_install", "post_install", "mail_controller") +class TestAttachmentController(MailControllerAttachmentCommon): + def test_independent_attachment_delete(self): + """Test access to delete an attachment whether or not limited `ownership_token` is sent""" + self._execute_subtests_delete(self.all_users, token=True, allowed=True) + self._execute_subtests_delete(self.user_admin, token=False, allowed=True) + self._execute_subtests_delete( + (self.guest, self.user_employee, self.user_portal, self.user_public), + token=False, + allowed=False, + ) + + def test_attachment_delete_linked_to_thread(self): + """Test access to delete an attachment associated with a thread + whether or not limited `ownership_token` is sent""" + thread = self.env["mail.test.simple"].create({"name": "Test"}) + self._execute_subtests_delete(self.all_users, token=True, allowed=True, thread=thread) + self._execute_subtests_delete( + (self.user_admin, self.user_employee), + token=False, + allowed=True, + thread=thread, + ) + self._execute_subtests_delete( + (self.guest, self.user_portal, self.user_public), + token=False, + allowed=False, + thread=thread, + ) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_controller_binary.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_controller_binary.py new file mode 100644 index 0000000..8255e0d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_controller_binary.py @@ -0,0 +1,47 @@ +from odoo.addons.mail.tests.common_controllers import MailControllerBinaryCommon +from odoo.tests import tagged + + +@tagged("-at_install", "post_install", "mail_controller") +class TestPublicBinaryController(MailControllerBinaryCommon): + + def test_avatar_no_public(self): + """Test access to open a guest / partner avatar who hasn't sent a message on a + public record.""" + for source in (self.guest_2, self.user_employee_nopartner.partner_id): + self._execute_subtests( + source, ( + (self.user_public, False), + (self.guest, False), + (self.user_portal, False), + (self.user_employee, True), + ) + ) + + def test_avatar_private(self): + """Test access to open a partner avatar who has sent a message on a private record.""" + document = self.env["mail.test.simple.unfollow"].create({"name": "Test"}) + self._post_message(document, self.user_employee_nopartner) + self._execute_subtests( + self.user_employee_nopartner.partner_id, ( + (self.user_public, False), + (self.guest, False), + (self.user_portal, False), + (self.user_employee, True), + ) + ) + + def test_avatar_public(self): + """Test access to open a guest avatar who has sent a message on a public record.""" + document = self.env["mail.test.access.public"].create({"name": "Test"}) + for author, source in ((self.guest_2, self.guest_2), (self.user_employee_nopartner, self.user_employee_nopartner.partner_id)): + self._post_message(document, author) + self._execute_subtests( + source, + ( + (self.user_public, False), + (self.guest, False), + (self.user_portal, False), + (self.user_employee, True), + ), + ) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_controller_thread.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_controller_thread.py new file mode 100644 index 0000000..079c5ea --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_controller_thread.py @@ -0,0 +1,167 @@ +import json + +from odoo import http +from odoo.addons.mail.tests.common_controllers import MailControllerThreadCommon +from odoo.tests import tagged +from odoo.tools import mute_logger + + +@tagged("-at_install", "post_install", "mail_controller") +class TestMessageController(MailControllerThreadCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.test_public_record = cls.env["mail.test.access.public"].create({"name": "Public Channel", "email": "john@test.be", "mobile": "+32455001122"}) + + @mute_logger("odoo.http") + def test_thread_attachment_hijack(self): + att = self.env["ir.attachment"].create({ + "name": "arguments_for_firing_marc_demo", + "res_id": 0, + "res_model": "mail.compose.message", + }) + self.authenticate(self.user_employee.login, self.user_employee.login) + record = self.env["mail.test.access.public"].create({"name": "Public Channel"}) + record.with_user(self.user_employee).write({'name': 'updated'}) # can access, update, ... + # if this test breaks, it might be due to a change in /web/content, or the default rules for accessing an attachment. This is not an issue but it makes this test irrelevant. + self.assertFalse(self.url_open(f"/web/content/{att.id}").ok) + response = self.url_open( + url="/mail/message/post", + headers={"Content-Type": "application/json"}, # route called as demo + data=json.dumps( + { + "params": { + "post_data": { + "attachment_ids": [att.id], # demo does not have access to this attachment id + "body": "", + "message_type": "comment", + "partner_ids": [], + "subtype_xmlid": "mail.mt_comment", + }, + "thread_id": record.id, + "thread_model": record._name, + } + }, + ), + ) + self.assertNotIn( + "arguments_for_firing_marc_demo", response.text + ) # demo should not be able to see the name of the document + + def test_thread_partner_from_email_authenticated(self): + self.authenticate(self.user_employee.login, self.user_employee.login) + res3 = self.url_open( + url="/mail/partner/from_email", + data=json.dumps( + { + "params": { + "thread_model": self.test_public_record._name, + "thread_id": self.test_public_record.id, + "emails": ["john@test.be"], + }, + } + ), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(res3.status_code, 200) + self.assertEqual( + 1, + self.env["res.partner"].search_count([('email', '=', "john@test.be"), ('phone', '=', "+32455001122")]), + "authenticated users can create a partner from an email", + ) + # should not create another partner with same email + res4 = self.url_open( + url="/mail/partner/from_email", + data=json.dumps( + { + "params": { + "thread_model": self.test_public_record._name, + "thread_id": self.test_public_record.id, + "emails": ["john@test.be"], + }, + } + ), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(res4.status_code, 200) + self.assertEqual( + 1, + self.env["res.partner"].search_count([('email', '=', "john@test.be")]), + "'mail/partner/from_email' does not create another user if there's already a user with matching email", + ) + + self.test_public_record.write({'email': 'john2@test.be'}) + res5 = self.url_open( + url="/mail/message/post", + data=json.dumps( + { + "params": { + "thread_model": self.test_public_record._name, + "thread_id": self.test_public_record.id, + "post_data": { + "body": "test", + "partner_emails": ["john2@test.be"], + }, + }, + } + ), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(res5.status_code, 200) + self.assertEqual( + 1, + self.env["res.partner"].search_count([('email', '=', "john2@test.be"), ('phone', '=', "+32455001122")]), + "authenticated users can create a partner from an email from message_post", + ) + # should not create another partner with same email + res6 = self.url_open( + url="/mail/message/post", + data=json.dumps( + { + "params": { + "thread_model": self.test_public_record._name, + "thread_id": self.test_public_record.id, + "post_data": { + "body": "test", + "partner_emails": ["john2@test.be"], + }, + }, + } + ), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(res6.status_code, 200) + self.assertEqual( + 1, + self.env["res.partner"].search_count([('email', '=', "john2@test.be")]), + "'mail/message/post' does not create another user if there's already a user with matching email", + ) + + def test_thread_post_archived_record(self): + self.authenticate(self.user_employee.login, self.user_employee.login) + archived_partner = self.env["res.partner"].create({"name": "partner", "active": False}) + + # 1. posting a message + data = self.make_jsonrpc_request("/mail/message/post", { + "thread_model": "res.partner", + "thread_id": archived_partner.id, + "post_data": { + "body": "A great message", + } + }) + message = next(filter(lambda m: m["id"] == data["message_id"], data["store_data"]["mail.message"])) + self.assertEqual(["markup", "

A great message

"], message["body"]) + + # 2. attach a file + response = self.url_open( + "/mail/attachment/upload", + { + "csrf_token": http.Request.csrf_token(self), + "thread_id": archived_partner.id, + "thread_model": "res.partner", + }, + files={"ufile": b""}, + ) + self.assertEqual(response.status_code, 200) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_invite.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_invite.py index 718481a..cc64f74 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_invite.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_invite.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.addons.test_mail.tests.common import TestMailCommon +from odoo.addons.mail.tests.common import MailCommon from odoo.tests import tagged from odoo.tools import mute_logger @tagged('mail_followers') -class TestInvite(TestMailCommon): +class TestInvite(MailCommon): @mute_logger('odoo.addons.mail.models.mail_mail') def test_invite_email(self): @@ -16,18 +16,38 @@ class TestInvite(TestMailCommon): 'name': 'Valid Lelitre', 'email': 'valid.lelitre@agrolait.com'}) - mail_invite = self.env['mail.wizard.invite'].with_context({ + mail_invite = self.env['mail.followers.edit'].with_context({ 'default_res_model': 'mail.test.simple', - 'default_res_id': test_record.id - }).with_user(self.user_employee).create({ - 'partner_ids': [(4, test_partner.id), (4, self.user_admin.partner_id.id)], - 'send_mail': True}) - with self.mock_mail_gateway(): - mail_invite.add_followers() + 'default_res_ids': [test_record.id], + }).with_user(self.user_employee).create({'partner_ids': [(4, test_partner.id), (4, self.user_admin.partner_id.id)], + 'notify': True}) + with self.mock_mail_app(), self.mock_mail_gateway(): + mail_invite.edit_followers() - # check added followers and that emails were sent + # Check added followers and that notifications are sent. + # Admin notification preference is inbox so the notification must be of inbox type + # while partner_employee must receive it by email. self.assertEqual(test_record.message_partner_ids, test_partner | self.user_admin.partner_id) + self.assertEqual(len(self._new_msgs), 1) + self.assertEqual(len(self._mails), 1) self.assertSentEmail(self.partner_employee, [test_partner]) - self.assertSentEmail(self.partner_employee, [self.partner_admin]) - self.assertEqual(len(self._mails), 2) + self.assertNotSentEmail([self.partner_admin]) + self.assertNotified( + self._new_msgs[0], + [{'partner': self.partner_admin, 'type': 'inbox', 'is_read': False}] + ) + + # Remove followers + mail_remove = self.env['mail.followers.edit'].with_context({ + 'default_res_model': 'mail.test.simple', + 'default_res_ids': [test_record.id], + }).with_user(self.user_employee).create({ + "operation": "remove", + 'partner_ids': [(4, test_partner.id), (4, self.user_admin.partner_id.id)]}) + + with self.mock_mail_app(), self.mock_mail_gateway(): + mail_remove.edit_followers() + + # Check removed followers and that notifications are sent. + self.assertEqual(test_record.message_partner_ids, self.env["res.partner"]) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_ir_actions.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_ir_actions.py index d214283..8ff8665 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_ir_actions.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_ir_actions.py @@ -2,13 +2,13 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.addons.base.tests.test_ir_actions import TestServerActionsBase -from odoo.addons.test_mail.tests.common import TestMailCommon +from odoo.addons.mail.tests.common import MailCommon from odoo.tests import tagged from odoo.tools import mute_logger @tagged('ir_actions') -class TestServerActionsEmail(TestMailCommon, TestServerActionsBase): +class TestServerActionsEmail(MailCommon, TestServerActionsBase): def setUp(self): super(TestServerActionsEmail, self).setUp() @@ -61,6 +61,17 @@ class TestServerActionsEmail(TestMailCommon, TestServerActionsBase): self.action.with_context(self.context).run() self.assertEqual(self.test_partner.message_partner_ids, self.env.ref('base.partner_admin') | random_partner) + def test_action_followers_warning(self): + self.test_partner.message_unsubscribe(self.test_partner.message_partner_ids.ids) + self.action.write({ + 'state': 'followers', + "followers_type": "generic", + "followers_partner_field_name": "user_id.name" + }) + self.assertEqual(self.action.warning, "The field 'Salesperson > Name' is not a partner field.") + self.action.write({"followers_partner_field_name": "parent_id.child_ids"}) + self.assertEqual(self.action.warning, False) + def test_action_message_post(self): # initial state self.assertEqual(len(self.test_partner.message_ids), 1, @@ -78,7 +89,10 @@ class TestServerActionsEmail(TestMailCommon, TestServerActionsBase): with self.assertSinglePostNotifications( [{'partner': self.test_partner, 'type': 'email', 'status': 'ready'}], message_info={'content': 'Hello %s' % self.test_partner.name, - 'message_type': 'notification', + 'mail_mail_values': { + 'author_id': self.env.user.partner_id, + }, + 'message_type': 'auto_comment', 'subtype': 'mail.mt_comment', } ): @@ -95,7 +109,7 @@ class TestServerActionsEmail(TestMailCommon, TestServerActionsBase): with self.assertSinglePostNotifications( [{'partner': self.test_partner, 'type': 'email', 'status': 'ready'}], message_info={'content': 'Hello %s' % self.test_partner.name, - 'message_type': 'notification', + 'message_type': 'auto_comment', 'subtype': 'mail.mt_note', } ): @@ -117,6 +131,18 @@ class TestServerActionsEmail(TestMailCommon, TestServerActionsBase): self.assertEqual(self.env['mail.activity'].search_count([]), before_count + 1) self.assertEqual(self.env['mail.activity'].search_count([('summary', '=', 'TestNew')]), 1) + def test_action_next_activity_warning(self): + self.action.write({ + 'state': 'next_activity', + 'activity_user_type': 'generic', + "activity_user_field_name": "user_id.name", + 'activity_type_id': self.env.ref('mail.mail_activity_data_meeting').id, + 'activity_summary': 'TestNew', + }) + self.assertEqual(self.action.warning, "The field 'Salesperson > Name' is not a user field.") + self.action.write({"activity_user_field_name": "parent_id.user_id"}) + self.assertEqual(self.action.warning, False) + def test_action_next_activity_due_date(self): """ Make sure we don't crash if a due date is set without a type. """ self.action.write({ @@ -132,3 +158,66 @@ class TestServerActionsEmail(TestMailCommon, TestServerActionsBase): self.assertFalse(run_res, 'ir_actions_server: create next activity action correctly finished should return False') self.assertEqual(self.env['mail.activity'].search_count([]), before_count + 1) self.assertEqual(self.env['mail.activity'].search_count([('summary', '=', 'TestNew')]), 1) + + def test_action_next_activity_from_x2m_user(self): + self.test_partner.user_ids = self.user_demo | self.user_admin + self.action.write({ + 'state': 'next_activity', + 'activity_user_type': 'generic', + 'activity_user_field_name': 'user_ids', + 'activity_type_id': self.env.ref('mail.mail_activity_data_meeting').id, + 'activity_summary': 'TestNew', + }) + before_count = self.env['mail.activity'].search_count([]) + run_res = self.action.with_context(self.context).run() + self.assertFalse(run_res, 'ir_actions_server: create next activity action correctly finished should return False') + self.assertEqual(self.env['mail.activity'].search_count([]), before_count + 1) + self.assertRecordValues( + self.env['mail.activity'].search([('res_model', '=', 'res.partner'), ('res_id', '=', self.test_partner.id)]), + [{ + 'summary': 'TestNew', + 'user_id': self.user_demo.id, # the first user found + }], + ) + + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_action_send_mail_without_mail_thread(self): + """ Check running a server action to send an email with custom layout on a non mail.thread model """ + no_thread_record = self.env['mail.test.nothread'].create({'name': 'Test NoMailThread', 'customer_id': self.test_partner.id}) + no_thread_template = self._create_template( + 'mail.test.nothread', + { + 'email_from': 'someone@example.com', + 'partner_to': '{{ object.customer_id.id }}', + 'subject': 'About {{ object.name }}', + 'body_html': '

Hello

', + 'email_layout_xmlid': 'mail.mail_notification_layout', + } + ) + + # update action: send an email + self.action.write({ + 'mail_post_method': 'email', + 'state': 'mail_post', + 'model_id': self.env['ir.model'].search([('model', '=', 'mail.test.nothread')], limit=1).id, + 'model_name': 'mail.test.nothread', + 'template_id': no_thread_template.id, + }) + + with self.mock_mail_gateway(), self.mock_mail_app(): + action_ctx = { + 'active_model': 'mail.test.nothread', + 'active_id': no_thread_record.id, + } + self.action.with_context(action_ctx).run() + + mail = self.assertMailMail( + self.test_partner, + None, + content='Hello Test NoMailThread', + fields_values={ + 'email_from': 'someone@example.com', + 'subject': 'About Test NoMailThread', + } + ) + self.assertNotIn('Powered by', mail.body_html, 'Body should contain the notification layout') diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_ir_attachment.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_ir_attachment.py new file mode 100644 index 0000000..d80f3e4 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_ir_attachment.py @@ -0,0 +1,61 @@ +import base64 + +from odoo.addons.mail.tests.common import MailCommon +from odoo.tests import tagged, users + + +@tagged("ir_attachment") +class TestAttachment(MailCommon): + + @users("employee") + def test_register_as_main_attachment(self): + """ Test 'register_as_main_attachment', especially the multi support """ + records_model1 = self.env["mail.test.simple.main.attachment"].create([ + { + "name": f"First model {idx}", + } + for idx in range(5) + ]) + records_model2 = self.env["mail.test.gateway.main.attachment"].create([ + { + "name": f"Second model {idx}", + } + for idx in range(5) + ]) + record_nomain = self.env["mail.test.simple"].create({"name": "No Main Attachment"}) + attachments = self.env["ir.attachment"].create([ + { + "datas": base64.b64encode(b'AttContent'), + "name": f"AttachName_{record.name}.pdf", + "mimetype": "application/pdf", + "res_id": record.id, + "res_model": record._name, + } + for record in records_model1 + ] + [ + { + "datas": base64.b64encode(b'AttContent'), + "name": f"AttachName_{record.name}.pdf", + "mimetype": "application/pdf", + "res_id": record.id, + "res_model": record._name, + } + for record in records_model2 + ] + [ + { + "datas": base64.b64encode(b'AttContent'), + "name": "AttachName_free.pdf", + "mimetype": "application/pdf", + }, { + "datas": base64.b64encode(b'AttContent'), + "name": f"AttachName_{record_nomain.name}.pdf", + "mimetype": "application/pdf", + "res_id": record_nomain.id, + "res_model": record_nomain._name, + } + ]) + attachments.register_as_main_attachment() + for record, attachment in zip(records_model1, attachments[:5]): + self.assertEqual(record.message_main_attachment_id, attachment) + for record, attachment in zip(records_model2, attachments[5:10]): + self.assertEqual(record.message_main_attachment_id, attachment) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_activity.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_activity.py index ac10710..fafeb39 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_activity.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_activity.py @@ -3,7 +3,6 @@ from datetime import date, datetime, timedelta from dateutil.relativedelta import relativedelta -from freezegun import freeze_time from psycopg2 import IntegrityError from unittest.mock import patch from unittest.mock import DEFAULT @@ -12,62 +11,160 @@ import pytz from odoo import fields, exceptions, tests from odoo.addons.mail.tests.common import mail_new_test_user -from odoo.addons.test_mail.tests.common import TestMailCommon +from odoo.addons.mail.tests.common_activity import ActivityScheduleCase from odoo.addons.test_mail.models.test_mail_models import MailTestActivity +from odoo.tests import Form, HttpCase, users +from odoo.tests.common import freeze_time from odoo.tools import mute_logger -from odoo.tests.common import Form, users -class TestActivityCommon(TestMailCommon): +class TestActivityCommon(ActivityScheduleCase): @classmethod def setUpClass(cls): super(TestActivityCommon, cls).setUpClass() - cls.test_record = cls.env['mail.test.activity'].with_context(cls._test_context).create({'name': 'Test'}) - # reset ctx - cls._reset_mail_context(cls.test_record) + cls.test_record, cls.test_record_2 = cls.env['mail.test.activity'].create([ + {'name': 'Test'}, + {'name': 'Test_2'}, + ]) @tests.tagged('mail_activity') class TestActivityRights(TestActivityCommon): - @mute_logger('odoo.addons.mail.models.mail_mail') - def test_activity_security_user_access_other(self): - activity = self.test_record.with_user(self.user_employee).activity_schedule( - 'test_mail.mail_act_test_todo', - user_id=self.user_admin.id) - self.assertTrue(activity.can_write) - activity.write({'user_id': self.user_employee.id}) + def test_activity_action_open_document_no_access(self): + def _employee_no_access(records, operation): + """Simulates employee having no access to the document""" + if records.env.uid == self.user_employee.id and not records.env.su: + return records, lambda: exceptions.AccessError('Access denied to document') + return DEFAULT + + test_activity = self.env['mail.activity'].with_user(self.user_admin).create({ + 'activity_type_id': self.env.ref('test_mail.mail_act_test_todo').id, + 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, + 'res_id': self.test_record.id, + 'user_id': self.user_employee.id, + 'summary': 'Test Activity', + }) + + action = test_activity.with_user(self.user_employee).action_open_document() + self.assertEqual(action['res_model'], self.test_record._name) + self.assertEqual(action['res_id'], self.test_record.id) + + # If user has no access to the record, should return activity view instead + with patch.object(MailTestActivity, '_check_access', autospec=True, side_effect=_employee_no_access): + self.assertFalse(self.test_record.with_user(self.user_employee).has_access('read')) + + action = test_activity.with_user(self.user_employee).action_open_document() + self.assertEqual(action['res_model'], 'mail.activity') + self.assertEqual(action['res_id'], test_activity.id) @mute_logger('odoo.addons.mail.models.mail_mail') - def test_activity_security_user_access_own(self): - activity = self.test_record.with_user(self.user_employee).activity_schedule( - 'test_mail.mail_act_test_todo') - self.assertTrue(activity.can_write) - activity.write({'user_id': self.user_admin.id}) + def test_activity_security_user_access(self): + """ Internal user can modify assigned or created or if write on document """ + def _employee_crash(records, operation): + """ If employee is test employee, consider they have no access on document """ + if records.env.uid == self.user_employee.id and not records.env.su: + return records, lambda: exceptions.AccessError('Hop hop hop Ernest, please step back.') + return DEFAULT + + act_emp_for_adm = self.test_record.with_user(self.user_employee).activity_schedule( + 'test_mail.mail_act_test_todo', + user_id=self.user_admin.id, + ) + act_emp_for_emp = self.test_record.with_user(self.user_employee).activity_schedule( + 'test_mail.mail_act_test_todo', + user_id=self.user_employee.id, + ) + act_adm_for_adm = self.test_record.with_user(self.user_admin).activity_schedule( + 'test_mail.mail_act_test_todo', + user_id=self.user_admin.id, + ) + act_adm_for_emp = self.test_record.with_user(self.user_admin).activity_schedule( + 'test_mail.mail_act_test_todo', + user_id=self.user_employee.id, + ) + + for activity, can_write in [ + (act_emp_for_adm, True), (act_emp_for_emp, True), + (act_adm_for_adm, False), (act_adm_for_emp, True), + ]: + with self.subTest(user=activity.user_id.name, creator=activity.create_uid.name): + # no document access -> based on create_uid / user_id + with patch.object(MailTestActivity, '_check_access', autospec=True, side_effect=_employee_crash): + activity = activity.with_user(self.user_employee) + self.assertEqual(activity.can_write, can_write) + if can_write: + activity.write({'summary': 'Caramba'}) + else: + with self.assertRaises(exceptions.AccessError): + activity.write({'summary': 'Caramba'}) + + # document access -> ok bypass + activity.write({'summary': 'Caramba caramba'}) + + def test_activity_security_user_access_customized(self): + """ Test '_mail_get_operation_for_mail_message_operation' support when scheduling activities. """ + access_open, access_ro, access_locked = self.env['mail.test.access.custo'].with_user(self.user_admin).create([ + {'name': 'Open'}, + {'name': 'Open RO', 'is_readonly': True}, + {'name': 'Locked', 'is_locked': True}, + ]) + admin_activities = self.env['mail.activity'] + for record in access_open + access_ro + access_locked: + admin_activities += record.with_user(self.user_admin).activity_schedule( + 'test_mail.mail_act_test_todo_generic', + ) + + # sanity checks on rule implementation + (access_open + access_ro + access_locked).with_user(self.user_employee).check_access('read') + access_open.with_user(self.user_employee).check_access('write') + with self.assertRaises(exceptions.AccessError): + (access_ro + access_locked).with_user(self.user_employee).check_access('write') + + # '_mail_get_operation_for_mail_message_operation' allows to post, hence posting activities + emp_new_1 = access_open.with_user(self.user_employee).activity_schedule( + 'test_mail.mail_act_test_todo_generic', + ) + emp_new_2 = access_ro.with_user(self.user_employee).activity_schedule( + 'test_mail.mail_act_test_todo_generic', + ) + + with self.assertRaises(exceptions.AccessError): + access_locked.with_user(self.user_employee).activity_schedule( + 'test_mail.mail_act_test_todo_generic', + ) + + self.env.invalidate_all() + # check read access correctly uses '_mail_get_operation_for_mail_message_operation' + admin_activities[0].with_user(self.user_employee).read(['summary']) + admin_activities[1].with_user(self.user_employee).read(['summary']) + + self.env.invalidate_all() + # check search correctly uses '_get_mail_message_access' + found = self.env['mail.activity'].with_user(self.user_employee).search([('res_model', '=', 'mail.test.access.custo')]) + self.assertEqual(found, admin_activities[:2] + emp_new_1 + emp_new_2, 'Should respect _get_mail_message_access, reading non locked records') @mute_logger('odoo.addons.mail.models.mail_mail') def test_activity_security_user_noaccess_automated(self): - def _employee_crash(*args, **kwargs): + def _employee_crash(records, operation): """ If employee is test employee, consider they have no access on document """ - recordset = args[0] - if recordset.env.uid == self.user_employee.id: - raise exceptions.AccessError('Hop hop hop Ernest, please step back.') + if records.env.uid == self.user_employee.id and not records.env.su: + return records, lambda: exceptions.AccessError('Hop hop hop Ernest, please step back.') return DEFAULT - with patch.object(MailTestActivity, 'check_access_rights', autospec=True, side_effect=_employee_crash): - activity = self.test_record.activity_schedule( + with patch.object(MailTestActivity, '_check_access', autospec=True, side_effect=_employee_crash): + _activity = self.test_record.activity_schedule( 'test_mail.mail_act_test_todo', user_id=self.user_employee.id) - activity2 = self.test_record.activity_schedule('test_mail.mail_act_test_todo') + activity2 = self.test_record.activity_schedule('test_mail.mail_act_test_todo', user_id=self.user_admin.id) activity2.write({'user_id': self.user_employee.id}) def test_activity_security_user_noaccess_manual(self): - def _employee_crash(*args, **kwargs): + def _employee_crash(records, operation): """ If employee is test employee, consider they have no access on document """ - recordset = args[0] - if recordset.env.uid == self.user_employee.id: + if records.env.uid == self.user_employee.id and not records.env.su: raise exceptions.AccessError('Hop hop hop Ernest, please step back.') return DEFAULT @@ -82,63 +179,82 @@ class TestActivityRights(TestActivityCommon): # can _search activities if access to the document self.env['mail.activity'].with_user(self.user_employee)._search( - [('id', '=', test_activity.id)], count=False) + [('id', '=', test_activity.id)]) # cannot _search activities if no access to the document - with patch.object(MailTestActivity, 'check_access_rights', autospec=True, side_effect=_employee_crash): + with patch.object(MailTestActivity, '_check_access', autospec=True, side_effect=_employee_crash): with self.assertRaises(exceptions.AccessError): searched_activity = self.env['mail.activity'].with_user(self.user_employee)._search( - [('id', '=', test_activity.id)], count=False) + [('id', '=', test_activity.id)]) - # can read_group activities if access to the document - read_group_result = self.env['mail.activity'].with_user(self.user_employee).read_group( + # can formatted_read_group activities if access to the document + read_group_result = self.env['mail.activity'].with_user(self.user_employee).formatted_read_group( [('id', '=', test_activity.id)], ['summary'], - ['summary'], + ['__count'], ) - self.assertEqual(1, read_group_result[0]['summary_count']) + self.assertEqual(1, read_group_result[0]['__count']) self.assertEqual('Summary', read_group_result[0]['summary']) # cannot read_group activities if no access to the document - with patch.object(MailTestActivity, 'check_access_rights', autospec=True, side_effect=_employee_crash): + with patch.object(MailTestActivity, '_check_access', autospec=True, side_effect=_employee_crash): with self.assertRaises(exceptions.AccessError): - self.env['mail.activity'].with_user(self.user_employee).read_group( + self.env['mail.activity'].with_user(self.user_employee).formatted_read_group( [('id', '=', test_activity.id)], ['summary'], - ['summary'], + ['__count'], ) # cannot read activities if no access to the document - with patch.object(MailTestActivity, 'check_access_rights', autospec=True, side_effect=_employee_crash): + with patch.object(MailTestActivity, '_check_access', autospec=True, side_effect=_employee_crash): with self.assertRaises(exceptions.AccessError): searched_activity = self.env['mail.activity'].with_user(self.user_employee).search( [('id', '=', test_activity.id)]) searched_activity.read(['summary']) # cannot search_read activities if no access to the document - with patch.object(MailTestActivity, 'check_access_rights', autospec=True, side_effect=_employee_crash): + with patch.object(MailTestActivity, '_check_access', autospec=True, side_effect=_employee_crash): with self.assertRaises(exceptions.AccessError): self.env['mail.activity'].with_user(self.user_employee).search_read( [('id', '=', test_activity.id)], ['summary']) - # cannot create activities for people that cannot access record - with patch.object(MailTestActivity, 'check_access_rights', autospec=True, side_effect=_employee_crash): - with self.assertRaises(exceptions.UserError): - activity = self.env['mail.activity'].create({ - 'activity_type_id': self.env.ref('test_mail.mail_act_test_todo').id, - 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, - 'res_id': self.test_record.id, - 'user_id': self.user_employee.id, - }) + # can create activities for people that cannot access record + with patch.object(MailTestActivity, '_check_access', autospec=True, side_effect=_employee_crash): + self.env['mail.activity'].create({ + 'activity_type_id': self.env.ref('test_mail.mail_act_test_todo').id, + 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, + 'res_id': self.test_record.id, + 'user_id': self.user_employee.id, + }) # cannot create activities if no access to the document - with patch.object(MailTestActivity, 'check_access_rights', autospec=True, side_effect=_employee_crash): + with patch.object(MailTestActivity, '_check_access', autospec=True, side_effect=_employee_crash): with self.assertRaises(exceptions.AccessError): activity = self.test_record.with_user(self.user_employee).activity_schedule( 'test_mail.mail_act_test_todo', user_id=self.user_admin.id) + test_activity.user_id = self.user_employee + test_activity.flush_recordset() + + # user can read activities assigned to him even if he has no access to the document + with patch.object(MailTestActivity, '_check_access', autospec=True, side_effect=_employee_crash): + found = self.env['mail.activity'].with_user(self.user_employee).search( + [('id', '=', test_activity.id)]) + self.assertEqual(found, test_activity) + found.read(['summary']) + + # user can read_group activities assigned to him even if he has no access to the document + with patch.object(MailTestActivity, '_check_access', autospec=True, side_effect=_employee_crash): + read_group_result = self.env['mail.activity'].with_user(self.user_employee).formatted_read_group( + [('id', '=', test_activity.id)], + ['summary'], + ['__count'], + ) + self.assertEqual(1, read_group_result[0]['__count']) + self.assertEqual('Summary', read_group_result[0]['summary']) + @tests.tagged('mail_activity') class TestActivityFlow(TestActivityCommon): @@ -149,7 +265,7 @@ class TestActivityFlow(TestActivityCommon): self.assertEqual(test_record.activity_ids, self.env['mail.activity']) # employee record an activity and check the deadline - self.env['mail.activity'].create({ + activity = self.env['mail.activity'].create({ 'summary': 'Test Activity', 'date_deadline': date.today() + relativedelta(days=1), 'activity_type_id': self.env.ref('mail.mail_activity_data_email').id, @@ -166,7 +282,8 @@ class TestActivityFlow(TestActivityCommon): self.assertEqual(test_record.activity_state, 'today') # activity is done - test_record.activity_ids.action_feedback(feedback='So much feedback') + activity.action_feedback(feedback='So much feedback') + self.assertEqual(activity.feedback, 'So much feedback') self.assertEqual(test_record.activity_ids, self.env['mail.activity']) self.assertEqual(test_record.message_ids[0].subtype_id, self.env.ref('mail.mt_activities')) @@ -204,17 +321,27 @@ class TestActivityFlow(TestActivityCommon): def test_activity_summary_sync(self): """ Test summary from type is copied on activities if set (currently only in form-based onchange) """ ActivityType = self.env['mail.activity.type'] + call_activity_type = ActivityType.create({'name': 'call', 'sequence': 1}) email_activity_type = ActivityType.create({ 'name': 'email', 'summary': 'Email Summary', + 'sequence': '30' }) - call_activity_type = ActivityType.create({'name': 'call'}) - with Form(self.env['mail.activity'].with_context(default_res_model_id=self.env['ir.model']._get_id('mail.test.activity'), default_res_id=self.test_record.id)) as ActivityForm: + call_activity_type = ActivityType.create({'name': 'call', 'summary': False}) + with Form( + self.env['mail.activity'].with_context( + default_res_model_id=self.env['ir.model']._get_id('mail.test.activity'), + default_res_id=self.test_record.id, + ) + ) as ActivityForm: + # coming from default activity type, which is to do + self.assertEqual(ActivityForm.activity_type_id, self.env.ref("mail.mail_activity_data_todo")) + self.assertEqual(ActivityForm.summary, "TodoSummary") # `res_model_id` and `res_id` are invisible, see view `mail.mail_activity_view_form_popup` # they must be set using defaults, see `action_feedback_schedule_next` ActivityForm.activity_type_id = call_activity_type # activity summary should be empty - self.assertEqual(ActivityForm.summary, False) + self.assertEqual(ActivityForm.summary, "TodoSummary", "Did not erase if void on type") ActivityForm.activity_type_id = email_activity_type # activity summary should be replaced with email's default summary @@ -224,6 +351,25 @@ class TestActivityFlow(TestActivityCommon): # activity summary remains unchanged from change of activity type as call activity doesn't have default summary self.assertEqual(ActivityForm.summary, email_activity_type.summary) + def test_activity_type_unlink(self): + """ Removing type should allocate activities to Todo """ + email_activity_type = self.env['mail.activity.type'].create({ + 'name': 'email', + 'summary': 'Email Summary', + }) + temp_record = self.env['mail.test.activity'].create({'name': 'Test'}) + activity = temp_record.activity_schedule( + activity_type_id=email_activity_type.id, + user_id=self.user_employee.id, + ) + self.assertEqual(activity.activity_type_id, email_activity_type) + email_activity_type.unlink() + self.assertEqual(activity.activity_type_id, self.env.ref('mail.mail_activity_data_todo')) + + # Todo is protected, niark niark + with self.assertRaises(exceptions.UserError): + self.env.ref('mail.mail_activity_data_todo').unlink() + @mute_logger('odoo.sql_db') def test_activity_values(self): """ Test activities are created with right model / res_id values linking @@ -236,6 +382,7 @@ class TestActivityFlow(TestActivityCommon): test_record = self.env['mail.test.activity'].browse(self.test_record.ids) + # document should be complete: both model and res_id with self.assertRaises(IntegrityError): self.env['mail.activity'].create({ 'res_model_id': self.env['ir.model']._get_id(test_record._name), @@ -249,6 +396,8 @@ class TestActivityFlow(TestActivityCommon): self.env['mail.activity'].create({ 'res_id': test_record.id, }) + # free activity is ok (no model, no res_id) + self.env['mail.activity'].create({'user_id': self.env.uid}) activity = self.env['mail.activity'].create({ 'res_id': test_record.id, @@ -265,498 +414,553 @@ class TestActivityFlow(TestActivityCommon): self.env.flush_all() -@tests.tagged('mail_activity') -class TestActivityMixin(TestActivityCommon): +@tests.tagged("mail_activity", "post_install", "-at_install") +class TestActivitySystray(TestActivityCommon, HttpCase): + """Test for systray_get_activities""" @classmethod def setUpClass(cls): - super(TestActivityMixin, cls).setUpClass() + super().setUpClass() + cls.test_lead_records = cls.env['mail.test.multi.company.with.activity'].create([ + {'name': 'Test Lead 1'}, + {'name': 'Test Lead 2'}, + {'name': 'Test Lead 3 (to remove)'}, + {'name': 'Test Lead 4 (Company2)', 'company_id': cls.company_2.id}, + ]) + cls.deleted_record = cls.test_lead_records[2] + cls.dt_reference = datetime(2024, 1, 15, 8, 0, 0) - cls.user_utc = mail_new_test_user( - cls.env, - name='User UTC', - login='User UTC', + # remove potential demo data on admin, to make test deterministic + cls.env['mail.activity'].search([('user_id', '=', cls.user_admin.id)]).unlink() + + # records and leads and free activities + # have 1 record (or activity) for today, one for tomorrow + cls.test_activities = cls.env['mail.activity'] + for record, summary, dt, creator in ( + (cls.test_record, "Summary Today'", cls.dt_reference, cls.user_employee), + (cls.test_record_2, "Summary Tomorrow'", cls.dt_reference + timedelta(days=1), cls.user_employee), + (cls.test_lead_records[0], "Summary Today'", cls.dt_reference, cls.user_employee), + (cls.test_lead_records[1], "Summary Tomorrow'", cls.dt_reference + timedelta(days=1), cls.user_employee), + (cls.test_lead_records[2], "Summary Tomorrow'", cls.dt_reference + timedelta(days=1), cls.user_employee), + (cls.test_lead_records[3], "Summary Tomorrow'", cls.dt_reference + timedelta(days=1), cls.user_admin), + ): + cls.test_activities += record.with_user(creator).activity_schedule( + "test_mail.mail_act_test_todo_generic", + date_deadline=dt.date(), + summary=summary, + user_id=cls.user_employee.id, + ) + + cls.test_lead_activities = cls.test_activities[2:] + cls.test_activities_removed = cls.deleted_record.activity_ids + cls.test_activities_company_2 = cls.test_lead_records[3].activity_ids + + # add atttachments on lead-like test records + cls.lead_act_attachments = cls.env['ir.attachment'].create( + cls._generate_attachments_data(1, 'mail.activity', cls.test_lead_activities[-4]) + + cls._generate_attachments_data(1, 'mail.activity', cls.test_lead_activities[-3]) + + cls._generate_attachments_data(1, 'mail.activity', cls.test_lead_activities[-2]) + + cls._generate_attachments_data(1, 'mail.activity', cls.test_lead_activities[-1]) ) - cls.user_utc.tz = 'UTC' - cls.user_australia = mail_new_test_user( - cls.env, - name='user Australia', - login='user Australia', + # free (no model) activities + cls.test_activities_free = cls.env['mail.activity'].with_user(cls.user_employee).create([ + { + 'date_deadline': dt, + 'summary': "Summary", + 'user_id': cls.user_employee.id, + } + for dt in (cls.dt_reference, cls.dt_reference + timedelta(days=1)) + ]) + + # In the mean time, some FK deletes the record where the message is + # scheduled, skipping its unlink() override + cls.env.cr.execute( + f"DELETE FROM {cls.test_lead_records._table} WHERE id = %s", (cls.deleted_record.id,) ) - cls.user_australia.tz = 'Australia/Sydney' - @mute_logger('odoo.addons.mail.models.mail_mail') - def test_activity_mixin(self): - self.user_employee.tz = self.user_admin.tz + cls.env.invalidate_all() + + @users("employee") + def test_systray_activities_for_various_records(self): + """Check that activities made on archived or not archived records, as + well as on removed record, to check systray activities behavior and + robustness. """ + # archive record 1 + self.test_record.action_archive() + self.assertTrue(self.test_activities[0].exists(), 'Archiving record keeps activities') + + self.authenticate(self.user_employee.login, self.user_employee.login) + with freeze_time(self.dt_reference): + groups_data = self.make_jsonrpc_request("/mail/data", {"fetch_params": ["systray_get_activities"]}).get('Store', {}).get('activityGroups', []) + self.assertEqual(len(groups_data), 3, 'Should have activities for 2 test models + generic for non accessible') + + for model_name, msg, (exp_total, exp_today, exp_planned, exp_overdue), exp_domain in [ + ('mail.activity', '2 free + 2 linked to 1', (1, 1, 3, 0), []), + (self.test_record._name, 'Archived keeps activities', (1, 1, 1, 0), [['active', 'in', [True, False]]]), + (self.test_lead_records._name, 'Planned do not count in total', (1, 1, 1, 0), []), + ]: + with self.subTest(model_name=model_name, msg=msg): + group_values = next(values for values in groups_data if values['model'] == model_name) + self.assertEqual(group_values['total_count'], exp_total) + self.assertEqual(group_values['today_count'], exp_today) + self.assertEqual(group_values['planned_count'], exp_planned) + self.assertEqual(group_values['overdue_count'], exp_overdue) + self.assertEqual(group_values['domain'], exp_domain) + + # check search results with removed records + self.env.invalidate_all() + test_with_removed = self.env['mail.activity'].sudo().search([ + ('id', 'in', self.test_activities.ids), + ('res_model', '=', self.test_lead_records._name), + ]) + self.assertEqual(len(test_with_removed), 4, 'Without ACL check, activities linked to removed records are kept') + + self.env.invalidate_all() + test_with_removed_as_admin = self.env['mail.activity'].with_user(self.user_admin).search([ + ('id', 'in', self.test_activities.ids), + ('res_model', '=', self.test_lead_records._name), + ]) + self.assertEqual(len(test_with_removed_as_admin), 3, 'With ACL check, activities linked to removed records are not kept is not assigned to the user') + + self.env.invalidate_all() + self.assertFalse( + self.test_activities_removed.with_user(self.user_admin).has_access('read'), + 'No access to an activity linked to someone and whose record has been removed ' + '(considered as no access to record); and should not crash (no MissingError)' + ) + with self.assertRaises(exceptions.AccessError): # should not raise a MissingError + self.test_activities_removed.with_user(self.user_admin).read(['summary']) + + self.env.invalidate_all() + test_with_removed = self.env['mail.activity'].search([ + ('id', 'in', self.test_activities.ids), + ('res_model', '=', self.test_lead_records._name), + ]) + self.assertEqual(len(test_with_removed), 4, 'Even with ACL check, activities linked to removed records are kept if assigned to the user (see odoo/odoo#112126)') + + # if not assigned -> should filter out + self.env.invalidate_all() + self.test_activities_removed.write({'user_id': self.user_admin.id}) + test_with_removed = self.env['mail.activity'].search([ + ('id', 'in', self.test_activities.ids), + ('res_model', '=', self.test_lead_records._name), + ]) + self.assertEqual(len(test_with_removed), 3, 'With ACL check, activities linked to removed records are not kept if assigned to the another user') + self.test_activities_removed.write({'user_id': self.user_employee.id}) + + # be sure activities on removed records do not crash when managed, and that + # lost attachments are removed as well + self.env.invalidate_all() + lead_activities = self.test_lead_activities.with_user(self.user_employee) + lead_act_attachments = self.lead_act_attachments.with_user(self.user_employee) + self.assertEqual(len(lead_activities), 4, 'Simulate UI where activities are still displayed even if record removed') + self.assertEqual(len(lead_act_attachments), 4, 'Simulate UI where activities are still displayed even if record removed') + messages, _next_activities = lead_activities._action_done() + self.assertEqual(len(messages), 3, 'Should have posted one message / live record') + self.assertEqual(lead_activities.exists(), lead_activities - self.test_activities_removed, 'Mark done should unlink activities linked to removed records') + self.assertEqual(lead_activities.exists().mapped('active'), [False] * 3) + self.assertEqual( + set(lead_act_attachments.exists().mapped('res_id')), set(messages.ids), + 'Mark done should clean up attachments linked to removed record, and linked other attachments to messages') + self.assertEqual( + set(lead_act_attachments.exists().mapped('res_model')), set(['mail.message'] * 2)) + + @users("employee") + def test_systray_activities_multi_company(self): + """ Explicitly check MC support, as well as allowed_company_ids, that + limits visible records in a given session, should impact systray activities. """ + self.user_employee.write({'company_ids': [(4, self.company_2.id)]}) + + self.authenticate(self.user_employee.login, self.user_employee.login) + with freeze_time(self.dt_reference): + groups_data = self.make_jsonrpc_request("/mail/data", {"fetch_params": ["systray_get_activities"]}).get('Store', {}).get('activityGroups', []) + + for model_name, msg, (exp_total, exp_today, exp_planned, exp_overdue) in [ + ('mail.activity', 'Non accessible: deleted', (1, 1, 2, 0)), + (self.test_record._name, 'Archiving removes activities', (1, 1, 1, 0)), + (self.test_lead_records._name, 'Accessible (MC with all companies)', (1, 1, 2, 0)), + ]: + with self.subTest(model_name=model_name, msg=msg): + group_values = next(values for values in groups_data if values['model'] == model_name) + self.assertEqual(group_values['total_count'], exp_total) + self.assertEqual(group_values['today_count'], exp_today) + self.assertEqual(group_values['planned_count'], exp_planned) + self.assertEqual(group_values['overdue_count'], exp_overdue) + if model_name == 'mail.activity': # for mail.activity, there is a key with activities we can check + self.assertEqual(sorted(group_values['activity_ids']), sorted((self.test_activities_removed + self.test_activities_free).ids)) + + # when allowed companies restrict visible records, linked activities are + # removed from systray, considering you have to log into the right company + # to see them (change in 18+) + with freeze_time(self.dt_reference): + groups_data = self.make_jsonrpc_request("/mail/data", { + "fetch_params": ["systray_get_activities"], + "context": {"allowed_company_ids": self.company_admin.ids}, + }).get('Store', {}).get('activityGroups', []) + + for model_name, msg, (exp_total, exp_today, exp_planned, exp_overdue) in [ + ('mail.activity', 'Non accessible: deleted (MC ignored, stripped out like inaccessible records)', (1, 1, 2, 0)), + (self.test_record._name, 'Archiving removes activities', (1, 1, 1, 0)), + (self.test_lead_records._name, 'Accessible', (1, 1, 1, 0)), + ]: + with self.subTest(model_name=model_name, msg=msg): + group_values = next(values for values in groups_data if values['model'] == model_name) + self.assertEqual(group_values['total_count'], exp_total) + self.assertEqual(group_values['today_count'], exp_today) + self.assertEqual(group_values['planned_count'], exp_planned) + self.assertEqual(group_values['overdue_count'], exp_overdue) + if model_name == 'mail.activity': # for mail.activity, there is a key with activities we can check + self.assertEqual(sorted(group_values['activity_ids']), sorted((self.test_activities_removed + self.test_activities_free).ids)) + + # now not having accessible to company 2 records: tread like forbidden + self.user_employee.write({'company_ids': [(3, self.company_2.id)]}) + with freeze_time(self.dt_reference): + groups_data = self.make_jsonrpc_request("/mail/data", { + "fetch_params": ["systray_get_activities"], + "context": {"allowed_company_ids": self.company_admin.ids}, + }).get('Store', {}).get('activityGroups', []) + + for model_name, msg, (exp_total, exp_today, exp_planned, exp_overdue) in [ + ('mail.activity', 'Non accessible: deleted + company error managed like forbidden record', (1, 1, 3, 0)), + (self.test_record._name, 'Archiving removes activities', (1, 1, 1, 0)), + (self.test_lead_records._name, 'Accessible', (1, 1, 1, 0)), + ]: + with self.subTest(model_name=model_name, msg=msg): + group_values = next(values for values in groups_data if values['model'] == model_name) + self.assertEqual(group_values['total_count'], exp_total) + self.assertEqual(group_values['today_count'], exp_today) + self.assertEqual(group_values['planned_count'], exp_planned) + self.assertEqual(group_values['overdue_count'], exp_overdue) + if model_name == 'mail.activity': # for mail.activity, there is a key with activities we can check + self.assertEqual(sorted(group_values['activity_ids']), sorted((self.test_activities_removed + self.test_activities_company_2 + self.test_activities_free).ids)) + + +@tests.tagged('mail_activity') +@freeze_time("2024-01-01 09:00:00") +class TestActivitySystrayBusNotify(TestActivityCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user_employee_2 = cls.user_employee.copy(default={'login': 'employee_2', 'email': 'user_employee_2@test.lan'}) + + cls.activity_vals = [ + { + 'res_model_id': cls.env['ir.model']._get_id(cls.test_record._name), + 'res_id': cls.test_record.id, + 'date_deadline': dt, + 'user_id': cls.user_employee.id, + } | extra + for dt, extra in zip( + (datetime(2023, 12, 31, 15, 0, 0), datetime(2023, 12, 31, 15, 0, 0), datetime(2024, 1, 1, 15, 0, 0), datetime(2024, 1, 2, 15, 0, 0)), + ({'active': False}, {}, {}, {}), + ) + ] + + @users('employee') + def test_notify_create_unlink_activities(self): + """Check creating and unlinking activities notifies of the change in 'to be done' activity count per user.""" + users = self.env.user + self.user_employee_2 + + expected_create_notifs = [ + ([(self.env.cr.dbname, user.partner_id._name, user.partner_id.id)], [{ + "type": "mail.activity/updated", + "payload": { + "activity_created": True, + "count_diff": 2, + }, + }]) + for user in users + ] + expected_unlink_notifs = [ + ([(self.env.cr.dbname, user.partner_id._name, user.partner_id.id)], [{ + "type": "mail.activity/updated", + "payload": { + "activity_deleted": True, + "count_diff": -2, + }, + }]) + for user in users + ] + for ( + user, + (expected_create_notif_channels, expected_create_notif_message_items), + (expected_unlink_notif_channels, expected_unlink_notif_message_items), + ) in zip(users, expected_create_notifs, expected_unlink_notifs): + user_activity_vals = [vals | {'user_id': user.id} for vals in self.activity_vals] + with self.assertBus(expected_create_notif_channels, expected_create_notif_message_items): + activities = self.env['mail.activity'].create(user_activity_vals) + with self.assertBus(expected_unlink_notif_channels, expected_unlink_notif_message_items): + activities.unlink() + + @users('employee') + def test_notify_update_activities(self): + write_vals_all = [ + # added to counter for employee 2, removed from counter for current employee + {'user_id': self.user_employee_2.id}, + {'user_id': self.user_employee_2.id, 'date_deadline': datetime(2023, 12, 31, 15, 0, 0), 'active': True}, + # just notify + {'date_deadline': datetime(2024, 1, 2, 15, 0, 0)}, # everything is in the future -> all removed from counter + {'date_deadline': datetime(2023, 12, 31, 15, 0, 0)}, # everything is in the past -> the one from the future is added + {'active': False}, # everything is archived -> all removed from counter + {'active': True}, # the archived one is unarchived -> added to counter + {}, # no "to be done" count change -> no notif + [{'date_deadline': datetime(2024, 1, 2, 15, 0, 0), 'active': True}, {}, {}, {}], + ] + + expected_notifs = [ + # transfer 4 activities to the second employee, 2 todos taken and 2 given + [ + ([(self.env.cr.dbname, user.partner_id._name, user.partner_id.id)], [{ + "type": "mail.activity/updated", + "payload": { + "count_diff": count_diff, + } | ({"activity_created": True} if count_diff > 0 else {"activity_deleted": True}), + }]) + for user, count_diff + in zip(self.user_employee + self.user_employee_2, [-2, 2]) + ], + # transfer 4 activities to the second employee, 2 todos are taken and 4 are given + [ + ([(self.env.cr.dbname, user.partner_id._name, user.partner_id.id)], [{ + "type": "mail.activity/updated", + "payload": { + "count_diff": count_diff, + } | ({"activity_created": True} if count_diff > 0 else {"activity_deleted": True}), + }]) + for user, count_diff + in zip(self.user_employee + self.user_employee_2, [-2, 4]) + ], + ] + [[ + ([(self.env.cr.dbname, self.user_employee.partner_id._name, self.user_employee.partner_id.id)], [{ + "type": "mail.activity/updated", + "payload": { + "count_diff": count_diff, + } | ({"activity_created": True} if count_diff > 0 else {"activity_deleted": True}), + }]) + ] for count_diff in (-2, 1, -2, 1) + ] + [ + [([], [])], # no change -> no notif + [([], [])], # no change in "todo" count -> no notif + ] + for write_vals, expected_notif_vals in zip(write_vals_all, expected_notifs): + with self.subTest(vals=write_vals): + _past_archived, _past_active, _today, _tomorrow = activities = self.env['mail.activity'].create(self.activity_vals) + self._reset_bus() + if isinstance(write_vals, list): + for activity, vals in zip(activities, write_vals): + activity.write(vals) + else: + activities.write(write_vals) + for (notif_channels, notif_messages) in expected_notif_vals: + self.assertBusNotifications(notif_channels, notif_messages) + activities.unlink() + + +@tests.tagged('mail_activity') +class TestActivityViewHelpers(TestActivityCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.type_todo = cls.env.ref('test_mail.mail_act_test_todo') + cls.type_call = cls.env.ref('test_mail.mail_act_test_call') + cls.type_upload = cls.env.ref('test_mail.mail_act_test_upload_document') + + cls.user_employee_2 = mail_new_test_user( + cls.env, + name='Employee2', + login='employee2', + ) + cls.attachment_1, cls.attachment_2 = cls.env['ir.attachment'].create([{ + 'name': f"Uploaded doc_{idx + 1}", + 'raw': b'bar', + 'res_model': cls.test_record_2._name, + 'res_id': cls.test_record_2.id, + } for idx in range(2)]) + cls.user_employee.tz = cls.user_admin.tz + + @freeze_time("2023-10-18 06:00:00") + def test_get_activity_data(self): + get_activity_data = self.env['mail.activity'].get_activity_data + with self.with_user('employee'): - self.test_record = self.env['mail.test.activity'].browse(self.test_record.id) - self.assertEqual(self.test_record.env.user, self.user_employee) - + # Setup activities: 3 for the first record, 2 "done" and 2 ongoing for the second + test_record, test_record_2 = self.env['mail.test.activity'].browse( + (self.test_record + self.test_record_2).ids + ) now_utc = datetime.now(pytz.UTC) now_user = now_utc.astimezone(pytz.timezone(self.env.user.tz or 'UTC')) today_user = now_user.date() - # Test various scheduling of activities - act1 = self.test_record.activity_schedule( - 'test_mail.mail_act_test_todo', - today_user + relativedelta(days=1), - user_id=self.user_admin.id) - self.assertEqual(act1.automated, True) + for days, user_id in ((-1, self.user_employee_2), (0, self.user_employee), (1, self.user_admin)): + test_record.activity_schedule( + 'test_mail.mail_act_test_upload_document', + today_user + relativedelta(days=days), + user_id=user_id.id) + for days, user_id in ((-2, self.user_admin), (0, self.user_employee), (2, self.user_employee_2), + (3, self.user_admin), (4, self.env['res.users'])): + test_record_2.activity_schedule( + 'test_mail.mail_act_test_upload_document', + today_user + relativedelta(days=days), + user_id=user_id.id) + record_activities = test_record.activity_ids + record_2_activities = test_record_2.activity_ids + record_2_activities[0].action_feedback(feedback='Done', attachment_ids=self.attachment_1.ids) + record_2_activities[1].action_feedback(feedback='Done', attachment_ids=self.attachment_2.ids) - act_type = self.env.ref('test_mail.mail_act_test_todo') - self.assertEqual(self.test_record.activity_summary, act_type.summary) - self.assertEqual(self.test_record.activity_state, 'planned') - self.assertEqual(self.test_record.activity_user_id, self.user_admin) + # Check get activity data + activity_data = get_activity_data('mail.test.activity', None, fetch_done=True) + self.assertEqual(activity_data['activity_res_ids'], [test_record.id, test_record_2.id]) + self.assertDictEqual( + next((t for t in activity_data['activity_types'] if t['id'] == self.type_upload.id), {}), + { + 'id': self.type_upload.id, + 'name': 'Document', + 'template_ids': [], + }) - act2 = self.test_record.activity_schedule( - 'test_mail.mail_act_test_meeting', - today_user + relativedelta(days=-1)) - self.assertEqual(self.test_record.activity_state, 'overdue') - # `activity_user_id` is defined as `fields.Many2one('res.users', 'Responsible User', related='activity_ids.user_id')` - # it therefore relies on the natural order of `activity_ids`, according to which activity comes first. - # As we just created the activity, its not yet in the right order. - # We force it by invalidating it so it gets fetched from database, in the right order. - self.test_record.invalidate_recordset(['activity_ids']) - self.assertEqual(self.test_record.activity_user_id, self.user_employee) - - act3 = self.test_record.activity_schedule( - 'test_mail.mail_act_test_todo', - today_user + relativedelta(days=3), - user_id=self.user_employee.id) - self.assertEqual(self.test_record.activity_state, 'overdue') - # `activity_user_id` is defined as `fields.Many2one('res.users', 'Responsible User', related='activity_ids.user_id')` - # it therefore relies on the natural order of `activity_ids`, according to which activity comes first. - # As we just created the activity, its not yet in the right order. - # We force it by invalidating it so it gets fetched from database, in the right order. - self.test_record.invalidate_recordset(['activity_ids']) - self.assertEqual(self.test_record.activity_user_id, self.user_employee) - - self.test_record.invalidate_recordset() - self.assertEqual(self.test_record.activity_ids, act1 | act2 | act3) - - # Perform todo activities for admin - self.test_record.activity_feedback( - ['test_mail.mail_act_test_todo'], - user_id=self.user_admin.id, - feedback='Test feedback',) - self.assertEqual(self.test_record.activity_ids, act2 | act3) - - # Reschedule all activities, should update the record state - self.assertEqual(self.test_record.activity_state, 'overdue') - self.test_record.activity_reschedule( - ['test_mail.mail_act_test_meeting', 'test_mail.mail_act_test_todo'], - date_deadline=today_user + relativedelta(days=3) - ) - self.assertEqual(self.test_record.activity_state, 'planned') - - # Perform todo activities for remaining people - self.test_record.activity_feedback( - ['test_mail.mail_act_test_todo'], - feedback='Test feedback') - - # Setting activities as done should delete them and post messages - self.assertEqual(self.test_record.activity_ids, act2) - self.assertEqual(len(self.test_record.message_ids), 2) - self.assertEqual(self.test_record.message_ids.mapped('subtype_id'), self.env.ref('mail.mt_activities')) - - # Perform meeting activities - self.test_record.activity_unlink(['test_mail.mail_act_test_meeting']) - - # Canceling activities should simply remove them - self.assertEqual(self.test_record.activity_ids, self.env['mail.activity']) - self.assertEqual(len(self.test_record.message_ids), 2) - - @mute_logger('odoo.addons.mail.models.mail_mail') - def test_activity_mixin_archive(self): - rec = self.test_record.with_user(self.user_employee) - new_act = rec.activity_schedule( - 'test_mail.mail_act_test_todo', - user_id=self.user_admin.id) - self.assertEqual(rec.activity_ids, new_act) - rec.toggle_active() - self.assertEqual(rec.active, False) - self.assertEqual(rec.activity_ids, self.env['mail.activity']) - rec.toggle_active() - self.assertEqual(rec.active, True) - self.assertEqual(rec.activity_ids, self.env['mail.activity']) - - @mute_logger('odoo.addons.mail.models.mail_mail') - def test_activity_mixin_reschedule_user(self): - rec = self.test_record.with_user(self.user_employee) - rec.activity_schedule( - 'test_mail.mail_act_test_todo', - user_id=self.user_admin.id) - self.assertEqual(rec.activity_ids[0].user_id, self.user_admin) - - # reschedule its own should not alter other's activities - rec.activity_reschedule( - ['test_mail.mail_act_test_todo'], - user_id=self.user_employee.id, - new_user_id=self.user_employee.id) - self.assertEqual(rec.activity_ids[0].user_id, self.user_admin) - - rec.activity_reschedule( - ['test_mail.mail_act_test_todo'], - user_id=self.user_admin.id, - new_user_id=self.user_employee.id) - self.assertEqual(rec.activity_ids[0].user_id, self.user_employee) - - @users('employee') - def test_feedback_w_attachments(self): - test_record = self.env['mail.test.activity'].browse(self.test_record.ids) - - activity = self.env['mail.activity'].create({ - 'activity_type_id': 1, - 'res_id': test_record.id, - 'res_model_id': self.env['ir.model']._get_id('mail.test.activity'), - 'summary': 'Test', - }) - attachments = self.env['ir.attachment'].create([{ - 'name': 'test', - 'res_name': 'test', - 'res_model': 'mail.activity', - 'res_id': activity.id, - 'datas': 'test', - }, { - 'name': 'test2', - 'res_name': 'test', - 'res_model': 'mail.activity', - 'res_id': activity.id, - 'datas': 'testtest', - }]) - - # Checking if the attachment has been forwarded to the message - # when marking an activity as "Done" - activity.action_feedback() - activity_message = test_record.message_ids[-1] - self.assertEqual(set(activity_message.attachment_ids.ids), set(attachments.ids)) - for attachment in attachments: - self.assertEqual(attachment.res_id, activity_message.id) - self.assertEqual(attachment.res_model, activity_message._name) - - @users('employee') - def test_feedback_chained_current_date(self): - frozen_now = datetime(2021, 10, 10, 14, 30, 15) - - test_record = self.env['mail.test.activity'].browse(self.test_record.ids) - first_activity = self.env['mail.activity'].create({ - 'activity_type_id': self.env.ref('test_mail.mail_act_test_chained_1').id, - 'date_deadline': frozen_now + relativedelta(days=-2), - 'res_id': test_record.id, - 'res_model_id': self.env['ir.model']._get_id('mail.test.activity'), - 'summary': 'Test', - }) - first_activity_id = first_activity.id - - with freeze_time(frozen_now): - first_activity.action_feedback(feedback='Done') - self.assertFalse(first_activity.exists()) - - # check chained activity - new_activity = test_record.activity_ids - self.assertNotEqual(new_activity.id, first_activity_id) - self.assertEqual(new_activity.summary, 'Take the second step.') - self.assertEqual(new_activity.date_deadline, frozen_now.date() + relativedelta(days=10)) - - @users('employee') - def test_feedback_chained_previous(self): - self.env.ref('test_mail.mail_act_test_chained_2').sudo().write({'delay_from': 'previous_activity'}) - frozen_now = datetime(2021, 10, 10, 14, 30, 15) - - test_record = self.env['mail.test.activity'].browse(self.test_record.ids) - first_activity = self.env['mail.activity'].create({ - 'activity_type_id': self.env.ref('test_mail.mail_act_test_chained_1').id, - 'date_deadline': frozen_now + relativedelta(days=-2), - 'res_id': test_record.id, - 'res_model_id': self.env['ir.model']._get_id('mail.test.activity'), - 'summary': 'Test', - }) - first_activity_id = first_activity.id - - with freeze_time(frozen_now): - first_activity.action_feedback(feedback='Done') - self.assertFalse(first_activity.exists()) - - # check chained activity - new_activity = test_record.activity_ids - self.assertNotEqual(new_activity.id, first_activity_id) - self.assertEqual(new_activity.summary, 'Take the second step.') - self.assertEqual(new_activity.date_deadline, frozen_now.date() + relativedelta(days=8), - 'New deadline should take into account original activity deadline, not current date') - - def test_mail_activity_state(self): - """Create 3 activity for 2 different users in 2 different timezones. - - User UTC (+0h) - User Australia (+11h) - Today datetime: 1/1/2020 16h - - Activity 1 & User UTC - 1/1/2020 - 16h UTC -> The state is today - - Activity 2 & User Australia - 1/1/2020 - 16h UTC - 2/1/2020 - 1h Australia -> State is overdue - - Activity 3 & User UTC - 1/1/2020 - 23h UTC -> The state is today - """ - today_utc = datetime(2020, 1, 1, 16, 0, 0) - - class MockedDatetime(datetime): - @classmethod - def utcnow(cls): - return today_utc - - record = self.env['mail.test.activity'].create({'name': 'Record'}) - - with patch('odoo.addons.mail.models.mail_activity.datetime', MockedDatetime): - activity_1 = self.env['mail.activity'].create({ - 'summary': 'Test', - 'activity_type_id': 1, - 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, - 'res_id': record.id, - 'date_deadline': today_utc, - 'user_id': self.user_utc.id, + grouped = activity_data['grouped_activities'][test_record.id][self.type_upload.id] + grouped['ids'] = set(grouped['ids']) # ids order doesn't matter + self.assertDictEqual(grouped, { + 'state': 'overdue', + 'count_by_state': {'overdue': 1, 'planned': 1, 'today': 1}, + 'ids': set(record_activities.ids), + 'reporting_date': record_activities[0].date_deadline, + 'user_assigned_ids': record_activities.user_id.ids, + 'summaries': [act.summary for act in record_activities], }) - activity_2 = activity_1.copy() - activity_2.user_id = self.user_australia - activity_3 = activity_1.copy() - activity_3.date_deadline += relativedelta(hours=7) - - self.assertEqual(activity_1.state, 'today') - self.assertEqual(activity_2.state, 'overdue') - self.assertEqual(activity_3.state, 'today') - - @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests') - def test_mail_activity_mixin_search_state_basic(self): - """Test the search method on the "activity_state". - - Test all the operators and also test the case where the "activity_state" is - different because of the timezone. There's also a tricky case for which we - "reverse" the domain for performance purpose. - """ - today_utc = datetime(2020, 1, 1, 16, 0, 0) - - class MockedDatetime(datetime): - @classmethod - def utcnow(cls): - return today_utc - - # Create some records without activity schedule on it for testing - self.env['mail.test.activity'].create([ - {'name': 'Record %i' % record_i} - for record_i in range(5) - ]) - - origin_1, origin_2 = self.env['mail.test.activity'].search([], limit=2) - - with patch('odoo.addons.mail.models.mail_activity.datetime', MockedDatetime), \ - patch('odoo.addons.mail.models.mail_activity_mixin.datetime', MockedDatetime): - origin_1_activity_1 = self.env['mail.activity'].create({ - 'summary': 'Test', - 'activity_type_id': 1, - 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, - 'res_id': origin_1.id, - 'date_deadline': today_utc, - 'user_id': self.user_utc.id, + grouped = activity_data['grouped_activities'][test_record_2.id][self.type_upload.id] + grouped['ids'] = set(grouped['ids']) + self.assertDictEqual(grouped, { + 'state': 'planned', + 'count_by_state': {'done': 2, 'planned': 3}, # free user is planned + 'ids': set(record_2_activities.ids), + 'reporting_date': record_2_activities[2].date_deadline, + 'user_assigned_ids': record_2_activities[2:].user_id.ids, + 'attachments_info': { + 'count': 2, 'most_recent_id': self.attachment_2.id, 'most_recent_name': 'Uploaded doc_2'}, + 'summaries': [act.summary for act in record_2_activities], }) - origin_1_activity_2 = origin_1_activity_1.copy() - origin_1_activity_2.user_id = self.user_australia - origin_1_activity_3 = origin_1_activity_1.copy() - origin_1_activity_3.date_deadline += relativedelta(hours=8) + # Mark all first record activities as "done" and check activity data + record_activities.action_feedback(feedback='Done', attachment_ids=self.attachment_1.ids) + self.assertEqual(record_activities[2].date_done, date.today()) # Thanks to freeze_time + activity_data = get_activity_data('mail.test.activity', None, fetch_done=True) + grouped = activity_data['grouped_activities'][test_record.id][self.type_upload.id] + grouped['ids'] = set(grouped['ids']) + self.assertDictEqual(grouped, { + 'state': 'done', + 'count_by_state': {'done': 3}, + 'ids': set(record_activities.ids), + 'reporting_date': record_activities[2].date_done, + 'user_assigned_ids': [], + 'attachments_info': { + 'count': 1, # 1 instead of 3 because all attachments are the same one + 'most_recent_id': self.attachment_1.id, + 'most_recent_name': self.attachment_1.name, + }, + 'summaries': [act.summary for act in record_activities], + }) + self.assertEqual(activity_data['activity_res_ids'], [test_record_2.id, test_record.id]) - self.assertEqual(origin_1_activity_1.state, 'today') - self.assertEqual(origin_1_activity_2.state, 'overdue') - self.assertEqual(origin_1_activity_3.state, 'today') + # Check filters (domain, pagination and fetch_done) + self.assertEqual( + get_activity_data('mail.test.activity', domain=[('id', 'in', test_record.ids)], + fetch_done=True)['activity_res_ids'], + [test_record.id]) + self.assertEqual(get_activity_data('mail.test.activity', None, fetch_done=False)['activity_res_ids'], + [test_record_2.id]) + # Note that the records are ordered by ids not by deadline (so we get the "wrong" order) + self.assertEqual( + get_activity_data('mail.test.activity', None, offset=1, fetch_done=True)['activity_res_ids'], + [test_record_2.id]) + self.assertEqual( + get_activity_data('mail.test.activity', None, limit=1, fetch_done=True)['activity_res_ids'], + [test_record.id]) - origin_2_activity_1 = self.env['mail.activity'].create({ - 'summary': 'Test', - 'activity_type_id': 1, - 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, - 'res_id': origin_2.id, - 'date_deadline': today_utc + relativedelta(hours=8), - 'user_id': self.user_utc.id, + # Unarchiving activities should restore the activity + record_activities.action_unarchive() + self.assertFalse(any(act.date_done for act in record_activities)) + self.assertTrue(all(act.date_deadline for act in record_activities)) + activity_data = get_activity_data('mail.test.activity', None, fetch_done=True) + grouped = activity_data['grouped_activities'][test_record.id][self.type_upload.id] + self.assertEqual(grouped['state'], 'overdue') + self.assertEqual(grouped['count_by_state'], {'overdue': 1, 'planned': 1, 'today': 1}) + self.assertEqual(grouped['reporting_date'], record_activities[0].date_deadline) + self.assertEqual(activity_data['activity_res_ids'], [test_record.id, test_record_2.id]) + grouped['ids'] = set(grouped['ids']) + self.assertDictEqual(grouped, { + 'state': 'overdue', + 'count_by_state': {'overdue': 1, 'planned': 1, 'today': 1}, + 'ids': set(record_activities.ids), + 'reporting_date': record_activities[0].date_deadline, + 'user_assigned_ids': record_activities.user_id.ids, + 'summaries': [act.summary for act in record_activities], }) - origin_2_activity_2 = origin_2_activity_1.copy() - origin_2_activity_2.user_id = self.user_australia - origin_2_activity_3 = origin_2_activity_1.copy() - origin_2_activity_3.date_deadline -= relativedelta(hours=8) - origin_2_activity_4 = origin_2_activity_1.copy() - origin_2_activity_4.date_deadline = datetime(2020, 1, 2, 0, 0, 0) - self.assertEqual(origin_2_activity_1.state, 'planned') - self.assertEqual(origin_2_activity_2.state, 'today') - self.assertEqual(origin_2_activity_3.state, 'today') - self.assertEqual(origin_2_activity_4.state, 'planned') +@tests.tagged('post_install', '-at_install') +class TestTours(HttpCase): - all_activity_mixin_record = self.env['mail.test.activity'].search([]) - - result = self.env['mail.test.activity'].search([('activity_state', '=', 'today')]) - self.assertTrue(len(result) > 0) - self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: p.activity_state == 'today')) - - result = self.env['mail.test.activity'].search([('activity_state', 'in', ('today', 'overdue'))]) - self.assertTrue(len(result) > 0) - self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: p.activity_state in ('today', 'overdue'))) - - result = self.env['mail.test.activity'].search([('activity_state', 'not in', ('today',))]) - self.assertTrue(len(result) > 0) - self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: p.activity_state not in ('today',))) - - result = self.env['mail.test.activity'].search([('activity_state', '=', False)]) - self.assertTrue(len(result) >= 3, "There is more than 3 records without an activity schedule on it") - self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: not p.activity_state)) - - result = self.env['mail.test.activity'].search([('activity_state', 'not in', ('planned', 'overdue', 'today'))]) - self.assertTrue(len(result) >= 3, "There is more than 3 records without an activity schedule on it") - self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: not p.activity_state)) - - # test tricky case when the domain will be reversed in the search method - # because of falsy value - result = self.env['mail.test.activity'].search([('activity_state', 'not in', ('today', False))]) - self.assertTrue(len(result) > 0) - self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: p.activity_state not in ('today', False))) - - result = self.env['mail.test.activity'].search([('activity_state', 'in', ('today', False))]) - self.assertTrue(len(result) > 0) - self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: p.activity_state in ('today', False))) - - @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests') - def test_mail_activity_mixin_search_state_different_day_but_close_time(self): - """Test the case where there's less than 24 hours between the deadline and now_tz, - but one day of difference (e.g. 23h 01/01/2020 & 1h 02/02/2020). So the state - should be "planned" and not "today". This case was tricky to implement in SQL - that's why it has its own test. - """ - today_utc = datetime(2020, 1, 1, 23, 0, 0) - - class MockedDatetime(datetime): - @classmethod - def utcnow(cls): - return today_utc - - # Create some records without activity schedule on it for testing - self.env['mail.test.activity'].create([ - {'name': 'Record %i' % record_i} - for record_i in range(5) - ]) - - origin_1 = self.env['mail.test.activity'].search([], limit=1) - - with patch('odoo.addons.mail.models.mail_activity.datetime', MockedDatetime): - origin_1_activity_1 = self.env['mail.activity'].create({ - 'summary': 'Test', - 'activity_type_id': 1, - 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, - 'res_id': origin_1.id, - 'date_deadline': today_utc + relativedelta(hours=2), - 'user_id': self.user_utc.id, - }) - - self.assertEqual(origin_1_activity_1.state, 'planned') - result = self.env['mail.test.activity'].search([('activity_state', '=', 'today')]) - self.assertNotIn(origin_1, result, 'The activity state miss calculated during the search') - - @mute_logger('odoo.addons.mail.models.mail_mail') - def test_my_activity_flow_employee(self): - Activity = self.env['mail.activity'] - date_today = date.today() - Activity.create({ - 'activity_type_id': self.env.ref('test_mail.mail_act_test_todo').id, - 'date_deadline': date_today, - 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, - 'res_id': self.test_record.id, - 'user_id': self.user_admin.id, - }) - Activity.create({ - 'activity_type_id': self.env.ref('test_mail.mail_act_test_call').id, - 'date_deadline': date_today + relativedelta(days=1), - 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, - 'res_id': self.test_record.id, - 'user_id': self.user_employee.id, - }) - - test_record_1 = self.env['mail.test.activity'].with_context(self._test_context).create({'name': 'Test 1'}) - Activity.create({ - 'activity_type_id': self.env.ref('test_mail.mail_act_test_todo').id, - 'date_deadline': date_today, - 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, - 'res_id': test_record_1.id, - 'user_id': self.user_employee.id, - }) - with self.with_user('employee'): - record = self.env['mail.test.activity'].search([('my_activity_date_deadline', '=', date_today)]) - self.assertEqual(test_record_1, record) - - -@tests.tagged('mail_activity') -class TestORM(TestActivityCommon): - """Test for read_progress_bar""" - - def test_week_grouping(self): - """The labels associated to each record in read_progress_bar should match - the ones from read_group, even in edge cases like en_US locale on sundays - """ - MailTestActivityCtx = self.env['mail.test.activity'].with_context({"lang": "en_US"}) - - # Don't mistake fields date and date_deadline: - # * date is just a random value - # * date_deadline defines activity_state - self.env['mail.test.activity'].create({ + def test_activity_view_data_with_offset(self): + self.patch(MailTestActivity, '_order', 'date desc, id desc') + MailTestActivityModel = self.env['mail.test.activity'] + MailTestActivityCtx = MailTestActivityModel.with_context({"lang": "en_US"}) + MailTestActivityModel.create({ 'date': '2021-05-02', - 'name': "Yesterday, all my troubles seemed so far away", + 'name': "Task 1", }).activity_schedule( 'test_mail.mail_act_test_todo', - summary="Make another test super asap (yesterday)", + summary="Activity 1", date_deadline=fields.Date.context_today(MailTestActivityCtx) - timedelta(days=7), + user_id=self.env.uid, ) - self.env['mail.test.activity'].create({ - 'date': '2021-05-09', - 'name': "Things we said today", - }).activity_schedule( - 'test_mail.mail_act_test_todo', - summary="Make another test asap", - date_deadline=fields.Date.context_today(MailTestActivityCtx), - ) - self.env['mail.test.activity'].create({ + MailTestActivityModel.create({ 'date': '2021-05-16', - 'name': "Tomorrow Never Knows", + 'name': "Task 1 without activity", + }) + MailTestActivityModel.create({ + 'date': '2021-05-09', + 'name': "Task 2", }).activity_schedule( 'test_mail.mail_act_test_todo', - summary="Make a test tomorrow", - date_deadline=fields.Date.context_today(MailTestActivityCtx) + timedelta(days=7), + summary="Activity 2", + date_deadline=fields.Date.context_today(MailTestActivityCtx), + user_id=self.env.uid, ) + MailTestActivityModel.create({ + 'date': '2021-05-16', + 'name': "Task 3", + }).activity_schedule( + 'test_mail.mail_act_test_todo', + summary="Activity 3", + date_deadline=fields.Date.context_today(MailTestActivityCtx) + timedelta(days=7), + user_id=self.env.uid, + ) + MailTestActivityModel.create({ + 'date': '2021-05-16', + 'name': "Task 2 without activity", + }) - domain = [('date', "!=", False)] - groupby = "date:week" - progress_bar = { - 'field': 'activity_state', - 'colors': { - "overdue": 'danger', - "today": 'warning', - "planned": 'success', - } - } - - # call read_group to compute group names - groups = MailTestActivityCtx.read_group(domain, fields=['date'], groupby=[groupby]) - progressbars = MailTestActivityCtx.read_progress_bar(domain, group_by=groupby, progress_bar=progress_bar) - self.assertEqual(len(groups), 3) - self.assertEqual(len(progressbars), 3) - - # format the read_progress_bar result to get a dictionary under this - # format: {activity_state: group_name}; the original format - # (after read_progress_bar) is {group_name: {activity_state: count}} - pg_groups = { - next(state for state, count in data.items() if count): group_name - for group_name, data in progressbars.items() - } - - self.assertEqual(groups[0][groupby], pg_groups["overdue"]) - self.assertEqual(groups[1][groupby], pg_groups["today"]) - self.assertEqual(groups[2][groupby], pg_groups["planned"]) + self.env["ir.ui.view"].create({ + "name": "Test Activity View", + "model": "mail.test.activity", + "type": 'activity', + "arch": """ + + +
+ +
+
+
+ """, + }) + self.start_tour( + "/odoo?debug=1", + "mail_activity_view", + login="admin", + ) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_activity_mixin.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_activity_mixin.py new file mode 100644 index 0000000..00f8d54 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_activity_mixin.py @@ -0,0 +1,730 @@ +from datetime import date, datetime, timedelta, timezone +from dateutil.relativedelta import relativedelta +from freezegun import freeze_time +from unittest.mock import patch + +import pytz +import random + +from odoo import fields, tests +from odoo.addons.mail.models.mail_activity import MailActivity +from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.test_mail.tests.test_mail_activity import TestActivityCommon +from odoo.tests import tagged, users +from odoo.tools import mute_logger + + +@tagged('mail_activity', 'mail_activity_mixin') +class TestActivityMixin(TestActivityCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.user_utc = mail_new_test_user( + cls.env, + name='User UTC', + login='User UTC', + ) + cls.user_utc.tz = 'UTC' + + cls.user_australia = mail_new_test_user( + cls.env, + name='user Australia', + login='user Australia', + ) + cls.user_australia.tz = 'Australia/Sydney' + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_activity_mixin(self): + self.user_employee.tz = self.user_admin.tz + with self.with_user('employee'): + self.test_record = self.env['mail.test.activity'].browse(self.test_record.id) + self.assertEqual(len(self.test_record.message_ids), 1) + self.assertEqual(self.test_record.env.user, self.user_employee) + + now_utc = datetime.now(pytz.UTC) + now_user = now_utc.astimezone(pytz.timezone(self.env.user.tz or 'UTC')) + today_user = now_user.date() + + # Test various scheduling of activities + act1 = self.test_record.activity_schedule( + 'test_mail.mail_act_test_todo', + today_user + relativedelta(days=1), + user_id=self.user_admin.id) + self.assertEqual(act1.automated, True) + + act_type = self.env.ref('test_mail.mail_act_test_todo') + self.assertEqual(self.test_record.activity_summary, act_type.summary) + self.assertEqual(self.test_record.activity_state, 'planned') + self.assertEqual(self.test_record.activity_user_id, self.user_admin) + + act2 = self.test_record.activity_schedule( + 'test_mail.mail_act_test_meeting', + today_user + relativedelta(days=-1), + user_id=self.user_employee.id, + ) + self.assertEqual(self.test_record.activity_state, 'overdue') + # `activity_user_id` is defined as `fields.Many2one('res.users', 'Responsible User', related='activity_ids.user_id')` + # it therefore relies on the natural order of `activity_ids`, according to which activity comes first. + # As we just created the activity, its not yet in the right order. + # We force it by invalidating it so it gets fetched from database, in the right order. + self.test_record.invalidate_recordset(['activity_ids']) + self.assertEqual(self.test_record.activity_user_id, self.user_employee) + + act3 = self.test_record.activity_schedule( + 'test_mail.mail_act_test_todo', + today_user + relativedelta(days=3), + user_id=self.user_employee.id, + ) + self.assertEqual(self.test_record.activity_state, 'overdue') + # `activity_user_id` is defined as `fields.Many2one('res.users', 'Responsible User', related='activity_ids.user_id')` + # it therefore relies on the natural order of `activity_ids`, according to which activity comes first. + # As we just created the activity, its not yet in the right order. + # We force it by invalidating it so it gets fetched from database, in the right order. + self.test_record.invalidate_recordset(['activity_ids']) + self.assertEqual(self.test_record.activity_user_id, self.user_employee) + + self.test_record.invalidate_recordset() + self.assertEqual(self.test_record.activity_ids, act1 | act2 | act3) + + # Perform todo activities for admin + self.test_record.activity_feedback( + ['test_mail.mail_act_test_todo'], + user_id=self.user_admin.id, + feedback='Test feedback 1', + ) + self.assertEqual(self.test_record.activity_ids, act2 | act3) + self.assertFalse(act1.active) + + # Reschedule all activities, should update the record state + self.assertEqual(self.test_record.activity_state, 'overdue') + self.test_record.activity_reschedule( + ['test_mail.mail_act_test_meeting', 'test_mail.mail_act_test_todo'], + date_deadline=today_user + relativedelta(days=3) + ) + self.assertEqual(self.test_record.activity_state, 'planned') + + # Perform todo activities for remaining people + self.test_record.activity_feedback( + ['test_mail.mail_act_test_todo'], + feedback='Test feedback 2') + self.assertFalse(act3.active) + + # Setting activities as done should delete them and post messages + self.assertEqual(self.test_record.activity_ids, act2) + self.assertEqual(len(self.test_record.message_ids), 3) + self.assertEqual(len(self.test_record.message_ids), 3) + feedback2, feedback1, _create_log = self.test_record.message_ids + self.assertEqual((feedback2 + feedback1).subtype_id, self.env.ref('mail.mt_activities')) + + # Unlink meeting activities + self.test_record.activity_unlink(['test_mail.mail_act_test_meeting']) + + # Canceling activities should simply remove them + self.assertEqual(self.test_record.activity_ids, self.env['mail.activity']) + self.assertEqual(len(self.test_record.message_ids), 3, 'Should not produce additional message') + self.assertFalse(self.test_record.activity_state) + self.assertFalse(act2.exists()) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_activity_mixin_not_only_automated(self): + + # Schedule activity and create manual activity + act_type_todo = self.env.ref('test_mail.mail_act_test_todo') + auto_act = self.test_record.activity_schedule( + 'test_mail.mail_act_test_todo', + date_deadline=date.today() + relativedelta(days=1), + ) + man_act = self.env['mail.activity'].create({ + 'activity_type_id': act_type_todo.id, + 'res_id': self.test_record.id, + 'res_model_id': self.env['ir.model']._get_id(self.test_record._name), + 'date_deadline': date.today() + relativedelta(days=1) + }) + self.assertEqual(auto_act.automated, True) + self.assertEqual(man_act.automated, False) + + # Test activity reschedule on not only automated activities + self.test_record.activity_reschedule( + ['test_mail.mail_act_test_todo'], + date_deadline=date.today() + relativedelta(days=2), + only_automated=False + ) + self.assertEqual(auto_act.date_deadline, date.today() + relativedelta(days=2)) + self.assertEqual(man_act.date_deadline, date.today() + relativedelta(days=2)) + + # Test activity feedback on not only automated activities + self.test_record.activity_feedback( + ['test_mail.mail_act_test_todo'], + feedback='Test feedback', + only_automated=False + ) + self.assertEqual(self.test_record.activity_ids, self.env['mail.activity']) + self.assertFalse(auto_act.active) + self.assertFalse(man_act.active) + + # Test activity unlink on not only automated activities + auto_act = self.test_record.activity_schedule( + 'test_mail.mail_act_test_todo', + ) + man_act = self.env['mail.activity'].create({ + 'activity_type_id': act_type_todo.id, + 'res_id': self.test_record.id, + 'res_model_id': self.env['ir.model']._get_id(self.test_record._name) + }) + self.test_record.activity_unlink(['test_mail.mail_act_test_todo'], only_automated=False) + self.assertEqual(self.test_record.activity_ids, self.env['mail.activity']) + self.assertFalse(auto_act.exists()) + self.assertFalse(man_act.exists()) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_activity_mixin_archive(self): + rec = self.test_record.with_user(self.user_employee) + new_act = rec.activity_schedule( + 'test_mail.mail_act_test_todo', + user_id=self.user_admin.id, + ) + self.assertEqual(rec.activity_ids, new_act) + rec.action_archive() + self.assertEqual(rec.active, False) + self.assertEqual(rec.activity_ids, new_act) + rec.action_unarchive() + self.assertEqual(rec.active, True) + self.assertEqual(rec.activity_ids, new_act) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_activity_mixin_archive_user(self): + """ + Test when archiving an user, we unlink all his related activities + """ + test_users = self.env['res.users'] + for i in range(5): + test_users += mail_new_test_user(self.env, name=f'test_user_{i}', login=f'test_password_{i}') + for user in test_users: + self.test_record.activity_schedule(user_id=user.id) + archived_users = self.env['res.users'].browse(x.id for x in random.sample(test_users, 2)) # pick 2 users to archive + archived_users.action_archive() + active_users = test_users - archived_users + + # archive user with company disabled + user_admin = self.user_admin + user_employee_c2 = self.user_employee_c2 + self.assertIn(self.company_2, user_admin.company_ids) + self.test_record.env['ir.rule'].create({ + 'model_id': self.env.ref('test_mail.model_mail_test_activity').id, + 'domain_force': "[('company_id', 'in', company_ids)]" + }) + self.test_record.activity_schedule(user_id=user_employee_c2.id) + user_employee_c2.with_user(user_admin).with_context( + allowed_company_ids=(user_admin.company_ids - self.company_2).ids + ).action_archive() + archived_users += user_employee_c2 + + self.assertFalse(any(archived_users.mapped('active')), "Users should be archived.") + + # activities of active users shouldn't be touched, each has exactly 1 activity present + activities = self.env['mail.activity'].search([('user_id', 'in', active_users.ids)]) + self.assertEqual(len(activities), 3, "We should have only 3 activities in total linked to our active users") + self.assertEqual(activities.mapped('user_id'), active_users, + "We should have 3 different users linked to the activities of the active users") + + # ensure the user's activities are removed + activities = self.env['mail.activity'].search([('user_id', 'in', archived_users.ids)]) + self.assertFalse(activities, "Activities of archived users should be deleted.") + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_activity_mixin_reschedule_user(self): + rec = self.test_record.with_user(self.user_employee) + rec.activity_schedule( + 'test_mail.mail_act_test_todo', + user_id=self.user_admin.id) + self.assertEqual(rec.activity_ids[0].user_id, self.user_admin) + + # reschedule its own should not alter other's activities + rec.activity_reschedule( + ['test_mail.mail_act_test_todo'], + user_id=self.user_employee.id, + new_user_id=self.user_employee.id) + self.assertEqual(rec.activity_ids[0].user_id, self.user_admin) + + rec.activity_reschedule( + ['test_mail.mail_act_test_todo'], + user_id=self.user_admin.id, + new_user_id=self.user_employee.id) + self.assertEqual(rec.activity_ids[0].user_id, self.user_employee) + + @users('employee') + def test_feedback_w_attachments(self): + test_record = self.env['mail.test.activity'].browse(self.test_record.ids) + + activity = self.env['mail.activity'].create({ + 'activity_type_id': 1, + 'res_id': test_record.id, + 'res_model_id': self.env['ir.model']._get_id('mail.test.activity'), + 'summary': 'Test', + }) + attachments = self.env['ir.attachment'].create([{ + 'name': 'test', + 'res_name': 'test', + 'res_model': 'mail.activity', + 'res_id': activity.id, + 'datas': 'test', + }, { + 'name': 'test2', + 'res_name': 'test', + 'res_model': 'mail.activity', + 'res_id': activity.id, + 'datas': 'testtest', + }]) + + # Checking if the attachment has been forwarded to the message + # when marking an activity as "Done" + activity.action_feedback() + activity_message = test_record.message_ids[0] + self.assertEqual(set(activity_message.attachment_ids.ids), set(attachments.ids)) + for attachment in attachments: + self.assertEqual(attachment.res_id, activity_message.id) + self.assertEqual(attachment.res_model, activity_message._name) + + @users('employee') + def test_feedback_chained_current_date(self): + frozen_now = datetime(2021, 10, 10, 14, 30, 15) + + test_record = self.env['mail.test.activity'].browse(self.test_record.ids) + first_activity = self.env['mail.activity'].create({ + 'activity_type_id': self.env.ref('test_mail.mail_act_test_chained_1').id, + 'date_deadline': frozen_now + relativedelta(days=-2), + 'res_id': test_record.id, + 'res_model_id': self.env['ir.model']._get_id('mail.test.activity'), + 'summary': 'Test', + }) + first_activity_id = first_activity.id + + with freeze_time(frozen_now): + first_activity.action_feedback(feedback='Done') + self.assertFalse(first_activity.active) + + # check chained activity + new_activity = test_record.activity_ids + self.assertNotEqual(new_activity.id, first_activity_id) + self.assertEqual(new_activity.summary, 'Take the second step.') + self.assertEqual(new_activity.date_deadline, frozen_now.date() + relativedelta(days=10)) + + @users('employee') + def test_feedback_chained_previous(self): + self.env.ref('test_mail.mail_act_test_chained_2').sudo().write({'delay_from': 'previous_activity'}) + frozen_now = datetime(2021, 10, 10, 14, 30, 15) + + test_record = self.env['mail.test.activity'].browse(self.test_record.ids) + first_activity = self.env['mail.activity'].create({ + 'activity_type_id': self.env.ref('test_mail.mail_act_test_chained_1').id, + 'date_deadline': frozen_now + relativedelta(days=-2), + 'res_id': test_record.id, + 'res_model_id': self.env['ir.model']._get_id('mail.test.activity'), + 'summary': 'Test', + }) + first_activity_id = first_activity.id + + with freeze_time(frozen_now): + first_activity.action_feedback(feedback='Done') + self.assertFalse(first_activity.active) + + # check chained activity + new_activity = test_record.activity_ids + self.assertNotEqual(new_activity.id, first_activity_id) + self.assertEqual(new_activity.summary, 'Take the second step.') + self.assertEqual(new_activity.date_deadline, frozen_now.date() + relativedelta(days=8), + 'New deadline should take into account original activity deadline, not current date') + + def test_mail_activity_state(self): + """Create 3 activity for 2 different users in 2 different timezones. + + User UTC (+0h) + User Australia (+11h) + Today datetime: 1/1/2020 16h + + Activity 1 & User UTC + 1/1/2020 - 16h UTC -> The state is today + + Activity 2 & User Australia + 1/1/2020 - 16h UTC + 2/1/2020 - 1h Australia -> State is overdue + + Activity 3 & User UTC + 1/1/2020 - 23h UTC -> The state is today + """ + record = self.env['mail.test.activity'].create({'name': 'Record'}) + + with freeze_time(datetime(2020, 1, 1, 16)): + today_utc = datetime.today() + activity_1 = self.env['mail.activity'].create({ + 'summary': 'Test', + 'activity_type_id': 1, + 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, + 'res_id': record.id, + 'date_deadline': today_utc, + 'user_id': self.user_utc.id, + }) + + activity_2 = activity_1.copy() + activity_2.user_id = self.user_australia + activity_3 = activity_1.copy() + activity_3.date_deadline += relativedelta(hours=7) + + self.assertEqual(activity_1.state, 'today') + self.assertEqual(activity_2.state, 'overdue') + self.assertEqual(activity_3.state, 'today') + + @users('employee') + def test_mail_activity_mixin_search_activity_user_id_false(self): + """Test the search method on the "activity_user_id" when searching for non-set user""" + MailTestActivity = self.env['mail.test.activity'] + test_records = self.test_record | self.test_record_2 + self.assertFalse(test_records.activity_ids) + self.assertEqual(MailTestActivity.search([('activity_user_id', '=', False)]), test_records) + + self.env['mail.activity'].create({ + 'summary': 'Test', + 'activity_type_id': self.env.ref('test_mail.mail_act_test_todo').id, + 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, + 'res_id': self.test_record.id, + }) + self.assertEqual(MailTestActivity.search([('activity_user_id', '!=', True)]), self.test_record_2) + + def test_mail_activity_mixin_search_exception_decoration(self): + """Test the search on "activity_exception_decoration". + + Domain ('activity_exception_decoration', '!=', False) should only return + records that have at least one warning/danger activity. + """ + record_warning, record_normal, _ = self.test_record, self.test_record_2, self.env['mail.test.activity'].create({'name': 'No activities'}) + record_warning.activity_schedule('mail.mail_activity_data_warning', user_id=self.env.user.id) + record_normal.activity_schedule('test_mail.mail_act_test_todo', user_id=self.env.user.id) + + records = self.env['mail.test.activity'].search([('activity_exception_decoration', '!=', False)]) + self.assertEqual(records, record_warning) + + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests') + def test_mail_activity_mixin_search_state_basic(self): + """Test the search method on the "activity_state". + + Test all the operators and also test the case where the "activity_state" is + different because of the timezone. There's also a tricky case for which we + "reverse" the domain for performance purpose. + """ + + # Create some records without activity schedule on it for testing + self.env['mail.test.activity'].create([ + {'name': 'Record %i' % record_i} + for record_i in range(5) + ]) + + origin_1, origin_2 = self.env['mail.test.activity'].search([], limit=2) + activity_type = self.env.ref('test_mail.mail_act_test_todo') + + with freeze_time(datetime(2020, 1, 1, 16)): + today_utc = datetime.today() + origin_1_activity_1 = self.env['mail.activity'].create({ + 'summary': 'Test', + 'activity_type_id': activity_type.id, + 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, + 'res_id': origin_1.id, + 'date_deadline': today_utc, + 'user_id': self.user_utc.id, + }) + + origin_1_activity_2 = origin_1_activity_1.copy() + origin_1_activity_2.user_id = self.user_australia + origin_1_activity_3 = origin_1_activity_1.copy() + origin_1_activity_3.date_deadline += relativedelta(hours=8) + + self.assertEqual(origin_1_activity_1.state, 'today') + self.assertEqual(origin_1_activity_2.state, 'overdue') + self.assertEqual(origin_1_activity_3.state, 'today') + + origin_2_activity_1 = self.env['mail.activity'].create({ + 'summary': 'Test', + 'activity_type_id': activity_type.id, + 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, + 'res_id': origin_2.id, + 'date_deadline': today_utc + relativedelta(hours=8), + 'user_id': self.user_utc.id, + }) + + origin_2_activity_2 = origin_2_activity_1.copy() + origin_2_activity_2.user_id = self.user_australia + origin_2_activity_3 = origin_2_activity_1.copy() + origin_2_activity_3.date_deadline -= relativedelta(hours=8) + origin_2_activity_4 = origin_2_activity_1.copy() + origin_2_activity_4.date_deadline = datetime(2020, 1, 2, 0, 0, 0) + + self.assertEqual(origin_2_activity_1.state, 'planned') + self.assertEqual(origin_2_activity_2.state, 'today') + self.assertEqual(origin_2_activity_3.state, 'today') + self.assertEqual(origin_2_activity_4.state, 'planned') + + all_activity_mixin_record = self.env['mail.test.activity'].search([]) + + result = self.env['mail.test.activity'].search([('activity_state', '=', 'today')]) + self.assertTrue(len(result) > 0) + self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: p.activity_state == 'today')) + + result = self.env['mail.test.activity'].search([('activity_state', 'in', ('today', 'overdue'))]) + self.assertTrue(len(result) > 0) + self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: p.activity_state in ('today', 'overdue'))) + + result = self.env['mail.test.activity'].search([('activity_state', 'not in', ('today'))]) + self.assertTrue(len(result) > 0) + self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: p.activity_state != 'today')) + + result = self.env['mail.test.activity'].search([('activity_state', '=', False)]) + self.assertTrue(len(result) >= 3, "There is more than 3 records without an activity schedule on it") + self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: not p.activity_state)) + + result = self.env['mail.test.activity'].search([('activity_state', 'not in', ('planned', 'overdue', 'today'))]) + self.assertTrue(len(result) >= 3, "There is more than 3 records without an activity schedule on it") + self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: not p.activity_state)) + + # test tricky case when the domain will be reversed in the search method + # because of falsy value + result = self.env['mail.test.activity'].search([('activity_state', 'not in', ('today', False))]) + self.assertTrue(len(result) > 0) + self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: p.activity_state not in ('today', False))) + + result = self.env['mail.test.activity'].search([('activity_state', 'in', ('today', False))]) + self.assertTrue(len(result) > 0) + self.assertEqual(result, all_activity_mixin_record.filtered(lambda p: p.activity_state in ('today', False))) + + # Check that activity done are not taken into account by group and search by activity_state. + Model = self.env['mail.test.activity'] + search_params = { + 'domain': [('id', 'in', (origin_1 | origin_2).ids), ('activity_state', '=', 'overdue')]} + read_group_params = { + 'domain': [('id', 'in', (origin_1 | origin_2).ids)], + 'groupby': ['activity_state'], + 'aggregates': ['__count'], + } + self.assertEqual(Model.search(**search_params), origin_1) + self.assertEqual( + {(e['activity_state'], e['__count']) for e in Model.formatted_read_group(**read_group_params)}, + {('today', 1), ('overdue', 1)}) + origin_1_activity_2.action_feedback(feedback='Done') + self.assertFalse(Model.search(**search_params)) + self.assertEqual( + {(e['activity_state'], e['__count']) for e in Model.formatted_read_group(**read_group_params)}, + {('today', 2)}) + + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests') + def test_mail_activity_mixin_search_state_different_day_but_close_time(self): + """Test the case where there's less than 24 hours between the deadline and now_tz, + but one day of difference (e.g. 23h 01/01/2020 & 1h 02/02/2020). So the state + should be "planned" and not "today". This case was tricky to implement in SQL + that's why it has its own test. + """ + + # Create some records without activity schedule on it for testing + self.env['mail.test.activity'].create([ + {'name': 'Record %i' % record_i} + for record_i in range(5) + ]) + + origin_1 = self.env['mail.test.activity'].search([], limit=1) + + with freeze_time(datetime(2020, 1, 1, 23)): + today_utc = datetime.today() + origin_1_activity_1 = self.env['mail.activity'].create({ + 'summary': 'Test', + 'activity_type_id': 1, + 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, + 'res_id': origin_1.id, + 'date_deadline': today_utc + relativedelta(hours=2), + 'user_id': self.user_utc.id, + }) + + self.assertEqual(origin_1_activity_1.state, 'planned') + result = self.env['mail.test.activity'].search([('activity_state', '=', 'today')]) + self.assertNotIn(origin_1, result, 'The activity state miss calculated during the search') + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_my_activity_flow_employee(self): + Activity = self.env['mail.activity'] + date_today = date.today() + Activity.create({ + 'activity_type_id': self.env.ref('test_mail.mail_act_test_todo').id, + 'date_deadline': date_today, + 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, + 'res_id': self.test_record.id, + 'user_id': self.user_admin.id, + }) + Activity.create({ + 'activity_type_id': self.env.ref('test_mail.mail_act_test_call').id, + 'date_deadline': date_today + relativedelta(days=1), + 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, + 'res_id': self.test_record.id, + 'user_id': self.user_employee.id, + }) + + test_record_1 = self.env['mail.test.activity'].with_context(self._test_context).create({'name': 'Test 1'}) + test_record_1_late_activity = Activity.create({ + 'activity_type_id': self.env.ref('test_mail.mail_act_test_todo').id, + 'date_deadline': date_today, + 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, + 'res_id': test_record_1.id, + 'user_id': self.user_employee.id, + }) + with self.with_user('employee'): + record = self.env['mail.test.activity'].search([('my_activity_date_deadline', '=', date_today)]) + self.assertEqual(test_record_1, record) + test_record_1_late_activity._action_done() + record = self.env['mail.test.activity'].with_context(active_test=False).search([ + ('my_activity_date_deadline', '=', date_today) + ]) + self.assertFalse(record, "Should not find record if the only late activity is done") + + @users('employee') + def test_record_unlink(self): + test_record = self.test_record.with_user(self.env.user) + act1 = test_record.activity_schedule(summary='Active', user_id=self.env.uid) + act2 = test_record.activity_schedule(summary='Archived', active=False, user_id=self.env.uid) + test_record.unlink() + self.assertFalse((act1 + act2).exists(), 'Removing records should remove activities, even archived') + + @users('employee') + def test_record_unlinked_orphan_activities(self): + """Test the fix preventing error on corrupted database where activities without related record are present.""" + test_record = self.env['mail.test.activity'].with_context( + self._test_context).create({'name': 'Test'}).with_user(self.user_employee) + act = test_record.activity_schedule("test_mail.mail_act_test_todo", summary='Orphan activity') + act.action_done() + # Delete the record while preventing the cascade deletion of the activity to simulate a corrupted database + with patch.object(MailActivity, 'unlink', lambda self: None): + test_record.unlink() + self.assertTrue(act.exists()) + self.assertFalse(act.active) + self.assertFalse(test_record.exists()) + + self.env.invalidate_all() + self.assertEqual( + self.env['mail.activity'].with_user(self.user_admin).with_context(active_test=False).search( + [('active', '=', False)]), act, + 'Should consider unassigned activity on removed record = access without crash' + ) + self.env.invalidate_all() + _dummy = act.with_user(self.user_admin).read(['summary']) + + +@tests.tagged('mail_activity', 'mail_activity_mixin') +class TestORM(TestActivityCommon): + """Test for read_progress_bar""" + + def test_groupby_activity_state_progress_bar_behavior(self): + """ Test activity_state groupby logic on mail.test.lead when 'activity_state' + is present multiple times in the groupby field list. """ + lead_timedelta_setup = [0, 0, -2, -2, -2, 2] + + leads = self.env["mail.test.lead"].create([ + {"name": f"CRM Lead {i}"} + for i in range(1, len(lead_timedelta_setup) + 1) + ]) + + with freeze_time("2025-05-21 10:00:00"): + self.env["mail.activity"].create([ + { + "date_deadline": datetime.now(timezone.utc) + timedelta(days=delta_days), + "res_id": lead.id, + "res_model_id": self.env["ir.model"]._get_id("mail.test.lead"), + "summary": f"Test activity for CRM lead {lead.id}", + "user_id": self.env.user.id, + } for lead, delta_days in zip(leads, lead_timedelta_setup) + ]) + + # grouping by 'activity_state' and 'activity_state' as the progress bar + domain = [("name", "!=", "")] + groupby = "activity_state" + progress_bar = { + "field": "activity_state", + "colors": { + "overdue": "danger", + "today": "warning", + "planned": "success", + }, + } + progressbars = self.env["mail.test.lead"].read_progress_bar( + domain=domain, group_by=groupby, progress_bar=progress_bar, + ) + + self.assertEqual(len(progressbars), 3) + expected_progressbars = { + "overdue": {"overdue": 3, "today": 0, "planned": 0}, + "today": {"overdue": 0, "today": 2, "planned": 0}, + "planned": {"overdue": 0, "today": 0, "planned": 1}, + } + self.assertEqual(dict(progressbars), expected_progressbars) + + def test_week_grouping(self): + """The labels associated to each record in read_progress_bar should match + the ones from read_group, even in edge cases like en_US locale on sundays + """ + MailTestActivityCtx = self.env['mail.test.activity'].with_context({"lang": "en_US"}) + + # Don't mistake fields date and date_deadline: + # * date is just a random value + # * date_deadline defines activity_state + with freeze_time("2024-09-24 10:00:00"): + self.env['mail.test.activity'].create({ + 'date': '2021-05-02', + 'name': "Yesterday, all my troubles seemed so far away", + }).activity_schedule( + 'test_mail.mail_act_test_todo', + summary="Make another test super asap (yesterday)", + date_deadline=fields.Date.context_today(MailTestActivityCtx) - timedelta(days=7), + user_id=self.env.uid, + ) + self.env['mail.test.activity'].create({ + 'date': '2021-05-09', + 'name': "Things we said today", + }).activity_schedule( + 'test_mail.mail_act_test_todo', + summary="Make another test asap", + date_deadline=fields.Date.context_today(MailTestActivityCtx), + user_id=self.env.uid, + ) + self.env['mail.test.activity'].create({ + 'date': '2021-05-16', + 'name': "Tomorrow Never Knows", + }).activity_schedule( + 'test_mail.mail_act_test_todo', + summary="Make a test tomorrow", + date_deadline=fields.Date.context_today(MailTestActivityCtx) + timedelta(days=7), + user_id=self.env.uid, + ) + + domain = [('date', "!=", False)] + groupby = "date:week" + progress_bar = { + 'field': 'activity_state', + 'colors': { + "overdue": 'danger', + "today": 'warning', + "planned": 'success', + } + } + + # call read_group to compute group names + groups = MailTestActivityCtx.formatted_read_group(domain, groupby=[groupby]) + progressbars = MailTestActivityCtx.read_progress_bar(domain, group_by=groupby, progress_bar=progress_bar) + self.assertEqual(len(groups), 3) + self.assertEqual(len(progressbars), 3) + + # format the read_progress_bar result to get a dictionary under this + # format: {activity_state: group_name}; the original format + # (after read_progress_bar) is {group_name: {activity_state: count}} + pg_groups = { + next(state for state, count in data.items() if count): group_name + for group_name, data in progressbars.items() + } + + self.assertEqual(groups[0][groupby][0], pg_groups["overdue"]) + self.assertEqual(groups[1][groupby][0], pg_groups["today"]) + self.assertEqual(groups[2][groupby][0], pg_groups["planned"]) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_activity_plan.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_activity_plan.py new file mode 100644 index 0000000..f0cacc5 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_activity_plan.py @@ -0,0 +1,410 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, timedelta +from dateutil.relativedelta import relativedelta +from freezegun import freeze_time + +from odoo import fields +from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.mail.tests.common_activity import ActivityScheduleCase +from odoo.exceptions import UserError, ValidationError +from odoo.tests import Form, tagged, users +from odoo.tools.misc import format_date + + +@tagged('mail_activity', 'mail_activity_plan') +class TestActivitySchedule(ActivityScheduleCase): + """ Test plan and activity schedule + + - activity scheduling on a single record and in batch + - plan scheduling on a single record and in batch + - plan creation and consistency + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # add some triggered and suggested next activitities + cls.test_type_1, cls.test_type_2, cls.test_type_3 = cls.env['mail.activity.type'].create([ + {'name': 'TestAct1', 'res_model': 'mail.test.activity',}, + {'name': 'TestAct2', 'res_model': 'mail.test.activity',}, + {'name': 'TestAct3', 'res_model': 'mail.test.activity',}, + ]) + cls.test_type_1.write({ + 'chaining_type': 'trigger', + 'delay_count': 2, + 'delay_from': 'current_date', + 'delay_unit': 'days', + 'triggered_next_type_id': cls.test_type_2.id, + }) + cls.test_type_2.write({ + 'chaining_type': 'suggest', + 'delay_count': 3, + 'delay_unit': 'weeks', + 'suggested_next_type_ids': [(4, cls.test_type_1.id), (4, cls.test_type_3.id)], + }) + + # prepare plans + cls.plan_party = cls.env['mail.activity.plan'].create({ + 'name': 'Test Plan A Party', + 'res_model': 'mail.test.activity', + 'template_ids': [ + (0, 0, { + 'activity_type_id': cls.activity_type_todo.id, + 'delay_count': 1, + 'delay_from': 'before_plan_date', + 'delay_unit': 'days', + 'responsible_type': 'on_demand', + 'sequence': 10, + 'summary': 'Book a place', + }), (0, 0, { + 'activity_type_id': cls.activity_type_todo.id, + 'delay_count': 1, + 'delay_from': 'after_plan_date', + 'delay_unit': 'weeks', + 'responsible_id': cls.user_admin.id, + 'responsible_type': 'other', + 'sequence': 20, + 'summary': 'Invite special guest', + }), + ], + }) + cls.plan_onboarding = cls.env['mail.activity.plan'].create({ + 'name': 'Test Onboarding', + 'res_model': 'mail.test.activity', + 'template_ids': [ + (0, 0, { + 'activity_type_id': cls.activity_type_todo.id, + 'delay_count': 3, + 'delay_from': 'before_plan_date', + 'delay_unit': 'days', + 'responsible_id': cls.user_admin.id, + 'responsible_type': 'other', + 'sequence': 10, + 'summary': 'Plan training', + }), (0, 0, { + 'activity_type_id': cls.activity_type_todo.id, + 'delay_count': 2, + 'delay_from': 'after_plan_date', + 'delay_unit': 'weeks', + 'responsible_id': cls.user_admin.id, + 'responsible_type': 'other', + 'sequence': 20, + 'summary': 'Training', + }), + ] + }) + + # test records + cls.reference_now = fields.Datetime.from_string('2023-09-30 14:00:00') + cls.test_records = cls.env['mail.test.activity'].create([ + { + 'date': cls.reference_now + timedelta(days=(idx - 10)), + 'email_from': f'customer.activity.{idx}@test.example.com', + 'name': f'test_record_{idx}' + } for idx in range(5) + ]) + + # some big dict comparisons + cls.maxDiff = None + + @users('employee') + def test_activity_schedule(self): + """ Test schedule of an activity on a single or multiple records. """ + test_records_all = [self.test_records[0], self.test_records[:3]] + # sanity check: new activity created without specifying activiy type + # will have default type of the available activity type with the lowest sequence, then lowest id + self.assertTrue(self.activity_type_todo.sequence < self.activity_type_call.sequence) + for test_idx, test_case in enumerate(['mono', 'multi']): + test_records = test_records_all[test_idx].with_env(self.env) + with self.subTest(test_case=test_case, test_records=test_records): + # 1. SCHEDULE ACTIVITIES + with freeze_time(self.reference_now): + form = self._instantiate_activity_schedule_wizard(test_records) + form.summary = 'Write specification' + form.note = '

Useful link ...

' + form.activity_user_id = self.user_admin + with self._mock_activities(): + form.save().action_schedule_activities() + + for record in test_records: + self.assertActivityCreatedOnRecord(record, { + 'activity_type_id': self.activity_type_todo, + 'automated': False, + 'date_deadline': self.reference_now.date() + timedelta(days=4), # activity type delay + 'note': '

Useful link ...

', + 'summary': 'Write specification', + 'user_id': self.user_admin, + }) + + # 2. LOG DONE ACTIVITIES + with freeze_time(self.reference_now): + form = self._instantiate_activity_schedule_wizard(test_records) + form.activity_type_id = self.activity_type_call + form.activity_user_id = self.user_admin + with self._mock_activities(), freeze_time(self.reference_now): + form.save().with_context( + mail_activity_quick_update=True + ).action_schedule_activities_done() + + for record in test_records: + self.assertActivityDoneOnRecord(record, self.activity_type_call) + + # 3. CONTINUE WITH SCHEDULE ACTIVITIES + # implies deadline addition on top of previous activities + with freeze_time(self.reference_now): + form = self._instantiate_activity_schedule_wizard(test_records) + form.activity_type_id = self.activity_type_call + form.activity_user_id = self.user_admin + with self._mock_activities(): + form.save().with_context( + mail_activity_quick_update=True + ).action_schedule_activities() + + for record in test_records: + self.assertActivityCreatedOnRecord(record, { + 'activity_type_id': self.activity_type_call, + 'automated': False, + 'date_deadline': self.reference_now.date() + timedelta(days=1), # activity call delay + 'note': False, + 'summary': 'TodoSumCallSummary', + 'user_id': self.user_admin, + }) + + # global activity creation from tests + self.assertEqual(len(self.test_records[0].activity_ids), 4) + self.assertEqual(len(self.test_records[1].activity_ids), 2) + self.assertEqual(len(self.test_records[2].activity_ids), 2) + self.assertEqual(len(self.test_records[3].activity_ids), 0) + self.assertEqual(len(self.test_records[4].activity_ids), 0) + + @users('admin') + def test_activity_schedule_rights_upload(self): + user = mail_new_test_user( + self.env, + groups='base.group_public', + login='bert', + name='Bert Tartignole', + ) + demo_record = self.env['mail.test.access'].create({'access': 'admin', 'name': 'Record'}) + form = self._instantiate_activity_schedule_wizard(demo_record) + form.activity_type_id = self.env.ref('test_mail.mail_act_test_upload_document') + with self.assertRaises(UserError): + form.activity_user_id = user + form.save() + + @users('employee') + def test_activity_schedule_norecord(self): + """ Test scheduling free activities, supported if assigned user. """ + scheduler = self._instantiate_activity_schedule_wizard(None) + self.assertEqual(scheduler.activity_type_id, self.activity_type_todo) + with self._mock_activities(): + scheduler.save().action_schedule_activities() + self.assertActivityValues(self._new_activities, { + 'res_id': False, + 'res_model': False, + 'summary': 'TodoSummary', + 'user_id': self.user_employee, + }) + + # cannot scheduler unassigned personal activities + scheduler = self._instantiate_activity_schedule_wizard(None) + scheduler = scheduler.save() + with self.assertRaises(ValidationError): + scheduler.activity_user_id = False + + def test_plan_copy(self): + """Test plan copy""" + copied_plan = self.plan_onboarding.copy() + self.assertEqual(copied_plan.name, f'{self.plan_onboarding.name} (copy)') + self.assertEqual(len(copied_plan.template_ids), len(self.plan_onboarding.template_ids)) + + @users('employee') + def test_plan_mode(self): + """ Test the plan_mode that allows to preselect a compatible plan. """ + test_record = self.test_records[0].with_env(self.env) + context = { + 'active_id': test_record.id, + 'active_ids': test_record.ids, + 'active_model': test_record._name + } + plan_mode_context = {**context, 'plan_mode': True} + + with Form(self.env['mail.activity.schedule'].with_context(context)) as form: + self.assertFalse(form.plan_id) + with Form(self.env['mail.activity.schedule'].with_context(plan_mode_context)) as form: + self.assertEqual(form.plan_id, self.plan_party) + # should select only model-plans + self.plan_party.res_model = 'res.partner' + with Form(self.env['mail.activity.schedule'].with_context(plan_mode_context)) as form: + self.assertEqual(form.plan_id, self.plan_onboarding) + + @users('admin') + def test_plan_next_activities(self): + """ Test that next activities are displayed correctly. """ + test_plan = self.env['mail.activity.plan'].create({ + 'name': 'Test Plan', + 'res_model': 'mail.test.activity', + 'template_ids': [ + (0, 0, {'activity_type_id': self.test_type_1.id}), + (0, 0, {'activity_type_id': self.test_type_2.id}), + (0, 0, {'activity_type_id': self.test_type_3.id}), + ], + }) + # Assert expected next activities + expected_next_activities = [['TestAct2'], ['TestAct1', 'TestAct3'], []] + for template, expected_names in zip(test_plan.template_ids, expected_next_activities, strict=True): + self.assertEqual(template.next_activity_ids.mapped('name'), expected_names) + # Test the plan summary + with self.subTest(test_case='Check plan summary'), \ + freeze_time(self.reference_now): + form = self._instantiate_activity_schedule_wizard(self.test_records[0]) + form.plan_id = test_plan + expected_values = [ + {'description': 'TestAct1', 'deadline': datetime(2023, 9, 30).date()}, + {'description': 'TestAct2', 'deadline': datetime(2023, 10, 21).date()}, + {'description': 'TestAct2', 'deadline': datetime(2023, 9, 30).date()}, + {'description': 'TestAct1', 'deadline': datetime(2023, 10, 2).date()}, + {'description': 'TestAct3', 'deadline': datetime(2023, 9, 30).date()}, + {'description': 'TestAct3', 'deadline': datetime(2023, 9, 30).date()}, + ] + for line, expected in zip(form.plan_schedule_line_ids._records, expected_values): + with self.subTest(line=line, expected_values=expected): + self.assertEqual(line['line_description'], expected['description']) + self.assertEqual(line['line_date_deadline'], expected['deadline']) + + @users('employee') + def test_plan_schedule(self): + """ Test schedule of a plan on a single or multiple records. """ + test_records_all = [self.test_records[0], self.test_records[:3]] + for test_idx, test_case in enumerate(['mono', 'multi']): + test_records = test_records_all[test_idx].with_env(self.env) + with self.subTest(test_case=test_case, test_records=test_records), \ + freeze_time(self.reference_now): + # No plan_date specified (-> self.reference_now is used), No responsible specified + form = self._instantiate_activity_schedule_wizard(test_records) + self.assertFalse(form.plan_schedule_line_ids) + form.plan_id = self.plan_onboarding + expected_values = [ + {'description': 'Plan training', 'deadline': datetime(2023, 9, 27).date()}, + {'description': 'Training', 'deadline': datetime(2023, 10, 14).date()}, + ] + for line, expected in zip(form.plan_schedule_line_ids._records, expected_values): + self.assertEqual(line['line_description'], expected['description']) + self.assertEqual(line['line_date_deadline'], expected['deadline']) + self.assertTrue(form._get_modifier('plan_on_demand_user_id', 'invisible')) + form.plan_id = self.plan_party + expected_values = [ + {'description': 'Book a place', 'deadline': datetime(2023, 9, 29).date()}, + {'description': 'Invite special guest', 'deadline': datetime(2023, 10, 7).date()}, + ] + for line, expected in zip(form.plan_schedule_line_ids._records, expected_values): + self.assertEqual(line['line_description'], expected['description']) + self.assertEqual(line['line_date_deadline'], expected['deadline']) + self.assertFalse(form._get_modifier('plan_on_demand_user_id', 'invisible')) + with self._mock_activities(): + form.save().action_schedule_plan() + + self.assertPlanExecution( + self.plan_party, test_records, + expected_deadlines=[(self.reference_now + relativedelta(days=-1)).date(), + (self.reference_now + relativedelta(days=7)).date()]) + + # plan_date specified, responsible specified + plan_date = self.reference_now.date() + relativedelta(days=14) + responsible_id = self.user_admin + form = self._instantiate_activity_schedule_wizard(test_records) + form.plan_id = self.plan_party + form.plan_date = plan_date + form.plan_on_demand_user_id = self.env['res.users'] + self.assertTrue(form.has_error) + self.assertIn(f'No responsible specified for {self.activity_type_todo.name}: Book a place', + form.error) + form.plan_on_demand_user_id = responsible_id + self.assertFalse(form.has_error) + deadline_1 = plan_date + relativedelta(days=-1) + deadline_2 = plan_date + relativedelta(days=7) + expected_values = [ + {'description': 'Book a place', 'deadline': deadline_1}, + {'description': 'Invite special guest', 'deadline': deadline_2}, + ] + for line, expected in zip(form.plan_schedule_line_ids._records, expected_values): + self.assertEqual(line['line_description'], expected['description']) + self.assertEqual(line['line_date_deadline'], expected['deadline']) + with self._mock_activities(): + form.save().action_schedule_plan() + + self.assertPlanExecution( + self.plan_party, test_records, + expected_deadlines=[plan_date + relativedelta(days=-1), + plan_date + relativedelta(days=7)], + expected_responsible=responsible_id) + + @users('admin') + def test_plan_setup_model_consistency(self): + """ Test the model consistency of a plan. + + Model consistency between activity_type - activity_template - plan: + - a plan is restricted to a model + - a plan contains activity plan templates which can be limited to some model + through activity type + """ + # Setup independent activities type to avoid interference with existing data + activity_type_1, activity_type_2, activity_type_3 = self.env['mail.activity.type'].create([ + {'name': 'Todo'}, + {'name': 'Call'}, + {'name': 'Partner-specific', 'res_model': 'res.partner'}, + ]) + test_plan = self.env['mail.activity.plan'].create({ + 'name': 'Test Plan', + 'res_model': 'mail.test.activity', + 'template_ids': [ + (0, 0, {'activity_type_id': activity_type_1.id}), + (0, 0, {'activity_type_id': activity_type_2.id}) + ], + }) + + # ok, all activities generic + test_plan.res_model = 'res.partner' + test_plan.res_model = 'mail.test.activity' + + with self.assertRaises( + ValidationError, + msg='Cannot set activity type to res.partner as linked to a plan of another model'): + activity_type_1.res_model = 'res.partner' + + activity_type_1.res_model = 'mail.test.activity' + with self.assertRaises( + ValidationError, + msg='Cannot set plan to res.partner as using activities linked to another model'): + test_plan.res_model = 'res.partner' + + with self.assertRaises( + ValidationError, + msg='Cannot create activity template for res.partner as linked to a plan of another model'): + self.env['mail.activity.plan.template'].create({ + 'activity_type_id': activity_type_3.id, + 'plan_id': test_plan.id, + }) + + @users('admin') + def test_plan_setup_validation(self): + """ Test plan consistency. """ + plan = self.env['mail.activity.plan'].create({ + 'name': 'test', + 'res_model': 'mail.test.activity', + }) + template = self.env['mail.activity.plan.template'].create({ + 'activity_type_id': self.activity_type_todo.id, + 'plan_id': plan.id, + 'responsible_type': 'other', + 'responsible_id': self.user_admin.id, + }) + template.responsible_type = 'on_demand' + self.assertFalse(template.responsible_id) + with self.assertRaises( + ValidationError, msg='When selecting responsible "other", you must specify a responsible.'): + template.responsible_type = 'other' + template.write({'responsible_type': 'other', 'responsible_id': self.user_admin}) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_alias.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_alias.py new file mode 100644 index 0000000..d450800 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_alias.py @@ -0,0 +1,1002 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import psycopg2 + +from ast import literal_eval + +from odoo import exceptions +from odoo.addons.mail.tests.common import MailCommon +from odoo.tests import tagged +from odoo.tests.common import users +from odoo.tools import formataddr, mute_logger + + +class TestMailAliasCommon(MailCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.test_alias_mc = cls.env['mail.alias'].create({ + 'alias_domain_id': cls.mail_alias_domain.id, + 'alias_model_id': cls.env['ir.model']._get('mail.test.container.mc').id, + 'alias_name': 'test.alias', + }) + + cls.company_no_alias = cls.env['res.company'].create({ + 'alias_domain_id': False, + 'country_id': cls.env.ref('base.be').id, + 'currency_id': cls.env.ref('base.EUR').id, + 'email': 'company_no_alias@test.example.com', + 'name': 'No Alias Company', + }) + cls.user_erp_manager.write({ + 'company_ids': [(4, cls.company_no_alias.id)], + }) + + +@tagged('mail_gateway', 'mail_alias', 'multi_company') +class TestMailAlias(TestMailAliasCommon): + """ Test alias model features, constraints and behavior. """ + + @users('admin') + def test_alias_domain_allowed_validation(self): + """ Check the validation of `mail.catchall.domain.allowed` system parameter""" + for value in [',', ',,', ', ,']: + with self.assertRaises(exceptions.ValidationError): + self.env['ir.config_parameter'].set_param('mail.catchall.domain.allowed', value) + + for value, expected in [ + ('', False), + ('hello.com', 'hello.com'), + ('hello.com,,', 'hello.com'), + ('hello.com,bonjour.com', 'hello.com,bonjour.com'), + ('hello.COM, BONJOUR.com', 'hello.com,bonjour.com'), + ]: + self.env['ir.config_parameter'].set_param('mail.catchall.domain.allowed', value) + self.assertEqual(self.env['ir.config_parameter'].get_param('mail.catchall.domain.allowed'), expected) + + @users('erp_manager') + def test_alias_domain_company_check(self): + """ Check constraint trying to avoid ill-defined company setup aka + having an alias domain on parent record / record to update that does + not match the alias domain. """ + misc_alias_domain = self.env['mail.alias.domain'].create({'name': 'misc.com'}) + record_mc_c1, record_mc_c2 = self.env['mail.test.container.mc'].create([ + { + 'alias_name': 'Test1', + 'company_id': self.company_admin.id, + }, { + 'alias_name': 'Test2', + 'company_id': self.company_2.id, + } + ]) + alias_mc_c1, alias_mc_c2 = record_mc_c1.alias_id, record_mc_c2.alias_id + self.assertEqual( + (alias_mc_c1 + alias_mc_c2).alias_parent_model_id, + self.env['ir.model']._get('mail.test.container.mc')) + self.assertEqual( + (alias_mc_c1 + alias_mc_c2).mapped('alias_parent_thread_id'), + (record_mc_c1 + record_mc_c2).ids) + self.assertEqual(alias_mc_c1.alias_domain_id, self.mail_alias_domain) + self.assertEqual(alias_mc_c2.alias_domain_id, self.mail_alias_domain_c2) + + # mail_alias_domain_c2 is linked to a conflicting company + with self.assertRaises(exceptions.ValidationError): + record_mc_c1.alias_domain_id = self.mail_alias_domain_c2 + with self.assertRaises(exceptions.ValidationError): + alias_mc_c1.sudo().alias_domain_id = self.mail_alias_domain_c2 + # misc_alias_domain is not linked to any company, therefore ok + record_mc_c1.alias_domain_id = misc_alias_domain + + # alias updating records + record_upd_c1, record_upd_c2 = self.env['mail.test.alias.optional'].sudo().create([ + { + 'alias_name': 'Update C1', + 'company_id': self.company_admin.id, + }, { + 'alias_name': 'Update C2', + 'company_id': self.company_2.id, + } + ]) + alias_update_c1, alias_update_c2 = record_upd_c1.alias_id, record_upd_c2.alias_id + self.assertEqual( + (alias_update_c1 + alias_update_c2).mapped('alias_force_thread_id'), + (record_upd_c1 + record_upd_c2).ids) + self.assertEqual(alias_update_c1.alias_domain_id, self.mail_alias_domain) + self.assertEqual(alias_update_c2.alias_domain_id, self.mail_alias_domain_c2) + + # mail_alias_domain_c2 is linked to a conflicting company + with self.assertRaises(exceptions.ValidationError): + record_upd_c1.alias_domain_id = self.mail_alias_domain_c2 + with self.assertRaises(exceptions.ValidationError): + alias_update_c1.sudo().alias_domain_id = self.mail_alias_domain_c2 + # misc_alias_domain is not linked to any company, therefore ok + record_upd_c1.alias_domain_id = misc_alias_domain + + @users('admin') + def test_alias_name_unique(self): + """ Check uniqueness constraint on alias names, at create and update. + Also check conflict management with bounce / catchall defined on + alias domains. """ + mail_alias_domain = self.mail_alias_domain.with_env(self.env) + mail_alias_domain_c2 = self.mail_alias_domain_c2.with_env(self.env) + alias_model_id = self.env['ir.model']._get('mail.test.gateway').id + + new_mail_alias = self.env['mail.alias'].create({ + 'alias_model_id': alias_model_id, + 'alias_name': 'unused.test.alias', + }) + other_alias = self.env['mail.alias'].create({ + 'alias_model_id': alias_model_id, + 'alias_name': 'other.test.alias', + }) + self.assertEqual((new_mail_alias + other_alias).alias_domain_id, mail_alias_domain) + + # test you cannot create or update aliases matching bounce / catchall of same alias domain + with self.assertRaises(exceptions.ValidationError): + self.env['mail.alias'].create({ + 'alias_model_id': alias_model_id, + 'alias_name': mail_alias_domain.catchall_alias, + }) + with self.assertRaises(exceptions.ValidationError): + self.env['mail.alias'].create({ + 'alias_model_id': alias_model_id, + 'alias_name': mail_alias_domain.bounce_alias, + }) + with self.assertRaises(exceptions.UserError): + new_mail_alias.write({'alias_name': mail_alias_domain.catchall_alias}) + with self.assertRaises(exceptions.UserError): + new_mail_alias.write({'alias_name': mail_alias_domain.bounce_alias}) + + # other domains bounce / catchall do not prevent + new_aliases = self.env['mail.alias'].create([ + {'alias_model_id': alias_model_id, 'alias_name': self.alias_catchall_c2}, + {'alias_model_id': alias_model_id, 'alias_name': self.alias_bounce_c2}, + ]) + self.assertEqual(new_aliases.alias_domain_id, mail_alias_domain) + new_aliases.unlink() + # bounce/catchall of another domain is ok + new_mail_alias.write({'alias_name': mail_alias_domain_c2.bounce_alias}) + other_alias.write({'alias_name': mail_alias_domain_c2.catchall_alias}) + # changing domain would clash with existing catchall + with self.assertRaises(exceptions.UserError): + new_mail_alias.write({'alias_domain_id': mail_alias_domain_c2.id,}) + + new_mail_alias.write({'alias_name': 'unused.test.alias'}) + # test that alias {name, alias_domain_id} should be unique + with self.assertRaises(exceptions.UserError): + self.env['mail.alias'].create({ + 'alias_model_id': alias_model_id, + 'alias_name': 'unused.test.alias', + }) + with self.assertRaises(exceptions.UserError): + self.env['mail.alias'].create([ + { + 'alias_model_id': alias_model_id, + 'alias_name': alias_name, + } + for alias_name in ('new.alias.1', 'new.alias.2', 'new.alias.1') + ]) + with self.assertRaises(exceptions.UserError): + other_alias.write({'alias_name': 'unused.test.alias'}) + + # also valid for void domain + nodom_alias = self.env['mail.alias'].create({ + 'alias_domain_id': False, + 'alias_model_id': alias_model_id, + 'alias_name': 'no.domain', + }) + self.assertFalse(nodom_alias.alias_domain_id) + with self.assertRaises(exceptions.UserError): + self.env['mail.alias'].create({ + 'alias_domain_id': False, + 'alias_model_id': alias_model_id, + 'alias_name': 'no.domain', + }) + with self.assertRaises(exceptions.UserError): + self.env['mail.alias'].create([ + { + 'alias_domain_id': False, + 'alias_model_id': alias_model_id, + 'alias_name': 'dupes.wo.domain', + } for _x in range(2) + ]) + with self.assertRaises(exceptions.UserError): + other_alias.write({ + 'alias_domain_id': False, + 'alias_name': 'no.domain', + }) + + # test that alias name can be duplicated in case of different alias domains + other_domain_alias = self.env['mail.alias'].create({ + 'alias_domain_id': mail_alias_domain_c2.id, + 'alias_model_id': alias_model_id, + 'alias_name': 'unused.test.alias' + }) + self.assertEqual(other_domain_alias.alias_domain_id, mail_alias_domain_c2) + # changing domain would violate uniqueness + with self.assertRaises(exceptions.UserError): + other_domain_alias.write({'alias_domain_id': mail_alias_domain.id}) + + @users('admin') + def test_alias_name_unique_copy(self): + """ Check uniqueness constraint check when copying aliases """ + mail_alias_domain = self.mail_alias_domain.with_env(self.env) + alias_model_id = self.env['ir.model']._get('mail.test.gateway').id + new_mail_alias = self.env['mail.alias'].create({ + 'alias_model_id': alias_model_id, + 'alias_name': 'unused.test.alias' + }) + + with mute_logger('odoo.sql_db'), self.assertRaises(psycopg2.errors.UniqueViolation): + new_mail_alias.copy({'alias_name': 'unused.test.alias'}) + + # test that duplicating an alias should have blank name + copy_1 = new_mail_alias.copy() + self.assertFalse(copy_1.alias_name) + self.assertEqual(copy_1.alias_domain_id, mail_alias_domain) + # test sanitize of copy with new name + copy_2 = new_mail_alias.copy({'alias_name': 'test.alias.2.éè#'}) + self.assertEqual(copy_2.alias_name, 'test.alias.2.ee#') + self.assertEqual(copy_2.alias_domain_id, mail_alias_domain) + + # cannot batch update, would create duplicates + with self.assertRaises(exceptions.UserError): + (copy_1 + copy_2).write({'alias_name': 'test.alias.other'}) + + @users('admin') + @mute_logger('odoo.models.unlink') + def test_alias_name_sanitize(self): + """ Check sanitizer, at both create, copy and write on alias name. """ + alias_names = [ + 'bidule...inc.', + 'b4r+=*R3wl_#_-$€{}[]()~|\\/!?&%^\'"`~', + 'hélène.prôvâïder', + '😊', + 'Déboulonneur 😊', + 'ぁ', + ] + expected_names = [ + 'bidule.inc', + 'b4r+=*r3wl_#_-$-{}-~|-/!?&%^\'-`~', + 'helene.provaider', + '-', + 'deboulonneur-', + '?', + ] + msgs = [ + 'Emails cannot start or end with a dot, there cannot be a sequence of dots.', + 'Disallowed chars should be replaced by hyphens', + 'Email alias should be unaccented', + 'Only a subset of unaccented latin chars are valid, others are replaced', + 'Only a subset of unaccented latin chars are valid, others are replaced', + 'Only a subset of unaccented latin chars are valid, others are replaced', + ] + for alias_name, expected, msg in zip(alias_names, expected_names, msgs): + with self.subTest(alias_name=alias_name): + alias = self.env['mail.alias'].create({ + 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, + 'alias_name': alias_name, + }) + self.assertEqual(alias.alias_name, expected, msg) + alias.unlink() + + alias = self.env['mail.alias'].create({ + 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, + }) + # check at write + for alias_name, expected, msg in zip(alias_names, expected_names, msgs): + with self.subTest(alias_name=alias_name): + alias.write({'alias_name': alias_name}) + self.assertEqual(alias.alias_name, expected, msg) + + @users('admin') + def test_alias_name_sanitize_false(self): + """ Check empty-like aliases are forced to False, as otherwise unique + constraint might fail with empty strings. """ + aliases = self.env['mail.alias'].create([ + { + 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, + 'alias_name': falsy_name, + } + # '.' -> not allowed to start with a "." hence False + for falsy_name in [False, None, '', ' ', '.'] + ]) + for alias in aliases: + with self.subTest(alias_name=alias.alias_name): + self.assertFalse(alias.alias_name, 'Void values should resolve to False') + + # try to reset names in batch: should work + for idx, alias in enumerate(aliases): + alias.write({'alias_name': f'unique-{idx}'}) + aliases.write({'alias_name': ''}) + for alias in aliases: + self.assertEqual(alias.alias_name, False) + + @users('admin') + def test_search(self): + """ Test search on aliases, notably searching on display_name which should + be split on searching on alias_name and alias_domain_id. """ + # ensure existing aliases to ease future asserts + existing = self.env['mail.alias'].search([('alias_domain_id', '!=', False)]) + self.assertEqual(existing.alias_domain_id, self.mail_alias_domain) + existing.write({'alias_name': False}) # don't be annoyed by existing aliases + + mail_alias_domain = self.mail_alias_domain.with_env(self.env) + mail_alias_domain_c2 = self.mail_alias_domain_c2.with_env(self.env) + self.assertEqual(mail_alias_domain.name, 'test.mycompany.com') + self.assertEqual(mail_alias_domain_c2.name, 'test.mycompany2.com') + + aliases = self.env['mail.alias'].create([ + { + 'alias_model_id': self.env['ir.model']._get('mail.test.container.mc').id, + 'alias_name': f'test.search.{idx}', + 'alias_domain_id': domain.id, + } + for idx in range(5) + for domain in (mail_alias_domain + mail_alias_domain_c2) + ]) + aliases_d1 = aliases.filtered(lambda a: a.alias_domain_id == mail_alias_domain) + aliases_d2 = aliases.filtered(lambda a: a.alias_domain_id == mail_alias_domain_c2) + + # search on alias_name: classic search + self.assertEqual( + self.env['mail.alias'].search([('alias_name', 'ilike', 'test.search')]), + aliases + ) + + # search on alias_fullname: search on aggregated of {name}@{domain} + for search_term, expected, msg in [ + ('mycompany', aliases, + 'Match all aliases on both domains as "mycompany" is contained in those two'), + (mail_alias_domain.name, aliases_d1, + 'Exact match on domain 1: should find all aliases in that domain'), + (mail_alias_domain_c2.name, aliases_d2, + 'Exact match on domain 2: should find all aliases in that domain'), + ('search.0@test.mycompany', aliases.filtered(lambda a: a.alias_name == 'test.search.0'), + 'Match in both domains'), + ('search.0@test.mycompany.com', aliases.filtered(lambda a: a.alias_name == 'test.search.0' and a.alias_domain_id == mail_alias_domain), + 'Match only in domain 1'), + ('search@test.mycompany.com', self.env['mail.alias'], + 'Does not match even as ilike'), + ]: + with self.subTest(search_term=search_term): + self.assertEqual( + self.env['mail.alias'].search([('alias_full_name', 'ilike', search_term)]), + expected, msg + ) + + # search using IN operator + for search_term, expected, msg in [ + (['mycompany'], self.env['mail.alias'], 'mycompany is too vague: does not match a left- and right- part (!= ilike)'), + ([mail_alias_domain.name], self.env['mail.alias'], 'Match only right-part of aliases emails'), + ]: + with self.subTest(search_term=search_term): + self.assertEqual(self.env['mail.alias'].search([('alias_full_name', 'in', search_term)]), + expected, msg + ) + + @users('admin') + def test_alias_setup(self): + """ Test various constraints / configuration of alias model""" + alias = self.env['mail.alias'].create({ + 'alias_model_id': self.env['ir.model']._get('mail.test.container.mc').id, + 'alias_name': 'unused.test.alias' + }) + self.assertEqual(alias.alias_status, 'not_tested') + + # validation of alias_defaults + with self.assertRaises(exceptions.ValidationError): + alias.write({'alias_defaults': "{'custom_field': brokendict"}) + alias.write({'alias_defaults': "{'custom_field': 'validdict'}"}) + + +@tagged('mail_alias', 'multi_company') +class TestAliasCompany(TestMailAliasCommon): + """ Test company / alias domain and configuration synchronization """ + + def test_alias_domain_setup_archived_company(self): + """Test initialization of alias domain with at least one archived company + and at least one mail.alias record points to one mail.thread of the + archived company""" + + # add archived company to multi company setup + self.company_archived = self.env['res.company'].create({ + 'country_id': self.env.ref('base.be').id, + 'currency_id': self.env.ref('base.EUR').id, + 'email': 'company_archived@test.example.com', + 'name': 'Company Archived Test', + }) + self.company_archived.action_archive() + + # create record inheriting from mail.thread to be used as owner/target thread + test_record_archived_company = self.env['mail.test.simple.unfollow'].create({ + 'name': 'Test record (mail.thread) specific to archived company', + 'company_id': self.company_archived.id, + }) + + unfollow_model_id = self.env['ir.model']._get_id('mail.test.simple.unfollow') + mc_archived_parent = self.env['mail.alias'].create({ + 'alias_name': 'alias_parent_specific_to_archived_company', + 'alias_parent_model_id': unfollow_model_id, + 'alias_model_id': unfollow_model_id, + 'alias_parent_thread_id': test_record_archived_company.id, + }) # case where the parent thread is specific to archived company + + mc_archived_target = self.env['mail.alias'].create({ + 'alias_name': 'alias_target_specific_to_archived_company', + 'alias_parent_model_id': unfollow_model_id, + 'alias_model_id': unfollow_model_id, + 'alias_force_thread_id': test_record_archived_company.id, + }) # case where the target thread is specific to archived company + + # eject linked aliases then remove all alias domains; should + # trigger the init condition at next create() call + all_mail_aliases = self.env['mail.alias'].search([]) + all_mail_aliases.write({'alias_domain_id': False}) + self.env['mail.alias.domain'].search([]).unlink() + + self.assertFalse(any(all_mail_aliases.mapped("alias_domain_id")), + 'Mail aliases should have no linked alias domain at this stage') + + # since we nuked all alias domain records, creating a new alias domain + # will initialize it as the default for all mail.alias records. + # Should not raise any errors (see _check_alias_domain_id_mc) + mc_alias_domain = self.env['mail.alias.domain'].create({ + 'bounce_alias': 'bounce.mc.archived', + 'catchall_alias': 'catchall.bounce.mc.archived', + 'name': 'test.init.mc.archived.com', + }) + + self.assertEqual(mc_archived_parent.alias_domain_id.id, mc_alias_domain.id, + 'Parent thread has the wrong alias domain') + self.assertEqual(mc_archived_target.alias_domain_id.id, mc_alias_domain.id, + 'Target thread has the wrong alias domain') + self.assertEqual(self.company_archived.alias_domain_id.id, mc_alias_domain.id, + 'Archived company was attributed wrong alias domain') + + @mute_logger('odoo.models.unlink') + @users('erp_manager') + def test_alias_domain_setup(self): + """ Test synchronization of alias domain with companies when adding / + updating / removing alias domains """ + mail_alias_domain = self.mail_alias_domain.with_env(self.env) + mail_alias_domain_c2 = self.mail_alias_domain_c2.with_env(self.env) + + self.assertEqual(self.company_admin.alias_domain_id, mail_alias_domain) + self.assertEqual(self.company_2.alias_domain_id, mail_alias_domain_c2) + + # cannot unlink alias domain as there are aliases linked to it + with self.assertRaises(psycopg2.errors.ForeignKeyViolation), mute_logger('odoo.sql_db'): + mail_alias_domain.unlink() + + # eject linked aliases then remove alias domain of first company; should + # not impact second company + self.env['mail.alias'].sudo().search([]).write({'alias_domain_id': False}) + mail_alias_domain.unlink() + self.assertFalse(self.company_admin.alias_domain_id) + self.assertEqual(self.company_2.alias_domain_id, mail_alias_domain_c2) + self.assertFalse(self.test_alias_mc.alias_domain_id) + + # remove all alias domains + self.env['mail.alias.domain'].search([]).unlink() + self.assertFalse(self.company_2.alias_domain_id) + self.assertEqual(self.company_2.bounce_email, '') + self.assertEqual(self.company_2.bounce_formatted, '') + self.assertEqual(self.company_2.catchall_email, '') + self.assertEqual(self.company_2.catchall_formatted, '') + self.assertFalse(self.company_2.default_from_email, '') + self.assertFalse(self.company_3.alias_domain_id) + + # create a new alias domain -> consider as re-init, populate all companies + alias_domain_new = self.env['mail.alias.domain'].create({ + 'bounce_alias': 'bounce.new', + 'catchall_alias': 'catchall.new', + 'name': 'test.global.bitnurk.com', + }) + self.assertEqual(self.company_admin.alias_domain_id, alias_domain_new, + 'MC Alias: first domain should populate void companies') + self.assertEqual(self.company_2.alias_domain_id, alias_domain_new, + 'MC Alias: should take alias domain with lower sequence') + self.assertEqual(self.company_3.alias_domain_id, alias_domain_new, + 'MC Alias: should take alias domain with lower sequence') + self.assertEqual(self.test_alias_mc.alias_domain_id, alias_domain_new, + 'MC Alias: first domain should populate void aliases') + + # manual update + self.company_2.alias_domain_id = alias_domain_new.id + self.assertEqual(self.company_2.alias_domain_id, alias_domain_new) + self.assertEqual(self.company_2.bounce_email, 'bounce.new@test.global.bitnurk.com') + self.assertEqual(self.company_2.catchall_email, 'catchall.new@test.global.bitnurk.com') + + def test_assert_initial_values(self): + """ Test initial setup values: currently all companies share the same + alias configuration as it is unique. """ + self.assertEqual(self.test_alias_mc.alias_domain_id, self.mail_alias_domain) + self.assertFalse(self.company_no_alias.alias_domain_id) + + self.assertEqual(self.company_admin.alias_domain_id, self.mail_alias_domain) + self.assertEqual(self.company_admin.bounce_email, f'{self.alias_bounce}@{self.alias_domain}') + self.assertEqual( + self.company_admin.bounce_formatted, + formataddr((self.company_admin.name, f'{self.alias_bounce}@{self.alias_domain}')) + ) + self.assertEqual(self.company_admin.catchall_email, f'{self.alias_catchall}@{self.alias_domain}') + self.assertEqual( + self.company_admin.catchall_formatted, + formataddr((self.company_admin.name, f'{self.alias_catchall}@{self.alias_domain}')) + ) + self.assertEqual(self.company_admin.default_from_email, f'{self.default_from}@{self.alias_domain}') + + self.assertEqual(self.company_2.alias_domain_id, self.mail_alias_domain_c2) + self.assertEqual(self.company_2.bounce_email, f'{self.alias_bounce_c2}@{self.alias_domain_c2_name}') + self.assertEqual( + self.company_2.bounce_formatted, + formataddr((self.company_2.name, f'{self.alias_bounce_c2}@{self.alias_domain_c2_name}')) + ) + self.assertEqual(self.company_2.catchall_email, f'{self.alias_catchall_c2}@{self.alias_domain_c2_name}') + self.assertEqual( + self.company_2.catchall_formatted, + formataddr((self.company_2.name, f'{self.alias_catchall_c2}@{self.alias_domain_c2_name}')) + ) + self.assertEqual(self.company_2.default_from_email, f'{self.alias_default_from_c2}@{self.alias_domain_c2_name}') + + self.assertEqual(self.company_3.alias_domain_id, self.mail_alias_domain_c3) + self.assertEqual(self.company_3.bounce_email, f'{self.alias_bounce_c3}@{self.alias_domain_c3_name}') + self.assertEqual( + self.company_3.bounce_formatted, + formataddr((self.company_3.name, f'{self.alias_bounce_c3}@{self.alias_domain_c3_name}')) + ) + self.assertEqual(self.company_3.catchall_email, f'{self.alias_catchall_c3}@{self.alias_domain_c3_name}') + self.assertEqual( + self.company_3.catchall_formatted, + formataddr((self.company_3.name, f'{self.alias_catchall_c3}@{self.alias_domain_c3_name}')) + ) + self.assertEqual(self.company_3.default_from_email, f'{self.alias_default_from_c3}@{self.alias_domain_c3_name}') + + @users('erp_manager') + def test_res_company_creation_alias_domain(self): + """ Test alias domain configuration when creating new companies """ + company = self.env['res.company'].create({ + 'email': '"Super Company" ', + 'name': 'Super Company', + }) + company.flush_recordset() + self.assertEqual( + company.alias_domain_id, self.mail_alias_domain, + 'Default alias domain: sequence based') + + # respect forced value + company = self.env['res.company'].create({ + 'alias_domain_id': self.mail_alias_domain_c2.id, + 'email': '"Yet Another Company" ', + 'name': 'Yet Another Company', + }) + self.assertEqual(company.alias_domain_id, self.mail_alias_domain_c2) + + +@tagged('mail_gateway', 'mail_alias', 'multi_company') +class TestMailAliasDomain(TestMailAliasCommon): + + @users('admin') + def test_alias_domain_config_alias_clash(self): + """ Domain names are not unique e.g. owning multiple gmail.com accounts. + However bounce / catchall should not clash with aliases. """ + alias_domain = self.mail_alias_domain.with_env(self.env) + + for domain_config in {'bounce_alias', 'catchall_alias'}: + with self.subTest(domain_config=domain_config): + with self.assertRaises(exceptions.ValidationError): + self.env['mail.alias.domain'].create({ + domain_config: self.test_alias_mc.alias_name, + 'name': self.test_alias_mc.alias_domain_id.name, + }) + # left-part should not clech + self.env['mail.alias.domain'].create({ + domain_config: self.test_alias_mc.alias_name, + 'name': 'another.domain.name.com', + }) + + # should not clash with existing aliases, to avoid valid aliases be + # considered as bounce / catchall + with self.assertRaises(exceptions.UserError): + alias_domain.write({'bounce_alias': self.test_alias_mc.alias_name}) + with self.assertRaises(exceptions.UserError): + alias_domain.write({'catchall_alias': self.test_alias_mc.alias_name}) + + @users('admin') + def test_alias_domain_config_unique(self): + """ Domain names are not unique e.g. owning multiple gmail.com accounts. + However bounce / catchall should be unique. """ + alias_domain = self.mail_alias_domain.with_env(self.env) + + # copying directly would duplicate bounce / catchall emails + with mute_logger('odoo.sql_db'), self.assertRaises(psycopg2.errors.UniqueViolation): + new_alias_domain = alias_domain.copy() + + # same domain name is authorized if bounce and catchall are different + new_alias_domain = alias_domain.copy({ + 'bounce_alias': 'new.bounce', + 'catchall_alias': 'new.catchall', + }) + self.assertEqual(new_alias_domain.bounce_email, f'new.bounce@{alias_domain.name}') + self.assertEqual(new_alias_domain.catchall_email, f'new.catchall@{alias_domain.name}') + self.assertEqual(new_alias_domain.name, alias_domain.name) + + # check bounce / catchall are unique at create + self.env['mail.alias.domain'].create({ + 'bounce_alias': 'unique.bounce', + 'catchall_alias': 'unique.catchall', + 'name': alias_domain.name, + }) + # any not unique should raise UniqueViolation (SQL constraint fired after check) + with mute_logger('odoo.sql_db'), self.assertRaises(psycopg2.errors.UniqueViolation): + self.env['mail.alias.domain'].create({ + 'bounce_alias': alias_domain.bounce_alias, + 'name': alias_domain.name, + }) + with mute_logger('odoo.sql_db'), self.assertRaises(psycopg2.errors.UniqueViolation): + self.env['mail.alias.domain'].create({ + 'catchall_alias': alias_domain.catchall_alias, + 'name': alias_domain.name, + }) + + # also check write operation + with self.assertRaises(exceptions.ValidationError): + new_alias_domain.write({'bounce_alias': alias_domain.bounce_alias}) + with self.assertRaises(exceptions.ValidationError): + new_alias_domain.write({'catchall_alias': alias_domain.catchall_alias}) + + @users('admin') + def test_alias_domain_parameters_validation(self): + """ Test validation of bounce and catchall fields of alias domain as + well as sanitization. """ + alias_domain = self.mail_alias_domain.with_env(self.env) + + # sanitization of name (both create and write) + for failing_name in [ + 'outlook.fr, gmail.com', + # accents + 'provaïder', + 'provaïder.cöm', + # fail + '', ' ', + ]: + with self.subTest(failing_name=failing_name): + with self.assertRaises(exceptions.ValidationError): + _new_domain = self.env['mail.alias.domain'].create({'name': failing_name}) + + with self.assertRaises(exceptions.ValidationError): + alias_domain.write({'name': failing_name}) + + # sanitization of bounce / catchall + for ( + (bounce_alias, catchall_alias, default_from), + (exp_bounce, exp_catchall, exp_default_from), + (exp_bounce_email, exp_catchall_email, exp_default_from_email), + ) in zip( + [ + ( + 'bounce+b4r=*R3wl_#_-$€{}[]()~|\\/!?&%^\'"`~', + 'catchall+b4r=*R3wl_#_-$€{}[]()~|\\/!?&%^\'"`~', + 'notifications+b4r=*R3wl_#_-$€{}[]()~|\\/!?&%^\'"`~', + ), + ('bounce+😊', 'catchall+😊', 'notifications+😊'), + ('Bouncâïde 😊', 'Catchôïee 😊', 'Notificâtïons 😊'), + ('ぁ', 'ぁぁ', 'ぁぁぁ'), + # only default_from can be a valid email and taken as such + ( + 'bounce@wrong.complete.com', + 'catchall@wrong.complete.com', + 'notifications@valid.complete.com', + ), + ], + [ + ( + 'bounce+b4r=*r3wl_#_-$-{}-~|-/!?&%^\'-`~', + 'catchall+b4r=*r3wl_#_-$-{}-~|-/!?&%^\'-`~', + 'notifications+b4r=*r3wl_#_-$-{}-~|-/!?&%^\'-`~', + ), + ('bounce+-', 'catchall+-', 'notifications+-'), + ('bouncaide-', 'catchoiee-', 'notifications-'), + ('?', '??', '???'), + # only default_from can be a valid email and taken as such + ( + 'bounce', + 'catchall', + 'notifications@valid.complete.com', + ), + ], + [ + ( + f'bounce+b4r=*r3wl_#_-$-{{}}-~|-/!?&%^\'-`~@{alias_domain.name}', + f'catchall+b4r=*r3wl_#_-$-{{}}-~|-/!?&%^\'-`~@{alias_domain.name}', + f'notifications+b4r=*r3wl_#_-$-{{}}-~|-/!?&%^\'-`~@{alias_domain.name}', + ), + ( + f'bounce+-@{alias_domain.name}', + f'catchall+-@{alias_domain.name}', + f'notifications+-@{alias_domain.name}'), + ( + f'bouncaide-@{alias_domain.name}', + f'catchoiee-@{alias_domain.name}', + f'notifications-@{alias_domain.name}' + ), + ( + f'?@{alias_domain.name}', + f'??@{alias_domain.name}', + f'???@{alias_domain.name}' + ), + # only default_from can be a valid email and taken as such + ( + f'bounce@{alias_domain.name}', + f'catchall@{alias_domain.name}', + 'notifications@valid.complete.com', + ), + ] + ): + with self.subTest(bounce_alias=bounce_alias): + alias_domain.write({'bounce_alias': bounce_alias}) + self.assertEqual(alias_domain.bounce_alias, exp_bounce) + self.assertEqual(alias_domain.bounce_email, exp_bounce_email) + with self.subTest(catchall_alias=catchall_alias): + alias_domain.write({'catchall_alias': catchall_alias}) + self.assertEqual(alias_domain.catchall_alias, exp_catchall) + self.assertEqual(alias_domain.catchall_email, exp_catchall_email) + with self.subTest(default_from=default_from): + alias_domain.write({'default_from': default_from}) + self.assertEqual(alias_domain.default_from, exp_default_from) + self.assertEqual(alias_domain.default_from_email, exp_default_from_email) + + # falsy values + for config_value in [False, None, '', ' ']: + with self.subTest(config_value=config_value): + alias_domain.write({'bounce_alias': config_value}) + self.assertFalse(alias_domain.bounce_alias) + alias_domain.write({'catchall_alias': config_value}) + self.assertFalse(alias_domain.catchall_alias) + alias_domain.write({'default_from': config_value}) + self.assertFalse(alias_domain.default_from) + + # check successive param set, should not raise for unicity against itself + for _ in range(2): + alias_domain.write({ + 'bounce_alias': 'bounce+double.test', + 'catchall_alias': 'catchall+double.test', + }) + self.assertEqual(alias_domain.bounce_alias, 'bounce+double.test') + self.assertEqual(alias_domain.catchall_alias, 'catchall+double.test') + + +@tagged('mail_gateway', 'mail_alias', 'mail_alias_mixin', 'multi_company') +class TestMailAliasMixin(TestMailAliasCommon): + """ Test alias mixin implementation, synchronization of alias records + based on owner records. """ + + @users('employee') + @mute_logger('odoo.addons.base.models.ir_model') + def test_alias_mixin(self): + """ Various base checks on alias mixin behavior """ + self.assertEqual(self.env.company.alias_domain_id, self.mail_alias_domain) + + record = self.env['mail.test.gateway.groups'].create({ + 'name': 'Test Record', + 'alias_name': 'alias.test', + 'alias_contact': 'followers', + }) + self.assertEqual(record.alias_id.alias_domain_id, self.mail_alias_domain) + self.assertEqual(record.alias_id.alias_model_id, self.env['ir.model']._get('mail.test.gateway.groups')) + self.assertEqual(record.alias_id.alias_force_thread_id, record.id) + self.assertEqual(record.alias_id.alias_parent_model_id, self.env['ir.model']._get('mail.test.gateway.groups')) + self.assertEqual(record.alias_id.alias_parent_thread_id, record.id) + self.assertEqual(record.alias_id.alias_name, 'alias.test') + self.assertEqual(record.alias_id.alias_contact, 'followers') + + record.write({ + 'alias_domain_id': self.mail_alias_domain_c2.id, + 'alias_name': 'better.alias.test', + 'alias_defaults': "{'default_name': 'defaults'}" + }) + self.assertEqual(record.alias_domain, self.mail_alias_domain_c2.name) + self.assertEqual(record.alias_id.alias_name, 'better.alias.test') + self.assertEqual(record.alias_id.alias_defaults, "{'default_name': 'defaults'}") + + with self.assertRaises(exceptions.AccessError): + record.write({ + 'alias_force_thread_id': 0, + }) + + with self.assertRaises(exceptions.AccessError): + record.write({ + 'alias_model_id': self.env['ir.model']._get('mail.test.gateway').id, + }) + + with self.assertRaises(exceptions.ValidationError): + record.write({'alias_defaults': "{'custom_field': brokendict"}) + + rec = self.env['mail.test.gateway.groups'].create({ + 'name': 'Test Record2', + 'alias_name': 'alias.test', + 'alias_domain_id': self.mail_alias_domain_c2.id, + }) + self.assertEqual(rec.alias_id.alias_domain_id, self.mail_alias_domain_c2, "Should use the provided alias domain in priority") + + @users('erp_manager') + def test_alias_mixin_alias_email(self): + """ Test 'alias_email' mixin field computation and search capability """ + Model = self.env['mail.test.container.mc'] + records = Model.create([ + { + 'alias_name': f'alias.email.{idx}', # will be present in all companies + 'company_id': company.id, + 'name': f'Test {company.id} {idx}', + } + for company in (self.company_admin, self.company_2) + for idx in range(5) + ]) + self.assertEqual( + Model.search([('alias_email', 'ilike', 'alias.email')]), records, + 'Search: partial search: any domain, matching all left parts') + self.assertEqual( + Model.search([('alias_email', 'ilike', 'alias.email.0')]), records[0] + records[5], + 'Search: partial search: any domain, matching some left parts') + self.assertEqual( + Model.search([('alias_email', '=', self.mail_alias_domain.name)]), Model, + 'Search: partial search: does not match any complete email') + self.assertEqual( + Model.search([('alias_email', '=', f'alias.email.1@{self.mail_alias_domain.name}')]), records[1], + 'Search: both part search: search on name + domain') + + @users('employee') + @mute_logger('odoo.addons.base.models.ir_model') + def test_alias_mixin_alias_id_management(self): + """ Test alias_id being not mandatory """ + record_wo_alias, record_w_alias = self.env['mail.test.alias.optional'].create([ + { + 'name': 'Test WoAlias Name', + }, { + 'alias_name': 'Alias Name', + 'name': 'Test WoAlias Name', + } + ]) + self.assertFalse(record_wo_alias.alias_id, 'Alias record not created if not necessary (no alias_name)') + self.assertFalse(record_wo_alias.alias_id.alias_name) + self.assertFalse(record_wo_alias.alias_id.alias_defaults) + self.assertFalse(record_wo_alias.alias_name) + self.assertTrue(record_w_alias.alias_id, 'Alias record created as alias_name was given') + self.assertEqual(record_w_alias.alias_id.alias_name, 'alias-name', 'Alias name should go through sanitize') + self.assertEqual( + literal_eval(record_w_alias.alias_id.alias_defaults), + {'company_id': self.env.company.id} + ) + self.assertEqual(record_w_alias.alias_name, 'alias-name', 'Alias name should go through sanitize') + self.assertEqual( + literal_eval(record_w_alias.alias_defaults), + {'company_id': self.env.company.id} + ) + + # update existing alias + record_w_alias.write({'alias_contact': 'followers', 'alias_name': 'Updated Alias Name'}) + self.assertEqual(record_w_alias.alias_id.alias_contact, 'followers') + self.assertEqual(record_w_alias.alias_id.alias_name, 'updated-alias-name') + self.assertEqual(record_w_alias.alias_name, 'updated-alias-name') + + # update non existing alias -> creates alias + record_wo_alias.write({'alias_name': 'trying a name'}) + self.assertTrue(record_wo_alias.alias_id, 'Alias record should have been created to store the name') + self.assertEqual(record_wo_alias.alias_id.alias_name, 'trying-a-name') + self.assertEqual( + literal_eval(record_wo_alias.alias_id.alias_defaults), + {'company_id': self.env.company.id} + ) + self.assertEqual(record_wo_alias.alias_name, 'trying-a-name') + self.assertEqual( + literal_eval(record_wo_alias.alias_defaults), + {'company_id': self.env.company.id} + ) + + # reset alias -> keep the alias as void, don't remove it + existing_aliases = record_wo_alias.alias_id + record_w_alias.alias_id + (record_wo_alias + record_w_alias).write({'alias_name': False}) + self.assertEqual((record_wo_alias + record_w_alias).alias_id, existing_aliases) + self.assertFalse(list(filter(None, existing_aliases.mapped('alias_name')))) + + @users('employee') + def test_copy_content(self): + self.assertFalse( + self.env.user.has_group('base.group_system'), + 'Test user should not have Administrator access') + + record = self.env['mail.test.gateway.groups'].create({ + 'name': 'Test Record', + 'alias_name': 'test.record', + 'alias_contact': 'followers', + 'alias_bounced_content': False, + }) + record_alias = record.alias_id + self.assertFalse(record.alias_bounced_content) + record_copy = record.copy() + record_alias_copy = record_copy.alias_id + self.assertNotEqual(record_alias, record_alias_copy) + self.assertEqual(record_alias.alias_force_thread_id, record.id) + self.assertEqual(record_alias_copy.alias_force_thread_id, record_copy.id) + self.assertFalse(record_copy.alias_bounced_content) + self.assertEqual(record_copy.alias_contact, record.alias_contact) + self.assertFalse(record_copy.alias_name, 'Copy should not duplicate name') + + new_content = '

Bounced Content

' + record_copy.write({'alias_bounced_content': new_content}) + self.assertEqual(record_copy.alias_bounced_content, new_content) + record_copy2 = record_copy.copy() + self.assertEqual(record_copy2.alias_bounced_content, new_content) + + @users('employee') + def test_copy_optional_alias_model(self): + """ Do not propagate alias_id to duplicate record as it could lead to + overwriting alias_name of old record. """ + record = self.env['mail.test.alias.optional'].create({ + 'name': 'Test Optional Alias Record', + 'alias_name': 'test.optional.alias.record', + }) + self.assertTrue(record.alias_id) + record_copy = record.copy() + self.assertFalse(record_copy.alias_id) + + @users('erp_manager') + def test_multi_company_setup(self): + """ Test company impact on alias domains when creating or updating + records in a MC environment. """ + counter = 0 + for create_cid, exp_company, exp_alias_domain in [ + (None, self.company_2, self.mail_alias_domain_c2), + (False, self.env['res.company'], self.mail_alias_domain_c2), + (self.env.user.company_id.id, self.company_2, self.mail_alias_domain_c2), + (self.company_admin.id, self.company_admin, self.mail_alias_domain), + # company without alias domain -> set False on alias also, to avoid MC issues + (self.company_no_alias.id, self.company_no_alias, self.env['mail.alias.domain']), + ]: + with self.subTest(create_cid=create_cid, exp_company=exp_company, exp_alias_domain=exp_alias_domain): + counter += 1 + base_values = { + 'name': f'Test Record {counter}', + 'alias_name': f'alias.test.{counter}', + 'alias_contact': 'followers', + } + if create_cid is not None: + base_values['company_id'] = create_cid + record = self.env['mail.test.container.mc'].create(base_values) + self.assertEqual(record.alias_domain_id, exp_alias_domain) + self.assertEqual(record.company_id, exp_company) + + # copy: keep company + record_copy = record.copy( + default={ + 'alias_name': f'alias.copy.{counter}', + 'name': f'Copy of {record.name}', + } + ) + self.assertEqual(record_copy.alias_domain_id, exp_alias_domain) + self.assertEqual(record_copy.company_id, record.company_id) + + # copy: force company + record_copy_2 = record.copy( + default={ + 'alias_name': f'alias.copy.{counter}.2', + 'company_id': self.company_admin.id, + 'name': f'Copy 2 of {record.name}', + } + ) + self.assertEqual(record_copy_2.alias_domain_id, self.mail_alias_domain) + self.assertEqual(record_copy_2.company_id, self.company_admin) + + # updating company: force same alias domain + record.write({'company_id': self.company_admin.id}) + self.assertEqual(record.alias_domain_id, self.mail_alias_domain) + self.assertEqual(record.company_id, self.company_admin) + + # reset company: should not impact alias_domain if set + record.write({'company_id': False}) + self.assertEqual(record.alias_domain_id, self.mail_alias_domain) + self.assertFalse(record.company_id) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_composer.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_composer.py index af91354..ba75f35 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_composer.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_composer.py @@ -1,30 +1,36 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from itertools import product +import base64 +import json + +from ast import literal_eval +from datetime import timedelta +from itertools import chain, product from unittest.mock import patch -from odoo.addons.mail.tests.common import mail_new_test_user -from odoo.addons.test_mail.models.test_mail_models import MailTestTicket -from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients -from odoo.exceptions import AccessError -from odoo.tests import tagged -from odoo.tests.common import users, Form +from odoo import Command +from odoo.addons.base.tests.test_ir_cron import CronMixinCase +from odoo.addons.mail.tests.common import mail_new_test_user, MailCommon +from odoo.addons.mail.wizard.mail_compose_message import MailComposeMessage +from odoo.addons.test_mail.models.mail_test_ticket import MailTestTicket +from odoo.addons.test_mail.tests.common import TestRecipients +from odoo.fields import Datetime as FieldDatetime +from odoo.exceptions import AccessError, UserError +from odoo.tests import Form, tagged, users from odoo.tools import email_normalize, mute_logger, formataddr @tagged('mail_composer') -class TestMailComposer(TestMailCommon, TestRecipients): +class TestMailComposer(MailCommon, TestRecipients): """ Test Composer internals """ @classmethod def setUpClass(cls): - super(TestMailComposer, cls).setUpClass() + super().setUpClass() - # ensure employee can create partners, necessary for templates - cls.user_employee.write({ - 'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)], - }) + # force 'now' to ease test about schedulers + cls.reference_now = FieldDatetime.from_string('2022-12-24 12:00:00') cls.user_employee_2 = mail_new_test_user( cls.env, login='employee2', groups='base.group_user', @@ -41,27 +47,42 @@ class TestMailComposer(TestMailCommon, TestRecipients): notification_type='inbox', signature='--\nErnest' ) - cls.env.ref('mail.group_mail_template_editor').users -= cls.user_rendering_restricted + cls.env.ref('mail.group_mail_template_editor').write({'implied_by_ids': [Command.clear()]}) - cls.test_record = cls.env['mail.test.ticket'].with_context(cls._test_context).create({ - 'name': 'TestRecord', - 'customer_id': cls.partner_1.id, - 'user_id': cls.user_employee_2.id, - }) - cls.test_records, cls.test_partners = cls._create_records_for_batch( - 'mail.test.ticket', 2, - additional_values={'user_id': cls.user_employee_2.id}, - ) + with cls.mock_datetime_and_now(cls, cls.reference_now): + cls.test_record = cls.env['mail.test.ticket.mc'].with_context(cls._test_context).create({ + 'name': 'TestRecord', + 'customer_id': cls.partner_1.id, + 'user_id': cls.user_employee_2.id, + }) + cls.test_records, cls.test_partners = cls._create_records_for_batch( + 'mail.test.ticket.mc', 2, + additional_values={'user_id': cls.user_employee_2.id}, + ) - cls.test_report = cls.env['ir.actions.report'].create({ - 'name': 'Test Report on mail test ticket', - 'model': 'mail.test.ticket', - 'report_type': 'qweb-pdf', - 'report_name': 'test_mail.mail_test_ticket_test_template', - }) - cls.test_record_report = cls.env['ir.actions.report']._render_qweb_pdf(cls.test_report, cls.test_record.ids) + cls.test_report, cls.test_report_2, cls.test_report_3 = cls.env['ir.actions.report'].create([ + { + 'name': 'Test Report on Mail Test Ticket', + 'model': 'mail.test.ticket.mc', + 'print_report_name': "'TestReport for %s' % object.name", + 'report_type': 'qweb-pdf', + 'report_name': 'test_mail.mail_test_ticket_test_template', + }, { + 'name': 'Test Report 2 on Mail Test Ticket', + 'model': 'mail.test.ticket.mc', + 'print_report_name': "'TestReport2 for %s' % object.name", + 'report_type': 'qweb-pdf', + 'report_name': 'test_mail.mail_test_ticket_test_template_2', + }, { + 'name': 'Test Report 3 with variable data on Mail Test Ticket', + 'model': 'mail.test.ticket.mc', + 'print_report_name': "'TestReport3 for %s' % object.name", + 'report_type': 'qweb-pdf', + 'report_name': 'test_mail.mail_test_ticket_test_variable_template', + } + ]) - cls.test_from = '"John Doe" ' + cls.test_from = '"John Doe" ' cls.template = cls.env['mail.template'].create({ 'auto_delete': True, @@ -73,8 +94,10 @@ class TestMailComposer(TestMailCommon, TestRecipients): 'email_from': '{{ (object.user_id.email_formatted or user.email_formatted) }}', 'lang': '{{ object.customer_id.lang }}', 'mail_server_id': cls.mail_server_domain.id, - 'model_id': cls.env['ir.model']._get('mail.test.ticket').id, + 'model_id': cls.env['ir.model']._get('mail.test.ticket.mc').id, 'reply_to': '{{ ctx.get("custom_reply_to") or "info@test.example.com" }}', + 'scheduled_date': '{{ (object.create_date or datetime.datetime(2022, 12, 26, 18, 0, 0)) + datetime.timedelta(days=2) }}', + 'use_default_to': False, }) # activate translations @@ -91,26 +114,19 @@ class TestMailComposer(TestMailCommon, TestRecipients): :param add_web: add web context, generally making noise especially in mass mail mode (active_id/ids both present in context) """ - base_context = { - 'default_model': records._name, - } - if len(records) == 1: - base_context['default_composition_mode'] = 'comment' - base_context['default_res_id'] = records.id - else: - base_context['default_composition_mode'] = 'mass_mail' - base_context['active_ids'] = records.ids - if add_web: - base_context['active_model'] = records._name - base_context['active_id'] = records[0].id - if values: - base_context.update(**values) - return base_context + return self._get_mail_composer_web_context(records, add_web=add_web, **values) @tagged('mail_composer') class TestComposerForm(TestMailComposer): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.template.write({ + 'email_layout_xmlid': 'mail.test_layout', + }) + def test_assert_initial_data(self): """ Ensure class initial data to ease understanding """ self.assertTrue(self.template.auto_delete) @@ -124,23 +140,58 @@ class TestComposerForm(TestMailComposer): composer_form = Form(self.env['mail.compose.message'].with_context( self._get_web_context(self.test_record, add_web=True) )) - self.assertFalse(composer_form.auto_delete) - self.assertFalse(composer_form.auto_delete_message) + self.assertTrue(composer_form.auto_delete, 'MailComposer: comment mode should remove notification emails by default') + self.assertFalse(composer_form.auto_delete_keep_log, 'MailComposer: keep_log makes no sense in comment mode, only auto_delete') self.assertEqual(composer_form.author_id, self.env.user.partner_id) self.assertFalse(composer_form.body) + self.assertFalse(composer_form.composition_batch) self.assertEqual(composer_form.composition_mode, 'comment') self.assertEqual(composer_form.email_from, self.env.user.email_formatted) + self.assertFalse(composer_form.email_layout_xmlid) + self.assertTrue(composer_form.force_send, 'MailComposer: single record post send notifications right away') self.assertFalse(composer_form.mail_server_id) self.assertEqual(composer_form.model, self.test_record._name) + self.assertFalse(composer_form.notify_author) + self.assertFalse(composer_form.notify_author_mention) + self.assertFalse(composer_form.notify_skip_followers) self.assertFalse(composer_form.partner_ids) - self.assertEqual(composer_form.record_name, self.test_record.name, 'MailComposer: comment mode should compute record name') + self.assertEqual(composer_form.record_alias_domain_id, self.mail_alias_domain) + self.assertEqual(composer_form.record_company_id, self.env.company) self.assertFalse(composer_form.reply_to) - self.assertFalse(composer_form.reply_to_force_new) - self.assertEqual(composer_form.res_id, self.test_record.id) - self.assertEqual( - composer_form.subject, 'Re: %s' % self.test_record.name, - 'MailComposer: comment mode should have default subject Re: record_name') + self.assertFalse(composer_form.reply_to_force_new, 'By default, replies land on same thread') + self.assertFalse(composer_form.scheduled_date) + self.assertEqual(literal_eval(composer_form.res_ids), self.test_record.ids) + self.assertEqual(composer_form.subject, self.test_record._message_compute_subject()) + self.assertIn(f'Ticket for {self.test_record.name}', composer_form.subject, + 'Check effective content') self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) + self.assertFalse(composer_form.subtype_is_log) + + @users('employee') + def test_mail_composer_comment_mc(self): + """ Test values specific to multi-company setup, in comment mode, using + the Form tool. """ + # add access to second company to avoid MC rules on ticket model + self.env.user.company_ids = [(4, self.company_2.id)] + test_record = self.test_record.with_env(self.env) + source_company = [ + self.company_admin, + self.company_2, + self.env['res.company'], + ] + expected = [ + (self.company_admin, self.mail_alias_domain), + (self.company_2, self.mail_alias_domain_c2), + (self.company_admin, self.mail_alias_domain), # env company + ] + for company, (exp_company, exp_alias_domain) in zip(source_company, expected): + with self.subTest(cmopany=company): + test_record.write({'company_id': company.id}) + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_record, add_web=True) + )) + self.assertEqual(composer_form.record_alias_domain_id, exp_alias_domain) + self.assertEqual(composer_form.record_company_id, exp_company) @users('employee') def test_mail_composer_comment_attachments(self): @@ -150,14 +201,13 @@ class TestComposerForm(TestMailComposer): attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id) template_1 = self.template.copy({ 'attachment_ids': [(0, 0, a) for a in attachment_data], - 'report_name': 'TestReport for {{ object.name }}.html', # test cursor forces html - 'report_template': self.test_report.id, + 'report_template_ids': [(6, 0, (self.test_report + self.test_report_2).ids)], }) template_1_attachments = template_1.attachment_ids self.assertEqual(len(template_1_attachments), 2) template_2 = self.template.copy({ 'attachment_ids': False, - 'report_template': self.test_report.id, + 'report_template_ids': [(6, 0, self.test_report.ids)], }) # begins without attachments @@ -166,12 +216,12 @@ class TestComposerForm(TestMailComposer): )) self.assertEqual(len(composer_form.attachment_ids), 0) - # change template: 2 static (attachment_ids) and 1 dynamic (report) + # change template: 2 static (attachment_ids) and 2 dynamic (reports) composer_form.template_id = template_1 - self.assertEqual(len(composer_form.attachment_ids), 3) + self.assertEqual(len(composer_form.attachment_ids), 4) report_attachments = [att for att in composer_form.attachment_ids if att not in template_1_attachments] - self.assertEqual(len(report_attachments), 1) - tpl_attachments = composer_form.attachment_ids[:] - report_attachments[0] + self.assertEqual(len(report_attachments), 2) + tpl_attachments = composer_form.attachment_ids[:] - self.env['ir.attachment'].concat(*report_attachments) self.assertEqual(tpl_attachments, template_1_attachments) # change template: 0 static (attachment_ids) and 1 dynamic (report) @@ -179,42 +229,156 @@ class TestComposerForm(TestMailComposer): self.assertEqual(len(composer_form.attachment_ids), 1) report_attachments = [att for att in composer_form.attachment_ids if att not in template_1_attachments] self.assertEqual(len(report_attachments), 1) - tpl_attachments = composer_form.attachment_ids[:] - report_attachments[0] + tpl_attachments = composer_form.attachment_ids[:] - self.env['ir.attachment'].concat(*report_attachments) self.assertFalse(tpl_attachments) # change back to template 1 composer_form.template_id = template_1 - self.assertEqual(len(composer_form.attachment_ids), 3) + self.assertEqual(len(composer_form.attachment_ids), 4) report_attachments = [att for att in composer_form.attachment_ids if att not in template_1_attachments] - self.assertEqual(len(report_attachments), 1) - tpl_attachments = composer_form.attachment_ids[:] - report_attachments[0] + self.assertEqual(len(report_attachments), 2) + tpl_attachments = composer_form.attachment_ids[:] - self.env['ir.attachment'].concat(*report_attachments) self.assertEqual(tpl_attachments, template_1_attachments) # reset template composer_form.template_id = self.env['mail.template'] self.assertEqual(len(composer_form.attachment_ids), 0) + @users('employee') + def test_mail_composer_comment_batch(self): + """ Batch mode of composer in comment mode. """ + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_records, add_web=True, default_composition_mode='comment')) + ) + self.assertTrue(composer_form.auto_delete, 'MailComposer: comment mode should remove notification emails by default') + self.assertFalse(composer_form.auto_delete_keep_log, 'MailComposer: keep_log makes no sense in comment mode, only auto_delete') + self.assertEqual(composer_form.author_id, self.env.user.partner_id) + self.assertFalse(composer_form.body) + self.assertTrue(composer_form.composition_batch) + self.assertEqual(composer_form.composition_mode, 'comment') + self.assertEqual(composer_form.email_from, self.env.user.email_formatted) + self.assertFalse(composer_form.email_layout_xmlid) + self.assertFalse(composer_form.force_send, 'MailComposer: batch mode uses queue') + self.assertFalse(composer_form.mail_server_id) + self.assertEqual(composer_form.model, self.test_record._name) + self.assertFalse(composer_form.notify_author) + self.assertFalse(composer_form.notify_author_mention) + self.assertFalse(composer_form.notify_skip_followers) + self.assertFalse(composer_form.partner_ids) + self.assertFalse(composer_form.record_alias_domain_id, 'MailComposer: batch mode does not precompute alias domain (MC, ...)') + self.assertFalse(composer_form.record_company_id, 'MailComposer: batch mode does not precompute company environment (MC, ...)') + self.assertFalse(composer_form.reply_to) + self.assertFalse(composer_form.reply_to_force_new, 'By default, replies land on same thread') + self.assertFalse(composer_form.scheduled_date) + self.assertEqual(literal_eval(composer_form.res_ids), self.test_records.ids) + self.assertFalse(composer_form.subject) + self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) + self.assertFalse(composer_form.subtype_is_log) + @users('employee') def test_mail_composer_comment_wtpl(self): composer_form = Form(self.env['mail.compose.message'].with_context( self._get_web_context(self.test_record, add_web=True, default_template_id=self.template.id) )) - # self.assertTrue(composer_form.auto_delete) - self.assertFalse(composer_form.auto_delete) # FIXME: currently not taking template value - self.assertFalse(composer_form.auto_delete_message) - self.assertEqual(composer_form.author_id, self.env.user.partner_id) - self.assertEqual(composer_form.body, '

TemplateBody %s

' % self.test_record.name) + self.assertTrue(composer_form.auto_delete, 'Should take template value') + self.assertFalse(composer_form.auto_delete_keep_log, 'MailComposer: keep_log makes no sense in comment mode, only auto_delete') + self.assertEqual(composer_form.author_id, self.user_employee_2.partner_id, + 'MailComposer: author is synchronized with email_from when possible') + self.assertEqual(composer_form.body, f'

TemplateBody {self.test_record.name}

') + self.assertFalse(composer_form.composition_batch) self.assertEqual(composer_form.composition_mode, 'comment') self.assertEqual(composer_form.email_from, self.user_employee_2.email_formatted) + self.assertEqual(composer_form.email_layout_xmlid, 'mail.test_layout') + self.assertTrue(composer_form.force_send, 'MailComposer: single record post send notifications right away') self.assertEqual(composer_form.mail_server_id, self.mail_server_domain) self.assertEqual(composer_form.model, self.test_record._name) + self.assertFalse(composer_form.notify_author) + self.assertFalse(composer_form.notify_author_mention) + self.assertFalse(composer_form.notify_skip_followers) self.assertEqual(composer_form.partner_ids[:], self.partner_1) - self.assertEqual(composer_form.record_name, self.test_record.name, 'MailComposer: comment mode should compute record name') + self.assertEqual(composer_form.record_alias_domain_id, self.mail_alias_domain) + self.assertEqual(composer_form.record_company_id, self.env.company) self.assertEqual(composer_form.reply_to, 'info@test.example.com') - self.assertFalse(composer_form.reply_to_force_new) - self.assertEqual(composer_form.res_id, self.test_record.id) - self.assertEqual(composer_form.subject, 'TemplateSubject %s' % self.test_record.name) + self.assertTrue(composer_form.reply_to_force_new, 'Reply-To on template forces its usage for new thread creation') + self.assertEqual(literal_eval(composer_form.res_ids), self.test_record.ids) + self.assertEqual(composer_form.scheduled_date, FieldDatetime.to_string(self.reference_now + timedelta(days=2))) + self.assertEqual(composer_form.subject, f'TemplateSubject {self.test_record.name}') self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) + self.assertFalse(composer_form.subtype_is_log) + + @users('employee') + def test_mail_composer_comment_wtpl_batch(self): + """ Batch mode of composer in comment mode. """ + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context( + self.test_records, + add_web=True, + default_composition_mode='comment', + default_template_id=self.template.id), + )) + self.assertTrue(composer_form.auto_delete, 'Should take composer value') + self.assertFalse(composer_form.auto_delete_keep_log, 'MailComposer: keep_log makes no sense in comment mode, only auto_delete') + self.assertEqual(composer_form.author_id, self.env.user.partner_id) + self.assertEqual(composer_form.body, self.template.body_html, + 'MailComposer: comment in batch mode should have template raw body if template') + self.assertTrue(composer_form.composition_batch) + self.assertEqual(composer_form.composition_mode, 'comment') + self.assertEqual(composer_form.email_from, self.template.email_from, + 'MailComposer: comment in batch mode should have template raw email_from if template') + self.assertEqual(composer_form.email_layout_xmlid, 'mail.test_layout') + self.assertFalse(composer_form.force_send, 'MailComposer: batch record post use email queue for notifications') + self.assertEqual(composer_form.mail_server_id, self.mail_server_domain) + self.assertEqual(composer_form.model, self.test_record._name) + self.assertFalse(composer_form.notify_author) + self.assertFalse(composer_form.notify_author_mention) + self.assertFalse(composer_form.notify_skip_followers) + self.assertFalse(composer_form.record_alias_domain_id, 'MailComposer: comment in batch mode should have void alias domain') + self.assertFalse(composer_form.record_company_id, 'MailComposer: comment in batch mode should have void company') + self.assertEqual(composer_form.reply_to, self.template.reply_to) + self.assertTrue(composer_form.reply_to_force_new, 'Reply-To on template forces its usage for new thread creation') + self.assertEqual(literal_eval(composer_form.res_ids), self.test_records.ids) + self.assertEqual(composer_form.scheduled_date, self.template.scheduled_date) + self.assertEqual(composer_form.subject, self.template.subject, + 'MailComposer: comment in batch mode should have template raw subject if template') + self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) + self.assertFalse(composer_form.subtype_is_log) + + @users('employee') + def test_mail_composer_comment_wtpl_domain(self): + """ Batch mode of composer in comment mode, using a domain. """ + composer_form = Form(self.env['mail.compose.message'].with_context( + default_composition_mode='comment', + default_model=self.test_records._name, + default_res_domain=[('id', 'in', self.test_records.ids)], + default_template_id=self.template.id, + )) + self.assertTrue(composer_form.auto_delete, 'Should take composer value') + self.assertFalse(composer_form.auto_delete_keep_log, 'MailComposer: keep_log makes no sense in comment mode, only auto_delete') + self.assertEqual(composer_form.author_id, self.env.user.partner_id) + self.assertEqual(composer_form.body, self.template.body_html, + 'MailComposer: comment in batch mode should have template raw body if template') + self.assertTrue(composer_form.composition_batch) + self.assertEqual(composer_form.composition_mode, 'comment') + self.assertEqual(composer_form.email_from, self.template.email_from, + 'MailComposer: comment in batch mode should have template raw email_from if template') + self.assertEqual(composer_form.email_layout_xmlid, 'mail.test_layout') + self.assertFalse(composer_form.force_send, 'MailComposer: batch record post use email queue for notifications') + self.assertEqual(composer_form.mail_server_id, self.mail_server_domain) + self.assertEqual(composer_form.model, self.test_record._name) + self.assertFalse(composer_form.notify_author) + self.assertFalse(composer_form.notify_author_mention) + self.assertFalse(composer_form.notify_skip_followers) + self.assertFalse(composer_form.record_alias_domain_id, 'MailComposer: comment in batch mode should have void alias domain') + self.assertFalse(composer_form.record_company_id, 'MailComposer: comment in batch mode should have void company') + self.assertEqual(composer_form.reply_to, self.template.reply_to) + self.assertTrue(composer_form.reply_to_force_new, 'Reply-To on template forces its usage for new thread creation') + self.assertFalse(composer_form.res_ids) + self.assertEqual(literal_eval(composer_form.res_domain), [('id', 'in', self.test_records.ids)]) + self.assertEqual(composer_form.scheduled_date, self.template.scheduled_date) + self.assertEqual(composer_form.subject, self.template.subject, + 'MailComposer: comment in batch mode should have template raw subject if template') + self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) + self.assertFalse(composer_form.subtype_is_log) @users('employee') def test_mail_composer_comment_wtpl_norecords(self): @@ -222,71 +386,137 @@ class TestComposerForm(TestMailComposer): when nothing is given as context. """ composer_form = Form(self.env['mail.compose.message'].with_context( default_composition_mode='comment', - default_model='mail.test.ticket', + default_model='mail.test.ticket.mc', default_template_id=self.template.id, )) - # self.assertTrue(composer_form.auto_delete) - self.assertFalse(composer_form.auto_delete) # FIXME: currently not taking template value - self.assertFalse(composer_form.auto_delete_message) + self.assertTrue(composer_form.auto_delete, 'Should take composer value') + self.assertFalse(composer_form.auto_delete_keep_log, 'MailComposer: keep_log makes no sense in comment mode, only auto_delete') self.assertEqual(composer_form.author_id, self.env.user.partner_id) self.assertEqual(composer_form.body, '

TemplateBody

') + self.assertFalse(composer_form.composition_batch) self.assertEqual(composer_form.composition_mode, 'comment') self.assertEqual(composer_form.email_from, self.env.user.partner_id.email_formatted) + self.assertEqual(composer_form.email_layout_xmlid, 'mail.test_layout') + self.assertTrue(composer_form.force_send) self.assertEqual(composer_form.mail_server_id, self.mail_server_domain) + self.assertFalse(composer_form.notify_author) + self.assertFalse(composer_form.notify_author_mention) + self.assertFalse(composer_form.notify_skip_followers) self.assertEqual(composer_form.model, self.test_record._name) self.assertFalse(composer_form.partner_ids[:]) - self.assertFalse(composer_form.record_name) + self.assertFalse(composer_form.record_alias_domain_id) + self.assertFalse(composer_form.record_company_id) self.assertEqual(composer_form.reply_to, 'info@test.example.com') - self.assertFalse(composer_form.reply_to_force_new) - self.assertFalse(composer_form.res_id) + self.assertTrue(composer_form.reply_to_force_new, 'Reply-To on template forces its usage for new thread creation') + self.assertFalse(composer_form.res_ids) + self.assertEqual(composer_form.scheduled_date, + '2022-12-28 18:00:00', + 'No record but rendered, see expression in template') self.assertEqual(composer_form.subject, 'TemplateSubject ') self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) + self.assertFalse(composer_form.subtype_is_log) @users('employee') def test_mail_composer_mass(self): + """ Test composer called in mass mailing mode. """ composer_form = Form(self.env['mail.compose.message'].with_context( self._get_web_context(self.test_records, add_web=True) )) self.assertFalse(composer_form.auto_delete) - self.assertFalse(composer_form.auto_delete_message) + self.assertFalse(composer_form.auto_delete_keep_log, 'MailComposer: if emails are kept, logs are automatically kept') self.assertEqual(composer_form.author_id, self.env.user.partner_id) self.assertFalse(composer_form.body) + self.assertTrue(composer_form.composition_batch) self.assertEqual(composer_form.composition_mode, 'mass_mail') self.assertEqual(composer_form.email_from, self.env.user.email_formatted) + self.assertFalse(composer_form.email_layout_xmlid) + self.assertTrue(composer_form.force_send, 'MailComposer: mass mode sends emails right away') self.assertFalse(composer_form.mail_server_id) + self.assertFalse(composer_form.notify_author) + self.assertFalse(composer_form.notify_author_mention) + self.assertFalse(composer_form.notify_skip_followers) self.assertEqual(composer_form.model, self.test_records._name) - self.assertFalse(composer_form.record_name, 'MailComposer: mass mode should have void record name') + self.assertFalse(composer_form.record_alias_domain_id, 'MailComposer: mass mode should have void alias domain') + self.assertFalse(composer_form.record_company_id, 'MailComposer: mass mode should have void company') self.assertFalse(composer_form.reply_to) self.assertFalse(composer_form.reply_to_force_new) - self.assertEqual(composer_form.res_id, self.test_records[0].id, - 'MailComposer: even in mass mode web active_id presence may add a res_id') + self.assertEqual(sorted(literal_eval(composer_form.res_ids)), sorted(self.test_records.ids)) + self.assertFalse(composer_form.scheduled_date) self.assertFalse(composer_form.subject, 'MailComposer: mass mode should have void default subject if no template') - self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) + self.assertFalse(composer_form.subtype_id, 'MailComposer: subtype is not used in mail mode') + self.assertFalse(composer_form.subtype_is_log, 'MailComposer: subtype is log has no meaning in mail mode') @users('employee') def test_mail_composer_mass_wtpl(self): + """ Test composer called in mass mailing mode with a template. It globally + takes the template value raw (aka not rendered). """ composer_form = Form(self.env['mail.compose.message'].with_context( self._get_web_context(self.test_records, add_web=True, default_template_id=self.template.id) )) - # self.assertTrue(composer_form.auto_delete) - self.assertFalse(composer_form.auto_delete) # FIXME: currently not taking template value - self.assertFalse(composer_form.auto_delete_message) + self.assertTrue(composer_form.auto_delete, 'Should take composer value') + self.assertTrue(composer_form.auto_delete_keep_log) self.assertEqual(composer_form.author_id, self.env.user.partner_id) self.assertEqual(composer_form.body, self.template.body_html, 'MailComposer: mass mode should have template raw body if template') + self.assertTrue(composer_form.composition_batch) self.assertEqual(composer_form.composition_mode, 'mass_mail') self.assertEqual(composer_form.email_from, self.template.email_from, 'MailComposer: mass mode should have template raw email_from if template') + self.assertEqual(composer_form.email_layout_xmlid, 'mail.test_layout') + self.assertTrue(composer_form.force_send, 'MailComposer: mass mode sends emails right away') self.assertEqual(composer_form.mail_server_id, self.mail_server_domain) self.assertEqual(composer_form.model, self.test_records._name) - self.assertFalse(composer_form.record_name, 'MailComposer: mass mode should have void record name') + self.assertFalse(composer_form.notify_author) + self.assertFalse(composer_form.notify_author_mention) + self.assertFalse(composer_form.notify_skip_followers) + self.assertFalse(composer_form.record_alias_domain_id, 'MailComposer: mass mode should have void alias domain') + self.assertFalse(composer_form.record_company_id, 'MailComposer: mass mode should have void company') self.assertEqual(composer_form.reply_to, self.template.reply_to) - self.assertFalse(composer_form.reply_to_force_new) - self.assertEqual(composer_form.res_id, self.test_records[0].id, - 'MailComposer: even in mass mode web active_id presence may add a res_id') + self.assertTrue(composer_form.reply_to_force_new, 'Reply-To on template forces its usage for new thread creation') + self.assertEqual(sorted(literal_eval(composer_form.res_ids)), sorted(self.test_records.ids)) + self.assertEqual(composer_form.scheduled_date, self.template.scheduled_date) self.assertEqual(composer_form.subject, self.template.subject, 'MailComposer: mass mode should have template raw subject if template') - self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) + self.assertFalse(composer_form.subtype_id, 'MailComposer: subtype is not used in mail mode') + self.assertFalse(composer_form.subtype_is_log, 'MailComposer: subtype is log has no meaning in mail mode') + + @users('employee') + def test_mail_composer_mass_wtpl_domain(self): + """ Same as test_mail_composer_mass_wtpl, but using a domain instead + of res_ids, to check support of domain. """ + composer_form = Form(self.env['mail.compose.message'].with_context( + default_composition_mode='mass_mail', + default_model=self.test_records._name, + default_res_domain=[('id', 'in', self.test_records.ids)], + default_template_id=self.template.id, + )) + self.assertTrue(composer_form.auto_delete, 'Should take composer value') + self.assertTrue(composer_form.auto_delete_keep_log) + self.assertEqual(composer_form.author_id, self.env.user.partner_id) + self.assertEqual(composer_form.body, self.template.body_html, + 'MailComposer: mass mode should have template raw body if template') + self.assertTrue(composer_form.composition_batch) + self.assertEqual(composer_form.composition_mode, 'mass_mail') + self.assertEqual(composer_form.email_from, self.template.email_from, + 'MailComposer: mass mode should have template raw email_from if template') + self.assertEqual(composer_form.email_layout_xmlid, 'mail.test_layout') + self.assertFalse(composer_form.force_send, 'MailComposer: mass mode with domain uses email queue') + self.assertEqual(composer_form.mail_server_id, self.mail_server_domain) + self.assertEqual(composer_form.model, self.test_records._name) + self.assertFalse(composer_form.notify_author) + self.assertFalse(composer_form.notify_author_mention) + self.assertFalse(composer_form.notify_skip_followers) + self.assertFalse(composer_form.record_alias_domain_id, 'MailComposer: mass mode should have void alias domain') + self.assertFalse(composer_form.record_company_id, 'MailComposer: mass mode should have void company') + self.assertEqual(composer_form.reply_to, self.template.reply_to) + self.assertTrue(composer_form.reply_to_force_new, 'Reply-To on template forces its usage for new thread creation') + self.assertFalse(composer_form.res_ids) + self.assertEqual(literal_eval(composer_form.res_domain), [('id', 'in', self.test_records.ids)]) + self.assertEqual(composer_form.scheduled_date, self.template.scheduled_date) + self.assertEqual(composer_form.subject, self.template.subject, + 'MailComposer: mass mode should have template raw subject if template') + self.assertFalse(composer_form.subtype_id, 'MailComposer: subtype is not used in mail mode') + self.assertFalse(composer_form.subtype_is_log, 'MailComposer: subtype is log has no meaning in mail mode') @users('employee') def test_mail_composer_mass_wtpl_norecords(self): @@ -294,27 +524,35 @@ class TestComposerForm(TestMailComposer): when nothing is given as context. """ composer_form = Form(self.env['mail.compose.message'].with_context( default_composition_mode='mass_mail', - default_model='mail.test.ticket', + default_model='mail.test.ticket.mc', default_template_id=self.template.id, )) - # self.assertTrue(composer_form.auto_delete) - self.assertFalse(composer_form.auto_delete) # FIXME: currently not taking template value - self.assertFalse(composer_form.auto_delete_message) + self.assertTrue(composer_form.auto_delete, 'Should take composer value') + self.assertTrue(composer_form.auto_delete_keep_log) self.assertEqual(composer_form.author_id, self.env.user.partner_id) self.assertEqual(composer_form.body, self.template.body_html, 'MailComposer: mass mode should have template raw body if template') + self.assertFalse(composer_form.composition_batch) self.assertEqual(composer_form.composition_mode, 'mass_mail') self.assertEqual(composer_form.email_from, self.template.email_from, 'MailComposer: mass mode should have template raw email_from if template') + self.assertEqual(composer_form.email_layout_xmlid, 'mail.test_layout') + self.assertTrue(composer_form.force_send, 'MailComposer: mass mode sends emails right away') self.assertEqual(composer_form.mail_server_id, self.mail_server_domain) self.assertEqual(composer_form.model, self.test_records._name) - self.assertFalse(composer_form.record_name, 'MailComposer: mass mode should have void record name') + self.assertFalse(composer_form.notify_author) + self.assertFalse(composer_form.notify_author_mention) + self.assertFalse(composer_form.notify_skip_followers) + self.assertFalse(composer_form.record_alias_domain_id, 'MailComposer: mass mode should have void alias domain') + self.assertFalse(composer_form.record_company_id, 'MailComposer: mass mode should have void company') self.assertEqual(composer_form.reply_to, self.template.reply_to) - self.assertFalse(composer_form.reply_to_force_new) - self.assertEqual(composer_form.res_id, 0) + self.assertTrue(composer_form.reply_to_force_new, 'Reply-To on template forces its usage for new thread creation') + self.assertFalse(composer_form.res_ids) + self.assertEqual(composer_form.scheduled_date, self.template.scheduled_date) self.assertEqual(composer_form.subject, self.template.subject, 'MailComposer: mass mode should have template raw subject if template') - self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) + self.assertFalse(composer_form.subtype_id, 'MailComposer: subtype is not used in mail mode') + self.assertFalse(composer_form.subtype_is_log, 'MailComposer: subtype is log has no meaning in mail mode') @users('employee') def test_mail_composer_template_switching(self): @@ -358,42 +596,53 @@ class TestComposerInternals(TestMailComposer): attachment_data = self._generate_attachments_data(3, self.template._name, self.template.id) self.template.write({ 'attachment_ids': [(0, 0, a) for a in attachment_data], - 'report_name': 'TestReport for {{ object.name }}.html', # test cursor forces html - 'report_template': self.test_report.id, + 'report_template_ids': [(6, 0, (self.test_report + self.test_report_2).ids)], }) template_void = self.template.copy(default={ 'attachment_ids': False, - 'report_name': False, - 'report_template': False, + 'report_template_ids': False, }) - attachs = self.env['ir.attachment'].search([('name', 'in', [a['name'] for a in attachment_data])]) + attachs = self.env['ir.attachment'].sudo().search([('name', 'in', [a['name'] for a in attachment_data])]) self.assertEqual(len(attachs), 3) + extra_attach = self.env['ir.attachment'].create({ + 'datas': base64.b64encode(b'ExtraData'), + 'mimetype': 'text/plain', + 'name': 'ExtraAttFileName.txt', + 'res_model': False, + 'res_id': False, + }) - for composition_mode, batch in (('comment', False), ('mass_mail', True)): - with self.subTest(composition_mode=composition_mode, batch=batch): + for composition_mode, batch_mode in product(('comment', 'mass_mail'), + (False, True, 'domain')): + with self.subTest(composition_mode=composition_mode, batch_mode=batch_mode): + batch = bool(batch_mode) test_records = self.test_records if batch else self.test_record - ctx = self._get_web_context( - test_records, add_web=False, - default_composition_mode=composition_mode, - default_template_id=self.template.id - ) + ctx = { + 'default_model': test_records._name, + 'default_composition_mode': composition_mode, + 'default_template_id': self.template.id, + } + if batch_mode == 'domain': + ctx['default_res_domain'] = [('id', 'in', test_records.ids)] + else: + ctx['default_res_ids'] = test_records.ids composer = self.env['mail.compose.message'].with_context(ctx).create({ 'body': '

Test Body

', }) - # currently onchange necessary - composer._onchange_template_id_wrapper() # values coming from template: attachment_ids + report in comment - if composition_mode == 'comment': - self.assertEqual(len(composer.attachment_ids), 4) + if composition_mode == 'comment' and not batch: + self.assertEqual(len(composer.attachment_ids), 5) for attach in attachs: self.assertIn(attach, composer.attachment_ids) generated = composer.attachment_ids - attachs - self.assertEqual(len(generated), 1, 'MailComposer: should have 1 additional attachment for report') - self.assertEqual(generated.name, f'TestReport for {self.test_record.name}.html') - self.assertEqual(generated.res_model, 'mail.compose.message') - self.assertEqual(generated.res_id, 0) + self.assertEqual(len(generated), 2, 'MailComposer: should have 2 additional attachments for reports') + self.assertEqual( + sorted(generated.mapped('name')), + sorted([f'TestReport for {self.test_record.name}.html', f'TestReport2 for {self.test_record.name}.html'])) + self.assertEqual(generated.mapped('res_model'), ['mail.compose.message'] * 2) + self.assertEqual(generated.mapped('res_id'), [0] * 2) # values coming from template: attachment_ids only (report is dynamic) else: self.assertEqual( @@ -401,29 +650,31 @@ class TestComposerInternals(TestMailComposer): sorted(attachs.ids) ) - # update with template with void values: values are kept + # manual update + composer.write({ + 'attachment_ids': [(4, extra_attach.id)], + }) + if composition_mode == 'comment' and not batch: + self.assertEqual(composer.attachment_ids, attachs + extra_attach + generated) + else: + self.assertEqual(composer.attachment_ids, attachs + extra_attach) + + # update with template with void values: values are kept, void + # value is not forced in rendering mode as well as when copying + # template values composer.write({'template_id': template_void.id}) - # currently onchange necessary - composer._onchange_template_id_wrapper() - if composition_mode == 'comment': - self.assertEqual(composer.attachment_ids, attachs + generated, - 'TODO: Values are kept (should be reset ?)') + if composition_mode == 'comment' and not batch: + self.assertEqual(composer.attachment_ids, attachs + extra_attach + generated) else: - self.assertEqual(composer.attachment_ids, attachs, - 'TODO: Values are kept (should be reset ?)') + self.assertEqual(composer.attachment_ids, attachs + extra_attach) - # reset template: values are kept + # reset template: values are reset composer.write({'template_id': False}) - # currently onchange necessary - composer._onchange_template_id_wrapper() - - if composition_mode == 'comment': - self.assertEqual(composer.attachment_ids, attachs + generated, - 'TODO: Values are kept (should be reset ?)') + if composition_mode == 'comment' and not batch: + self.assertFalse(composer.attachment_ids) else: - self.assertEqual(composer.attachment_ids, attachs, - 'TODO: Values are kept (should be reset ?)') + self.assertFalse(composer.attachment_ids) @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') @@ -434,13 +685,19 @@ class TestComposerInternals(TestMailComposer): 'email_from': False, }) - for composition_mode, batch in (('comment', False), ('mass_mail', True)): - with self.subTest(composition_mode=composition_mode, batch=batch): + for composition_mode, batch_mode in product(('comment', 'mass_mail'), + (False, True, 'domain')): + with self.subTest(composition_mode=composition_mode, batch_mode=batch_mode): + batch = bool(batch_mode) test_records = self.test_records if batch else self.test_record - ctx = self._get_web_context( - test_records, add_web=False, - default_composition_mode=composition_mode - ) + ctx = { + 'default_model': test_records._name, + 'default_composition_mode': composition_mode, + } + if batch_mode == 'domain': + ctx['default_res_domain'] = [('id', 'in', test_records.ids)] + else: + ctx['default_res_ids'] = test_records.ids composer = self.env['mail.compose.message'].with_context(ctx).create({ 'body': '

Test Body

', @@ -448,53 +705,67 @@ class TestComposerInternals(TestMailComposer): # default values are current user self.assertEqual(composer.author_id, self.env.user.partner_id) + self.assertEqual(composer.composition_mode, composition_mode) self.assertEqual(composer.email_from, self.env.user.email_formatted) - # author values reset email (FIXME: currently not synchronized) + # author update should reset email (FIXME: currently not synchronized) composer.write({'author_id': self.partner_1}) self.assertEqual(composer.author_id, self.partner_1) - self.assertEqual(composer.email_from, self.env.user.email_formatted) + self.assertEqual(composer.email_from, self.env.user.email_formatted, + 'MailComposer: TODO: author / email_from are not synchronized') # self.assertEqual(composer.email_from, self.partner_1.email_formatted) # changing template should update its email_from - composer.write({'template_id': self.template.id, 'author_id': self.env.user.partner_id}) - # currently onchange necessary - composer._onchange_template_id_wrapper() - self.assertEqual(composer.author_id, self.env.user.partner_id, - 'MailComposer: should take value given by user') - if composition_mode == 'comment': + composer.write({'template_id': self.template.id}) + + if composition_mode == 'comment' and not batch: + self.assertEqual(composer.author_id, self.test_record.user_id.partner_id, + f'MailComposer: should try to link in rendered mode: {composer.author_id.name}, expected {self.env.user.name}') self.assertEqual(composer.email_from, self.test_record.user_id.email_formatted, 'MailComposer: should take email_from rendered from template') else: + self.assertEqual(composer.author_id, self.env.user.partner_id, + f'MailComposer: should reset to current user in raw mode: {composer.author_id.name}, expected {self.env.user.name}') self.assertEqual(composer.email_from, self.template.email_from, 'MailComposer: should take email_from raw from template') - # manual values are kept over template values + # manual values are kept over template values; if email does not + # match any author, reset author composer.write({'email_from': self.test_from}) - self.assertEqual(composer.author_id, self.env.user.partner_id) - self.assertEqual(composer.email_from, self.test_from) + if composition_mode == 'comment' and not batch: + self.assertEqual(composer.author_id, self.test_record.user_id.partner_id, + 'MailComposer: TODO: compute not called') + self.assertEqual(composer.email_from, self.test_from, + 'MailComposer: manual values should be kept') + else: + self.assertEqual(composer.author_id, self.env.user.partner_id, + 'MailComposer: TODO: compute not called') + self.assertEqual(composer.email_from, self.test_from, + 'MailComposer: manual values should be kept') # Update with template with void email_from field, should result in reseting email_from to a default value # rendering mode as well as when copying template values composer.write({'template_id': template_void.id}) - # currently onchange necessary - composer._onchange_template_id_wrapper() - if composition_mode == 'comment': - self.assertEqual(composer.author_id, self.env.user.partner_id) + + if composition_mode == 'comment' and not batch: + self.assertEqual(composer.author_id, self.env.user.partner_id, + 'MailComposer: TODO: author / email_from are not synchronized') self.assertEqual(composer.email_from, self.env.user.email_formatted) else: - self.assertEqual(composer.author_id, self.env.user.partner_id) + self.assertEqual(composer.author_id, self.env.user.partner_id, + 'MailComposer: TODO: author / email_from are not synchronized') self.assertEqual(composer.email_from, self.env.user.email_formatted) # reset template: values are reset due to call to default_get composer.write({'template_id': False}) - # currently onchange necessary - composer._onchange_template_id_wrapper() - if composition_mode == 'comment': - self.assertEqual(composer.author_id, self.env.user.partner_id) + + if composition_mode == 'comment' and not batch: + self.assertEqual(composer.author_id, self.env.user.partner_id, + 'MailComposer: TODO: author / email_from are not synchronized') self.assertEqual(composer.email_from, self.env.user.email_formatted) else: - self.assertEqual(composer.author_id, self.env.user.partner_id) + self.assertEqual(composer.author_id, self.env.user.partner_id, + 'MailComposer: TODO: author / email_from are not synchronized') self.assertEqual(composer.email_from, self.env.user.email_formatted) @users('employee') @@ -506,13 +777,19 @@ class TestComposerInternals(TestMailComposer): 'auto_delete': False, }) - for composition_mode, batch in (('comment', False), ('mass_mail', True)): - with self.subTest(composition_mode=composition_mode, batch=batch): + for composition_mode, batch_mode in product(('comment', 'mass_mail'), + (False, True, 'domain')): + with self.subTest(composition_mode=composition_mode, batch_mode=batch_mode): + batch = bool(batch_mode) test_records = self.test_records if batch else self.test_record - ctx = self._get_web_context( - test_records, add_web=False, - default_composition_mode=composition_mode - ) + ctx = { + 'default_model': test_records._name, + 'default_composition_mode': composition_mode, + } + if batch_mode == 'domain': + ctx['default_res_domain'] = [('id', 'in', test_records.ids)] + else: + ctx['default_res_ids'] = test_records.ids # 1. check without template (default values) + template update composer = self.env['mail.compose.message'].with_context(ctx).create({ @@ -520,33 +797,37 @@ class TestComposerInternals(TestMailComposer): }) # default creation values - self.assertFalse(composer.auto_delete) - self.assertFalse(composer.auto_delete_message) - self.assertTrue(composer.email_add_signature) + if composition_mode == 'comment': + self.assertTrue(composer.auto_delete, 'By default, remove notification emails') + self.assertFalse(composer.auto_delete_keep_log, 'Not used in comment mode') + self.assertTrue(composer.email_add_signature, 'Default value in comment mode') + self.assertEqual(composer.subtype_id, self.env.ref('mail.mt_comment')) + else: + self.assertFalse(composer.auto_delete, 'By default, keep mailing emails') + self.assertFalse(composer.auto_delete_keep_log, 'Emails are not unlinked, logs are already kept') + self.assertFalse(composer.email_add_signature, 'Not supported in mass mailing mode') + self.assertFalse(composer.subtype_id) self.assertEqual(composer.email_layout_xmlid, 'mail.test_layout') self.assertEqual(composer.message_type, 'comment') - self.assertEqual(composer.subtype_id, self.env.ref('mail.mt_comment')) # changing template should update its content composer.write({'template_id': self.template.id}) - # currently onchange necessary - composer._onchange_template_id_wrapper() # values come from template if composition_mode == 'comment': - self.assertFalse(composer.auto_delete, 'TODO: should be updated with template') - self.assertFalse(composer.auto_delete_message) - self.assertTrue(composer.email_add_signature, 'TODO: should be False as template negates this config') + self.assertTrue(composer.auto_delete) + self.assertFalse(composer.auto_delete_keep_log, 'Not used in comment mode') + self.assertFalse(composer.email_add_signature, 'Template is considered as complete') self.assertEqual(composer.email_layout_xmlid, 'mail.test_layout') self.assertEqual(composer.message_type, 'comment') self.assertEqual(composer.subtype_id, self.env.ref('mail.mt_comment')) else: - self.assertFalse(composer.auto_delete, 'TODO: should be updated with template') - self.assertFalse(composer.auto_delete_message) - self.assertTrue(composer.email_add_signature, 'TODO: should be False as template negates this config') + self.assertTrue(composer.auto_delete) + self.assertTrue(composer.auto_delete_keep_log) + self.assertFalse(composer.email_add_signature, 'Template is considered as complete') self.assertEqual(composer.email_layout_xmlid, 'mail.test_layout') self.assertEqual(composer.message_type, 'comment') - self.assertEqual(composer.subtype_id, self.env.ref('mail.mt_comment')) + self.assertFalse(composer.subtype_id) # manual update composer.write({ @@ -555,157 +836,141 @@ class TestComposerInternals(TestMailComposer): }) self.assertEqual(composer.message_type, 'notification') self.assertEqual(composer.subtype_id, self.env.ref('mail.mt_note')) + self.assertTrue(composer.subtype_is_log) - # force some composer values to see changes (due to previous bugs) - composer.write({ - 'auto_delete': True, - }) # update with template with void values: void value is forced for # booleans, cannot distinguish composer.write({'template_id': template_falsy.id}) - # currently onchange necessary - composer._onchange_template_id_wrapper() if composition_mode == 'comment': - # self.assertFalse(composer.auto_delete, 'TODO: should be updated') - self.assertTrue(composer.auto_delete) + self.assertFalse(composer.auto_delete) self.assertEqual(composer.message_type, 'notification') self.assertEqual(composer.subtype_id, self.env.ref('mail.mt_note')) + self.assertTrue(composer.subtype_is_log) else: - # self.assertFalse(composer.auto_delete, 'TODO: should be updated') - self.assertTrue(composer.auto_delete) + self.assertFalse(composer.auto_delete) self.assertEqual(composer.message_type, 'notification') self.assertEqual(composer.subtype_id, self.env.ref('mail.mt_note')) + self.assertTrue(composer.subtype_is_log) @users('employee') def test_mail_composer_content(self): - """ Test content management (body, mail_server_id, record_name, subject) - in both comment and mass mailing mode. Template update is also tested. """ + """ Test content management (body, mail_server_id, scheduled_date, + subject) in both comment and mass mailing mode. Template update is also + tested. + + TDE TODO: add test for record_alias_domain_id and record_company_id """ template_void = self.template.copy(default={ 'body_html': False, 'mail_server_id': False, + 'scheduled_date': False, 'subject': False, }) - for composition_mode, batch in (('comment', False), ('mass_mail', True)): - with self.subTest(composition_mode=composition_mode, batch=batch): + for composition_mode, batch_mode in product(('comment', 'mass_mail'), + (False, True, 'domain')): + with self.subTest(composition_mode=composition_mode, batch_mode=batch_mode): + batch = bool(batch_mode) test_records = self.test_records if batch else self.test_record - ctx = self._get_web_context( - test_records, add_web=False, - default_composition_mode=composition_mode - ) + ctx = { + 'default_model': test_records._name, + 'default_composition_mode': composition_mode, + } + if batch_mode == 'domain': + ctx['default_res_domain'] = [('id', 'in', test_records.ids)] + else: + ctx['default_res_ids'] = test_records.ids # 1. check without template + template update composer = self.env['mail.compose.message'].with_context(ctx).create({ - 'body': '

Test Body

', + 'mail_server_id': self.mail_server_default.id, + 'scheduled_date': '{{ datetime.datetime(2023, 1, 10, 10, 0, 0) }}', 'subject': 'My amazing subject for {{ record.name }}', }) # creation values are taken - self.assertEqual(composer.body, '

Test Body

') + self.assertEqual(composer.mail_server_id, self.mail_server_default) + self.assertEqual(composer.scheduled_date, '{{ datetime.datetime(2023, 1, 10, 10, 0, 0) }}') self.assertEqual(composer.subject, 'My amazing subject for {{ record.name }}') - if composition_mode == 'comment': - self.assertEqual(composer.record_name, self.test_record.name) - else: - self.assertFalse(composer.record_name) # changing template should update its content composer.write({'template_id': self.template.id}) - # currently onchange necessary - composer._onchange_template_id_wrapper() # values come from template - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertEqual(composer.body, f'

TemplateBody {self.test_record.name}

') self.assertEqual(composer.mail_server_id, self.template.mail_server_id) - self.assertEqual(composer.record_name, self.test_record.name) + self.assertEqual(FieldDatetime.from_string(composer.scheduled_date), + self.reference_now + timedelta(days=2)) self.assertEqual(composer.subject, f'TemplateSubject {self.test_record.name}') else: self.assertEqual(composer.body, self.template.body_html) self.assertEqual(composer.mail_server_id, self.template.mail_server_id) - self.assertFalse(composer.record_name) + self.assertEqual(composer.scheduled_date, self.template.scheduled_date) self.assertEqual(composer.subject, self.template.subject) # manual values is kept over template composer.write({ - 'body': '

Back to my amazing body

', + 'mail_server_id': self.mail_server_default.id, + 'scheduled_date': '{{ datetime.datetime(2023, 1, 10, 10, 0, 0) }}', 'subject': 'Back to my amazing subject for {{ record.name }}', }) - self.assertEqual(composer.body, '

Back to my amazing body

') + self.assertEqual(composer.mail_server_id, self.mail_server_default) + self.assertEqual(composer.scheduled_date, '{{ datetime.datetime(2023, 1, 10, 10, 0, 0) }}') self.assertEqual(composer.subject, 'Back to my amazing subject for {{ record.name }}') # update with template with void values: void value is not forced in - # rendering mode as well as when copying template values, except for - # body in rendering mode + # rendering mode as well as in raw mode composer.write({'template_id': template_void.id}) - # currently onchange necessary - composer._onchange_template_id_wrapper() - if composition_mode == 'comment': - self.assertFalse(composer.body, 'Void template body resets while other fields not, maybe to fix') - self.assertEqual(composer.mail_server_id, self.mail_server_global) - self.assertEqual(composer.record_name, 'Manual update') + if composition_mode == 'comment' and not batch: + self.assertEqual(composer.body, '

Back to my amazing body

') + self.assertEqual(composer.mail_server_id, self.mail_server_default) + self.assertEqual(composer.scheduled_date, '{{ datetime.datetime(2023, 1, 10, 10, 0, 0) }}') self.assertEqual(composer.subject, 'Back to my amazing subject for {{ record.name }}') else: - self.assertEqual(composer.body, '

Back to my amazing body

') + self.assertEqual(composer.mail_server_id, self.mail_server_default) + self.assertEqual(composer.scheduled_date, '{{ datetime.datetime(2023, 1, 10, 10, 0, 0) }}') self.assertEqual(composer.subject, 'Back to my amazing subject for {{ record.name }}') # reset template should reset values - composer.write({'body': '

Back to my amazing body Test Body

') self.assertEqual(composer.parent_id, parent) self.assertEqual(composer.partner_ids, self.partner_1 + self.partner_2) - self.assertEqual(composer.record_name, self.test_record.name) - self.assertEqual(composer.subject, 'Re: %s' % self.test_record.name) + self.assertEqual(composer.subject, parent_subject) @users('user_rendering_restricted') @mute_logger('odoo.tests', 'odoo.addons.base.models.ir_rule', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') def test_mail_composer_rights_attachments(self): """ Ensure a user without write access to a template can send an email""" template_1 = self.template.copy({ - 'report_name': 'TestReport for {{ object.name }} (thanks TDE).html', # test cursor forces html - 'report_template': self.test_report.id, + 'report_template_ids': [(6, 0, self.test_report.ids)], }) attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id) template_1.write({ @@ -948,16 +1209,14 @@ class TestComposerInternals(TestMailComposer): template_1_attachments = template_1.attachment_ids self.assertEqual(len(template_1_attachments), 2) - template_1_attachment_name = list(template_1_attachments.mapped('name')) + ["TestReport for TestRecord (thanks TDE).html"] + template_1_attachment_name = list(template_1_attachments.mapped('name')) + [f"TestReport for {self.test_record.name}.html"] composer = self.env['mail.compose.message'].with_context( self._get_web_context(self.test_record) ).create({ 'body': '

Template Body

', - 'partner_ids': [self.partner_employee_2.id], 'template_id': template_1.id, }) - composer._onchange_template_id_wrapper() composer._action_send_mail() self.assertEqual( @@ -974,49 +1233,196 @@ class TestComposerInternals(TestMailComposer): self.test_record.message_subscribe(partner_ids=portal_user.partner_id.ids) # patch check access rights for write access, required to post a message by default - with patch.object(MailTestTicket, 'check_access_rights', return_value=True): - self.env['mail.compose.message'].with_user(portal_user).with_context( - self._get_web_context(self.test_record) - ).create({ - 'subject': 'Subject', - 'body': '

Body text

', - 'partner_ids': [] - })._action_send_mail() - - self.assertEqual(self.test_record.message_ids[0].body, '

Body text

') - self.assertEqual(self.test_record.message_ids[0].author_id, portal_user.partner_id) - - self.env['mail.compose.message'].with_user(portal_user).with_context({ - 'default_composition_mode': 'comment', - 'default_parent_id': self.test_record.message_ids.ids[0], - }).create({ - 'subject': 'Subject', - 'body': '

Body text 2

' - })._action_send_mail() - - self.assertEqual(self.test_record.message_ids[0].body, '

Body text 2

') - self.assertEqual(self.test_record.message_ids[0].author_id, portal_user.partner_id) + with patch.object(MailTestTicket, '_check_access', return_value=None): + with self.assertRaises(AccessError): + # ensure portal can not send messages + self.env['mail.compose.message'].with_user(portal_user).with_context( + self._get_web_context(self.test_record) + ).create({ + 'subject': 'Subject', + 'body': '

Body text

', + 'partner_ids': [] + })._action_send_mail() @users('employee') def test_mail_composer_save_template(self): self.env['mail.compose.message'].with_context( self._get_web_context(self.test_record, add_web=False) ).create({ + 'template_name': 'My Template', 'subject': 'Template Subject', 'body': '

Template Body

', - }).action_save_as_template() + }).create_mail_template() # Test: email_template subject, body_html, model template = self.env['mail.template'].search([ ('model', '=', self.test_record._name), - ('subject', '=', 'Template Subject') + ('name', '=', 'My Template') ], limit=1) - self.assertEqual(template.name, "%s: %s" % (self.env['ir.model']._get(self.test_record._name).name, 'Template Subject')) + + self.assertEqual(template.name, 'My Template') + self.assertFalse(template.subject) self.assertEqual(template.body_html, '

Template Body

', 'email_template incorrect body_html') + @users('employee') + def test_mail_composer_schedule_message(self): + """ Test scheduling of a message from the composer""" -@tagged('mail_composer', 'multi_lang') -class TestComposerResultsComment(TestMailComposer): + # cannot schedule a message in mass_mail mode + composer = self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_record) + ).create({'body': 'Test', 'composition_mode': 'mass_mail', 'scheduled_date': FieldDatetime.to_string(self.reference_now + timedelta(hours=2))}) + with self.assertRaises(UserError): + composer.action_schedule_message() + + # schedule a message in mono-comment mode + attachment_data = self._generate_attachments_data(1, 'mail.compose.message', 0)[0] + composer = self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_record, False, additional_ctx_key=True) + ).create({ + 'body': '

Test Body

', + 'subject': 'Test Subject', + 'attachment_ids': [(0, 0, attachment_data)], + 'partner_ids': [(4, self.test_record.customer_id.id)], + }) + # cannot schedule a message without a scheduled date + with self.assertRaises(UserError): + composer.action_schedule_message() + + composer.write({'scheduled_date': FieldDatetime.to_string(self.reference_now + timedelta(hours=2))}) + composer_attachment = composer.attachment_ids + with self.mock_datetime_and_now(self.reference_now): + composer.action_schedule_message() + # should have created a scheduled message with correct parameters + scheduled_message = self.env['mail.scheduled.message'].search([ + ['model', '=', self.test_record._name], + ['res_id', '=', self.test_record.id], + ]) + self.assertEqual(scheduled_message.body, '

Test Body

') + self.assertEqual(scheduled_message.subject, 'Test Subject') + self.assertEqual(scheduled_message.scheduled_date, self.reference_now + timedelta(hours=2)) + self.assertEqual(scheduled_message.attachment_ids, composer_attachment) + self.assertEqual(scheduled_message.model, self.test_record._name) + self.assertEqual(scheduled_message.res_id, self.test_record.id) + self.assertEqual(scheduled_message.author_id, self.partner_employee) + self.assertEqual(scheduled_message.partner_ids, self.test_record.customer_id) + self.assertFalse(scheduled_message.is_note) + self.assertEqual(scheduled_message.send_context, {'additional_ctx_key': True}) + # attachment transfer + self.assertEqual(composer_attachment.res_model, scheduled_message._name) + self.assertEqual(composer_attachment.res_id, scheduled_message.id) + + @users('erp_manager') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_mail_composer_wtpl_populate_new_recipients_mc(self): + """ Test auto-populate of auto created partner with related record + values when sending a mail with a template, in multi-company environment. + """ + companies = self.company_admin + self.company_2 + test_records = self.env['mail.test.ticket.mc'].create([ + { + 'email_from': f'newpartner{idx}@example.com', + 'company_id': companies[idx].id, + 'customer_id': False, + 'phone_number': f'+3319900{idx:02d}{idx:02d}', + 'name': f'TestRecord{idx}', + 'user_id': False, + } for idx in range(2) + ]) + manual_recipients = test_records.mapped('email_from') + template = self.env['mail.template'].create({ + 'email_to': '{{ object.email_from }}', + 'model_id': self.env['ir.model']._get_id(test_records._name), + 'partner_to': False, + }) + + for composition_mode, batch_mode in product( + ('comment', 'mass_mail'), + (True, False) + ): + with self.subTest(composition_mode=composition_mode, batch_mode=batch_mode): + test_records = test_records if batch_mode else test_records[0] + + self.assertFalse( + self.env['res.partner'].search( + [('email_normalized', 'in', manual_recipients)] + ) + ) + ctx = { + 'default_composition_mode': composition_mode, + 'default_model': test_records._name, + 'default_res_ids': test_records.ids, + } + composer = self.env['mail.compose.message'].with_context(ctx).create({ + 'template_id': template.id, + }) + + with self.mock_mail_gateway(): + composer._action_send_mail() + + new_partners = self.env['res.partner'].search([('email_normalized', 'in', manual_recipients)], + order='email') + try: + self.assertEqual( + len(new_partners), len(test_records) + ) + self.assertEqual( + new_partners.mapped('company_id'), + test_records.mapped('company_id') + ) + self.assertEqual( + new_partners.mapped('phone'), + test_records.mapped('phone_number') + ) + finally: + new_partners.unlink() + + @users('employee') + def test_mail_composer_wtpl_schedule_message(self): + """ Test scheduling message using a template. Should use the scheduled date of the + template if not date is passed.""" + composer = self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_record) + ).create({'template_id': self.template.id}) + + for additional_params, exp_notif_params in zip( + [ + {}, + {'notify_author': False, 'notify_author_mention': True}, + {'notify_skip_followers': True}, + ], [ + {}, + {'notify_author_mention': True}, + {'notify_skip_followers': True}, + ], strict=True + ): + with self.subTest(params=additional_params): + if additional_params: + composer.write(additional_params) + # scheduling the message + with self.mock_datetime_and_now(self.reference_now): + scheduled_message = composer._action_schedule_message() + + self.assertEqual(scheduled_message.body, '

TemplateBody TestRecord

') + self.assertEqual(scheduled_message.subject, 'TemplateSubject TestRecord') + self.assertEqual(scheduled_message.scheduled_date, self.test_record.create_date + timedelta(days=2)) + self.assertEqual(scheduled_message.model, self.test_record._name) + self.assertEqual(scheduled_message.res_id, self.test_record.id) + self.assertEqual(scheduled_message.author_id, self.test_record.user_id.partner_id) + self.assertEqual(scheduled_message.partner_ids, self.test_record.customer_id) + self.assertFalse(scheduled_message.is_note) + notification_parameters = json.loads(scheduled_message.notification_parameters) + self.assertEqual(notification_parameters['email_from'], self.test_record.user_id.email_formatted) + self.assertEqual(notification_parameters['force_email_lang'], self.test_record.customer_id.lang) + self.assertEqual(notification_parameters['mail_server_id'], self.mail_server_domain.id) + self.assertEqual(notification_parameters['mail_auto_delete'], True) + self.assertEqual(notification_parameters['message_type'], 'comment') + for key, val in exp_notif_params.items(): + self.assertEqual(notification_parameters[key], val) + + +@tagged('mail_composer', 'multi_lang', 'multi_company') +class TestComposerResultsComment(TestMailComposer, CronMixinCase): """ Test global output of composer used in comment mode. Test notably notification and emails generated during this process. """ @@ -1024,17 +1430,62 @@ class TestComposerResultsComment(TestMailComposer): """ Ensure class initial data to ease understanding """ self.assertTrue(self.template.auto_delete) + self.assertEqual(len(self.test_record), 1) + self.assertEqual(self.test_record.user_id, self.user_employee_2) + self.assertEqual(self.test_record.message_partner_ids, self.partner_employee_2) + self.assertEqual(self.test_record[0].customer_id.lang, 'en_US') + self.assertEqual(self.test_record.company_id, self.company_admin) + self.assertEqual(len(self.test_records), 2) self.assertEqual(self.test_records.user_id, self.user_employee_2) self.assertEqual(self.test_records.message_partner_ids, self.partner_employee_2) self.assertEqual(self.test_records[0].customer_id.lang, 'en_US') self.assertEqual(self.test_records[1].customer_id.lang, 'en_US') + self.assertEqual(self.test_records.company_id, self.company_admin) self.assertEqual(len(self.test_partners), 2) self.assertEqual(self.user_employee.lang, 'en_US') self.assertEqual(self.user_employee_2.lang, 'en_US') + @users('employee') + def test_mail_composer_default_subject(self): + """ Make sure the default subject is applied in the composer. """ + simple_record = self.env['mail.test.simple'].create({'name': 'TestA'}) + ticket_record = self.env['mail.test.ticket'].create({'name': 'Test1'}) + nonthread_record = self.env['mail.test.nothread'].create({'name': 'TestNoThread'}) + + # default behavior: use the record name + ctx = self._get_web_context(simple_record, add_web=False, default_composition_mode='comment') + _, message = self.env['mail.compose.message'].with_context(ctx).create({ + 'body': '

Test Body

', + })._action_send_mail() + self.assertEqual(message.subject, simple_record.name) + + # default behavior without thread: use record name + ctx = self._get_web_context(nonthread_record, add_web=False, default_composition_mode='comment') + _, message = self.env['mail.compose.message'].with_context(ctx).create({ + 'body': '

Test Body

', + 'partner_ids': self.env.user.partner_id.ids, + })._action_send_mail() + self.assertEqual(message.subject, nonthread_record.name) + + # custom subject + ctx = self._get_web_context(ticket_record, add_web=False, default_composition_mode='comment') + _, message = self.env['mail.compose.message'].with_context(ctx).create({ + 'body': '

Test Body

', + })._action_send_mail() + self.assertEqual(message.subject, ticket_record._message_compute_subject()) + self.assertEqual(message.subject, f'Ticket for {ticket_record.name} on {ticket_record.datetime.strftime("%m/%d/%Y, %H:%M:%S")}') + + # forced value + ctx = self._get_web_context(ticket_record, add_web=False, default_composition_mode='comment') + _, message = self.env['mail.compose.message'].with_context(ctx).create({ + 'body': '

Test Body

', + 'subject': 'Forced Subject', + })._action_send_mail() + self.assertEqual(message.subject, 'Forced Subject') + @users('employee') @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') def test_mail_composer_notifications_delete(self): @@ -1045,8 +1496,8 @@ class TestComposerResultsComment(TestMailComposer): 'body': '

Test Body

', 'partner_ids': [(4, self.partner_1.id), (4, self.partner_2.id)] }) - self.assertFalse(composer.auto_delete) - self.assertFalse(composer.auto_delete_message) + self.assertTrue(composer.auto_delete, 'Comment mode removes notification emails by default') + self.assertFalse(composer.auto_delete_keep_log, 'Not used in comment mode') with self.mock_mail_gateway(mail_unlink_sent=True): composer._action_send_mail() @@ -1068,27 +1519,7 @@ class TestComposerResultsComment(TestMailComposer): 'partner_ids': [(4, self.partner_1.id), (4, self.partner_2.id)] }) self.assertFalse(composer.auto_delete) - self.assertFalse(composer.auto_delete_message) - with self.mock_mail_gateway(mail_unlink_sent=True): - composer._action_send_mail() - - # notifications - message = self.test_record.message_ids[0] - self.assertEqual(message.notified_partner_ids, self.partner_employee_2 + self.partner_1 + self.partner_2) - - # global outgoing - self.assertEqual(len(self._mails), 3, 'Should have sent an email each recipient') - self.assertEqual(len(self._new_mails), 2, 'Should have created 2 mail.mail (1 for users, 1 for customers)') - self.assertEqual(len(self._new_mails.exists()), 0, 'To fix: does not respect auto_delete') - - # ensure ``mail_auto_delete`` context key allow to override this behavior - composer = self.env['mail.compose.message'].with_context( - self._get_web_context(self.test_record), - mail_auto_delete=False, - ).create({ - 'body': '

Test Body

', - 'partner_ids': [(4, self.partner_1.id), (4, self.partner_2.id)] - }) + self.assertFalse(composer.auto_delete_keep_log, 'Not used in comment mode') with self.mock_mail_gateway(mail_unlink_sent=True): composer._action_send_mail() @@ -1117,7 +1548,6 @@ class TestComposerResultsComment(TestMailComposer): self.assertTrue(message.email_add_signature) self.assertFalse(message.email_layout_xmlid) self.assertEqual(message.message_type, 'comment', 'Mail: default message type with composer is user comment') - self.assertEqual(message.record_name, self.test_record.name) self.assertEqual(message.subtype_id, self.env.ref('mail.mt_comment', 'Mail: default subtype is comment')) # tweaks @@ -1127,28 +1557,121 @@ class TestComposerResultsComment(TestMailComposer): 'body': '

Test Body 2

', 'email_add_signature': False, 'email_layout_xmlid': 'mail.mail_notification_light', - 'is_log': False, 'message_type': 'notification', 'subtype_id': self.env.ref('mail.mt_note').id, 'partner_ids': [(4, self.partner_1.id), (4, self.partner_2.id)], - 'record_name': 'Custom record name', }) _mail, message = composer._action_send_mail() self.assertEqual(message.body, '

Test Body 2

') self.assertFalse(message.email_add_signature) self.assertEqual(message.email_layout_xmlid, 'mail.mail_notification_light') self.assertEqual(message.message_type, 'notification') - self.assertEqual(message.record_name, 'Custom record name') self.assertEqual(message.subtype_id, self.env.ref('mail.mt_note')) - # log forces note - composer.write({ - 'is_log': True, - 'subtype_id': self.env.ref('mail.mt_comment').id, + # subtype through xml id + composer = self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_record), + default_subtype_xmlid='mail.mt_note', + ).create({ + 'body': '

Default subtype through xml id

', }) _mail, message = composer._action_send_mail() self.assertEqual(message.subtype_id, self.env.ref('mail.mt_note')) + # Check that the message created from the mail composer gets the values + # we passed to the composer context. When we provide a custom `body` + # and `email_add_signature` flag, the message should keep those values + # and should not add any signature to the message body. + composer = self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_record), + default_body='

Hello world

', + default_email_add_signature=False + ).create({}) + _mail, message = composer._action_send_mail() + self.assertFalse(message.email_add_signature) + self.assertEqual(message.body, '

Hello world

') + + composer = self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_record), + default_body='

Hi there

', + default_email_add_signature=True + ).create({}) + _mail, message = composer._action_send_mail() + self.assertTrue(message.email_add_signature) + self.assertEqual(message.body, '

Hi there

') + + # check author notification parameters support + self.assertTrue(self.user_employee.partner_id in self.test_record.message_partner_ids) + for notify_author, notify_author_mention, add_pid, should_mention in [ + (False, False, False, False), # never add, even in pids + (False, False, True, False), # never add, even in pids + (False, True, False, False), # needs to be in pids + (False, True, True, True), # needs to be in pids + (True, False, False, True), + ]: + with self.subTest(notify_author=notify_author, notify_author_mention=notify_author_mention, add_pid=add_pid): + composer = self.env['mail.compose.message'].with_user(self.env.user).with_context( + self._get_web_context(self.test_record), + ).create({ + 'body': 'Test Own Notify', + 'message_type': 'comment', + 'notify_author': notify_author, + 'notify_author_mention': notify_author_mention, + 'partner_ids': [(4, self.env.user.partner_id.id)] if add_pid else [], + 'subtype_id': self.env.ref('mail.mt_comment').id, + }) + _mail, message = composer._action_send_mail() + self.assertEqual(message.author_id, self.user_employee.partner_id) + if should_mention: + self.assertTrue(self.user_employee.partner_id in message.notified_partner_ids) + else: + self.assertFalse(self.user_employee.partner_id in message.notified_partner_ids) + + # check notification control parameter support + self.assertEqual(self.test_record.message_partner_ids, self.partner_employee + self.partner_employee_2) + for notify_skip_followers in (False, True): + with self.subTest(notify_skip_followers=notify_skip_followers): + composer = self.env['mail.compose.message'].with_user(self.env.user).with_context( + self._get_web_context(self.test_record), + ).create({ + 'body': 'Test Notify Params', + 'message_type': 'comment', + 'notify_skip_followers': notify_skip_followers, + 'partner_ids': [(4, self.partner_admin.id)], + 'subtype_id': self.env.ref('mail.mt_comment').id, + }) + _mail, message = composer._action_send_mail() + if notify_skip_followers: + self.assertEqual( + message.notified_partner_ids, self.partner_admin, + 'notify_skip_followers parameter is either broken, either not propagated') + else: + self.assertEqual(message.notified_partner_ids, self.partner_employee_2 + self.partner_admin, + 'classic notify: followers + recipients - author') + self.assertEqual(message.partner_ids, self.partner_admin) + + # check notification UI control parameters + for option in (False, 'reply_all', 'forward'): + with self.subTest(option=option): + composer = self.env['mail.compose.message'].with_user(self.env.user).with_context( + self._get_web_context(self.test_record), + ).create({ + 'body': 'Test Notify Params', + 'message_type': 'comment', + 'composition_comment_option': option, + 'partner_ids': [(4, self.partner_admin.id)], + 'subtype_id': self.env.ref('mail.mt_comment').id, + }) + _mail, message = composer._action_send_mail() + if option == 'forward': + self.assertEqual( + message.notified_partner_ids, self.partner_admin, + 'Either forward is broken, either notify_skip_followers parameter is broken') + else: + self.assertEqual(message.notified_partner_ids, self.partner_employee_2 + self.partner_admin, + 'classic notify: followers + recipients - author') + self.assertEqual(message.partner_ids, self.partner_admin) + @users('employee') @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') def test_mail_composer_recipients(self): @@ -1164,22 +1687,179 @@ class TestComposerResultsComment(TestMailComposer): message = self.test_record.message_ids[0] self.assertEqual(message.author_id, self.user_employee.partner_id) self.assertEqual(message.body, '

Test Body

') - self.assertEqual(message.subject, 'Re: %s' % self.test_record.name) + self.assertEqual(message.subject, self.test_record._message_compute_subject()) self.assertEqual(message.subtype_id, self.env.ref('mail.mt_comment')) self.assertEqual(message.partner_ids, self.partner_1 | self.partner_2) + def test_mail_composer_recipients_email_only(self): + """Check that messages can be sent to standalone emails, with no associated partner.""" + email_addrs = [ + 'test1@test.lan', 'test2@test.lan', '"Formatted" ', + 'test.multi.1@test.lan, test.multi.2@test.lan, "Formatted" , InvalidEmailMulti', + 'InvalidEmail', '' + ] + notified_emails = { + 'test1@test.lan', 'test2@test.lan', 'test.multi.1@test.lan', 'test.multi.2@test.lan', + '"Formatted" ', 'InvalidEmail', False + } + mail_to_vals = { + 'test1@test.lan', 'test2@test.lan', 'test.multi.1@test.lan,test.multi.2@test.lan,"Formatted" ', + '"Formatted" ', 'InvalidEmail', '' + } + records = self.env['mail.test.ticket'].create([ + {'name': f'Test {email}', 'email_from': email} + for email in email_addrs + ]) + composer = self.env['mail.compose.message'].with_context(self._get_web_context(records)).create({}) + with self.mock_mail_gateway(mail_unlink_sent=False): + composer._action_send_mail() + + self.assertEqual(len(self._new_mails), 6, 'Should have created 1 mail.mail per record') + self.assertEqual(len(self._mails), 4, 'Should have sent 1 email per record with a valid email') + self.assertEqual(self._new_mails.notification_ids.mapped('notification_status'), ['sent', 'sent', 'sent', 'sent', 'sent', 'sent', 'exception', 'exception']) + self.assertEqual(set(self._new_mails.notification_ids.mapped('mail_email_address')), set(notified_emails), 'Email address should be retained on the notification') + self.assertEqual(set(self._new_mails.mapped('email_to')), mail_to_vals, 'Should have sent emails to the default recipients') + @users('employee') - @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_mail_composer_recipients_status(self): + test_record = self.test_record + external_partner = self.env['res.partner'].sudo().create({ + "name": "Customer", + "partner_share": True, + "email": "custo@kuzco.com" + }) + ctx = { + 'default_model': test_record._name, + 'default_composition_mode': "comment", + 'default_res_ids': test_record.ids + } + for is_customer_subscribed in [True, False]: + composer = self.env['mail.compose.message'].with_context(ctx).create({ + 'body': '

Test Body

', + 'reply_to': 'my_reply_to@test.example.com', + 'subject': 'My amazing subject', + }) + with self.subTest(is_customer_subscribed=is_customer_subscribed, partner_id=external_partner): + if is_customer_subscribed: + test_record.message_subscribe(partner_ids=external_partner.ids) # add external follower + self.assertTrue(composer.notified_bcc_contains_share, 'The created partner is external so should be True') + else: + test_record.message_unsubscribe(external_partner.ids) # remove external follower + self.assertFalse(composer.notified_bcc_contains_share, 'External follower should be unsubscribed') + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_composer_server_config(self): + """ Test various configuration to check behavior of outgoing mail + servers, notifications, .... """ + # add access to second company to avoid MC rules on ticket model + self.env.user.company_ids = [(4, self.company_2.id)] + + # initial data + self.assertEqual(self.env.company, self.company_admin) + self.assertEqual(self.user_admin.company_id, self.company_admin) + + # update test configuration + test_records = self.test_records + test_companies = self.company_admin + self.company_2 + self.company_2.alias_domain_id = self.mail_alias_domain + for company, record in zip(test_companies, test_records): + record.company_id = company.id + + # various from / servers configuration + self.template.write({'mail_server_id': False}) # allow server archive + server_other = self.env['ir.mail_server'].sudo().create({ + 'name': 'Server Other', + 'from_filter': 'test.othercompany.com', + 'sequence': 4, + 'smtp_encryption': 'none', + 'smtp_host': 'smtp_host', + }) + servers_all = self.mail_servers + server_other + for (emails_from, servers_active, mail_config), (exp_smtp_from_lst, exp_msg_from_lst, exp_from_filter) in zip( + [ + ( + [f'user.from@{self.alias_domain}', 'user@other.domain.com'], + self.env['ir.mail_server'], + {'default_from': f'notifications@{self.alias_domain}', 'from_filter': self.alias_domain} + ), # odoo-style configuration + ], [ + ( + [f'{self.alias_bounce}@{self.alias_domain}', f'{self.alias_bounce}@{self.alias_domain}'], + [f'"{self.env.user.name}" ', f'"{self.env.user.name}" '], + self.alias_domain + ), # no spoof + ], + ): + with self.subTest(emails_from=emails_from, + servers_active=servers_active): + # update servers + servers_all.active = False + if servers_active: + servers_active.active = True + # update mail config + default_from = mail_config.get('default_from', self.default_from) + from_filter = mail_config.get('from_filter', self.default_from_filter) + self.mail_alias_domain.default_from = default_from + self.env['ir.config_parameter'].sudo().set_param('mail.default.from_filter', from_filter) + + for email_from, exp_smtp_from, exp_msg_from in zip(emails_from, exp_smtp_from_lst, exp_msg_from_lst): + self.env.user.email = email_from + + # open a composer and run it in comment mode + composer = Form(self.env['mail.compose.message'].with_context( + default_composition_mode='comment', + default_force_send=True, # force sending emails directly to check SMTP + default_model=test_records._name, + default_res_ids=test_records.ids, + # avoid successive tests issues with followers + mail_post_autofollow_author_skip=True, + )) + composer.body = 'Hello {{ object.name }}' + composer.subject = 'My Subject' + composer = composer.save() + with self.mock_mail_gateway(mail_unlink_sent=False), \ + self.mock_mail_app(): + composer._action_send_mail() + + self.assertSMTPEmailsSent( + smtp_from=exp_smtp_from, + smtp_to_list=[self.partner_employee_2.email_normalized], + emails_count=2, # same on both records + message_from=exp_msg_from, + from_filter=exp_from_filter, + ) + + @users('employee') + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_message_schedule') def test_mail_composer_wtpl_complete(self): """ Test a posting process using a complex template, holding several - additional recipients and attachments. + additional recipients and attachments. It is done in monorecord and + in batch since this is now supported. This tests notifies: 2 new email_to (+ 1 duplicated), 1 email_cc, - test_record followers and partner_admin added in partner_to. + test_records followers and partner_admin added in partner_to. + + Global notification + * monorecord: send notifications right away (force_send=True) + * multirecord: delay notification sending (force_send=False) + + Use cases + * scheduled_date: creates mail.message.schedule (no email sent), then + scheduling send notifications with notification parameters kept + * otherwise: global behavior Test with and without notification layout specified. Test with and without languages. + + Setup with batch and langs + * record1: customer lang=es_ES + follower partner_employee_2 lang=en_US + 3 new partners (en_US) created by template + * record2: customer lang=en_US + follower partner_employee_2 lang=en_US + 3 new partners (en_US) created by template """ attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id) email_to_1 = 'test.to.1@test.example.com' @@ -1192,65 +1872,145 @@ class TestComposerResultsComment(TestMailComposer): 'email_to': '%s, %s, %s' % (email_to_1, email_to_2, email_to_3), 'email_cc': email_cc_1, 'partner_to': '%s, {{ object.customer_id.id if object.customer_id else "" }}' % self.partner_admin.id, - 'report_name': 'TestReport for {{ object.name }}', # test cursor forces html - 'report_template': self.test_report.id, + 'report_template_ids': [(6, 0, (self.test_report + self.test_report_2).ids)], }) - attachs = self.env['ir.attachment'].search([('name', 'in', [a['name'] for a in attachment_data])]) + attachs = self.env['ir.attachment'].sudo().search([('name', 'in', [a['name'] for a in attachment_data])]) self.assertEqual(len(attachs), 2) - # ensure initial data - self.assertEqual(self.test_record.user_id, self.user_employee_2) - self.assertEqual(self.test_record.message_partner_ids, self.partner_employee_2) - - for email_layout_xmlid, use_lang in product( + for batch_mode, scheduled_date, email_layout_xmlid, reply_to, use_lang in product( + (False, True, 'domain'), + (False, '{{ (object.create_date or datetime.datetime(2022, 12, 26, 18, 0, 0)) + datetime.timedelta(days=2) }}'), (False, 'mail.test_layout'), + (False, '{{ ctx.get("custom_reply_to") or "info@test.example.com" }}'), (False, True), ): - with self.subTest(email_layout_xmlid=email_layout_xmlid, + with self.subTest(batch_mode=batch_mode, + scheduled_date=scheduled_date, + email_layout_xmlid=email_layout_xmlid, + reply_to=reply_to, use_lang=use_lang): # update test configuration + batch = bool(batch_mode) + self.template.write({ + 'scheduled_date': scheduled_date, + 'email_layout_xmlid': email_layout_xmlid, + 'reply_to': reply_to, + }) if use_lang: - exp_lang = 'es_ES' - self.partner_1.lang = exp_lang + if batch: + langs = ('es_ES', 'en_US') + self.test_partners[0].lang = langs[0] + self.test_partners[1].lang = langs[1] + else: + langs = ('es_ES',) + self.partner_1.lang = langs[0] if not use_lang: - exp_lang = False - self.partner_1.lang = False - - test_record = self.test_record.with_env(self.env) + if batch: + langs = (False, False) + self.test_partners.lang = False + else: + langs = (False,) + self.partner_1.lang = False + test_records = self.test_records if batch else self.test_record # ensure initial data - self.assertEqual(test_record.user_id, self.user_employee_2) - self.assertEqual(test_record.message_partner_ids, self.partner_employee_2) + self.assertEqual(len(test_records.customer_id), len(test_records)) + self.assertEqual(test_records.user_id, self.user_employee_2) + self.assertEqual(test_records.message_partner_ids, self.partner_employee_2) ctx = { - 'default_model': test_record._name, + 'default_model': test_records._name, 'default_composition_mode': 'comment', - 'default_res_id': test_record.id, 'default_template_id': self.template.id, # avoid successive tests issues with followers - 'mail_create_nosubscribe': True, + 'mail_post_autofollow_author_skip': True, + # just to check template dynamic code evaluation (see reply_to above) + 'custom_reply_to': 'custom.reply.to@test.example.com', } - if email_layout_xmlid: - ctx['default_email_layout_xmlid'] = email_layout_xmlid + if batch_mode == 'domain': + ctx['default_res_domain'] = [('id', 'in', test_records.ids)] + else: + ctx['default_res_ids'] = test_records.ids # open a composer and run it in comment mode composer_form = Form(self.env['mail.compose.message'].with_context(ctx)) composer = composer_form.save() + self.assertEqual(composer.email_layout_xmlid, email_layout_xmlid) # ensure some parameters used afterwards - author = self.partner_employee - self.assertEqual(composer.author_id, author, - 'Author is synchronized with rendered email_from') - self.assertEqual(composer.email_from, self.partner_employee_2.email_formatted) - self.assertFalse(composer.reply_to_force_new, 'Mail: thread-enabled models should use auto thread by default') + if batch: + author = self.env.user.partner_id + self.assertEqual(composer.author_id, author, + 'Author cannot be synchronized with a raw email_from') + self.assertEqual(composer.email_from, self.template.email_from) + else: + author = self.partner_employee_2 + self.assertEqual(composer.author_id, author, + 'Author is synchronized with rendered email_from') + self.assertEqual(composer.email_from, self.partner_employee_2.email_formatted) + if reply_to: + if batch: + self.assertEqual(composer.reply_to, '{{ ctx.get("custom_reply_to") or "info@test.example.com" }}') + else: + self.assertEqual(composer.reply_to, 'custom.reply.to@test.example.com') + self.assertTrue(composer.reply_to_force_new, 'Template forces reply_to -> consider it is a new thread') + else: + self.assertFalse(composer.reply_to) + self.assertFalse(composer.reply_to_force_new) + # due to scheduled_date, cron for sending notification will be used + schedule_cron_id = self.env.ref('mail.ir_cron_send_scheduled_message').id with self.mock_mail_gateway(mail_unlink_sent=False), \ - self.mock_mail_app(): + self.mock_mail_app(), \ + self.mock_datetime_and_now(self.reference_now), \ + self.capture_triggers(schedule_cron_id) as capt: composer._action_send_mail() + # notification process should not have been sent + if scheduled_date: + self.assertFalse(self._new_mails) + self.assertFalse(self._mails) # monorecord: force_send notifications - self.assertEqual(self._new_mails.mapped('state'), ['sent'] * len(self._new_mails)) - self.assertEqual(len(self._mails), 5, 'Should have sent 5 emails, one per recipient per record') + elif not batch: + # as there are recipients with different langs: we have + # 3 outgoing mails: partner_employee2 (user) then customers + # in two langs + if use_lang: + self.assertEqual( + len(self._new_mails), 3, + 'Should have created 1 mail for user, then 2 for customers that belong to 2 langs') + # without lang, recipients are grouped by main usage aka user and customer + else: + self.assertEqual( + len(self._new_mails), 2, + 'Should have created 1 mail for user, then 1 for customers') + self.assertEqual(self._new_mails.mapped('state'), ['sent'] * len(self._new_mails)) + self.assertEqual(len(self._mails), 5, 'Should have sent 5 emails, one per recipient per record') + # multirecord: use email queue + else: + # see not-batch comment, then add 2 mails for the second + # record as all customers have same language + if use_lang: + self.assertEqual( + len(self._new_mails), 5, + 'Should have created 3 mails for first record, then 2 for second') + else: + self.assertEqual( + len(self._new_mails), 4, + 'Should have created 2 mails / record (one for user, one for customers)') + self.assertEqual(self._new_mails.mapped('state'), ['outgoing'] * len(self._new_mails)) + self.assertEqual(len(self._mails), 0, 'Should have put emails in queue and not sent any emails') + # simulate cron sending emails + self.env['mail.mail'].sudo().process_email_queue() + + # notification process should not have been sent + if scheduled_date: + self.assertEqual( + capt.records.mapped('call_at'), [self.reference_now + timedelta(days=2)] * len(test_records), + msg='Should have created a cron trigger for the scheduled sending' + ) + else: + self.assertFalse(capt.records) # check new partners have been created based on emails given new_partners = self.env['res.partner'].search([ @@ -1266,97 +2026,269 @@ class TestComposerResultsComment(TestMailComposer): {'en_US'}, ) - message = test_record.message_ids[0] + # if scheduled_date is set: simulate cron for sending notifications + if scheduled_date: + # Send the scheduled message from the CRON + with self.mock_mail_gateway(mail_unlink_sent=False), \ + self.mock_mail_app(), \ + self.mock_datetime_and_now(self.reference_now + timedelta(days=3)): + self.env['mail.message.schedule'].sudo()._send_notifications_cron() - # check created mail.mail and outgoing emails. In comment - # 2 mails are generated (due to group-based layouting): - # - one for recipient that is a user - # - one for recipients that are customers - # Then each recipient receives its own outging email. See - # 'assertMailMail' for more details. - if exp_lang == 'es_ES': - exp_body = f'SpanishBody for {test_record.name}' - exp_subject = f'SpanishSubject for {test_record.name}' - else: - exp_body = f'TemplateBody {test_record.name}' - exp_subject = f'TemplateSubject {test_record.name}' - self.assertMailMail(self.partner_employee_2, 'sent', - mail_message=message, - author=author, # author is different in batch and monorecord mode (raw or rendered email_from) - email_values={ - 'body_content': exp_body, - 'email_from': test_record.user_id.email_formatted, # set by template - 'subject': exp_subject, - 'attachments_info': [ - {'name': 'AttFileName_00.txt', 'raw': b'AttContent_00', 'type': 'text/plain'}, - {'name': 'AttFileName_01.txt', 'raw': b'AttContent_01', 'type': 'text/plain'}, - {'name': f'TestReport for {test_record.name}.html', 'type': 'text/plain'}, - ] - }, - fields_values={ - 'mail_server_id': self.mail_server_domain, - }, - ) - self.assertMailMail(test_record.customer_id + new_partners, 'sent', - mail_message=message, - author=author, # author is different in batch and monorecord mode (raw or rendered email_from) - email_values={ - 'body_content': exp_body, - 'email_from': test_record.user_id.email_formatted, # set by template - 'subject': exp_subject, - 'attachments_info': [ - {'name': 'AttFileName_00.txt', 'raw': b'AttContent_00', 'type': 'text/plain'}, - {'name': 'AttFileName_01.txt', 'raw': b'AttContent_01', 'type': 'text/plain'}, - {'name': f'TestReport for {test_record.name}.html', 'type': 'text/plain'}, - ] - }, - fields_values={ - 'mail_server_id': self.mail_server_domain, - }, - ) + # monorecord: force_send notifications + if not batch: + # as there are recipients with different langs: we have + # 3 outgoing mails: partner_employee2 (user) then customers + # in two langs + if use_lang: + self.assertEqual( + len(self._new_mails), 3, + 'Should have created 1 mail for user, then 2 for customers that belong to 2 langs') + # without lang, recipients are grouped by main usage aka user and customer + else: + self.assertEqual( + len(self._new_mails), 2, + 'Should have created 1 mail for user, then 1 for customers') + self.assertEqual(self._new_mails.mapped('state'), ['sent'] * len(self._new_mails)) + self.assertEqual(len(self._mails), 5, 'Should have sent 5 emails, one per recipient per record') + # multirecord: use email queue + else: + # see not-batch comment, then add 2 mails for the second + # record as all customers have same language + if use_lang: + self.assertEqual( + len(self._new_mails), 5, + 'Should have created 3 mails for first record, then 2 for second') + else: + self.assertEqual( + len(self._new_mails), 4, + 'Should have created 2 mails / record (one for user, one for customers)') + self.assertEqual(self._new_mails.mapped('state'), ['outgoing'] * len(self._new_mails)) + self.assertEqual(len(self._mails), 0, 'Should have put emails in queue and not sent any emails') + # simulate cron sending emails + self.env['mail.mail'].sudo().process_email_queue() - # Low-level checks on outgoing email for the recipient to - # check layouting and language. Note that standard layout - # is not tested against translations, only the custom one - # to ease translations checks. - email = self._find_sent_email(test_record.user_id.email_formatted, [test_record.customer_id.email_formatted]) - self.assertTrue(bool(email), 'Email not found, check recipients') + # template is sent only to partners (email_to are transformed) + for test_record, exp_lang in zip(test_records, langs): + message = test_record.message_ids[0] - # TDE FIXME: as it currently depends on a context-based hack - # translation is not supported when scheduling notifications - # or when a domain is given. Moreover access buttons are not - # translated - exp_layout_content_en = 'English Layout for Ticket-like model' - exp_layout_content_es = 'Spanish Layout para Spanish Model Description' - exp_button_en = 'View Ticket-like model' - # exp_button_es = 'SpanishView Spanish Model Description' - if email_layout_xmlid: + # check created mail.mail and outgoing emails. In comment + # 2 or 3 mails are generated (due to group-based layouting): + # - one for recipient that is a user + # - one / two for recipients that are customers, one / lang + # Then each recipient receives its own outgoing email. See + # 'assertMailMail' for more details. + + # user email (one user, one email) if exp_lang == 'es_ES': - self.assertIn(exp_layout_content_es, email['body']) - self.assertIn(exp_button_en, email['body'], - 'TODO: buttons should be translated') + exp_body = f'SpanishBody for {test_record.name}' + exp_subject = f'SpanishSubject for {test_record.name}' else: - self.assertIn(exp_layout_content_en, email['body']) - self.assertIn(exp_button_en, email['body']) - else: - # check default layouting applies - if exp_lang == 'es_ES': - self.assertIn('html lang="es_ES"', email['body']) + exp_body = f'TemplateBody {test_record.name}' + exp_subject = f'TemplateSubject {test_record.name}' + if reply_to: + exp_reply_to = 'custom.reply.to@test.example.com' else: - self.assertIn('html lang="en_US"', email['body']) + exp_reply_to = formataddr(( + author.name, + f'{self.alias_catchall}@{self.alias_domain}' + )) + self.assertMailMail(self.partner_employee_2, 'sent', + mail_message=message, + author=author, # author is different in batch and monorecord mode (raw or rendered email_from) + email_values={ + 'body_content': exp_body, + 'email_from': test_record.user_id.email_formatted, # set by template + 'reply_to': exp_reply_to, + 'subject': exp_subject, + 'attachments_info': [ + {'name': 'AttFileName_00.txt', 'raw': b'AttContent_00', 'type': 'text/plain'}, + {'name': 'AttFileName_01.txt', 'raw': b'AttContent_01', 'type': 'text/plain'}, + {'name': f'TestReport for {test_record.name}.html', 'type': 'text/plain'}, + {'name': f'TestReport2 for {test_record.name}.html', 'type': 'text/plain'}, + ] + }, + fields_values={ + 'mail_server_id': self.mail_server_domain, + 'reply_to_force_new': bool(reply_to), + }, + ) - # message is posted and notified admin - self.assertEqual(message.subtype_id, self.env.ref('mail.mt_comment')) - self.assertNotified(message, [{'partner': self.partner_admin, 'is_read': False, 'type': 'inbox'}]) - # attachments are copied on message and linked to document - self.assertEqual( - set(message.attachment_ids.mapped('name')), - set(['AttFileName_00.txt', 'AttFileName_01.txt', - f'TestReport for {test_record.name}.html']) - ) - self.assertEqual(set(message.attachment_ids.mapped('res_model')), set([test_record._name])) - self.assertEqual(set(message.attachment_ids.mapped('res_id')), set(test_record.ids)) - self.assertTrue(all(attach not in message.attachment_ids for attach in attachs), 'Should have copied attachments') + # customers emails (several customers, one or two emails depending + # on multi-lang testing environment) + if use_lang and test_record == test_records[0]: + # in this case, we are in a multi-lang customers testing + emails_recipients = [ + test_record.customer_id, # es_ES + new_partners # en_US (default lang of new customers) + ] + else: + # all recipients have same language, one email + emails_recipients = [test_record.customer_id + new_partners] + + for recipients in emails_recipients: + self.assertMailMail(recipients, 'sent', + mail_message=message, + author=author, # author is different in batch and monorecord mode (raw or rendered email_from) + email_values={ + 'body_content': exp_body, + 'email_from': test_record.user_id.email_formatted, # set by template + 'subject': exp_subject, + 'attachments_info': [ + {'name': 'AttFileName_00.txt', 'raw': b'AttContent_00', 'type': 'text/plain'}, + {'name': 'AttFileName_01.txt', 'raw': b'AttContent_01', 'type': 'text/plain'}, + {'name': f'TestReport for {test_record.name}.html', 'type': 'text/plain'}, + {'name': f'TestReport2 for {test_record.name}.html', 'type': 'text/plain'}, + ] + }, + fields_values={ + 'mail_server_id': self.mail_server_domain, + }, + ) + + # Specifically for the language-specific recipient, perform + # low-level checks on outgoing email for the recipient to + # check layouting and language. Note that standard layout + # is not tested against translations, only the custom one + # to ease translations checks. + # We could do the check for other layouts but it would be + # mainly noisy / duplicated check + email = self._find_sent_email(test_record.user_id.email_formatted, [test_record.customer_id.email_formatted]) + self.assertTrue(bool(email), 'Email not found, check recipients') + + exp_layout_content_en = 'English Layout for Ticket-like model' + exp_layout_content_es = 'Spanish Layout para Spanish Model Description' + exp_button_en = 'View Ticket-like model' + exp_button_es = 'SpanishView Spanish Model Description' + if email_layout_xmlid: + if exp_lang == 'es_ES': + self.assertIn(exp_layout_content_es, email['body']) + self.assertIn(exp_button_es, email['body']) + else: + self.assertIn(exp_layout_content_en, email['body']) + self.assertIn(exp_button_en, email['body']) + else: + # check default layouting applies + if exp_lang == 'es_ES': + self.assertIn('html lang="es_ES"', email['body']) + else: + self.assertIn('html lang="en_US"', email['body']) + + # message is posted and notified admin + self.assertEqual(message.subtype_id, self.env.ref('mail.mt_comment')) + self.assertNotified(message, [{'partner': self.partner_admin, 'is_read': False, 'type': 'inbox'}]) + # attachments are copied on message and linked to document + self.assertEqual( + set(message.attachment_ids.mapped('name')), + set(['AttFileName_00.txt', 'AttFileName_01.txt', + f'TestReport for {test_record.name}.html', + f'TestReport2 for {test_record.name}.html']) + ) + self.assertEqual(set(message.attachment_ids.mapped('res_model')), set([test_record._name])) + self.assertEqual(set(message.attachment_ids.mapped('res_id')), set(test_record.ids)) + self.assertTrue(all(attach not in message.attachment_ids for attach in attachs), 'Should have copied attachments') + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_composer_wtpl_mc(self): + """ Test specific to multi-company environment, notably company propagation + or aliases. """ + # add access to second company to avoid MC rules on ticket model + self.env.user.company_ids = [(4, self.company_2.id)] + + # initial data + self.assertEqual(self.env.company, self.company_admin) + self.assertEqual(self.user_admin.company_id, self.company_admin) + + attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id) + email_to_1 = 'test.to.1@test.example.com' + self.template.write({ + 'auto_delete': False, # keep sent emails to check content + 'attachment_ids': [(0, 0, a) for a in attachment_data], + 'email_from': False, # use current user as author + 'email_layout_xmlid': 'mail.test_layout', + 'email_to': email_to_1, + 'mail_server_id': False, # let it find a server + 'partner_to': '%s, {{ object.customer_id.id if object.customer_id else "" }}' % self.partner_admin.id, + }) + attachs = self.env['ir.attachment'].sudo().search([('name', 'in', [a['name'] for a in attachment_data])]) + self.assertEqual(len(attachs), 2) + + for batch, companies, expected_companies, expected_alias_domains in [ + (False, self.company_admin, self.company_admin, self.mail_alias_domain), + ( + True, self.company_admin + self.company_2, self.company_admin + self.company_2, + self.mail_alias_domain + self.mail_alias_domain_c2, + ), + ]: + with self.subTest(batch=batch, + companies=companies): + # update test configuration + test_records = self.test_records if batch else self.test_record + for company, record in zip(companies, test_records): + record.company_id = company.id + + # open a composer and run it in comment mode + composer = Form(self.env['mail.compose.message'].with_context( + default_composition_mode='comment', + default_force_send=True, # force sending emails directly to check SMTP + default_model=test_records._name, + default_res_ids=test_records.ids, + default_template_id=self.template.id, + # avoid successive tests issues with followers + mail_post_autofollow_author_skip=True, + )).save() + with self.mock_mail_gateway(mail_unlink_sent=False), \ + self.mock_mail_app(): + composer._action_send_mail() + + new_partner = self.env['res.partner'].search([('email_normalized', '=', 'test.to.1@test.example.com')]) + self.assertEqual(len(new_partner), 1) + # check output, company-specific values mainly for this test + for record, exp_company, exp_alias_domain in zip( + test_records, expected_companies, expected_alias_domains + ): + message = record.message_ids[0] + for recipient in [self.partner_employee_2, new_partner, record.customer_id]: + headers_recipients = f'{new_partner.email_formatted},{record.customer_id.email_formatted}' + self.assertMailMail( + recipient, + 'sent', + author=self.partner_employee, + mail_message=message, + email_values={ + 'headers': { + 'Return-Path': f'{exp_alias_domain.bounce_email}', + 'X-Odoo-Objects': f'{record._name}-{record.id}', + 'X-Msg-To-Add': headers_recipients, + }, + 'subject': f'TemplateSubject {record.name}', + }, + fields_values={ + 'headers': { + 'Return-Path': f'{exp_alias_domain.bounce_email}', + 'X-Odoo-Objects': f'{record._name}-{record.id}', + 'X-Msg-To-Add': headers_recipients, + }, + 'mail_server_id': self.env['ir.mail_server'], + 'record_alias_domain_id': exp_alias_domain, + 'record_company_id': exp_company, + 'subject': f'TemplateSubject {record.name}', + }, + ) + smtp_to_list = [recipient.email_normalized] + if exp_alias_domain == self.mail_alias_domain: + self.assertSMTPEmailsSent( + smtp_from=f'{self.default_from}@{self.alias_domain}', + smtp_to_list=smtp_to_list, + mail_server=self.mail_server_notification, + emails_count=1, + ) + else: + self.assertSMTPEmailsSent( + smtp_from=exp_alias_domain.bounce_email, + smtp_to_list=smtp_to_list, + emails_count=1, + ) @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') @@ -1534,7 +2466,7 @@ class TestComposerResultsComment(TestMailComposer): smtp_to_list = ['find.me.at@test.example.com'] else: smtp_to_list = [recipient.email_normalized] - self.assert_email_sent_smtp( + self.assertSMTPEmailsSent( smtp_from=f'{self.alias_bounce}@{self.alias_domain}', smtp_to_list=smtp_to_list, mail_server=self.mail_server_domain, @@ -1543,7 +2475,7 @@ class TestComposerResultsComment(TestMailComposer): (self.user_employee.name, 'email.from.1@test.mycompany.com', )), - # similar envelope, assert_email_sent_smtp cannot distinguish + # similar envelope, assertSMTPEmailsSent cannot distinguish # records (would have to dive into content, too complicated) emails_count=1, ) @@ -1565,11 +2497,6 @@ class TestComposerResultsCommentStatus(TestMailComposer): """ super(TestComposerResultsCommentStatus, cls).setUpClass() - # ensure employee can create partners, necessary for templates - cls.user_employee.write({ - 'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)], - }) - # add 2 new records with customers cls.test_records, cls.test_partners = cls._create_records_for_batch( 'mail.test.ticket.el', 4, @@ -1600,6 +2527,7 @@ class TestComposerResultsCommentStatus(TestMailComposer): cls.template.write({ 'auto_delete': False, 'model_id': cls.env['ir.model']._get_id(cls.test_records._name), + 'scheduled_date': False, }) def test_assert_initial_data(self): @@ -1637,7 +2565,7 @@ class TestComposerResultsCommentStatus(TestMailComposer): self.assertMailMail( recipient, 'sent', mail_message=message, - author=self.partner_employee, # author != email_from (template sets only email_from) + author=self.partner_employee_2, # author synchronized with email_from email_values={ 'email_from': self.user_employee_2.email_formatted, # set by template }, @@ -1651,9 +2579,8 @@ class TestComposerResultsMass(TestMailComposer): @classmethod def setUpClass(cls): super(TestComposerResultsMass, cls).setUpClass() - # ensure employee can create partners, necessary for templates - cls.user_employee.write({ - 'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)], + cls.template.write({ + "scheduled_date": False, }) @users('employee') @@ -1670,8 +2597,8 @@ class TestComposerResultsMass(TestMailComposer): default_template_id=self.template.id) )) composer = composer_form.save() - self.assertFalse(composer.auto_delete, 'Fixme: should take composer value') - self.assertFalse(composer.auto_delete_message) + self.assertTrue(composer.auto_delete, 'Should take composer value') + self.assertTrue(composer.auto_delete_keep_log) with self.mock_mail_gateway(mail_unlink_sent=True), self.mock_mail_app(): composer._action_send_mail() @@ -1680,6 +2607,8 @@ class TestComposerResultsMass(TestMailComposer): self.assertFalse(self._new_mails.exists(), 'Should have deleted mail.mail records') self.assertEqual(len(self._new_msgs), 2, 'Should have created 1 mail.mail per record') self.assertEqual(self._new_msgs.exists(), self._new_msgs, 'Should not have deleted mail.message records') + self.assertEqual(len(self._new_notifs), 2, 'Should have created 1 mail.notification per record') + self.assertEqual(self._new_notifs.exists(), self._new_notifs, 'Should not have deleted mail.notification') # force composer auto_delete field composer_form = Form(self.env['mail.compose.message'].with_context( @@ -1693,18 +2622,19 @@ class TestComposerResultsMass(TestMailComposer): self.assertEqual(len(self._mails), 2, 'Should have sent 1 email per record') self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record') - # self.assertEqual(self._new_mails.exists(), self._new_mails, 'Should not have deleted mail.mail records') - self.assertFalse(self._new_mails.exists(), 'TODO: Template is forced over composer value, which is not correct') + self.assertEqual(self._new_mails.exists(), self._new_mails, 'Should not have deleted mail.mail records') self.assertEqual(len(self._new_msgs), 2, 'Should have created 1 mail.mail per record') self.assertEqual(self._new_msgs.exists(), self._new_msgs, 'Should not have deleted mail.message records') + self.assertEqual(len(self._new_notifs), 2, 'Should have created 1 mail.notification per recipient per record') + self.assertEqual(self._new_notifs.exists(), self._new_notifs, 'Should not have deleted mail.notification') - # check composer auto_delete_message + # check composer auto_delete_keep_log composer_form = Form(self.env['mail.compose.message'].with_context( self._get_web_context(self.test_records, add_web=True, default_template_id=self.template.id) )) composer = composer_form.save() - composer.auto_delete_message = True + composer.auto_delete_keep_log = False with self.mock_mail_gateway(mail_unlink_sent=True), self.mock_mail_app(): composer._action_send_mail() @@ -1712,8 +2642,231 @@ class TestComposerResultsMass(TestMailComposer): self.assertEqual(len(self._mails), 2, 'Should have sent 1 email per record') self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record') self.assertFalse(self._new_mails.exists(), 'Should have deleted mail.mail records') - self.assertEqual(len(self._new_msgs), 2, 'Should have created 1 mail.mail per record') + self.assertEqual(len(self._new_msgs), 2, 'Should have created 1 mail.message per record') self.assertFalse(self._new_msgs.exists(), 'Should have deleted mail.message records') + self.assertFalse(self._new_notifs, 'Should not create notifications when removing logs in mass mode') + + @users('employee') + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_mail_composer_duplicates(self): + """ Ensures emails sent to the same recipient multiple times + are only sent when they are not duplicates + """ + # add access to Mail Template Editor + self.user_employee.group_ids += self.env.ref('mail.group_mail_template_editor') + # Access can also be made available to all users. + # self.env['ir.config_parameter'].sudo().set_param('mail.restrict.template.rendering', False) + + self.template.write({ + 'auto_delete': False, + 'body_html': '

Common Body

', + 'email_to': '', + 'partner_to': '{{ object.customer_id.id if object.customer_id else "" }}', + 'subject': 'Common Subject', + }) + + # Guarantee points of variation for test_report_3 + self.test_records[0].write({'count': 1, 'name': 'A'}) + self.test_records[1].write({'count': 2, 'name': 'B'}) + + template_attachment, composer_attachment = self.env['ir.attachment'].create([{ + 'datas': base64.b64encode(b'ExtraData'), + 'mimetype': 'text/plain', + 'name': f'{record._name}_Common_Attachment.txt', + 'res_id': record.id if record else False, + 'res_model': record._name, + } for record in (self.template, self.env['mail.compose.message'])]) + + customer_ids = self.partners[:2] + customer_ids.write({ + 'email': 'test@test.lan' + }) + + def _instanciate_composer(composer_attachments=False): + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_records[:2], add_web=True, + default_template_id=self.template.id) + )) + composer = composer_form.save() + if composer_attachments: + composer_attachments.res_id = composer.id + composer.attachment_ids += composer_attachments + return composer + + base_customer_values = [{'email': 'test@test.lan'}] + # different recipients should always receive emails + customer_diff_emails = [{'email': f'difftest{n}@test.lan'} for n in range(2)] + base_template_values = { + 'attachment_ids': [Command.clear()], + 'body_html': '

Common Body

', + 'report_template_ids': [Command.clear()], + 'subject': 'Common Subject', + } + same_attachments = {'attachment_ids': template_attachment.ids} + diff_body = {'body_html': '

'} + # regardless of whether they have different bodies or not, they are considered duplicates + diff_attachment_same_content = {'report_template_ids': [self.test_report_2.id]} + diff_attachment_diff_content = {'report_template_ids': [self.test_report_3.id]} + diff_subject = {'subject': '{{ object.name }}'} + + # all template variations using same recipients + diff_combinations = product( + [[diff_body], [diff_subject], + [diff_attachment_same_content], [diff_attachment_diff_content], + [diff_attachment_same_content, same_attachments], + [diff_body, diff_attachment_diff_content, diff_subject]], + [base_customer_values]) + # no template variations using different recipients + diff_combinations = chain(diff_combinations, [[[], customer_diff_emails]]) + # expect all sent + for template_changes, customer_changes in diff_combinations: + test_template_values = dict(base_template_values) + for change in template_changes: + test_template_values.update(change) + self.template.write(test_template_values) + for customer, base_vals, update_vals in zip(customer_ids, base_customer_values, customer_changes): + customer.write({**base_vals, **update_vals}) + with self.subTest(template_values=test_template_values, customer_changes=customer_changes): + composer = _instanciate_composer() + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + composer._action_send_mail() + self.assertEqual(len(self._new_mails), 2) + # check emails + for record in self.test_records[:2]: + # email_to will be normalized and formatted, even if already formatted + expected_subject = base_template_values['subject'] + if test_template_values['subject'] != base_template_values['subject']: + expected_subject = record.name + expected_body = base_template_values['body_html'] + if test_template_values['body_html'] != base_template_values['body_html']: + expected_body = f'

{record.name}

' + expected_attachment_info = [] + if self.template.attachment_ids: + expected_attachment_info.append({'name': 'mail.template_Common_Attachment.txt', 'type': 'text/plain'}) + if self.template.report_template_ids: + test_report_name = 'TestReport2' if self.template.report_template_ids == self.test_report_2 else 'TestReport3' + expected_attachment_info.append( + {'name': f'{test_report_name} for {record.name}.html', 'type': 'text/plain'}, + ) + self.assertMailMailWEmails( + [record.customer_id.email], + 'sent', + author=self.env.user.partner_id, + content=expected_body, + mail_message=record.message_ids[0], + email_to_recipients=[[record.customer_id.email_formatted]], + email_values={ + 'attachments_info': expected_attachment_info, + 'body': expected_body, + 'email_from': record.user_id.email_formatted, + 'subject': expected_subject, + }, + ) + + # expect duplicates + cases = [ + (False, []), + (composer_attachment, []), + ([], [same_attachments]), + (composer_attachment, [same_attachments]), + ] + for composer_attachment, template_changes in cases: + test_template_values = dict(base_template_values) + for change in template_changes: + test_template_values.update(change) + self.template.write(test_template_values) + # reset customers to have the same email + for customer, base_vals in zip(customer_ids, base_customer_values): + customer.write(base_vals) + with self.subTest(composer_attachments=composer_attachment, template_values=test_template_values): + composer = _instanciate_composer(composer_attachments=composer_attachment) + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + composer._action_send_mail() + self.assertEqual(len(self._new_mails), 2) + self.assertEqual(len(self._mails), 1) + # check email + expected_attachment_info = [] + if template_changes: + expected_attachment_info.append({ + 'name': f'{self.template._name}_Common_Attachment.txt', + 'raw': b'ExtraData', + 'type': 'text/plain', + }) + if composer_attachment: + expected_attachment_info.append({ + 'name': f'{composer._name}_Common_Attachment.txt', + 'raw': b'ExtraData', + 'type': 'text/plain', + }) + self.assertMailMailWRecord( + self.test_records[0], + [self.test_records[0].customer_id], + 'sent', + author=self.env.user.partner_id, + content='Common Body', + email_values={ + 'attachments_info': expected_attachment_info, + 'body_content': 'Common Body', + 'email_from': record.user_id.email_formatted, + 'subject': 'Common Subject', + }, + ) + self. assertMailMailWRecord( + self.test_records[1], + [self.test_records[1].customer_id], + 'cancel', + author=self.env.user.partner_id, + content='Common Body', + email_values={ + 'attachments_info': expected_attachment_info, + 'body_content': 'Common Body', + 'email_from': record.user_id.email_formatted, + 'subject': 'Common Subject', + }, + ) + + @users('employee') + def test_mail_composer_scalability(self): + """ Test scalability (big batch of emails) and related configuration """ + batch_records, _partners = self._create_records_for_batch( + 'mail.test.ticket.mc', 10, + ) + for (batch_size, send_limit), (exp_mail_create_count, exp_force_send, exp_state) in zip( + [ + (False, False), # unset + (8, 0), # 0 = always use queue + (8, False), # send limit defaults to 100, so force_send is set + (0, 8), # render: defaults to 500 hence 1 iteration in test + ], + [ + (1, True, "sent"), + (2, False, "outgoing"), + (2, True, "sent"), + (1, False, "outgoing"), + ] + ): + with self.subTest(batch_size=batch_size, send_limit=send_limit): + self.env['ir.config_parameter'].sudo().set_param( + "mail.batch_size", batch_size + ) + self.env['ir.config_parameter'].sudo().set_param( + "mail.mail.force.send.limit", send_limit + ) + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context(batch_records, add_web=True, + default_auto_delete=False, + default_template_id=self.template.id) + )) + composer = composer_form.save() + self.assertFalse(composer.auto_delete) + self.assertEqual(composer.force_send, exp_force_send) + with self.mock_mail_gateway(mail_unlink_sent=True): + composer._action_send_mail() + + self.assertEqual(self.mail_mail_create_mocked.call_count, exp_mail_create_count) + self.assertTrue( + all(mail.state == exp_state for mail in self._new_mails) + ) @users('employee') @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') @@ -1729,7 +2882,10 @@ class TestComposerResultsMass(TestMailComposer): default_template_id=self.template.id) )) composer = composer_form.save() - self.assertFalse(composer.reply_to_force_new, 'Mail: thread-enabled models should use auto thread by default') + composer.attachment_ids = self.env['ir.attachment'].sudo().create( + self._generate_attachments_data(1, res_model=composer._name, res_id=composer.id) + ) + self.assertTrue(composer.reply_to_force_new, 'Should use template reply-to value') with self.mock_mail_gateway(mail_unlink_sent=True): composer._action_send_mail() @@ -1747,12 +2903,18 @@ class TestComposerResultsMass(TestMailComposer): author=self.partner_employee, email_values={ 'email_from': self.partner_employee_2.email_formatted, + 'reply_to': 'info@test.example.com', }) # message content self.assertEqual(message.subject, 'TemplateSubject %s' % record.name) self.assertEqual(message.body, '

TemplateBody %s

' % record.name) self.assertEqual(message.author_id, self.user_employee.partner_id) + self.assertEqual(len(message.attachment_ids), 1) + self.assertEqual(message.attachment_ids.res_model, record._name) + self.assertEqual(message.attachment_ids.res_id, record.id) + self.assertEqual(composer.attachment_ids.name, message.attachment_ids.name) + self.assertEqual(composer.attachment_ids.datas, message.attachment_ids.datas) # post-related fields are void self.assertFalse(message.subtype_id) self.assertFalse(message.partner_ids) @@ -1779,23 +2941,32 @@ class TestComposerResultsMass(TestMailComposer): 'email_to': '%s, %s, %s' % (email_to_1, email_to_2, email_to_3), 'email_cc': email_cc_1, 'partner_to': '%s, {{ object.customer_id.id if object.customer_id else "" }}' % self.partner_admin.id, - 'report_name': 'TestReport for {{ object.name }}', # test cursor forces html - 'report_template': self.test_report.id, + 'report_template_ids': [(6, 0, self.test_report.ids)], }) - attachs = self.env['ir.attachment'].search([('name', 'in', [a['name'] for a in attachment_data])]) + attachs = self.env['ir.attachment'].sudo().search([('name', 'in', [a['name'] for a in attachment_data])]) self.assertEqual(len(attachs), 2) # ensure initial data self.assertEqual(self.test_records.user_id, self.user_employee_2) self.assertEqual(self.test_records.message_partner_ids, self.partner_employee_2) - for email_layout_xmlid, use_lang in product( + for use_domain, scheduled_date, email_layout_xmlid, reply_to, use_lang in product( + (False, True), + (False, '{{ (object.create_date or datetime.datetime(2022, 12, 26, 18, 0, 0)) + datetime.timedelta(days=2) }}'), (False, 'mail.test_layout'), + (False, '{{ ctx.get("custom_reply_to") or "info@test.example.com" }}'), (False, True), ): - with self.subTest(email_layout_xmlid=email_layout_xmlid, + with self.subTest(use_domain=use_domain, + scheduled_date=scheduled_date, + email_layout_xmlid=email_layout_xmlid, + reply_to=reply_to, use_lang=use_lang): # update test configuration + self.template.write({ + 'reply_to': reply_to, + 'scheduled_date': scheduled_date, + }) if use_lang: langs = ('es_ES', 'en_US') self.test_partners[0].lang = langs[0] @@ -1805,11 +2976,17 @@ class TestComposerResultsMass(TestMailComposer): self.test_partners.lang = False ctx = { - 'active_ids': self.test_records.ids, 'default_model': self.test_records._name, 'default_composition_mode': 'mass_mail', 'default_template_id': self.template.id, + # just to check template dynamic code evaluation (see reply_to above) + 'custom_reply_to': 'custom.reply.to@test.example.com', } + if use_domain: + ctx['default_res_domain'] = [('id', 'in', self.test_records.ids)] + ctx['default_force_send'] = True # otherwise domain = email queue + else: + ctx['default_res_ids'] = self.test_records.ids if email_layout_xmlid: ctx['default_email_layout_xmlid'] = email_layout_xmlid @@ -1818,40 +2995,61 @@ class TestComposerResultsMass(TestMailComposer): composer = composer_form.save() # ensure some parameters used afterwards - author = self.env.user.partner_id + author = self.partner_employee self.assertEqual(composer.author_id, author, - 'Author cannot be synchronized with a raw email_from') + 'Author is not synchronized, as template email_from does not match existing partner') self.assertEqual(composer.email_from, self.template.email_from) - with self.mock_mail_gateway(mail_unlink_sent=False): + with self.mock_mail_gateway(mail_unlink_sent=False), \ + self.mock_datetime_and_now(self.reference_now): composer._action_send_mail() + # partners created from raw emails new_partners = self.env['res.partner'].search([ ('email', 'in', [email_to_1, email_to_2, email_to_3, email_cc_1]) ]) self.assertEqual(len(new_partners), 3) + self.assertEqual(new_partners.mapped('lang'), ['en_US'] * 3, + 'New partners lang is always the default DB one, whatever the context') - # global outgoing: emails sent + # check global outgoing self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record') - self.assertEqual(len(self._mails), 10, 'Should have sent emails') - self.assertEqual(self._new_mails.mapped('scheduled_date'), - [False] * 2) - self.assertEqual(len(self._mails), 10, 'Should have sent 5 emails per record') + if not scheduled_date: + # emails sent directly + self.assertEqual(len(self._mails), 10, 'Should have sent emails') + self.assertEqual(self._new_mails.mapped('scheduled_date'), + [False] * 2) + else: + # emails not sent due to scheduled_date + self.assertEqual(len(self._mails), 0, 'Should not send emails, scheduled in the future') + self.assertEqual(self._new_mails.mapped('scheduled_date'), + [self.reference_now + timedelta(days=2)] * 2) + # simulate cron queue at right time for sending + with self.mock_datetime_and_now(self.reference_now + timedelta(days=2)): + self.env['mail.mail'].sudo().process_email_queue() + + # everything should be sent now + self.assertEqual(len(self._mails), 10, 'Should have sent 5 emails per record') + + # check email content for record, exp_lang in zip(self.test_records, langs): # message copy is kept message = record.message_ids[0] - # translation are currently not supported at all as they - # are fetched composer side, which is most probably not - # translated (only template is) - if False and exp_lang == 'es_ES': + if exp_lang == 'es_ES': exp_body = f'SpanishBody for {record.name}' exp_subject = f'SpanishSubject for {record.name}' else: exp_body = f'TemplateBody {record.name}' exp_subject = f'TemplateSubject {record.name}' - + if reply_to: + exp_reply_to = 'custom.reply.to@test.example.com' + else: + exp_reply_to = formataddr(( + author.name, + f'{self.alias_catchall}@{self.alias_domain}', + )) # template is sent only to partners (email_to are transformed) self.assertMailMail(record.customer_id + new_partners + self.partner_admin, 'sent', @@ -1867,15 +3065,14 @@ class TestComposerResultsMass(TestMailComposer): 'email_from': self.partner_employee_2.email_formatted, # profit from this test to check references are set to message_id in mailing emails 'references_message_id_check': True, + 'reply_to': exp_reply_to, 'subject': exp_subject, }, fields_values={ 'email_from': self.partner_employee_2.email_formatted, 'mail_server_id': self.mail_server_domain, - 'reply_to': formataddr(( - f'{self.env.user.company_id.name} {record.name}', - f'{self.alias_catchall}@{self.alias_domain}' - )), + 'reply_to': exp_reply_to, + 'reply_to_force_new': bool(reply_to), 'subject': exp_subject, }, ) @@ -1895,61 +3092,195 @@ class TestComposerResultsMass(TestMailComposer): bool(sent_mail), f'Expected mail from {self.partner_employee_2.email_formatted} to {formataddr((record.customer_id.name, record.customer_id.email))} not found in {debug_info}' ) - # Currently layouting in mailing mode is not supported. - # Hence no translations. - self.assertEqual( - sent_mail['body'], - f'

TemplateBody {record.name}

' + if record == self.test_records[0]: + self.assertEqual(sent_mail['email_to'], ['"Partner_0" '], + 'Should take email normalized in to') + else: + self.assertEqual(sent_mail['email_to'], ['"Partner_1" '], + 'Should take email normalized in to') + + if not email_layout_xmlid: + self.assertEqual( + sent_mail['body'], + f'

{exp_body}

' + ) + else: + exp_layout_content_en = 'English Layout for Ticket-like model' + exp_layout_content_es = 'Spanish Layout para Spanish Model Description' + exp_button_en = 'View Ticket-like model' + exp_button_es = 'Spanish Layout para Spanish Model Description' + if exp_lang == 'es_ES': + self.assertIn(exp_layout_content_es, sent_mail['body']) + self.assertIn(exp_button_es, sent_mail['body']) + else: + self.assertIn(exp_layout_content_en, sent_mail['body']) + # self.assertIn(exp_button_es, sent_mail['body']) + self.assertIn(exp_button_en, sent_mail['body']) + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_composer_wtpl_mc(self): + """ Test specific to multi-company environment, notably company propagation + or aliases. """ + # add access to second company to avoid MC rules on ticket model + self.env.user.company_ids = [(4, self.company_2.id)] + + # initial data + self.assertEqual(self.env.company, self.company_admin) + self.assertEqual(self.user_admin.company_id, self.company_admin) + + attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id) + email_to_1 = 'test.to.1@test.example.com' + self.template.write({ + 'auto_delete': False, # keep sent emails to check content + 'attachment_ids': [(0, 0, a) for a in attachment_data], + 'email_from': False, # use current user as author + 'email_layout_xmlid': 'mail.test_layout', + 'email_to': email_to_1, + 'mail_server_id': False, # let it find a server + 'partner_to': '%s, {{ object.customer_id.id if object.customer_id else "" }}' % self.partner_admin.id, + }) + attachs = self.env['ir.attachment'].sudo().search([('name', 'in', [a['name'] for a in attachment_data])]) + self.assertEqual(len(attachs), 2) + + for companies, expected_companies, expected_alias_domains in [ + ( + self.company_admin + self.company_2, + self.company_admin + self.company_2, + self.mail_alias_domain + self.mail_alias_domain_c2, + ), + ]: + with self.subTest(companies=companies): + # update test configuration + test_records = self.test_records + for company, record in zip(companies, test_records): + record.company_id = company.id + + # open a composer and run it in comment mode + composer = Form(self.env['mail.compose.message'].with_context( + default_composition_mode='mass_mail', + default_force_send=True, # force sending emails directly to check SMTP + default_model=test_records._name, + default_res_ids=test_records.ids, + default_template_id=self.template.id, + # avoid successive tests issues with followers + mail_post_autofollow_author_skip=True, + )).save() + with self.mock_mail_gateway(mail_unlink_sent=False), \ + self.mock_mail_app(): + composer._action_send_mail() + + new_partner = self.env['res.partner'].search([('email_normalized', '=', 'test.to.1@test.example.com')]) + self.assertEqual(len(new_partner), 1) + # check output, company-specific values mainly for this test + for record, exp_company, exp_alias_domain in zip( + test_records, expected_companies, expected_alias_domains + ): + # message copy is kept + message = record.message_ids[0] + recipients = record.customer_id + new_partner + self.partner_admin + self.assertMailMail( + record.customer_id + new_partner + self.partner_admin, + 'sent', + author=self.partner_employee, + mail_message=message, + email_values={ + 'headers': { + 'Return-Path': f'{exp_alias_domain.bounce_email}', + 'X-Odoo-Objects': f'{record._name}-{record.id}', + }, + 'subject': f'TemplateSubject {record.name}', + }, + fields_values={ + 'headers': { + 'Return-Path': f'{exp_alias_domain.bounce_email}', + 'X-Odoo-Objects': f'{record._name}-{record.id}', + }, + 'mail_server_id': self.env['ir.mail_server'], + 'record_alias_domain_id': exp_alias_domain, + 'record_company_id': exp_company, + 'subject': f'TemplateSubject {record.name}', + }, ) + for recipient in recipients: + smtp_to_list = [recipient.email_normalized] + if exp_alias_domain == self.mail_alias_domain: + self.assertSMTPEmailsSent( + smtp_from=f'{self.default_from}@{self.alias_domain}', + smtp_to_list=smtp_to_list, + mail_server=self.mail_server_notification, + emails_count=1, + ) + else: + self.assertSMTPEmailsSent( + smtp_from=exp_alias_domain.bounce_email, + smtp_to_list=smtp_to_list, + emails_count=1, + ) @users('employee') @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_mail_composer_wtpl_recipients(self): - """ Test various combinations of recipients: active_domain, active_id, + """ Test various combinations of recipients: res_domain, active_id, active_ids, ... to ensure fallback behavior are working. """ - # 1: active_domain + # 1: active ids composer_form = Form(self.env['mail.compose.message'].with_context( - self._get_web_context(self.test_records, add_web=True, - default_template_id=self.template.id, - active_ids=[], - default_use_active_domain=True, - default_active_domain=[('id', 'in', self.test_records.ids)]) + active_ids=self.test_records.ids, + default_composition_mode='mass_mail', + default_model=self.test_records._name, + default_template_id=self.template.id, )) composer = composer_form.save() + self.assertEqual(sorted(literal_eval(composer.res_ids)), sorted(self.test_records.ids)) + with self.mock_mail_gateway(mail_unlink_sent=True): composer._action_send_mail() + # should create emails in a single batch + self.assertEqual(self.build_email_mocked.call_count, 2, 'One build email per outgoing email') + self.assertEqual(self.mail_mail_create_mocked.call_count, 1, 'Emails are created in batch') # global outgoing - self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record') - self.assertEqual(len(self._mails), 2, 'Should have sent 1 email per record') + self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record based on active_ids') + self.assertEqual(len(self._mails), 2, 'Should have sent 1 email per record based on active_ids') for record in self.test_records: # template is sent directly using customer field, even if author is partner_employee self.assertSentEmail(self.partner_employee_2.email_formatted, record.customer_id) - # 2: active_domain not taken into account if use_active_domain is False + # 2: default_res_ids + active_ids -> res_ids takes lead composer_form = Form(self.env['mail.compose.message'].with_context( - self._get_web_context(self.test_records, add_web=True, + self._get_web_context(self.test_record, add_web=False, + default_composition_mode='mass_mail', + default_res_ids=self.test_record.ids, default_template_id=self.template.id, - default_use_active_domain=False, - default_active_domain=[('id', 'in', -1)]) + active_ids=self.test_records.ids, + ) )) composer = composer_form.save() + self.assertEqual(literal_eval(composer.res_ids), self.test_record.ids) + with self.mock_mail_gateway(mail_unlink_sent=True): composer._action_send_mail() # global outgoing - self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record') - self.assertEqual(len(self._mails), 2, 'Should have sent 1 email per record') + self.assertEqual(len(self._new_mails), 1, 'Should have taken default_res_ids (1 record)') + self.assertEqual(len(self._mails), 1, 'Should have taken default_res_ids (1 record)') + + # template is sent directly using customer field, even if author is partner_employee + self.assertSentEmail(self.partner_employee_2.email_formatted, + self.test_record.customer_id) # 3: fallback on active_id if not active_ids composer_form = Form(self.env['mail.compose.message'].with_context( - self._get_web_context(self.test_records, add_web=True, - default_template_id=self.template.id, - active_ids=[]) + active_id=self.test_record.id, + default_composition_mode='mass_mail', + default_model=self.test_records._name, + default_template_id=self.template.id, )) composer = composer_form.save() + self.assertEqual(literal_eval(composer.res_ids), self.test_record.ids) + with self.mock_mail_gateway(mail_unlink_sent=False): composer._action_send_mail() @@ -1957,9 +3288,56 @@ class TestComposerResultsMass(TestMailComposer): self.assertEqual(len(self._new_mails), 1, 'Should have created 1 mail.mail per record') self.assertEqual(len(self._mails), 1, 'Should have sent 1 email per record') - # 3: void is void: raise in comment mode, just don't send anything in mass mail mode + # 4: _batch_size limit for active_ids + with patch.object(MailComposeMessage, '_batch_size', new=1): + composer_form = Form(self.env['mail.compose.message'].with_context( + active_ids=self.test_records.ids, + default_composition_mode='mass_mail', + default_model=self.test_records._name, + default_template_id=self.template.id, + )) + composer = composer_form.save() + self.assertTrue(composer.composition_batch) + self.assertEqual(composer.composition_mode, 'mass_mail') + self.assertEqual(sorted(literal_eval(composer.res_ids)), sorted(self.test_records.ids)) + + with self.mock_mail_gateway(mail_unlink_sent=True): + composer._action_send_mail() + + # should create emails in 2 batches of 1 + self.assertEqual(self.build_email_mocked.call_count, 2) + self.assertEqual(self.mail_mail_create_mocked.call_count, 2) + # global outgoing + self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record based on active_ids') + self.assertEqual(len(self._mails), 2, 'Should have sent 1 email per record based on on active_ids') + + # 5: mail.batch_size config parameter support, for sending only + self.env['ir.config_parameter'].sudo().set_param('mail.batch_size', 1) + with patch.object(MailComposeMessage, '_batch_size', new=50): + composer_form = Form(self.env['mail.compose.message'].with_context( + active_ids=self.test_records.ids, + default_composition_mode='mass_mail', + default_model=self.test_records._name, + default_template_id=self.template.id, + )) + composer = composer_form.save() + self.assertTrue(composer.composition_batch) + self.assertEqual(composer.composition_mode, 'mass_mail') + self.assertEqual(sorted(literal_eval(composer.res_ids)), sorted(self.test_records.ids)) + + with self.mock_mail_gateway(mail_unlink_sent=True): + composer._action_send_mail() + + # should create emails in 2 batches of 1 + self.assertEqual(self.build_email_mocked.call_count, 2) + self.assertEqual(self.mail_mail_create_mocked.call_count, 2) + # global outgoing + self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record based on active_ids') + self.assertEqual(len(self._mails), 2, 'Should have sent 1 email per record based on on active_ids') + + # 6: void is void: raise in comment mode, just don't send anything in mass mail mode composer_form = Form(self.env['mail.compose.message'].with_context( - default_model='mail.test.ticket', + default_model='mail.test.ticket.mc', default_template_id=self.template.id )) composer = composer_form.save() @@ -1970,7 +3348,7 @@ class TestComposerResultsMass(TestMailComposer): composer_form = Form(self.env['mail.compose.message'].with_context( default_composition_mode='mass_mail', - default_model='mail.test.ticket', + default_model='mail.test.ticket.mc', default_template_id=self.template.id )) composer = composer_form.save() @@ -1979,6 +3357,41 @@ class TestComposerResultsMass(TestMailComposer): composer._action_send_mail() self.assertNotSentEmail() + # 7: invalid email in mass mode is exception, unless deleting messages + # If keeping logs, then it is useful to let users know that the logged message was not actually sent + # If not keeping logs, then the message would not appear anyway and we can cancel it to avoid creating it + invalid_partners = self.env['res.partner'].create([ + {'name': 'Invalid', 'email': 'InvalidEmail'}, + {'name': 'NoEmail', 'email': ''}, + ]) + invalid_records = self.env[self.test_record._name].create([ + {'name': f'Test {customer.email}', 'customer_id': customer.id} + for customer in invalid_partners + ]) + composer = self.env['mail.compose.message'].with_context(self._get_web_context(invalid_records)).create({}) + with self.mock_mail_gateway(mail_unlink_sent=False): + composer._action_send_mail() + + self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record') + self.assertEqual(self._new_mails.mapped('state'), ['exception', 'exception'], 'All emails should be errors') + self.assertEqual( + self._new_mails.notification_ids.mapped('notification_status'), ['exception', 'exception'], + ) + self.assertEqual( + self._new_mails.notification_ids.mapped('failure_type'), ['mail_email_invalid', 'mail_email_missing'], + 'One email should fail because missing, the other because invalid' + ) + + composer = self.env['mail.compose.message'].with_context(self._get_web_context(invalid_records)).create({ + 'auto_delete': True, + 'auto_delete_keep_log': False, + }) + with self.mock_mail_gateway(mail_unlink_sent=False): + composer._action_send_mail() + self.assertEqual(len(self._new_mails), 2) + self.assertEqual(self._new_mails.mapped('state'), ['cancel', 'cancel']) + self.assertFalse(self._new_mails.notification_ids) + @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') def test_mail_composer_wtpl_recipients_email_fields(self): @@ -2005,6 +3418,7 @@ class TestComposerResultsMass(TestMailComposer): 'email_from': '{{ user.email_formatted }}', 'email_to': ', '.join(email_tos + (partner_format_tofind + partner_multi_tofind + partner_at_tofind).mapped('email')), 'partner_to': f'{self.partner_1.id},{self.partner_2.id},0,test', + 'reply_to': False, }) self.user_employee.write({'email': 'email.from.1@test.mycompany.com, email.from.2@test.mycompany.com'}) self.partner_1.write({'email': '"Valid Formatted" '}) @@ -2033,8 +3447,7 @@ class TestComposerResultsMass(TestMailComposer): )) composer = composer_form.save() with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): - # don't want to test duplicates, more email management - composer.with_context(mailing_document_based=True).action_send_mail() + composer.action_send_mail() # find partners created during sending (as emails are transformed into partners) # FIXME: currently email finding based on formatted / multi emails does @@ -2111,7 +3524,7 @@ class TestComposerResultsMass(TestMailComposer): # single email event if email field is multi-email 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.mycompany.com')), 'reply_to': formataddr(( - f'{self.env.user.company_id.name} {record.name}', + self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}' )), 'subject': f'TemplateSubject {record.name}', @@ -2120,7 +3533,7 @@ class TestComposerResultsMass(TestMailComposer): # currently holding multi-email 'email_from' 'email_from': self.partner_employee.email_formatted, 'reply_to': formataddr(( - f'{self.env.user.company_id.name} {record.name}', + self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}' )), }, @@ -2148,7 +3561,7 @@ class TestComposerResultsMass(TestMailComposer): smtp_to_list = ['find.me.at@test.example.com'] else: smtp_to_list = [recipient.email_normalized] - self.assert_email_sent_smtp( + self.assertSMTPEmailsSent( smtp_from=f'{self.alias_bounce}@{self.alias_domain}', smtp_to_list=smtp_to_list, mail_server=self.mail_server_domain, @@ -2157,23 +3570,27 @@ class TestComposerResultsMass(TestMailComposer): (self.user_employee.name, 'email.from.1@test.mycompany.com', )), - # similar envelope, assert_email_sent_smtp cannot distinguish + # similar envelope, assertSMTPEmailsSent cannot distinguish # records (would have to dive into content, too complicated) emails_count=2, ) @users('employee') @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') - def test_mail_composer_wtpl_reply_to(self): + def test_mail_composer_wtpl_reply_to_default_to_from(self): # test without catchall filling reply-to composer_form = Form(self.env['mail.compose.message'].with_context( self._get_web_context(self.test_records, add_web=True, + default_reply_to_force_new=False, # force usage of standard reply to computation default_template_id=self.template.id) )) composer = composer_form.save() + + # remove alias so that _notify_get_reply_to will return the default value instead of alias + self.company_admin.write({ + 'alias_domain_id': False, + }) with self.mock_mail_gateway(mail_unlink_sent=False): - # remove alias so that _notify_get_reply_to will return the default value instead of alias - self.env['ir.config_parameter'].sudo().set_param("mail.catchall.domain", None) composer.action_send_mail() for record in self.test_records: @@ -2196,13 +3613,16 @@ class TestComposerResultsMass(TestMailComposer): @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_mail_composer_wtpl_reply_to_force_new(self): """ Test no auto thread behavior, notably with reply-to. """ + # add access to Mail Template Editor + self.user_employee.group_ids += self.env.ref('mail.group_mail_template_editor') + # launch composer in mass mode composer_form = Form(self.env['mail.compose.message'].with_context( self._get_web_context(self.test_records, add_web=True, default_template_id=self.template.id) )) composer_form.reply_to_mode = 'new' - composer_form.reply_to = "{{ '\"' + object.name + '\" <%s>' % 'dynamic.reply.to@test.com' }}" + composer_form.reply_to = "{{ '\"' + object.name + '\" <%s>' % 'dynamic.reply.to@test.mycompany.com' }}" composer = composer_form.save() self.assertTrue(composer.reply_to_force_new) with self.mock_mail_gateway(mail_unlink_sent=False): @@ -2218,7 +3638,7 @@ class TestComposerResultsMass(TestMailComposer): 'email_from': self.partner_employee_2.email_formatted, 'reply_to': formataddr(( f'{record.name}', - 'dynamic.reply.to@test.com' + 'dynamic.reply.to@test.mycompany.com' )), 'subject': 'TemplateSubject %s' % record.name, }, @@ -2226,7 +3646,7 @@ class TestComposerResultsMass(TestMailComposer): 'email_from': self.partner_employee_2.email_formatted, 'reply_to': formataddr(( f'{record.name}', - 'dynamic.reply.to@test.com' + 'dynamic.reply.to@test.mycompany.com' )), }, ) @@ -2247,11 +3667,6 @@ class TestComposerResultsMassStatus(TestMailComposer): """ super(TestComposerResultsMassStatus, cls).setUpClass() - # ensure employee can create partners, necessary for templates - cls.user_employee.write({ - 'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)], - }) - # add 2 new records with customers cls.test_records, cls.test_partners = cls._create_records_for_batch( 'mail.test.ticket.el', 4, @@ -2281,6 +3696,7 @@ class TestComposerResultsMassStatus(TestMailComposer): ]) cls.template.write({ 'model_id': cls.env['ir.model']._get_id(cls.test_records._name), + 'scheduled_date': False, }) def test_assert_initial_data(self): @@ -2331,31 +3747,24 @@ class TestComposerResultsMassStatus(TestMailComposer): ) self.assertEqual(len(self._mails), 1, 'Should have sent 1 email, and skipped an excluded email.') - @users('employee') - @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') - def test_mailing_duplicates_document_based(self): - """ Tests a document-based mass mailing with the same address mails - This should be allowed and not considered as duplicate in this context - """ - test_records = self.test_records.with_env(self.env) + # test exclusion list bypass composer_form = Form(self.env['mail.compose.message'].with_context( self._get_web_context(test_records, add_web=True, - default_template_id=self.template.id) + default_template_id=self.template.id, + default_use_exclusion_list=False) )) composer = composer_form.save() - - # by default duplicates are canceled with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): composer._action_send_mail() for record, expected_state, expected_ft in zip( test_records, - ['cancel', 'sent', 'sent', 'sent', 'cancel', 'sent', 'cancel'], - ['mail_bl', False, False, False, 'mail_dup', False, 'mail_dup'] + ['sent', 'sent'], + [False, False] ): with self.subTest(record=record, expected_state=expected_state, expected_ft=expected_ft): - self.assertMailMailWRecord( - record, record.customer_id, expected_state, + self.assertMailMail( + record.customer_id, expected_state, # author is current user, email_from is coming from template (user_id of record) author=self.user_employee.partner_id, fields_values={ @@ -2367,29 +3776,4 @@ class TestComposerResultsMassStatus(TestMailComposer): 'email_from': self.user_employee_2.email_formatted, } ) - self.assertEqual(len(self._mails), 4, 'Should have sent 4 emails, and skipped an excluded and 2 duplicate emails.') - - # magic context key allowing to send duplicates when necessary - with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): - composer.with_context(mailing_document_based=True)._action_send_mail() - - for record, expected_state, expected_ft in zip( - test_records, - ['cancel', 'sent', 'sent', 'sent', 'sent', 'sent', 'sent'], - ['mail_bl', False, False, False, False, False, False] - ): - with self.subTest(record=record, expected_state=expected_state, expected_ft=expected_ft): - self.assertMailMailWRecord( - record, record.customer_id, expected_state, - # author is current user, email_from is coming from template (user_id of record) - author=self.user_employee.partner_id, - fields_values={ - 'email_from': self.user_employee_2.email_formatted, - 'failure_reason': False, - 'failure_type': expected_ft, - }, - email_values={ - 'email_from': self.user_employee_2.email_formatted, - } - ) - self.assertEqual(len(self._mails), 6, 'Should have sent 6 emails, and skipped an excluded email') + self.assertEqual(len(self._mails), 2, 'Should have sent 2 emails, even to excluded email.') diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_composer_mixin.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_composer_mixin.py index 9591646..092bf5a 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_composer_mixin.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_composer_mixin.py @@ -1,22 +1,19 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients +from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user +from odoo.addons.test_mail.tests.common import TestRecipients +from odoo.exceptions import AccessError from odoo.tests import tagged from odoo.tests.common import users @tagged('mail_composer_mixin') -class TestMailComposerMixin(TestMailCommon, TestRecipients): +class TestMailComposerMixin(MailCommon, TestRecipients): @classmethod def setUpClass(cls): - super(TestMailComposerMixin, cls).setUpClass() - - # ensure employee can create partners, necessary for templates - cls.user_employee.write({ - 'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)], - }) + super().setUpClass() cls.mail_template = cls.env['mail.template'].create({ 'body_html': '

EnglishBody for

', @@ -30,6 +27,22 @@ class TestMailComposerMixin(TestMailCommon, TestRecipients): 'customer_id': cls.partner_1.id, }) + # Enable group-based template management + cls.env['ir.config_parameter'].set_param('mail.restrict.template.rendering', True) + + # User without the group "mail.group_mail_template_editor" + cls.user_rendering_restricted = mail_new_test_user( + cls.env, + company_id=cls.company_admin.id, + groups='base.group_user', + login='user_rendering_restricted', + name='Code Template Restricted User', + notification_type='inbox', + signature='--\nErnest' + ) + cls.user_rendering_restricted.group_ids -= cls.env.ref('mail.group_mail_template_editor') + cls.user_employee.group_ids += cls.env.ref('mail.group_mail_template_editor') + cls._activate_multi_lang( layout_arch_db=' English Layout for ', lang_code='es_ES', @@ -41,19 +54,86 @@ class TestMailComposerMixin(TestMailCommon, TestRecipients): def test_content_sync(self): """ Test updating template updates the dynamic fields accordingly. """ source = self.test_record.with_env(self.env) + template = self.mail_template.with_env(self.env) + template_void = template.copy() + template_void.write({ + 'body_html': '


', + 'lang': False, + 'subject': False, + }) + composer = self.env['mail.test.composer.mixin'].create({ + 'name': 'Invite', + 'template_id': template.id, + 'source_ids': [(4, source.id)], + }) + self.assertEqual(composer.body, template.body_html) + self.assertTrue(composer.body_has_template_value) + self.assertEqual(composer.lang, template.lang) + self.assertEqual(composer.subject, template.subject) + + # check rendering + body = composer._render_field('body', source.ids)[source.id] + self.assertEqual(body, f'

EnglishBody for {source.name}

') + subject = composer._render_field('subject', source.ids)[source.id] + self.assertEqual(subject, f'EnglishSubject for {source.name}') + + # manual values > template default values + composer.write({ + 'body': '

CustomBody for

', + 'subject': 'CustomSubject for {{ object.name }}', + }) + self.assertFalse(composer.body_has_template_value) + + body = composer._render_field('body', source.ids)[source.id] + self.assertEqual(body, f'

CustomBody for {source.name}

') + subject = composer._render_field('subject', source.ids)[source.id] + self.assertEqual(subject, f'CustomSubject for {source.name}') + + # template with void values: should not force void (TODO) + composer.template_id = template_void.id + self.assertEqual(composer.body, '

CustomBody for

') + self.assertFalse(composer.body_has_template_value) + self.assertEqual(composer.lang, template.lang) + self.assertEqual(composer.subject, 'CustomSubject for {{ object.name }}') + + # reset template TOOD should reset + composer.write({'template_id': False}) + self.assertFalse(composer.body) + self.assertFalse(composer.body_has_template_value) + self.assertFalse(composer.lang) + self.assertFalse(composer.subject) + + @users("user_rendering_restricted") + def test_mail_composer_mixin_render_lang(self): + """ Test _render_lang when rendering is involved, depending on template + editor rights. """ + source = self.test_record.with_env(self.env) + composer = self.env['mail.test.composer.mixin'].create({ + 'description': '

Description for

', 'name': 'Invite', 'template_id': self.mail_template.id, 'source_ids': [(4, source.id)], }) - self.assertEqual(composer.body, self.mail_template.body_html) - self.assertEqual(composer.subject, self.mail_template.subject) - self.assertFalse(composer.lang, 'Fixme: lang is not propagated currently') - subject = composer._render_field('subject', source.ids)[source.id] - self.assertEqual(subject, f'EnglishSubject for {source.name}') - body = composer._render_field('body', source.ids)[source.id] - self.assertEqual(body, f'

EnglishBody for {source.name}

') + # _render_lang should be ok when content is the same as template + rendered = composer._render_lang(source.ids) + self.assertEqual(rendered, {source.id: self.partner_1.lang}) + + # _render_lang should crash when content is dynamic and not coming from template + composer.lang = " {{ 'en_US' }}" + with self.assertRaises(AccessError): + rendered = composer._render_lang(source.ids) + + # _render_lang should not crash when content is not coming from template + # but not dynamic and/or is actually the default computed based on partner + for lang_value, expected in [ + (False, self.partner_1.lang), ("", self.partner_1.lang), ("fr_FR", "fr_FR") + ]: + with self.subTest(lang_value=lang_value): + composer.lang = lang_value + rendered = composer._render_lang(source.ids) + self.assertEqual(rendered, {source.id: expected}) @users("employee") def test_rendering_custom(self): @@ -84,7 +164,6 @@ class TestMailComposerMixin(TestMailCommon, TestRecipients): source = self.test_record.with_env(self.env) composer = self.env['mail.test.composer.mixin'].create({ 'description': '

Description for

', - 'lang': '{{ object.customer_id.lang }}', 'name': 'Invite', 'template_id': self.mail_template.id, 'source_ids': [(4, source.id)], @@ -103,11 +182,22 @@ class TestMailComposerMixin(TestMailCommon, TestRecipients): # ask for dynamic language computation subject = composer._render_field('subject', source.ids, compute_lang=True)[source.id] - self.assertEqual(subject, f'EnglishSubject for {source.name}', - 'Fixme: translations are not done, as taking composer translations and not template one') + self.assertEqual(subject, f'SpanishSubject for {source.name}', + 'Translation comes from the template, as both values equal') body = composer._render_field('body', source.ids, compute_lang=True)[source.id] - self.assertEqual(body, f'

EnglishBody for {source.name}

', - 'Fixme: translations are not done, as taking composer translations and not template one' - ) + self.assertEqual(body, f'

SpanishBody for {source.name}

', + 'Translation comes from the template, as both values equal') description = composer._render_field('description', source.ids)[source.id] self.assertEqual(description, f'

Description for {source.name}

') + + # check default computation when 'lang' is void -> actually rerouted to template lang + composer.lang = False + subject = composer._render_field('subject', source.ids, compute_lang=True)[source.id] + self.assertEqual(subject, f'SpanishSubject for {source.name}', + 'Translation comes from the template, as both values equal') + + # check default computation when 'lang' is void in both -> main customer lang + self.mail_template.lang = False + subject = composer._render_field('subject', source.ids, compute_lang=True)[source.id] + self.assertEqual(subject, f'SpanishSubject for {source.name}', + 'Translation comes from customer lang, being default when no value is rendered') diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_flow.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_flow.py new file mode 100644 index 0000000..54dc263 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_flow.py @@ -0,0 +1,558 @@ +from odoo.addons.mail.tests.common import mail_new_test_user, MailCommon +from odoo.addons.test_mail.data.test_mail_data import MAIL_TEMPLATE, MAIL_TEMPLATE_SHORT +from odoo.addons.test_mail.tests.common import TestRecipients +from odoo.tools.mail import formataddr +from odoo.tests import tagged + + +@tagged('mail_gateway', 'mail_flow', 'post_install', '-at_install') +class TestMailFlow(MailCommon, TestRecipients): + """ Test flows matching business cases with incoming / outgoing emails. """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.user_employee_2 = mail_new_test_user( + cls.env, + company_id=cls.user_employee.company_id.id, + email='eglantine@example.com', + groups='base.group_user,base.group_partner_manager', + login='employee2', + name='Eglantine Employee', + notification_type='email', + signature='--\nEglantine', + ) + cls.partner_employee_2 = cls.user_employee_2.partner_id + cls.user_employee_3 = mail_new_test_user( + cls.env, + company_id=cls.user_employee.company_id.id, + email='emmanuel@example.com', + groups='base.group_user,base.group_partner_manager', + login='employee3', + name='Emmanuel Employee', + notification_type='email', + signature='--\nEmmanuel', + ) + cls.partner_employee_3 = cls.user_employee_3.partner_id + cls.user_portal = cls._create_portal_user() + cls.partner_portal = cls.user_portal.partner_id + + cls.test_emails = [ + # emails only + '"Sylvie Lelitre" ', + '"Josiane Quichopoils" ', + 'pay@zboing.com', + 'invoicing@zboing.com', + # existing partners + '"Robert Brutijus" ', + # existing portal users + '"Portal Zboing" ', + ] + cls.test_emails_normalized = [ + 'sylvie.lelitre@zboing.com', 'accounting@zboing.com', 'invoicing@zboing.com', + 'pay@zboing.com', 'robert@zboing.com', 'portal@zboing.com', + ] + cls.customer_zboing = cls.env['res.partner'].create({ + 'email': cls.test_emails[4], + 'name': 'Robert Brutijus', + 'phone': '+32455335577', + }) + cls.user_portal_zboing = mail_new_test_user( + cls.env, + email=cls.test_emails[5], + groups='base.group_portal', + login='portal_zboing', + name='Portal Zboing', + ) + cls.customer_portal_zboing = cls.user_portal_zboing.partner_id + + # lead@test.mycompany.com will cause the creation of new mail.test.lead + cls.mail_test_lead_model = cls.env['ir.model']._get('mail.test.lead') + cls.alias = cls.env['mail.alias'].create({ + 'alias_domain_id': cls.mail_alias_domain.id, + 'alias_contact': 'everyone', + 'alias_model_id': cls.mail_test_lead_model.id, + 'alias_name': 'lead', + }) + # help@test.mycompany.com will cause the creation of new mail.test.ticket.mc + cls.ticket_template = cls.env['mail.template'].create({ + 'auto_delete': True, + 'body_html': '

Received

', + 'email_from': '{{ object.user_id.email_formatted or user.email_formatted }}', + 'lang': '{{ object.customer_id.lang }}', + 'model_id': cls.env['ir.model']._get_id('mail.test.ticket.partner'), + 'name': 'Received', + 'subject': 'Received {{ object.name }}', + 'use_default_to': True, + }) + cls.container = cls.env['mail.test.container.mc'].create({ + # triggers automatic answer yay ! + 'alias_defaults': {'state': 'new', 'state_template_id': cls.ticket_template.id}, + 'alias_name': 'help', + 'company_id': cls.user_employee.company_id.id, + 'name': 'help', + }) + cls.container.alias_id.write({ + 'alias_model_id': cls.env['ir.model']._get_id('mail.test.ticket.partner') + }) + + def test_assert_initial_values(self): + """ Assert base values for tests """ + self.assertEqual( + self.env['res.partner'].search([('email_normalized', 'in', self.test_emails_normalized)]), + self.customer_zboing + self.customer_portal_zboing, + ) + + def test_lead_email_to_email(self): + """ Test email-to-email (e.g. gmail) usage """ + self.user_employee.notification_type = 'email' + lead = self.env['mail.test.lead'].with_user(self.user_employee).create({ + 'partner_id': self.customer_zboing.id, + }) + # employee posts, pinging the customer + recipients = lead._message_get_suggested_recipients( + reply_discussion=True, no_create=False, + ) + self.assertEqual(recipients, [{ + 'create_values': {}, + 'email': self.customer_zboing.email_normalized, + 'name': self.customer_zboing.name, + 'partner_id': self.customer_zboing.id, + }]) + with self.mock_mail_gateway(), self.mock_mail_app(): + emp_msg = lead.message_post( + body='Hello @customer', + message_type='comment', + partner_ids=[recipients[0]['partner_id']], + subtype_xmlid='mail.mt_comment', + ) + reply_to_emp = emp_msg.reply_to + self.assertEqual(reply_to_emp, formataddr((self.user_employee.name, f'{self.alias_catchall}@{self.alias_domain}'))) + self.assertSMTPEmailsSent( + mail_server=self.mail_server_notification, + msg_from=formataddr( + (self.partner_employee.name, f'{self.default_from}@{self.alias_domain}') + ), + smtp_from=self.mail_server_notification.from_filter, + smtp_to_list=[self.customer_zboing.email_normalized], + msg_to_lst=[self.customer_zboing.email_formatted], + ) + + # customer replies from their email reader, adds a CC and someone in the To + cust_reply = self.gateway_mail_reply_from_smtp_email( + MAIL_TEMPLATE_SHORT, [self.customer_zboing.email_normalized], reply_all=True, + add_to_lst=[self.test_emails[0]], cc=self.test_emails[1], + ) + self.assertMailNotifications( + cust_reply, + [ + { + 'content': "Eli alla à l'eau", + 'message_type': 'email', + 'message_values': { + 'author_id': self.customer_zboing, + 'email_from': self.customer_zboing.email_formatted, + 'incoming_email_cc': self.test_emails[1], + # be sure to not have catchall.test inside the incoming_email_to ! + 'incoming_email_to': self.test_emails[0], + 'notified_partner_ids': self.user_employee.partner_id, + # only recognized partners + 'partner_ids': self.env['res.partner'], + 'reply_to': formataddr((self.customer_zboing.name, f'{self.alias_catchall}@{self.alias_domain}')), + 'subject': 'Re: False', + 'subtype_id': self.env.ref('mail.mt_comment'), + }, + 'notif': [{'partner': self.user_employee.partner_id, 'type': 'email'}], + }, + ], + ) + self.assertSMTPEmailsSent( + mail_server=self.mail_server_notification, + msg_from=formataddr( + (self.customer_zboing.name, f'{self.default_from}@{self.alias_domain}') + ), + smtp_from=self.mail_server_notification.from_filter, + smtp_to_list=[self.user_employee.email_normalized], + # customers in To/Cc of reply added in envelope to keep them in discussions + msg_to_lst=[self.user_employee.email_formatted, self.test_emails[0], self.test_emails[1]], + msg_cc_lst=[], + ) + + # employee replies from their email reader, adds their colleague + emp_reply = self.gateway_mail_reply_from_smtp_email( + MAIL_TEMPLATE_SHORT, [self.user_employee.email_normalized], reply_all=True, + cc=self.partner_employee_2.email_formatted, + ) + self.assertMailNotifications( + emp_reply, + [ + { + 'content': "Eli alla à l'eau", + 'message_type': 'email', + 'message_values': { + 'author_id': self.partner_employee, + 'email_from': self.partner_employee.email_formatted, + 'incoming_email_cc': self.partner_employee_2.email_formatted, + # be sure not to have catchall reply-to ! customers are in 'To' due to Reply-All + 'incoming_email_to': f'{self.test_emails[0]}, {self.test_emails[1]}', + 'notified_partner_ids': self.customer_zboing, + # only recognized partners + 'partner_ids': self.partner_employee_2, + 'subject': 'Re: Re: False', + 'subtype_id': self.env.ref('mail.mt_comment'), + }, + # partner_employee_2 received an email, hence no duplicate notification + 'notif': [{'partner': self.customer_zboing, 'type': 'email'}], + }, + ], + ) + self.assertSMTPEmailsSent( + mail_server=self.mail_server_notification, + msg_from=formataddr( + (self.partner_employee.name, f'{self.default_from}@{self.alias_domain}') + ), + smtp_from=self.mail_server_notification.from_filter, + smtp_to_list=[self.customer_zboing.email_normalized], + # customers are still in discussion + msg_to_lst=[self.customer_zboing.email_formatted, self.partner_employee_2.email_formatted, self.test_emails[0], self.test_emails[1]], + msg_cc_lst=[], + ) + + def test_lead_mailgateway(self): + """ Flow of this test + * incoming email creating a lead -> email set as first message + * a salesperson is assigned + * - he adds followers (internal and portal) + * - he replies through chatter, using suggested recipients + * - customer replies, adding other people + + Tested features + * cc / to support + * suggested recipients computation + * outgoing SMTP envelope + + Recipients + * incoming: From: sylvie (email) - To: employee, accounting (email) - Cc: pay (email), portal (portal) + * reply: creates partner for sylvie and pay through suggested recipients + * customer reply: Cc: invoicing (email) and robert (partner) + """ + # incoming customer email: lead alias + recipients (to + cc) + # ------------------------------------------------------------ + email_to = f'lead@{self.alias_domain}, {self.test_emails[1]}, {self.partner_employee.email_formatted}' + email_to_filtered = f'{self.test_emails[1]}, {self.partner_employee.email_formatted}' + email_cc = f'{self.test_emails[2]}, {self.test_emails[5]}' + with self.mock_mail_gateway(), self.mock_mail_app(): + lead = self.format_and_process( + MAIL_TEMPLATE, + self.test_emails[0], + email_to, + cc=email_cc, + subject='Inquiry', + target_model='mail.test.lead', + ) + self.assertEqual(lead.email_cc, email_cc, 'Filled by mail.thread.cc mixin') + self.assertEqual(lead.email_from, self.test_emails[0]) + self.assertEqual(lead.name, 'Inquiry') + self.assertFalse(lead.partner_id) + # followers + self.assertFalse(lead.message_partner_ids) + # messages + self.assertEqual(len(lead.message_ids), 1, 'Incoming email should be only message, no creation message') + incoming_email = lead.message_ids + self.assertMailNotifications( + incoming_email, + [ + { + 'content': 'Please call me as soon as possible', + 'message_type': 'email', + 'message_values': { + 'author_id': self.env['res.partner'], + 'email_from': self.test_emails[0], + 'incoming_email_cc': email_cc, + 'incoming_email_to': email_to_filtered, + 'mail_server_id': self.env['ir.mail_server'], + 'parent_id': self.env['mail.message'], + 'notified_partner_ids': self.env['res.partner'], + # only recognized partners + 'partner_ids': self.partner_employee + self.customer_portal_zboing, + 'subject': 'Inquiry', + 'subtype_id': self.env.ref('mail.mt_comment'), + }, + 'notif': [], # no notif, mailgateway sets recipients without notification + }, + ], + ) + + # user is assigned, should notify him + with self.mock_mail_gateway(), self.mock_mail_app(): + lead.write({'user_id': self.user_employee.id}) + lead_as_emp = lead.with_user(self.user_employee.id) + self.assertEqual(lead_as_emp.message_partner_ids, self.partner_employee) + + # adds other employee and a portal customer as followers + lead_as_emp.message_subscribe(partner_ids=(self.partner_employee_2 + self.partner_portal).ids) + self.assertEqual(lead_as_emp.message_partner_ids, self.partner_employee + self.partner_employee_2 + self.partner_portal) + # updates some customer information + lead_as_emp.write({ + 'customer_name': 'Sylvie Lelitre (Zboing)', + 'phone': '+32455001122', + 'lang_code': 'fr_FR', + }) + + # uses Chatter: fetches suggested recipients, post a message + # - checks all suggested: email_cc field, primary email + # ------------------------------------------------------------ + suggested_all = lead_as_emp._message_get_suggested_recipients( + reply_discussion=True, no_create=False, + ) + partner_sylvie = self.env['res.partner'].search( + [('email_normalized', '=', 'sylvie.lelitre@zboing.com')] + ) + partner_pay = self.env['res.partner'].search( + [('email_normalized', '=', 'pay@zboing.com')] + ) + partner_accounting = self.env['res.partner'].search( + [('email_normalized', '=', 'accounting@zboing.com')] + ) + expected_all = [ + { # existing partners come first + 'create_values': {}, + 'email': 'portal@zboing.com', + 'name': 'Portal Zboing', + 'partner_id': self.customer_portal_zboing.id, + }, + { # primary email comes first + 'create_values': {}, + 'email': 'sylvie.lelitre@zboing.com', + 'name': 'Sylvie Lelitre (Zboing)', + 'partner_id': partner_sylvie.id, + }, + { # mail.thread.cc: email_cc field + 'create_values': {}, + 'email': 'pay@zboing.com', + 'name': 'pay@zboing.com', + 'partner_id': partner_pay.id, + }, + { # reply message + 'create_values': {}, + 'email': 'accounting@zboing.com', + 'name': 'Josiane Quichopoils', + 'partner_id': partner_accounting.id, + }, + ] + for suggested, expected in zip(suggested_all, expected_all): + self.assertDictEqual(suggested, expected) + + # finally post the message with recipients + with self.mock_mail_gateway(): + responsible_answer = lead_as_emp.message_post( + body='

Well received !', + partner_ids=(partner_sylvie + partner_pay + partner_accounting + self.customer_portal_zboing).ids, + message_type='comment', + subject=f'Re: {lead.name}', + subtype_id=self.env.ref('mail.mt_comment').id, + ) + self.assertEqual(lead_as_emp.message_partner_ids, self.partner_employee + self.partner_employee_2 + self.partner_portal) + + external_partners = partner_sylvie + partner_pay + partner_accounting + self.customer_portal_zboing + self.partner_portal + internal_partners = self.partner_employee + self.partner_employee_2 + self.assertMailNotifications( + responsible_answer, + [ + { + 'content': 'Well received !', + 'mail_mail_values': { + 'mail_server_id': self.env['ir.mail_server'], # no specified server + }, + 'message_type': 'comment', + 'message_values': { + 'author_id': self.partner_employee, + 'email_from': self.partner_employee.email_formatted, + 'incoming_email_cc': False, + 'incoming_email_to': False, + 'mail_server_id': self.env['ir.mail_server'], + # followers + recipients - author + 'notified_partner_ids': external_partners + self.partner_employee_2, + 'parent_id': incoming_email, + # matches posted message + 'partner_ids': partner_sylvie + partner_pay + partner_accounting + self.customer_portal_zboing, + 'reply_to': formataddr(( + self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}' + )), + 'subtype_id': self.env.ref('mail.mt_comment'), + }, + 'notif': [ + {'partner': partner_sylvie, 'type': 'email'}, + {'partner': partner_pay, 'type': 'email'}, + {'partner': partner_accounting, 'type': 'email'}, + {'partner': self.customer_portal_zboing, 'type': 'email'}, + {'partner': self.partner_employee_2, 'type': 'email'}, + {'partner': self.partner_portal, 'type': 'email'}, + ], + }, + ], + ) + # expected Msg['To'] : Reply-All behavior: actual recipient, then + # all "not internal partners" and catchall (to receive answers) + for partner in responsible_answer.notified_partner_ids: + exp_msg_to_partners = partner | external_partners + exp_msg_to = exp_msg_to_partners.mapped('email_formatted') + with self.subTest(name=partner.name): + self.assertSMTPEmailsSent( + mail_server=self.mail_server_notification, + msg_from=formataddr( + (self.partner_employee.name, f'{self.default_from}@{self.alias_domain}') + ), + smtp_from=self.mail_server_notification.from_filter, + smtp_to_list=[partner.email_normalized], + msg_to_lst=exp_msg_to, + ) + + # customer replies using "Reply All" + adds new people + # added: Cc: invoicing (email) and robert (partner) + # ------------------------------------------------------------ + self.gateway_mail_reply_from_smtp_email( + MAIL_TEMPLATE, [partner_sylvie.email_normalized], reply_all=True, + cc=f'{self.test_emails[3]}, {self.test_emails[4]}', # used mainly for existing partners currently + ) + external_partners += self.customer_zboing # added in CC just above + self.assertEqual(len(lead.message_ids), 3, 'Incoming email + chatter reply + customer reply') + self.assertEqual( + lead.message_partner_ids, + internal_partners + self.partner_portal, + 'Mail gateway: author (partner_sylvie) should not added in followers if external') + + customer_reply = lead.message_ids[0] + self.assertMailNotifications( + customer_reply, + [ + { + 'content': 'Please call me as soon as possible', + 'message_type': 'email', + 'message_values': { + 'author_id': partner_sylvie, + 'email_from': partner_sylvie.email_formatted, + # Cc: received email CC - an email still not partnerized (invoicing) and customer_zboing + 'incoming_email_cc': f'{self.test_emails[3]}, {self.test_emails[4]}', + # To: received email Msg-To - customer who replies + email Reply-To + 'incoming_email_to': ', '.join((external_partners - partner_sylvie - self.customer_zboing).mapped('email_formatted')), + 'mail_server_id': self.env['ir.mail_server'], + # notified: followers - already mailed, aka internal only + 'notified_partner_ids': internal_partners, + 'parent_id': responsible_answer, + # same reasoning as email_to/cc + 'partner_ids': external_partners - partner_sylvie, + 'reply_to': formataddr(( + partner_sylvie.name, f'{self.alias_catchall}@{self.alias_domain}' + )), + 'subject': f'Re: Re: {lead.name}', + 'subtype_id': self.env.ref('mail.mt_comment'), + }, + # portal was already in email_to, hence not notified twice through odoo + 'notif': [ + {'partner': self.partner_employee, 'type': 'inbox'}, + {'partner': self.partner_employee_2, 'type': 'email'}, + ], + }, + ], + ) + + def test_ticket_mailgateway(self): + """ Flow of this test + * incoming email creating a ticket in 'new' state + * automatic answer based on template + """ + # incoming customer email: help alias + recipients (to + cc) + # ------------------------------------------------------------ + email_to = f'help@{self.alias_domain}, {self.test_emails[1]}, {self.partner_employee.email_formatted}' + email_to_filtered = f'{self.test_emails[1]}, {self.partner_employee.email_formatted}' + email_cc = f'{self.test_emails[2]}, {self.test_emails[5]}' + with self.mock_mail_gateway(), self.mock_mail_app(): + ticket = self.format_and_process( + MAIL_TEMPLATE, + self.test_emails[0], + email_to, + cc=email_cc, + subject='Inquiry', + target_model='mail.test.ticket.partner', + ) + self.flush_tracking() + + # author -> partner, as automatic email creates partner + partner_sylvie = self.env['res.partner'].search([('email_normalized', '=', 'sylvie.lelitre@zboing.com')]) + self.assertTrue(partner_sylvie, 'Acknowledgement template should create a partner for incoming email') + self.assertEqual(partner_sylvie.email, 'sylvie.lelitre@zboing.com', 'Should parse name/email correctly') + self.assertEqual(partner_sylvie.name, 'sylvie.lelitre@zboing.com', 'TDE FIXME: should parse name/email correctly') + # create ticket + self.assertEqual(ticket.container_id, self.container) + self.assertEqual( + ticket.customer_id, partner_sylvie, + 'Should put partner as customer, due to after hook') + self.assertEqual(ticket.email_from, self.test_emails[0]) + self.assertEqual(ticket.name, 'Inquiry') + self.assertEqual(ticket.state, 'new', 'Should come from alias defaults') + self.assertEqual(ticket.state_template_id, self.ticket_template, 'Should come from alias defaults') + # followers + self.assertFalse(ticket.message_partner_ids) + # messages + self.assertEqual(len(ticket.message_ids), 3, 'Incoming email + Acknowledgement + Tracking') + + # first message: incoming email + incoming_email = ticket.message_ids[2] + self.assertMailNotifications( + incoming_email, + [ + { + 'content': 'Please call me as soon as possible', + 'message_type': 'email', + 'message_values': { + 'author_id': self.env['res.partner'], + 'email_from': self.test_emails[0], + # coming from incoming email + 'incoming_email_cc': email_cc, + 'incoming_email_to': email_to_filtered, + 'mail_server_id': self.env['ir.mail_server'], + 'parent_id': self.env['mail.message'], + 'notified_partner_ids': self.env['res.partner'], + # only recognized partners + 'partner_ids': self.partner_employee + self.customer_portal_zboing, + 'subject': 'Inquiry', + # subtype from '_creation_subtype' + 'subtype_id': self.env.ref('test_mail.st_mail_test_ticket_partner_new'), + }, + 'notif': [], # no notif, mailgateway sets recipients without notification + }, + ], + ) + + # second message: acknowledgement + acknowledgement = ticket.message_ids[1] + self.assertMailNotifications( + acknowledgement, + [ + { + 'content': f'Received {ticket.name}', + 'message_type': 'auto_comment', + 'message_values': { + # defined by template, root is the cron user as no responsible + 'author_id': self.partner_root, + 'email_from': self.partner_root.email_formatted, + 'incoming_email_cc': False, + 'incoming_email_to': False, + 'mail_server_id': self.env['ir.mail_server'], + # no followers, hence only template default_to + 'notified_partner_ids': partner_sylvie, + 'parent_id': incoming_email, + # no followers, hence only template default_to + 'partner_ids': partner_sylvie, + 'subject': f'Received {ticket.name}', + # subtype from '_track_template' + 'subtype_id': self.env.ref('mail.mt_note'), + }, + 'notif': [ + {'partner': partner_sylvie, 'type': 'email',}, + ], + }, + ], + ) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_followers.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_followers.py index 68e9241..21cf133 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_followers.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_followers.py @@ -1,14 +1,23 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.addons.test_mail.tests.common import TestMailCommon +import re +from unittest.mock import patch +from urllib.parse import urlparse + +from markupsafe import Markup + +from odoo import Command +from odoo.addons.mail.models.mail_mail import _UNFOLLOW_REGEX +from odoo.addons.mail.tests.common import MailCommon from odoo.exceptions import AccessError from odoo.tests import tagged, users +from odoo.tests.common import HttpCase from odoo.tools import mute_logger @tagged('mail_followers') -class BaseFollowersTest(TestMailCommon): +class BaseFollowersTest(MailCommon): @classmethod def setUpClass(cls): @@ -16,9 +25,6 @@ class BaseFollowersTest(TestMailCommon): cls.test_record = cls.env['mail.test.simple'].with_context(cls._test_context).create({'name': 'Test', 'email_from': 'ignasse@example.com'}) cls._create_portal_user() - # allow employee to update partners - cls.user_employee.write({'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)]}) - Subtype = cls.env['mail.message.subtype'] # global cls.mt_al_def = Subtype.create({'name': 'mt_al_def', 'default': True, 'res_model': False}) @@ -52,6 +58,8 @@ class BaseFollowersTest(TestMailCommon): followed_after = self.env['mail.test.simple'].search([('message_partner_ids', 'in', partner.ids)]) self.assertTrue(partner in test_record.message_partner_ids) self.assertEqual(followed_before + test_record, followed_after) + with self.assertRaisesRegex(AccessError, 'Portal users can only filter threads'): + self.env['mail.test.simple'].with_user(self.user_portal).search([('message_partner_ids', 'in', partner.ids)]) def test_field_followers(self): test_record = self.test_record.with_user(self.user_employee) @@ -141,7 +149,7 @@ class BaseFollowersTest(TestMailCommon): 'name': 'Valid Lelitre', 'email': 'valid.lelitre@agrolait.com', 'country_id': self.env.ref('base.be').id, - 'mobile': '0456001122', + 'phone': '0456001122', 'active': False, }) document = self.env['mail.test.simple'].browse(self.test_record.id) @@ -195,6 +203,18 @@ class BaseFollowersTest(TestMailCommon): test_record.write({'message_partner_ids': [(4, partner0.id), (4, partner1.id)]}) self.assertEqual(test_record.message_follower_ids.partner_id, partner1) + # Test when the method inverse is called in batch + other_record = test_record.create({ + 'name': 'Other', + }) + records = test_record + other_record + + records.message_partner_ids = (partner2 + partner3) + self.assertEqual(records.message_partner_ids, partner2 + partner3) + + records.message_partner_ids -= partner2 + self.assertEqual(records.message_partner_ids, partner3) + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.models') def test_followers_inverse_message_partner_access_rights(self): """ Make sure we're not bypassing security checks by setting a partner @@ -218,14 +238,16 @@ class BaseFollowersTest(TestMailCommon): @users('employee') def test_followers_private_address(self): - """ Test standard API does not subscribe private addresses """ - private_address = self.env['res.partner'].sudo().create({ + """ Test standard API does subscribe IDs the user can't read """ + other_company = self.env['res.company'].sudo().create({'name': 'Other Company'}) + private_address = self.env['res.partner'].create({ 'name': 'Private Address', - 'type': 'private', + 'company_id': other_company.id, }) + self.env.user.write({'company_ids': [(3, other_company.id)]}) document = self.env['mail.test.simple'].browse(self.test_record.id) document.message_subscribe(partner_ids=(self.partner_portal | private_address).ids) - self.assertEqual(document.message_follower_ids.partner_id, self.partner_portal) + self.assertEqual(document.message_follower_ids.partner_id, self.partner_portal | private_address) # works through low-level API document._message_subscribe(partner_ids=(self.partner_portal | private_address).ids) @@ -255,7 +277,7 @@ class BaseFollowersTest(TestMailCommon): @tagged('mail_followers') -class AdvancedFollowersTest(TestMailCommon): +class AdvancedFollowersTest(MailCommon): @classmethod def setUpClass(cls): @@ -288,6 +310,10 @@ class AdvancedFollowersTest(TestMailCommon): 'name': 'Default track subtype', 'default': True, 'internal': False, 'res_model': 'mail.test.track' }) + cls.sub_track_parent_def = Subtype.create({ + 'name': 'Parent track subtype', 'default': False, 'res_model': 'mail.test.track', + 'parent_id': cls.sub_track_def.id, 'relation_field': 'parent_id' + }) # mail.test.container subtypes (aka: project records) cls.umb_nodef = Subtype.create({ @@ -327,7 +353,18 @@ class AdvancedFollowersTest(TestMailCommon): def test_auto_subscribe_create(self): """ Creator of records are automatically added as followers """ - self.assertEqual(self.test_track.message_partner_ids, self.user_employee.partner_id) + for user, should_subscribe in [ + (self.user_root, False), + (self.user_employee, True), + (self.user_portal, False), + ]: + with self.subTest(user_name=user.name): + # sudo, as done through mailgateway for example + if user == self.user_portal: + new_rec = self.env['mail.test.track'].with_user(user).sudo().create({}) + else: + new_rec = self.env['mail.test.track'].with_user(user).create({}) + self.assertEqual(new_rec.message_partner_ids, user.partner_id if should_subscribe else self.env['res.partner']) @mute_logger('odoo.models.unlink') def test_auto_subscribe_inactive(self): @@ -355,19 +392,27 @@ class AdvancedFollowersTest(TestMailCommon): 'Does not subscribe inactive partner') def test_auto_subscribe_post(self): - """ People posting a message are automatically added as followers """ - self.test_track.with_user(self.user_admin).message_post(body='Coucou hibou', message_type='comment') - self.assertEqual(self.test_track.message_partner_ids, self.user_employee.partner_id | self.user_admin.partner_id) - - def test_auto_subscribe_post_email(self): - """ People posting an email are automatically added as followers """ - self.test_track.with_user(self.user_admin).message_post(body='Coucou hibou', message_type='email') - self.assertEqual(self.test_track.message_partner_ids, self.user_employee.partner_id | self.user_admin.partner_id) - - def test_auto_subscribe_not_on_notification(self): - """ People posting an automatic notification are not subscribed """ - self.test_track.with_user(self.user_admin).message_post(body='Coucou hibou', message_type='notification') - self.assertEqual(self.test_track.message_partner_ids, self.user_employee.partner_id) + """ People posting a discussion message are automatically added as + followers """ + record = self.test_track.with_user(self.user_admin) + for message_type, subtype, should_subscribe in [ + ('comment', self.env.ref('mail.mt_note'), False), + ('comment', self.env.ref('mail.mt_comment'), True), + ('email_outgoing', self.env.ref('mail.mt_note'), False), + ('email_outgoing', self.env.ref('mail.mt_comment'), True), + ('notification', self.env.ref('mail.mt_comment'), False), + ]: + with self.subTest(message_type=message_type, subtype_name=subtype.name): + record.message_unsubscribe(partner_ids=self.user_admin.partner_id.ids) + record.message_post( + body=f'Posting with {message_type} {subtype.name}', + message_type=message_type, + subtype_id=subtype.id, + ) + if should_subscribe: + self.assertIn(self.user_admin.partner_id, record.message_partner_ids) + else: + self.assertNotIn(self.user_admin.partner_id, record.message_partner_ids) def test_auto_subscribe_responsible(self): """ Responsibles are tracked and added as followers """ @@ -465,8 +510,23 @@ class AdvancedFollowersTest(TestMailCommon): 'AutoSubscribe: at create auto subscribe as creator + from parent take both subtypes' ) + container.message_follower_ids = [Command.clear()] + parent_track = self.env['mail.test.track'].with_user(self.user_employee).create({ + 'name': 'Task-Like', + 'container_id': container.id, + }) + + child_track = self.env['mail.test.track'].with_user(self.user_admin).create({ + 'name': 'Task-Like Test-sub-task', + 'parent_id': parent_track.id, + 'container_id': container.id, + }) + self.assertIn(self.user_employee.partner_id, child_track.message_follower_ids.partner_id, 'The partner from the parent has not been added as follower.') + + +@tagged('mail_followers') +class AdvancedResponsibleNotifiedTest(MailCommon): -class AdvancedResponsibleNotifiedTest(TestMailCommon): def setUp(self): super(AdvancedResponsibleNotifiedTest, self).setUp() @@ -478,7 +538,7 @@ class AdvancedResponsibleNotifiedTest(TestMailCommon): def test_auto_subscribe_notify_email(self): """ Responsible is notified when assigned """ - partner = self.env['res.partner'].create({"name": "demo1", "email": "demo1@test.com"}) + partner = self.env['res.partner'].create({"name": "demo1", "email": "demo1@test.mycompany.com"}) notified_user = self.env['res.users'].create({ 'login': 'demo1', 'partner_id': partner.id, @@ -512,7 +572,7 @@ class AdvancedResponsibleNotifiedTest(TestMailCommon): @tagged('mail_followers', 'post_install', '-at_install') -class RecipientsNotificationTest(TestMailCommon): +class RecipientsNotificationTest(MailCommon): """ Test advanced and complex recipients computation / notification, such as multiple users, batch computation, ... Post install because we need the registry to be ready to send notifications.""" @@ -539,12 +599,12 @@ class RecipientsNotificationTest(TestMailCommon): 'phone': '+32455998877', }) cls.user_1, cls.user_2 = cls.env['res.users'].with_context(no_reset_password=True).create([ - {'groups_id': [(4, cls.env.ref('base.group_portal').id)], + {'group_ids': [(4, cls.env.ref('base.group_portal').id)], 'login': '_login_portal', 'notification_type': 'email', 'partner_id': cls.common_partner.id, }, - {'groups_id': [(4, cls.env.ref('base.group_user').id)], + {'group_ids': [(4, cls.env.ref('base.group_user').id)], 'login': '_login_internal', 'notification_type': 'inbox', 'partner_id': cls.common_partner.id, @@ -572,8 +632,11 @@ class RecipientsNotificationTest(TestMailCommon): if not user: user = next((user for user in partner.user_ids), self.env['res.users']) self.assertEqual(partner_data['active'], partner.active) + self.assertEqual(partner_data['email_normalized'], partner.email_normalized) + self.assertEqual(partner_data['lang'], partner.lang) + self.assertEqual(partner_data['name'], partner.name) if user: - self.assertEqual(partner_data['groups'], set(user.groups_id.ids)) + self.assertEqual(partner_data['groups'], set(user.all_group_ids.ids)) self.assertEqual(partner_data['notif'], user.notification_type) self.assertEqual(partner_data['uid'], user.id) else: @@ -649,21 +712,21 @@ class RecipientsNotificationTest(TestMailCommon): user_2_1, user_2_2, user_2_3 = self.env['res.users'].sudo().with_context(no_reset_password=True).create([ {'company_ids': [(6, 0, cids)], 'company_id': self.company_admin.id, - 'groups_id': [(4, self.env.ref('base.group_portal').id)], + 'group_ids': [(4, self.env.ref('base.group_portal').id)], 'login': '_login2_portal', 'notification_type': 'email', 'partner_id': shared_partner.id, }, {'company_ids': [(6, 0, cids)], 'company_id': self.company_admin.id, - 'groups_id': [(4, self.env.ref('base.group_user').id)], + 'group_ids': [(4, self.env.ref('base.group_user').id)], 'login': '_login2_internal', 'notification_type': 'inbox', 'partner_id': shared_partner.id, }, {'company_ids': [(6, 0, cids)], 'company_id': company_other.id, - 'groups_id': [(4, self.env.ref('base.group_user').id), (4, self.env.ref('base.group_partner_manager').id)], + 'group_ids': [(4, self.env.ref('base.group_user').id), (4, self.env.ref('base.group_partner_manager').id)], 'login': '_login2_manager', 'notification_type': 'inbox', 'partner_id': shared_partner.id, @@ -684,7 +747,7 @@ class RecipientsNotificationTest(TestMailCommon): 'status': 'sent', 'type': 'inbox'}], message_info={'content': 'User Choice Notification'}): test.message_post( - body='

User Choice Notification

', + body=Markup('

User Choice Notification

'), message_type='comment', partner_ids=shared_partner.ids, subtype_xmlid='mail.mt_comment', @@ -788,3 +851,213 @@ class RecipientsNotificationTest(TestMailCommon): pids=test_partners.ids ) self.assertRecipientsData(recipients_data, False, test_partners) + + def test_subscribe_post_author(self): + """ Test author is added in followers, unless it is archived / odoobot """ + # some automated action post on behalf of author + test_record = self.env['mail.test.simple'].create({'name': 'Test'}) + self.partner_root.active = True # edge case, people activating Odoobot partner (not user) + (self.user_1 + self.user_2).active = False # archived users should not be subscribed + self.user_1.partner_id.active = False # archived authors should not be subscribed + self.assertFalse(test_record.message_partner_ids) + for user, author, exp_followers in [ + # active user = real author + (self.user_employee, self.user_2.partner_id, self.user_employee.partner_id), + # inactive user -> check for author + (self.user_2, self.user_employee.partner_id, self.user_employee.partner_id), + (self.user_2, self.user_1.partner_id, self.env['res.partner']), # no inactive ! + (self.user_2, self.user_root.partner_id, self.env['res.partner']), # no odoobot ! + ]: + with self.subTest(user=user.name, author=author.name): + test_record.with_user(user).message_post( + author_id=author.id, + body='Youpie', + message_type='comment', + subtype_id=self.env.ref('mail.mt_comment').id, + ) + self.assertEqual(test_record.message_partner_ids, exp_followers) + if exp_followers: + test_record.message_unsubscribe(partner_ids=exp_followers.ids) + +@tagged('mail_followers', 'post_install', '-at_install') +class UnfollowLinkTest(MailCommon, HttpCase): + """ Test unfollow links, notably used in notification emails """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user_portal = cls._create_portal_user() + cls.partner_portal = cls.user_portal.partner_id + cls.test_record = cls.env['mail.test.simple'].with_context(cls._test_context).create({'name': 'Test'}) + cls.test_record_copy = cls.test_record.copy() + cls.test_record_unfollow = cls.env['mail.test.simple.unfollow'].with_context(cls._test_context).create( + {'name': 'unfollow'}) + cls.partner_without_user = cls.env['res.partner'].create({ + 'name': 'Dave', + 'email': 'dave@odoo.com', + }) + cls.user_employee.write({'notification_type': 'email'}) + + def _message_unsubscribe_unreadable_record(self, user): + def raise_access_error(*args, **kwargs): + raise AccessError('Unreadable') + + with patch.object(self.test_record.__class__, 'check_access', side_effect=raise_access_error): + self.test_record.with_user(user).message_unsubscribe(user.partner_id.ids) + + + def _test_tampered_unfollow_url(self, record, unfollow_url, partner): + """ Test that tampered urls doesn't work. + + Test that: + - when the following parameters are altered, the browsing the URL returns + a 403 and doesn't unsubscribe the partner. + - when trying to use the same URL with another partner, it also returns a + 403 and doesn't unsubscribe the other partner. + """ + for param, value in ( + ('token', '0000000000000000000000000000000000000000'), + ('model', 'mail.test.gateway'), + ('res_id', self.test_record_copy.id), + ('partner_id', self.partner_admin.id), + ): + with self.subTest(f'Tampered {param}'): + tampered_unfollow_url = self._url_update_query_parameters(unfollow_url, **{param: value}) + response = self.url_open(tampered_unfollow_url) + self.assertEqual(response.status_code, 403) + self.assertIn(partner, record.message_partner_ids) + + def _test_unfollow_url(self, record, unfollow_url, partner): + """ Test that the unfollow url works. + + Test that: that browsing the unfollow URL unsubscribe the user from the record + """ + with self.subTest('Legitimate unfollow'): + # We test that the URL still work a second time if the user has been re-added + for _ in range(2): + try: + self.assertIn(partner, record.message_partner_ids) + response = self.url_open(unfollow_url) + self.assertEqual(response.status_code, 200) + self.assertNotIn(partner, record.message_partner_ids) + self.assertEqual(urlparse(response.url).path, '/mail/unfollow') + self.assertIn("You are no longer following the document", response.text) + self.assertIn('o_access_record_link', response.text) + finally: + record._message_subscribe(partner_ids=partner.ids) + + def test_assert_initial_data(self): + """ Test some initial value. """ + record_employee = self.test_record.with_user(self.user_employee) + record_employee.check_access('read') + record_portal = self.test_record.with_user(self.user_portal) + with self.assertRaises(AccessError): + record_portal.check_access('write') + for template_ref in ('mail.mail_notification_layout', 'mail.mail_notification_light'): + with self.subTest(f'Unfollow link in {template_ref}'): + mail_template_arch = self.env.ref(template_ref).arch + self.assertIn('/mail/unfollow', mail_template_arch) + self.assertNotIn('/mail/unfollow', re.sub(_UNFOLLOW_REGEX, '', mail_template_arch)) + + @users('employee') + @mute_logger('odoo.models') + def test_inbox_unfollow_information(self): + """ Check follow-up information for displaying inbox messages used to + implement "unfollow" in the inbox. + + Note that the actual mechanism to unfollow a record from a message is + tested in the client part. + """ + self.user_employee.write({'notification_type': 'inbox'}) + + test_record = self.env['mail.test.simple'].browse(self.test_record.ids) + _message = test_record.with_user(self.user_admin).message_post( + body="test message", + subtype_id=self.env.ref("mail.mt_comment").id, + partner_ids=self.partner_employee.ids, + ) + # The user doesn't follow the record + self.authenticate(self.env.user.login, self.env.user.login) + message_data = self.make_jsonrpc_request("/mail/inbox/messages")["data"] + self.assertFalse(message_data["mail.thread"][0]["selfFollower"]) + self.assertFalse(message_data.get("mail.followers"), "Should not have void followers data") + self.assertFalse(test_record.with_user(self.user_employee).message_is_follower) + + # The user follows the record + test_record._message_subscribe(partner_ids=self.env.user.partner_id.ids) + follower = test_record.message_follower_ids.filtered( + lambda follower: follower.partner_id == self.env.user.partner_id + ) + message_data = self.make_jsonrpc_request("/mail/inbox/messages")["data"] + self.assertEqual(message_data["mail.followers"], [ + { + "id": follower.id, + "is_active": True, + "partner_id": self.env.user.partner_id.id, + }, + ]) + self.assertEqual(message_data["mail.thread"][0]["selfFollower"], follower.id, "Should have follower ID") + + @mute_logger('odoo.addons.base.models', 'odoo.addons.mail.controllers.mail', 'odoo.http', 'odoo.models') + def test_notification_email_unfollow_link(self): + """ Internal user must receive an unfollow URL, that cannot be tampered + and redirects to the correct page. + """ + for test_partners, test_record, exp_has_url in [ + (self.partner_employee, self.test_record, [True]), + # customer should not receive an unfollow URL + (self.partner_without_user, self.test_record, [False]), + (self.partner_portal, self.test_record, [False]), + # always unfollow link (model definition) + (self.partner_without_user, self.test_record_unfollow, [True]), + (self.partner_portal, self.test_record_unfollow, [True]), + # multi partners + ( + self.partner_without_user + self.partner_portal + self.partner_employee, + self.test_record, [False, False, True], + ), + ( + self.partner_without_user + self.partner_portal + self.partner_employee, + self.test_record_unfollow, [True, True, True], + ), + ]: + with self.subTest(partners=test_partners.mapped('name')): + # Test that the user receives an unfollow URL when following the record + test_record._message_subscribe(partner_ids=test_partners.ids) + unfollow_urls = self._message_post_and_get_unfollow_urls(test_record, test_partners) + for test_partner, unfollow_url, has_url in zip(test_partners, unfollow_urls, exp_has_url): + self.assertEqual(bool(unfollow_url), has_url) + + # Test unfollowing URL when user is not logged + if has_url: + self.authenticate(None, None) + self._test_unfollow_url(test_record, unfollow_url, test_partner) + self._test_tampered_unfollow_url(test_record, unfollow_url, test_partner) + + if test_partner == self.partner_employee: + # Test unfollowing URL when user is logged + self.authenticate(self.user_employee.login, self.user_employee.login) + self._test_unfollow_url(test_record, unfollow_url, test_partner) + + # Test that the user doesn't receive the unfollow URL when not following the record + test_record.message_unsubscribe(partner_ids=test_partners.ids) + unfollow_urls = self._message_post_and_get_unfollow_urls(test_record, test_partners) + for test_partner, unfollow_url in zip(test_partners, unfollow_urls): + self.assertFalse(unfollow_url) + + def test_unsubscribe_unreadable(self): + """ Check internal can always unsubscribe form records while portal are + limited to records they can access. Other records are considered as customer + oriented and we don't want to lose emails. """ + for user, can_unsubscribe in [ + (self.user_employee, True), + (self.user_portal, False), + ]: + self.test_record._message_subscribe(partner_ids=user.partner_id.ids) + self.assertIn(user.partner_id, self.test_record.message_partner_ids) + if can_unsubscribe: + self._message_unsubscribe_unreadable_record(user) + self.assertNotIn(user.partner_id, self.test_record.message_partner_ids) + else: + with self.assertRaises(AccessError): + self._message_unsubscribe_unreadable_record(user) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_gateway.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_gateway.py index 3a2261a..7f2fa33 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_gateway.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_gateway.py @@ -2,35 +2,35 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 +import itertools import socket from datetime import datetime - from unittest.mock import DEFAULT from unittest.mock import patch from odoo import exceptions -from odoo.addons.mail.models.mail_message import Message +from odoo.addons.mail.models.mail_message import MailMessage from odoo.addons.mail.models.mail_thread import MailThread -from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.mail.tests.common import mail_new_test_user, MailCommon from odoo.addons.test_mail.data import test_mail_data -from odoo.addons.test_mail.data.test_mail_data import MAIL_TEMPLATE, THAI_EMAIL_WINDOWS_874 -from odoo.addons.test_mail.models.test_mail_models import MailTestGateway, MailTestGatewayGroups, MailTestTicket -from odoo.addons.test_mail.tests.common import TestMailCommon +from odoo.addons.test_mail.data.test_mail_data import MAIL_TEMPLATE, MAIL_TEMPLATE_EXTRA_HTML, THAI_EMAIL_WINDOWS_874 +from odoo.addons.test_mail.models.mail_test_ticket import MailTestTicket +from odoo.addons.test_mail.models.test_mail_models import MailTestGateway, MailTestGatewayGroups from odoo.sql_db import Cursor -from odoo.tests import tagged, RecordCapturer -from odoo.tests.common import Form, users -from odoo.tools import email_split_and_format, formataddr, mute_logger +from odoo.tests import Form, tagged, RecordCapturer +from odoo.tools import mute_logger +from odoo.tools.mail import email_normalize, email_split_and_format, formataddr @tagged('mail_gateway') -class TestEmailParsing(TestMailCommon): +class TestEmailParsing(MailCommon): def test_message_parse_and_replace_binary_octetstream(self): """ Incoming email containing a wrong Content-Type as described in RFC2046/section-3 """ received_mail = self.from_string(test_mail_data.MAIL_MULTIPART_BINARY_OCTET_STREAM) with self.assertLogs('odoo.addons.mail.models.mail_thread', level="WARNING") as capture: - extracted_mail = self.env['mail.thread']._message_parse_extract_payload(received_mail) + extracted_mail = self.env['mail.thread']._message_parse_extract_payload(received_mail, {}) self.assertEqual(len(extracted_mail['attachments']), 1) attachment = extracted_mail['attachments'][0] @@ -41,6 +41,21 @@ class TestEmailParsing(TestMailCommon): "Content-Type 'binary/octet-stream', assuming 'application/octet-stream'"), ]) + def test_message_parse_and_replace_wildcard(self): + """Incoming email containing a wrong Content-Type (*/*) as described in RFC2046/section-3""" + mail_with_wildcard_mime = self.format(test_mail_data.MAIL_PDF_MIME_TEMPLATE, pdf_mime="*/*") + self.assertIn("Content-Type: */*", mail_with_wildcard_mime, "Wildcard for content-type not found") + with self.assertLogs("odoo.addons.mail.models.mail_thread", level="WARNING") as capture: + extracted_mail = self.env['mail.thread'].message_parse(self.from_string(mail_with_wildcard_mime)) + + self.assertEqual(len(extracted_mail['attachments']), 1) + attachment = extracted_mail['attachments'][0] + self.assertEqual(attachment.fname, 'scan_soraya.lernout_1691652648.pdf') + self.assertEqual(capture.output, [ + ("WARNING:odoo.addons.mail.models.mail_thread:Message containing an unexpected " + "Content-Type '*/*', assuming 'application/octet-stream'"), + ]) + def test_message_parse_body(self): # test pure plaintext plaintext = self.format(test_mail_data.MAIL_TEMPLATE_PLAINTEXT, email_from='"Sylvie Lelitre" ') @@ -109,7 +124,11 @@ class TestEmailParsing(TestMailCommon): def test_message_parse_eml(self): # Test that the parsing of mail with embedded emails as eml(msg) which generates empty attachments, can be processed. - mail = self.format(test_mail_data.MAIL_EML_ATTACHMENT, email_from='"Sylvie Lelitre" ', to='generic@test.com') + mail = self.format(test_mail_data.MAIL_EML_ATTACHMENT, email_from='"Sylvie Lelitre" ', to=f'generic@{self.alias_domain}', + msg_id='', + references='', + subject='Re: test attac', + ) self.env['mail.thread'].message_parse(self.from_string(mail)) def test_message_parse_eml_bounce_headers(self): @@ -124,7 +143,7 @@ class TestEmailParsing(TestMailCommon): ) res = self.env['mail.thread'].message_parse(self.from_string(mail)) - self.assertEqual(res['bounced_msg_id'], [msg_id], "Message-Id is not extracted from Text/RFC822-Headers attachment") + self.assertEqual(res['bounced_msg_ids'], [msg_id], "Message-Id is not extracted from Text/RFC822-Headers attachment") def test_message_parse_extract_bounce_rfc822_headers_qp(self): # Incoming bounce for unexisting Outlook address @@ -143,17 +162,15 @@ class TestEmailParsing(TestMailCommon): email_to='bounce@xxx.odoo.com', delivered_to='bounce@xxx.odoo.com' ) - - msg_dict = {} - msg = self.env['mail.thread']._message_parse_extract_bounce(self.from_string(incoming_bounce), msg_dict) + msg = self.env['mail.thread'].message_parse(self.from_string(incoming_bounce)) self.assertEqual(msg['bounced_email'], partner.email, "The sender email should be correctly parsed") self.assertEqual(msg['bounced_partner'], partner, "A partner with this email should exist") - self.assertEqual(msg['bounced_msg_id'][0], message.message_id, "The sender message-id should correctly parsed") + self.assertEqual(msg['bounced_msg_ids'][0], message.message_id, "The sender message-id should correctly parsed") self.assertEqual(msg['bounced_message'], message, "An existing message with this message_id should exist") def test_message_parse_plaintext(self): """ Incoming email in plaintext should be stored as html """ - mail = self.format(test_mail_data.MAIL_TEMPLATE_PLAINTEXT, email_from='"Sylvie Lelitre" ', to='generic@test.com') + mail = self.format(test_mail_data.MAIL_TEMPLATE_PLAINTEXT, email_from='"Sylvie Lelitre" ', to=f'generic@{self.alias_domain}') res = self.env['mail.thread'].message_parse(self.from_string(mail)) self.assertIn('
\nPlease call me as soon as possible this afternoon!\n\n--\nSylvie\n
', res['body']) @@ -161,148 +178,15 @@ class TestEmailParsing(TestMailCommon): # Test that the parsing of XHTML mails does not fail self.env['mail.thread'].message_parse(self.from_string(test_mail_data.MAIL_XHTML)) -@tagged('mail_gateway') -class TestMailAlias(TestMailCommon): - - @users('employee') - @mute_logger('odoo.addons.base.models.ir_model') - def test_alias_creation(self): - record = self.env['mail.test.container'].create({ - 'name': 'Test Record', - 'alias_name': 'alias.test', - 'alias_contact': 'followers', - }) - self.assertEqual(record.alias_id.alias_model_id, self.env['ir.model']._get('mail.test.container')) - self.assertEqual(record.alias_id.alias_force_thread_id, record.id) - self.assertEqual(record.alias_id.alias_parent_model_id, self.env['ir.model']._get('mail.test.container')) - self.assertEqual(record.alias_id.alias_parent_thread_id, record.id) - self.assertEqual(record.alias_id.alias_name, 'alias.test') - self.assertEqual(record.alias_id.alias_contact, 'followers') - - record.write({ - 'alias_name': 'better.alias.test', - 'alias_defaults': "{'default_name': 'defaults'}" - }) - self.assertEqual(record.alias_id.alias_name, 'better.alias.test') - self.assertEqual(record.alias_id.alias_defaults, "{'default_name': 'defaults'}") - - with self.assertRaises(exceptions.AccessError): - record.write({ - 'alias_force_thread_id': 0, - }) - - with self.assertRaises(exceptions.AccessError): - record.write({ - 'alias_model_id': self.env['ir.model']._get('mail.test.gateway').id, - }) - - with self.assertRaises(exceptions.ValidationError): - record.write({'alias_defaults': "{'custom_field': brokendict"}) - - def test_alias_domain_allowed_validation(self): - """ Check the validation of `mail.catchall.domain.allowed` system parameter""" - for value in [',', ',,', ', ,']: - with self.assertRaises(exceptions.ValidationError, - msg=f"The value {value} should not be allowed"): - self.env['ir.config_parameter'].set_param('mail.catchall.domain.allowed', value) - - for value, expected in [ - ('', False), - ('hello.com', 'hello.com'), - ('hello.com,,', 'hello.com'), - ('hello.com,bonjour.com', 'hello.com,bonjour.com'), - ('hello.COM, BONJOUR.com', 'hello.com,bonjour.com'), - ]: - self.env['ir.config_parameter'].set_param('mail.catchall.domain.allowed', value) - self.assertEqual(self.env['ir.config_parameter'].get_param('mail.catchall.domain.allowed'), expected) - - def test_alias_sanitize(self): - alias = self.env['mail.alias'].create({ - 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, - 'alias_name': 'bidule...inc.', - }) - self.assertEqual(alias.alias_name, 'bidule.inc', 'Emails cannot start or end with a dot, there cannot be a sequence of dots.') - - def test_alias_setup(self): - alias = self.env['mail.alias'].create({ - 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, - 'alias_name': 'b4r+_#_R3wl$$', - }) - self.assertEqual(alias.alias_name, 'b4r+_-_r3wl-', 'Disallowed chars should be replaced by hyphens') - - with self.assertRaises(exceptions.ValidationError): - alias.write({'alias_defaults': "{'custom_field': brokendict"}) - - def test_alias_name_unique(self): - alias_model_id = self.env['ir.model']._get('mail.test.gateway').id - catchall_alias = self.env['ir.config_parameter'].sudo().get_param('mail.catchall.alias') - bounce_alias = self.env['ir.config_parameter'].sudo().get_param('mail.bounce.alias') - - # test you cannot create aliases matching bounce / catchall - with self.assertRaises(exceptions.UserError), self.cr.savepoint(): - self.env['mail.alias'].create({'alias_model_id': alias_model_id, 'alias_name': catchall_alias}) - with self.assertRaises(exceptions.UserError), self.cr.savepoint(): - self.env['mail.alias'].create({'alias_model_id': alias_model_id, 'alias_name': bounce_alias}) - - new_mail_alias = self.env['mail.alias'].create({ - 'alias_model_id': alias_model_id, - 'alias_name': 'unused.test.alias' - }) - - # test that re-using catchall and bounce alias raises UserError - with self.assertRaises(exceptions.UserError), self.cr.savepoint(): - new_mail_alias.write({ - 'alias_name': catchall_alias - }) - with self.assertRaises(exceptions.UserError), self.cr.savepoint(): - new_mail_alias.write({ - 'alias_name': bounce_alias - }) - - new_mail_alias.write({'alias_name': 'another.unused.test.alias'}) - - # test that duplicating an alias should have blank name - copy_new_mail_alias = new_mail_alias.copy() - self.assertFalse(copy_new_mail_alias.alias_name) - - # cannot set catchall / bounce to used alias - with self.assertRaises(exceptions.UserError), self.cr.savepoint(): - self.env['ir.config_parameter'].sudo().set_param('mail.catchall.alias', new_mail_alias.alias_name) - with self.assertRaises(exceptions.UserError), self.cr.savepoint(): - self.env['ir.config_parameter'].sudo().set_param('mail.bounce.alias', new_mail_alias.alias_name) - @tagged('mail_gateway') -class TestMailAliasMixin(TestMailCommon): - - @users('employee') - def test_alias_mixin_copy_content(self): - self.assertFalse(self.env.user.has_group('base.group_system'), 'Test user should not have Administrator access') - - record = self.env['mail.test.container'].create({ - 'name': 'Test Record', - 'alias_name': 'test.record', - 'alias_contact': 'followers', - 'alias_bounced_content': False, - }) - self.assertFalse(record.alias_bounced_content) - record_copy = record.copy() - self.assertFalse(record_copy.alias_bounced_content) - - new_content = '

Bounced Content

' - record_copy.write({'alias_bounced_content': new_content}) - self.assertEqual(record_copy.alias_bounced_content, new_content) - record_copy2 = record_copy.copy() - self.assertEqual(record_copy2.alias_bounced_content, new_content) - - -@tagged('mail_gateway') -class MailGatewayCommon(TestMailCommon): +class MailGatewayCommon(MailCommon): @classmethod def setUpClass(cls): super().setUpClass() - cls.test_model = cls.env['ir.model']._get('mail.test.gateway') + cls.mail_test_gateway_model = cls.env['ir.model']._get('mail.test.gateway') + cls.mail_test_gateway_company_model = cls.env['ir.model']._get('mail.test.gateway.company') cls.email_from = '"Sylvie Lelitre" ' cls.test_record = cls.env['mail.test.gateway'].with_context(mail_create_nolog=True).create({ @@ -314,15 +198,29 @@ class MailGatewayCommon(TestMailCommon): 'name': 'Valid Lelitre', 'email': 'valid.lelitre@agrolait.com', }) - # groups@.. will cause the creation of new mail.test.gateway + # groups@test.mycompany.com will cause the creation of new mail.test.gateway cls.alias = cls.env['mail.alias'].create({ + 'alias_domain_id': cls.mail_alias_domain.id, + 'alias_contact': 'everyone', + 'alias_model_id': cls.mail_test_gateway_model.id, 'alias_name': 'groups', - 'alias_user_id': False, - 'alias_model_id': cls.env['ir.model']._get_id('mail.test.gateway'), - 'alias_contact': 'everyone'}) + }) + # groups@test.mycompany2.com will cause the creation of new mail.test.gateway.company + cls.alias_c2 = cls.env['mail.alias'].create({ + 'alias_defaults': { + 'company_id': cls.company_2.id, + }, + 'alias_domain_id': cls.mail_alias_domain_c2.id, + 'alias_contact': 'everyone', + 'alias_model_id': cls.mail_test_gateway_company_model.id, + 'alias_name': 'groups', + }) # Set a first message on public group to test update and hierarchy - cls.fake_email = cls._create_gateway_message(cls.test_record, '123456') + cls.fake_email = cls._create_gateway_message( + cls.test_record, '123456', + date=datetime(2025, 11, 19, 10, 30, 0), + ) def _reinject(self, force_msg_id=False, debug_log=False): """ Tool to automatically 'inject' an outgoing mail into the gateway. @@ -345,6 +243,7 @@ class MailGatewayCommon(TestMailCommon): def _create_gateway_message(cls, record, msg_id_prefix, **values): msg_values = { 'author_id': cls.partner_1.id, + 'date': cls.env.cr.now(), 'email_from': cls.partner_1.email_formatted, 'body': '

Generic body

', 'message_id': f'<{msg_id_prefix}-openerp-{record.id}-{record._name}@{socket.gethostname()}>', @@ -372,7 +271,7 @@ class TestMailgateway(MailGatewayCommon): @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') def test_message_process_alias_basic(self): """ Test details of created message going through mailgateway """ - record = self.format_and_process(MAIL_TEMPLATE, self.email_from, 'groups@test.com', subject='Specific') + record = self.format_and_process(MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', subject='Specific') # Test: one group created by mailgateway administrator as user_id is not set self.assertEqual(len(record), 1, 'message_process: a new mail.test should have been created') @@ -397,8 +296,7 @@ class TestMailgateway(MailGatewayCommon): return res with patch.object(MailThread, '_message_parse_extract_payload', _message_parse_extract_payload): - record = self.format_and_process(test_mail_data.MAIL_MULTIPART_IMAGE, self.email_from, 'groups@test.com') - + record = self.format_and_process(test_mail_data.MAIL_MULTIPART_IMAGE, self.email_from, f'groups@{self.alias_domain}') message = record.message_ids[0] for attachment in message.attachment_ids: self.assertIn(f'/web/image/{attachment.id}', message.body) @@ -409,38 +307,28 @@ class TestMailgateway(MailGatewayCommon): @mute_logger('odoo.addons.mail.models.mail_thread') def test_message_process_followers(self): """ Incoming email: recognized author not archived and not odoobot: - added as follower. Also test corner cases: archived, private. """ - partner_archived, partner_private = self.env['res.partner'].create([ - { - 'active': False, - 'email': 'archived.customer@text.example.com', - 'phone': '0032455112233', - 'name': 'Archived Customer', - 'type': 'contact', - }, - { - 'email': 'private.customer@text.example.com', - 'phone': '0032455112233', - 'name': 'Private Customer', - 'type': 'private', - }, - ]) + added as follower. Also test corner cases: archived. """ + partner_archived = self.env['res.partner'].create({ + 'active': False, + 'email': 'archived.customer@text.example.com', + 'phone': '0032455112233', + 'name': 'Archived Customer', + 'type': 'contact', + }) with self.mock_mail_gateway(): - record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email_formatted, 'groups@test.com') + record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email_formatted, f'groups@{self.alias_domain}') self.assertEqual(record.message_ids[0].author_id, self.partner_1, 'message_process: recognized email -> author_id') self.assertEqual(record.message_ids[0].email_from, self.partner_1.email_formatted) - self.assertEqual(record.message_follower_ids.partner_id, self.partner_1, - 'message_process: recognized email -> added as follower') - self.assertEqual(record.message_partner_ids, self.partner_1, - 'message_process: recognized email -> added as follower') + self.assertFalse(record.message_partner_ids, + 'message_process: recognized email -> but not added as follower as external') # just an email -> no follower with self.mock_mail_gateway(): record2 = self.format_and_process( - MAIL_TEMPLATE, self.email_from, 'groups@test.com', + MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', subject='Another Email') self.assertEqual(record2.message_ids[0].author_id, self.env['res.partner']) @@ -453,7 +341,7 @@ class TestMailgateway(MailGatewayCommon): # archived partner -> no follower with self.mock_mail_gateway(): record3 = self.format_and_process( - MAIL_TEMPLATE, partner_archived.email_formatted, 'groups@test.com', + MAIL_TEMPLATE, partner_archived.email_formatted, f'groups@{self.alias_domain}', subject='Archived Partner') self.assertEqual(record3.message_ids[0].author_id, self.env['res.partner']) @@ -469,7 +357,7 @@ class TestMailgateway(MailGatewayCommon): odoobot.email = 'odoobot@example.com' with self.mock_mail_gateway(): record4 = self.format_and_process( - MAIL_TEMPLATE, odoobot.email_formatted, 'groups@test.com', + MAIL_TEMPLATE, odoobot.email_formatted, f'groups@{self.alias_domain}', subject='Odoobot Automatic Answer') self.assertEqual(record4.message_ids[0].author_id, odoobot) @@ -479,18 +367,17 @@ class TestMailgateway(MailGatewayCommon): self.assertEqual(record4.message_partner_ids, self.env['res.partner'], 'message_process: odoobot -> no follower') - # private partner + # internal user -> ok with self.mock_mail_gateway(): - record5 = self.format_and_process( - MAIL_TEMPLATE, partner_private.email_formatted, 'groups@test.com', - subject='Private Partner') + record = self.format_and_process( + MAIL_TEMPLATE, self.user_employee.email_formatted, f'groups@{self.alias_domain}', + subject='Internal Author') - self.assertEqual(record5.message_ids[0].author_id, partner_private) - self.assertEqual(record5.message_ids[0].email_from, partner_private.email_formatted) - self.assertEqual(record5.message_follower_ids.partner_id, partner_private, - 'message_process: private partner is recognized') - self.assertEqual(record5.message_partner_ids, partner_private, - 'message_process: private partner is recognized') + self.assertEqual(record.message_ids[0].author_id, self.partner_employee, + 'message_process: recognized email -> author_id') + self.assertEqual(record.message_ids[0].email_from, self.user_employee.email_formatted) + self.assertEqual(record.message_partner_ids, self.partner_employee, + 'message_process: recognized email -> added as follower') # -------------------------------------------------- # Author recognition @@ -499,7 +386,7 @@ class TestMailgateway(MailGatewayCommon): @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') def test_message_process_email_email_from(self): """ Incoming email: not recognized author: email_from, no author_id, no followers """ - record = self.format_and_process(MAIL_TEMPLATE, self.email_from, 'groups@test.com') + record = self.format_and_process(MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}') self.assertFalse(record.message_ids[0].author_id, 'message_process: unrecognized email -> no author_id') self.assertEqual(record.message_ids[0].email_from, self.email_from) self.assertEqual(len(record.message_partner_ids), 0, @@ -509,7 +396,7 @@ class TestMailgateway(MailGatewayCommon): def test_message_process_email_author(self): """ Incoming email: recognized author: email_from, author_id, added as follower """ with self.mock_mail_gateway(): - record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email_formatted, 'groups@test.com', subject='Test1') + record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email_formatted, f'groups@{self.alias_domain}', subject='Test1') self.assertEqual(record.message_ids[0].author_id, self.partner_1, 'message_process: recognized email -> author_id') @@ -550,40 +437,112 @@ class TestMailgateway(MailGatewayCommon): self.assertEqual(record.message_ids[0].email_from, test_email) self.assertNotSentEmail() # No notification / bounce should be sent - @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') - def test_message_process_email_partner_find(self): + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_thread', 'odoo.models.unlink', 'odoo.tests') + def test_message_process_email_author_partner_find(self): """ Finding the partner based on email, based on partner / user / follower """ self.alias.write({'alias_force_thread_id': self.test_record.id}) from_1 = self.env['res.partner'].create({'name': 'Brice Denisse', 'email': 'from.test@example.com'}) - self.format_and_process(MAIL_TEMPLATE, from_1.email_formatted, 'groups@test.com') + self.format_and_process(MAIL_TEMPLATE, from_1.email_formatted, f'groups@{self.alias_domain}') self.assertEqual(self.test_record.message_ids[0].author_id, from_1) self.test_record.message_unsubscribe([from_1.id]) from_2 = mail_new_test_user(self.env, login='B', groups='base.group_user', name='User Denisse', email='from.test@example.com') - self.format_and_process(MAIL_TEMPLATE, from_1.email_formatted, 'groups@test.com') + self.format_and_process(MAIL_TEMPLATE, from_1.email_formatted, f'groups@{self.alias_domain}') self.assertEqual(self.test_record.message_ids[0].author_id, from_2.partner_id) self.test_record.message_unsubscribe([from_2.partner_id.id]) from_3 = self.env['res.partner'].create({'name': 'FOllower Denisse', 'email': 'from.test@example.com'}) self.test_record.message_subscribe([from_3.id]) - self.format_and_process(MAIL_TEMPLATE, from_1.email_formatted, 'groups@test.com') + self.format_and_process(MAIL_TEMPLATE, from_1.email_formatted, f'groups@{self.alias_domain}') self.assertEqual(self.test_record.message_ids[0].author_id, from_3) @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') def test_message_process_email_author_exclude_alias(self): """ Do not set alias as author to avoid including aliases in discussions """ - from_1 = self.env['res.partner'].create({'name': 'Brice Denisse', 'email': 'from.test@test.com'}) self.env['mail.alias'].create({ + 'alias_domain_id': self.mail_alias_domain.id, 'alias_name': 'from.test', 'alias_model_id': self.env['ir.model']._get('mail.test.gateway').id }) + alias_impostors = self.env['res.partner'].create([ + { + 'name': 'Alias Impostor', + 'email': f'from.test@{self.mail_alias_domain.name}', + }, { + 'name': 'Alias Domain Impostor', + 'email': self.mail_alias_domain.catchall_email, + }, + ]) - record = self.format_and_process(MAIL_TEMPLATE, from_1.email_formatted, 'groups@test.com') - self.assertFalse(record.message_ids[0].author_id) - self.assertEqual(record.message_ids[0].email_from, from_1.email_formatted) + for email_from, impostor in [ + (f'from.test@{self.mail_alias_domain.name}', alias_impostors[0]), + (f'"Brice Denisse" ', alias_impostors[0]), + (f'"Catchall Impostor" <{self.mail_alias_domain.catchall_email}>', alias_impostors[1]), + ]: + with self.subTest(email_from=email_from): + record = self.format_and_process( + MAIL_TEMPLATE, email_from, f'groups@{self.alias_domain}', + subject=f'Incoming email from {email_from}', + ) + self.assertFalse(record.message_ids[0].author_id, f'Should not link a partner, especially not {impostor.name}') + self.assertEqual(record.message_ids[0].email_from, email_from) + + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_route_alias_owner_author_notify(self): + """ Make sure users are notified when a reply is sent to an alias address. + Alias owner should impact the message creator, but not notifications. """ + test_record = self.env['mail.test.ticket'].create({}) + author_partner = self.env['res.partner'].create({ + 'name': 'Author', + 'email': f'author-partner@{self.alias_domain}', + }) + message = self.env['mail.message'].create({ + 'body': '

test

', + 'email_from': f'author-partner@{self.alias_domain}', # email sent by author who also has an alias with their email + 'message_type': 'email_outgoing', + 'model': test_record._name, + 'res_id': test_record.id, + }) + self.env['mail.alias'].create({ + 'alias_model_id': self.env['ir.model']._get_id(test_record._name), + 'alias_name': 'author-partner', + }) + + test_record.message_subscribe((author_partner | self.user_employee.partner_id).ids) + + messages = test_record.message_ids + + self.assertFalse(self.user_root.active, 'notification logic relies on odoobot being archived') + + test_users = [self.user_employee, self.user_root] + email_tos = [f'author-partner@{self.alias_domain}', f'some_non_aliased_email@{self.alias_domain}'] + for email_to, test_user in itertools.product(email_tos, test_users): + with self.subTest(test_user=test_user, email_to=email_to): + with self.mock_mail_gateway(), self.mock_mail_app(): + self.format_and_process( + MAIL_TEMPLATE, self.email_from, email_to, + subject=message.message_id, extra=f'In-Reply-To:\r\n\t{message.message_id}\n', + model=None, with_user=test_user) + new_messages = test_record.message_ids - messages + + self.assertEqual(len(new_messages), 1) + self.assertEqual(new_messages.create_uid, self.user_root, + 'Odoobot should be creating the message') + + # Make sure the alias owner is notified if they are a follower + self.assertNotified(new_messages, [{ + 'partner': self.user_employee.partner_id, + 'is_read': False, + 'type': 'inbox', + }]) + # never notify the author of the incoming message + with self.assertRaises(Exception): + self.assertNotified(new_messages, [{'partner': author_partner}]) + + messages = test_record.message_ids # -------------------------------------------------- # Alias configuration @@ -599,9 +558,9 @@ class TestMailgateway(MailGatewayCommon): # Test: custom bounced content with self.mock_mail_gateway(): - record = self.format_and_process(MAIL_TEMPLATE, self.email_from, 'groups@test.com', subject='Should Bounce') + record = self.format_and_process(MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', subject='Should Bounce') self.assertFalse(record, 'message_process: should have bounced') - self.assertSentEmail('"MAILER-DAEMON" ', ['whatever-2a840@postmaster.twitter.com'], body_content='

What Is Dead May Never Die

') + self.assertSentEmail(f'"MAILER-DAEMON" <{self.alias_bounce}@{self.alias_domain}>', ['whatever-2a840@postmaster.twitter.com'], body_content='

What Is Dead May Never Die

') for empty_content in [ '


', '


', '


', @@ -617,20 +576,20 @@ class TestMailgateway(MailGatewayCommon): # Test: with "empty" bounced content (simulate view, putting always '


' in html field) with self.mock_mail_gateway(): - record = self.format_and_process(MAIL_TEMPLATE, self.email_from, 'groups@test.com', subject='Should Bounce') + record = self.format_and_process(MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', subject='Should Bounce') self.assertFalse(record, 'message_process: should have bounced') # Check if default (hardcoded) value is in the mail content self.assertSentEmail( f'"MAILER-DAEMON" <{self.alias_bounce}@{self.alias_domain}>', ['whatever-2a840@postmaster.twitter.com'], - body_content=f'

Dear Sender,

\nThe message below could not be accepted by the address {self.alias.display_name.lower()}', + body_content=f'

Dear Sender,

The message below could not be accepted by the address {self.alias.display_name.lower()}', ) @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') def test_message_process_alias_config_bounced_to(self): """ Check bounce message contains the bouncing alias, not a generic "to" """ self.alias.write({'alias_contact': 'partners'}) - bounce_message_with_alias = f'

Dear Sender,

\nThe message below could not be accepted by the address {self.alias.display_name.lower()}' + bounce_message_with_alias = f'

Dear Sender,

The message below could not be accepted by the address {self.alias.display_name.lower()}' # Bounce is To with self.mock_mail_gateway(): @@ -653,45 +612,90 @@ class TestMailgateway(MailGatewayCommon): subject='Should Bounce') self.assertIn(bounce_message_with_alias, self._mails[0].get('body')) + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.addons.mail.models.mail_mail', 'odoo.models', 'odoo.sql_db') + def test_message_process_alias_config_invalid_defaults(self): + """Sending a mail to a misconfigured alias must change its status to + invalid and notify sender.""" + test_model_track = self.env['ir.model']._get('mail.test.track') + container_custom = self.env['mail.test.container'].create({}) + alias_valid = self.env['mail.alias'].with_user(self.user_admin).create({ + 'alias_domain_id': self.mail_alias_domain.id, + 'alias_name': 'valid', + 'alias_model_id': test_model_track.id, + 'alias_contact': 'everyone', + 'alias_defaults': f"{{'container_id': {container_custom.id}}}", + }) + self.assertEqual(alias_valid.create_uid, self.user_admin) + + # Test that it works when the reference to container_id in alias default is not dangling. + self.assertEqual(alias_valid.alias_status, 'not_tested') + with self.mock_mail_gateway(), patch('odoo.addons.mail.models.mail_alias.MailAlias._alias_bounce_incoming_email', + autospec=True) as _alias_bounce_incoming_email_mock: + record = self.format_and_process(MAIL_TEMPLATE, self.email_from, f'valid@{self.alias_domain}', subject='Valid', + target_model=test_model_track.model) + _alias_bounce_incoming_email_mock.assert_not_called() + self.assertNotSentEmail() + self.assertEqual(record.container_id, container_custom) + self.assertEqual(alias_valid.alias_status, 'valid') + + # Test with a dangling reference that must trigger bounce emails and set the alias status to invalid. + container_custom.unlink() + with self.assertRaises(Exception), patch('odoo.addons.mail.models.mail_alias.MailAlias._alias_bounce_incoming_email', + autospec=True) as _alias_bounce_incoming_email_mock: + self.format_and_process(MAIL_TEMPLATE, self.email_from, f'valid@{self.alias_domain}', subject='Invalid', + target_model=test_model_track.model) + + # method executed in another transaction, so we cannot test its result directly but just below + _alias_bounce_incoming_email_mock.assert_called_once() + + # call notify_alias_invalid on the test transaction to validate its effect + alias, message, message_dict = _alias_bounce_incoming_email_mock.call_args.args + with self.mock_mail_gateway(): + alias = self.env['mail.alias'].browse(alias.id) # load alias in test transaction + alias._alias_bounce_incoming_email(message, message_dict) + + self.assertEqual(alias_valid.alias_status, 'invalid') + # Not sent to self.email_from because a return path is present in MAIL_TEMPLATE + self.assertSentEmail(f'"MAILER-DAEMON" <{self.alias_bounce}@{self.alias_domain}>', + ['whatever-2a840@postmaster.twitter.com'], + subject='Re: Invalid', + body=alias_valid._get_alias_invalid_body(message_dict)) + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') def test_message_process_alias_defaults(self): """ Test alias defaults and inner values """ self.alias.write({ - 'alias_user_id': self.user_employee.id, 'alias_defaults': "{'custom_field': 'defaults_custom'}" }) + self.assertEqual(self.alias.alias_status, 'not_tested') - record = self.format_and_process(MAIL_TEMPLATE, self.email_from, 'groups@test.com', subject='Specific') + record = self.format_and_process( + MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', + subject='Specific' + ) + self.assertEqual(self.alias.alias_status, 'valid') self.assertEqual(len(record), 1) - res = record.get_metadata()[0].get('create_uid') or [None] - self.assertEqual(res[0], self.user_employee.id) self.assertEqual(record.name, 'Specific') self.assertEqual(record.custom_field, 'defaults_custom') self.alias.write({'alias_defaults': '""'}) - record = self.format_and_process(MAIL_TEMPLATE, self.email_from, 'groups@test.com', subject='Specific2') + self.assertEqual(self.alias.alias_status, 'not_tested', 'Updating alias_defaults must reset status') + + record = self.format_and_process( + MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', + subject='Specific2' + ) self.assertEqual(len(record), 1) - res = record.get_metadata()[0].get('create_uid') or [None] - self.assertEqual(res[0], self.user_employee.id) self.assertEqual(record.name, 'Specific2') self.assertFalse(record.custom_field) - - @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') - def test_message_process_alias_user_id(self): - """ Test alias ownership """ - self.alias.write({'alias_user_id': self.user_employee.id}) - - record = self.format_and_process(MAIL_TEMPLATE, self.email_from, 'groups@test.com') - self.assertEqual(len(record), 1) - res = record.get_metadata()[0].get('create_uid') or [None] - self.assertEqual(res[0], self.user_employee.id) + self.assertEqual(self.alias.alias_status, 'valid') @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') def test_message_process_alias_everyone(self): """ Incoming email: everyone: new record + message_new """ self.alias.write({'alias_contact': 'everyone'}) - record = self.format_and_process(MAIL_TEMPLATE, self.email_from, 'groups@test.com', subject='Specific') + record = self.format_and_process(MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', subject='Specific') self.assertEqual(len(record), 1) self.assertEqual(len(record.message_ids), 1) @@ -702,9 +706,9 @@ class TestMailgateway(MailGatewayCommon): # Test: no group created, email bounced with self.mock_mail_gateway(): - record = self.format_and_process(MAIL_TEMPLATE, self.email_from, 'groups@test.com', subject='Should Bounce') + record = self.format_and_process(MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', subject='Should Bounce') self.assertFalse(record) - self.assertSentEmail('"MAILER-DAEMON" ', ['whatever-2a840@postmaster.twitter.com'], subject='Re: Should Bounce') + self.assertSentEmail(f'"MAILER-DAEMON" <{self.alias_bounce}@{self.alias_domain}>', ['whatever-2a840@postmaster.twitter.com'], subject='Re: Should Bounce') @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_message_process_alias_followers_bounce(self): @@ -717,22 +721,30 @@ class TestMailgateway(MailGatewayCommon): # Test: unknown on followers alias -> bounce with self.mock_mail_gateway(): - record = self.format_and_process(MAIL_TEMPLATE, self.email_from, 'groups@test.com', subject='Should Bounce') + record = self.format_and_process(MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', subject='Should Bounce') self.assertFalse(record, 'message_process: should have bounced') - self.assertSentEmail('"MAILER-DAEMON" ', ['whatever-2a840@postmaster.twitter.com'], subject='Re: Should Bounce') + self.assertSentEmail( + f'"MAILER-DAEMON" <{self.alias_bounce}@{self.alias_domain}>', + ['whatever-2a840@postmaster.twitter.com'], + subject='Re: Should Bounce' + ) # Test: partner on followers alias -> bounce self._init_mail_mock() with self.mock_mail_gateway(): - record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email_formatted, 'groups@test.com', subject='Should Bounce') + record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email_formatted, f'groups@{self.alias_domain}', subject='Should Bounce') self.assertFalse(record, 'message_process: should have bounced') - self.assertSentEmail('"MAILER-DAEMON" ', ['whatever-2a840@postmaster.twitter.com'], subject='Re: Should Bounce') + self.assertSentEmail( + f'"MAILER-DAEMON" <{self.alias_bounce}@{self.alias_domain}>', + ['whatever-2a840@postmaster.twitter.com'], + subject='Re: Should Bounce' + ) @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') def test_message_process_alias_partner(self): """ Incoming email from a known partner on a Partners alias -> ok (+ test on alias.user_id) """ self.alias.write({'alias_contact': 'partners'}) - record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email_formatted, 'groups@test.com') + record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email_formatted, f'groups@{self.alias_domain}') # Test: one group created by alias user self.assertEqual(len(record), 1) @@ -747,7 +759,7 @@ class TestMailgateway(MailGatewayCommon): 'alias_parent_thread_id': self.test_record.id, }) self.test_record.message_subscribe(partner_ids=[self.partner_1.id]) - record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email_formatted, 'groups@test.com') + record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email_formatted, f'groups@{self.alias_domain}') # Test: one group created by Raoul (or Sylvie maybe, if we implement it) self.assertEqual(len(record), 1) @@ -779,7 +791,7 @@ class TestMailgateway(MailGatewayCommon): if passed: self.assertEqual(len(record), 1) self.assertEqual(record.email_from, email_from) - self.assertEqual(record.message_partner_ids, self.partner_1) + self.assertFalse(record.message_partner_ids, 'Non internal are not added as followers when being post authors') # multi emails not recognized (no normalized email, recognition) else: self.assertEqual(len(record), 0, @@ -793,7 +805,7 @@ class TestMailgateway(MailGatewayCommon): self.test_record.message_subscribe(partner_ids=[self.partner_1.id]) with self.mock_mail_gateway(): record = self.format_and_process( - MAIL_TEMPLATE, self.email_from, 'groups@test.com>', + MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', msg_id='<1198923581.41972151344608186799.JavaMail.diff1@agrolait.com>', subject='Re: cats') # Test: no new group + new message @@ -808,28 +820,25 @@ class TestMailgateway(MailGatewayCommon): @mute_logger('odoo.addons.mail.models.mail_thread') def test_message_process_create_uid_crash(self): - def _employee_crash(*args, **kwargs): + def _employee_crash(records, operation): """ If employee is test employee, consider they have no access on document """ - recordset = args[0] - if recordset.env.uid == self.user_employee.id and not recordset.env.su: - if kwargs.get('raise_exception', True): - raise exceptions.AccessError('Hop hop hop Ernest, please step back.') - return False + if records.env.uid == self.user_employee.id and not records.env.su: + return lambda: exceptions.AccessError('Hop hop hop Ernest, please step back.'), records return DEFAULT - with patch.object(MailTestGateway, 'check_access_rights', autospec=True, side_effect=_employee_crash): - record = self.format_and_process(MAIL_TEMPLATE, self.user_employee.email_formatted, 'groups@test.com', subject='NoEmployeeAllowed') + with patch.object(MailTestGateway, 'check_access', autospec=True, side_effect=_employee_crash): + record = self.format_and_process(MAIL_TEMPLATE, self.user_employee.email_formatted, f'groups@{self.alias_domain}', subject='NoEmployeeAllowed') self.assertEqual(record.create_uid, self.user_employee) self.assertEqual(record.message_ids[0].subject, 'NoEmployeeAllowed') - self.assertEqual(record.message_ids[0].create_uid, self.user_employee) + self.assertEqual(record.message_ids[0].create_uid, self.user_root, 'Message should be created by caller of message_process.') self.assertEqual(record.message_ids[0].author_id, self.user_employee.partner_id) @mute_logger('odoo.addons.mail.models.mail_thread') def test_message_process_create_uid_email(self): - record = self.format_and_process(MAIL_TEMPLATE, self.user_employee.email_formatted, 'groups@test.com', subject='Email Found') + record = self.format_and_process(MAIL_TEMPLATE, self.user_employee.email_formatted, f'groups@{self.alias_domain}', subject='Email Found') self.assertEqual(record.create_uid, self.user_employee) self.assertEqual(record.message_ids[0].subject, 'Email Found') - self.assertEqual(record.message_ids[0].create_uid, self.user_employee) + self.assertEqual(record.message_ids[0].create_uid, self.user_root) self.assertEqual(record.message_ids[0].author_id, self.user_employee.partner_id) record = self.format_and_process( @@ -838,13 +847,13 @@ class TestMailgateway(MailGatewayCommon): subject='Email OtherName') self.assertEqual(record.create_uid, self.user_employee) self.assertEqual(record.message_ids[0].subject, 'Email OtherName') - self.assertEqual(record.message_ids[0].create_uid, self.user_employee) + self.assertEqual(record.message_ids[0].create_uid, self.user_root) self.assertEqual(record.message_ids[0].author_id, self.user_employee.partner_id) - record = self.format_and_process(MAIL_TEMPLATE, self.user_employee.email_normalized, 'groups@test.com', subject='Email SimpleEmail') + record = self.format_and_process(MAIL_TEMPLATE, self.user_employee.email_normalized, f'groups@{self.alias_domain}', subject='Email SimpleEmail') self.assertEqual(record.create_uid, self.user_employee) self.assertEqual(record.message_ids[0].subject, 'Email SimpleEmail') - self.assertEqual(record.message_ids[0].create_uid, self.user_employee) + self.assertEqual(record.message_ids[0].create_uid, self.user_root) self.assertEqual(record.message_ids[0].author_id, self.user_employee.partner_id) @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink') @@ -856,19 +865,19 @@ class TestMailgateway(MailGatewayCommon): follower_user = mail_new_test_user(self.env, login='better', groups='base.group_user', name='Ernest Follower', email=self.user_employee.email) self.test_record.message_subscribe(follower_user.partner_id.ids) - record = self.format_and_process(MAIL_TEMPLATE, self.user_employee.email_formatted, 'groups@test.com', subject='FollowerWinner') + record = self.format_and_process(MAIL_TEMPLATE, self.user_employee.email_formatted, f'groups@{self.alias_domain}', subject='FollowerWinner') self.assertEqual(record.create_uid, follower_user) self.assertEqual(record.message_ids[0].subject, 'FollowerWinner') - self.assertEqual(record.message_ids[0].create_uid, follower_user) + self.assertEqual(record.message_ids[0].create_uid, self.user_root) self.assertEqual(record.message_ids[0].author_id, follower_user.partner_id) # name order win self.test_record.message_unsubscribe(follower_user.partner_id.ids) self.test_record.flush_recordset() - record = self.format_and_process(MAIL_TEMPLATE, self.user_employee.email_formatted, 'groups@test.com', subject='FirstFoundWinner') + record = self.format_and_process(MAIL_TEMPLATE, self.user_employee.email_formatted, f'groups@{self.alias_domain}', subject='FirstFoundWinner') self.assertEqual(record.create_uid, self.user_employee) self.assertEqual(record.message_ids[0].subject, 'FirstFoundWinner') - self.assertEqual(record.message_ids[0].create_uid, self.user_employee) + self.assertEqual(record.message_ids[0].create_uid, self.user_root) self.assertEqual(record.message_ids[0].author_id, self.user_employee.partner_id) # -------------------------------------------------- @@ -877,13 +886,36 @@ class TestMailgateway(MailGatewayCommon): @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') def test_message_route_alias_no_domain(self): - """ Incoming email: write to alias even if no domain set: considered as valid alias """ - self.env['ir.config_parameter'].set_param('mail.catchall.domain', '') + """ Incoming email: write to alias with no domain set: not recognized as + a valid alias even when local-part only is checked. """ + self.alias.alias_domain_id = False - new_record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email_formatted, 'groups@another.domain.com', subject='Test Subject') - # Test: one group created + for incoming_ok in [True, False]: + with self.subTest(incoming_ok=incoming_ok): + with self.assertRaises(ValueError): + _new_record = self.format_and_process( + MAIL_TEMPLATE, self.partner_1.email_formatted, f'groups@{self.alias_domain}', + subject='Test Subject' + ) + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_route_alias_alias_incoming_local(self): + """ Incoming email: write to alias using local part only: depends on + alias accepting local only flag. """ + self.alias.alias_incoming_local = True + new_record = self.format_and_process( + MAIL_TEMPLATE, self.partner_1.email_formatted, 'groups@another.domain.com', + subject='Test Subject Global' + ) self.assertEqual(len(new_record), 1, 'message_process: a new mail.test.simple should have been created') + self.alias.alias_incoming_local = False + with self.assertRaises(ValueError): + _new_record = self.format_and_process( + MAIL_TEMPLATE, self.partner_1.email_formatted, 'groups@another.domain.com', + subject='Test Subject Local' + ) + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') def test_message_route_alias_forward_bypass_reply_first(self): """ Incoming email: write to two "new thread" alias, one as a reply, one being another model -> consider as a forward """ @@ -891,8 +923,8 @@ class TestMailgateway(MailGatewayCommon): # test@.. will cause the creation of new mail.test new_alias_2 = self.env['mail.alias'].create({ + 'alias_domain_id': self.mail_alias_domain.id, 'alias_name': 'test', - 'alias_user_id': False, 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, 'alias_contact': 'everyone', }) @@ -918,8 +950,8 @@ class TestMailgateway(MailGatewayCommon): # test@.. will cause the creation of new mail.test new_alias_2 = self.env['mail.alias'].create({ + 'alias_domain_id': self.mail_alias_domain.id, 'alias_name': 'test', - 'alias_user_id': False, 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, 'alias_contact': 'everyone', }) @@ -948,8 +980,8 @@ class TestMailgateway(MailGatewayCommon): # test@.. will cause the creation of new mail.test new_alias_2 = self.env['mail.alias'].create({ + 'alias_domain_id': self.mail_alias_domain.id, 'alias_name': 'test', - 'alias_user_id': False, 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, 'alias_contact': 'everyone', }) @@ -974,8 +1006,8 @@ class TestMailgateway(MailGatewayCommon): """ Incoming email: write to two aliases creating records: both should be activated """ # test@.. will cause the creation of new mail.test new_alias_2 = self.env['mail.alias'].create({ + 'alias_domain_id': self.mail_alias_domain.id, 'alias_name': 'test', - 'alias_user_id': False, 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, 'alias_contact': 'everyone', }) @@ -996,35 +1028,30 @@ class TestMailgateway(MailGatewayCommon): """ Incoming email: check that if domains are set in the optional system parameter `mail.catchall.domain.allowed` only incoming emails from these domains will generate records.""" - MailTestGatewayModel = self.env['mail.test.gateway'] - MailTestContainerModel = self.env['mail.test.container'] - - # test@.. will cause the creation of new mail.test + # test@.. will cause the creation of new mail.test.container new_alias_2 = self.env['mail.alias'].create({ 'alias_contact': 'everyone', - 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, + 'alias_domain_id': self.mail_alias_domain_c2.id, + 'alias_incoming_local': True, + 'alias_model_id': self.env['ir.model']._get_id('mail.test.container.mc'), 'alias_name': 'test', - 'alias_user_id': False, }) - allowed_domain = 'hello.com' - for (alias_right_part, allowed_domain), (gateway_created, container_created) in zip( + test_domain = 'hello.com' + for (alias_right_part, allowed_domain), container_created in zip( [ + # Test a valid alias domain, standard case + (self.mail_alias_domain_c2.name, ""), # Test with 'mail.catchall.domain.allowed' not set in system parameters # and with a domain not allowed ('bonjour.com', ""), # Test with 'mail.catchall.domain.allowed' set in system parameters # and with a domain not allowed - ('bonjour.com', allowed_domain), + ('bonjour.com', test_domain), # Test with 'mail.catchall.domain.allowed' set in system parameters # and with a domain allowed - (allowed_domain, allowed_domain), - ], [ - (True, True), - (True, False), - (True, True), - ] - ): + (test_domain, test_domain), + ], [True, True, False, True]): with self.subTest(alias_right_part=alias_right_part, allowed_domain=allowed_domain): self.env['ir.config_parameter'].set_param('mail.catchall.domain.allowed', allowed_domain) @@ -1037,9 +1064,9 @@ class TestMailgateway(MailGatewayCommon): target_model=self.alias.alias_model_id.model ) - res_alias_1 = MailTestGatewayModel.search([('name', '=', subject)]) - res_alias_2 = MailTestContainerModel.search([('name', '=', subject)]) - self.assertEqual(bool(res_alias_1), gateway_created) + res_alias_1 = self.env['mail.test.gateway'].search([('name', '=', subject)]) + res_alias_2 = self.env['mail.test.container.mc'].search([('name', '=', subject)]) + self.assertTrue(bool(res_alias_1), 'First alias should always be respected') self.assertEqual(bool(res_alias_2), container_created) # -------------------------------------------------- @@ -1123,10 +1150,14 @@ class TestMailgateway(MailGatewayCommon): with self.mock_mail_gateway(): record = self.format_and_process( MAIL_TEMPLATE, self.partner_1.email_formatted, - '"My Super Catchall" <%s@%s>, Unroutable ' % (self.alias_catchall, self.alias_domain, self.alias_domain), + f'"My Super Catchall" <{self.alias_catchall}@{self.alias_domain}>, Unroutable ', subject='Should Bounce') self.assertFalse(record) - self.assertSentEmail('"MAILER-DAEMON" ', ['whatever-2a840@postmaster.twitter.com'], subject='Re: Should Bounce') + self.assertSentEmail( + self.mailer_daemon_email, + ['whatever-2a840@postmaster.twitter.com'], + subject='Re: Should Bounce' + ) @mute_logger('odoo.addons.mail.models.mail_thread') def test_message_process_bounce_alias(self): @@ -1134,8 +1165,7 @@ class TestMailgateway(MailGatewayCommon): self.assertEqual(self.partner_1.message_bounce, 0) self.assertEqual(self.test_record.message_bounce, 0) - bounced_mail_id = 4442 - bounce_email_to = '%s@%s' % ('bounce.test', 'test.com') + bounce_email_to = f'{self.alias_bounce}@{self.alias_domain}' record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email_formatted, bounce_email_to, subject='Undelivered Mail Returned to Sender') self.assertFalse(record) # No information found in bounce email -> not possible to do anything except avoiding email @@ -1148,20 +1178,46 @@ class TestMailgateway(MailGatewayCommon): self.assertEqual(self.partner_1.message_bounce, 0) self.assertEqual(self.test_record.message_bounce, 0) - record = self.format_and_process(MAIL_TEMPLATE, 'MAILER-DAEMON@example.com', 'groups@test.com', subject='Undelivered Mail Returned to Sender') + record = self.format_and_process(MAIL_TEMPLATE, 'MAILER-DAEMON@example.com', f'groups@{self.alias_domain}', subject='Undelivered Mail Returned to Sender') self.assertFalse(record) # No information found in bounce email -> not possible to do anything except avoiding email self.assertEqual(self.partner_1.message_bounce, 0) self.assertEqual(self.test_record.message_bounce, 0) + @mute_logger('odoo.addons.mail.models.mail_thread') + def test_message_process_bounce_missing_final_recipient(self): + """The Final-Recipient header is missing, the partner must be found thanks to the original mail message.""" + email = test_mail_data.MAIL_BOUNCE.replace('Final-Recipient', 'XX') + email = email.replace('Original-Recipient', 'XX') + + self.assertEqual(self.partner_1.message_bounce, 0) + self.assertEqual(self.test_record.message_bounce, 0) + + # no notification to find, won't be able to find the correct recipient + extra = self.fake_email.message_id + record = self.format_and_process(email, self.partner_1.email_formatted, f'{self.alias_bounce}@{self.alias_domain}', subject='Undelivered Mail Returned to Sender', extra=extra) + self.assertFalse(record) + self.assertEqual(self.partner_1.message_bounce, 0) + self.assertEqual(self.test_record.message_bounce, 0) + + # the partner will be found in the res_partner_id + extra = self.fake_email.message_id + self.env['mail.notification'].create({ + "res_partner_id": self.partner_1.id, + "mail_message_id": self.fake_email.id, + }) + record = self.format_and_process(email, self.partner_1.email_formatted, f'{self.alias_bounce}@{self.alias_domain}', subject='Undelivered Mail Returned to Sender', extra=extra) + self.assertFalse(record) + self.assertEqual(self.partner_1.message_bounce, 1) + self.assertEqual(self.test_record.message_bounce, 1) + @mute_logger('odoo.addons.mail.models.mail_thread') def test_message_process_bounce_multipart_alias(self): """ Multipart/report bounce correctly make related partner bounce """ self.assertEqual(self.partner_1.message_bounce, 0) self.assertEqual(self.test_record.message_bounce, 0) - bounced_mail_id = 4442 - bounce_email_to = '%s@%s' % ('bounce.test', 'test.com') + bounce_email_to = f'{self.alias_bounce}@{self.alias_domain}' record = self.format_and_process(test_mail_data.MAIL_BOUNCE, self.partner_1.email_formatted, bounce_email_to, subject='Undelivered Mail Returned to Sender') self.assertFalse(record) # Missing in reply to message_id -> cannot find original record @@ -1174,13 +1230,23 @@ class TestMailgateway(MailGatewayCommon): self.assertEqual(self.partner_1.message_bounce, 0) self.assertEqual(self.test_record.message_bounce, 0) - bounced_mail_id = 4442 - bounce_email_to = '%s@%s' % ('bounce.test', 'test.com') + notification = self.env['mail.notification'].create({ + 'mail_message_id': self.fake_email.id, + 'res_partner_id': self.partner_1.id, + }) + + bounce_email_to = f'{self.alias_bounce}@{self.alias_domain}' extra = self.fake_email.message_id record = self.format_and_process(test_mail_data.MAIL_BOUNCE, self.partner_1.email_formatted, bounce_email_to, subject='Undelivered Mail Returned to Sender', extra=extra) self.assertFalse(record) self.assertEqual(self.partner_1.message_bounce, 1) self.assertEqual(self.test_record.message_bounce, 1) + self.assertIn( + 'This is the mail system at host mail2.test.ironsky.', + notification.failure_reason, + msg='Should store the bounce email body on the notification') + self.assertEqual(notification.failure_type, 'mail_bounce') + self.assertEqual(notification.notification_status, 'bounce') @mute_logger('odoo.addons.mail.models.mail_thread') def test_message_process_bounce_multipart_alias_whatever_from(self): @@ -1188,8 +1254,7 @@ class TestMailgateway(MailGatewayCommon): self.assertEqual(self.partner_1.message_bounce, 0) self.assertEqual(self.test_record.message_bounce, 0) - bounced_mail_id = 4442 - bounce_email_to = '%s@%s' % ('bounce.test', 'test.com') + bounce_email_to = f'{self.alias_bounce}@{self.alias_domain}' extra = self.fake_email.message_id record = self.format_and_process(test_mail_data.MAIL_BOUNCE, 'Whatever ', bounce_email_to, subject='Undelivered Mail Returned to Sender', extra=extra) self.assertFalse(record) @@ -1203,26 +1268,38 @@ class TestMailgateway(MailGatewayCommon): self.assertEqual(self.test_record.message_bounce, 0) extra = self.fake_email.message_id - record = self.format_and_process(test_mail_data.MAIL_BOUNCE, 'Whatever ', 'groups@test.com', subject='Undelivered Mail Returned to Sender', extra=extra) + record = self.format_and_process(test_mail_data.MAIL_BOUNCE, 'Whatever ', f'groups@{self.alias_domain}', subject='Undelivered Mail Returned to Sender', extra=extra) self.assertFalse(record) self.assertEqual(self.partner_1.message_bounce, 0) self.assertEqual(self.test_record.message_bounce, 1) + # The local part of the FROM is not "MAILER-DAEMON", and the Content type is slightly + # different. Thanks to the report type, it still should be detected as a bounce email. + email = test_mail_data.MAIL_BOUNCE.replace('multipart/report;', 'multipart/report:') + email = email.replace('MAILER-DAEMON@mail2.test.ironsky', 'email@mail2.test.ironsky') + self.assertIn('report-type=delivery-status', email) + extra = self.fake_email.message_id + record = self.format_and_process(email, 'Whatever ', f'groups@{self.alias_domain}', subject='Undelivered Mail Returned to Sender', extra=extra) + self.assertFalse(record) + self.assertEqual(self.partner_1.message_bounce, 0) + self.assertEqual(self.test_record.message_bounce, 2) + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink') def test_message_process_bounce_records_channel(self): - """ Test blacklist allow to multi-bounce and auto update of mail.channel """ + """ Test blacklist allow to multi-bounce and auto update of discuss.channel """ other_record = self.env['mail.test.gateway'].create({ 'email_from': f'Another name <{self.partner_1.email}>' }) yet_other_record = self.env['mail.test.gateway'].create({ 'email_from': f'Yet Another name <{self.partner_1.email.upper()}>' }) - test_channel = self.env['mail.channel'].create({ + test_channel = self.env['discuss.channel'].create({ 'name': 'Test', 'channel_partner_ids': [(4, self.partner_1.id)], + 'group_public_id': None, }) self.fake_email.write({ - 'model': 'mail.channel', + 'model': 'discuss.channel', 'res_id': test_channel.id, }) self.assertIn(self.partner_1, test_channel.channel_partner_ids) @@ -1231,7 +1308,7 @@ class TestMailgateway(MailGatewayCommon): self.assertEqual(yet_other_record.message_bounce, 0) extra = self.fake_email.message_id - for i in range(10): + for _i in range(10): record = self.format_and_process( test_mail_data.MAIL_BOUNCE, f'A third name <{self.partner_1.email}>', f'groups@{self.alias_domain}', @@ -1242,8 +1319,19 @@ class TestMailgateway(MailGatewayCommon): self.assertEqual(self.test_record.message_bounce, 0) self.assertEqual(other_record.message_bounce, 10) self.assertEqual(yet_other_record.message_bounce, 10) + # MAX_BOUNCE_LIMIT in discuss_channel is set to 10, + # If this partner exceeds the limit, remove them from the channel. self.assertNotIn(self.partner_1, test_channel.channel_partner_ids) + # On a new successful incoming email, the partner bounce counter should be reset. + self.format_and_process( + MAIL_TEMPLATE, self.partner_1.email_formatted, + f'groups@{self.alias_domain}', + subject='Test Working Email Subject', + extra=f'In-Reply-To:\r\n\t{self.fake_email.message_id}\n', + ) + self.assertEqual(self.partner_1.message_bounce, 0) + @mute_logger('odoo.addons.mail.models.mail_thread') def test_message_process_bounce_records_partner(self): """ Test blacklist + bounce on ``res.partner`` model """ @@ -1254,16 +1342,59 @@ class TestMailgateway(MailGatewayCommon): }) extra = self.fake_email.message_id - record = self.format_and_process(test_mail_data.MAIL_BOUNCE, self.partner_1.email_formatted, 'groups@test.com', subject='Undelivered Mail Returned to Sender', extra=extra) + record = self.format_and_process(test_mail_data.MAIL_BOUNCE, self.partner_1.email_formatted, f'groups@{self.alias_domain}', subject='Undelivered Mail Returned to Sender', extra=extra) self.assertFalse(record) self.assertEqual(self.partner_1.message_bounce, 1) self.assertEqual(self.test_record.message_bounce, 0) + @mute_logger('odoo.addons.mail.models.mail_thread') + def test_message_process_bounce_records_partner_multi(self): + """Bounce must only affect the notification matching the bounced email.""" + + bounce_email = 'specific.bounce.address@example.com' + + message = self._create_gateway_message( + self.test_record, + 'bounce_multi', + body='Test message', + message_type='email', + partner_ids=(self.partner_1 + self.partner_employee).ids, + subject='Test Multi Partner', + ) + + notif_partner, notif_employee = self.env['mail.notification'].create([ + { + 'mail_message_id': message.id, + 'res_partner_id': self.partner_1.id, + 'notification_type': 'email', + 'notification_status': 'sent', + 'mail_email_address': bounce_email, + }, + { + 'mail_message_id': message.id, + 'res_partner_id': self.partner_employee.id, + 'notification_type': 'email', + 'notification_status': 'sent', + }, + ]) + + with self.mock_mail_gateway(): + self.format_and_process( + test_mail_data.MAIL_BOUNCE, + bounce_email, + f'groups@{self.alias_domain}', + subject='Undelivered Mail Returned to Sender', + extra=message.message_id, + ) + + self.assertEqual(notif_partner.notification_status, 'bounce') + self.assertEqual(notif_employee.notification_status, 'sent') + # -------------------------------------------------- # Thread formation # -------------------------------------------------- - @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink', 'odoo.addons.mail.models.mail_mail', 'odoo.tests') def test_message_process_external_notification_reply(self): """Ensure responses bot messages are discussions.""" bot_notification_message = self._create_gateway_message( @@ -1330,66 +1461,119 @@ class TestMailgateway(MailGatewayCommon): @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink', 'odoo.addons.mail.models.mail_mail', 'odoo.tests') def test_message_process_references_multi_parent(self): """ Incoming email with multiple references """ - reply1 = self._create_gateway_message( - self.test_record, 'reply1', parent_id=self.fake_email.id, - ) - reply2 = self._create_gateway_message( - self.test_record, 'reply2', parent_id=self.fake_email.id, - subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), - ) - reply1_1 = self._create_gateway_message( - self.test_record, 'reply1_1', parent_id=reply1.id, - subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), - ) - reply2_1 = self._create_gateway_message( - self.test_record, 'reply2_1', parent_id=reply2.id, - ) + alias_update = self.env['mail.alias'].create({ + 'alias_domain_id': self.mail_alias_domain.id, + 'alias_force_thread_id': self.test_record.id, + 'alias_name': 'test.update', + 'alias_model_id': self.env['ir.model']._get(self.test_record._name).id, + 'alias_contact': 'everyone', + }) + with self.mock_datetime_and_now(datetime(2025, 11, 19, 10, 30, 0)): + reply1 = self._create_gateway_message( + self.test_record, 'reply1', parent_id=self.fake_email.id, + ) + reply2 = self._create_gateway_message( + self.test_record, 'reply2', parent_id=self.fake_email.id, + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + ) + reply1_1 = self._create_gateway_message( + self.test_record, 'reply1_1', parent_id=reply1.id, + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + ) + reply2_1 = self._create_gateway_message( + self.test_record, 'reply2_1', parent_id=reply2.id, + ) # reply to reply1 using multiple references - self.format_and_process( - MAIL_TEMPLATE, self.email_from, 'groups@test.com', - subject='Reply to reply1', - extra=f'References: {reply1.message_id} {self.fake_email.message_id}' - ) + with self.mock_datetime_and_now(datetime(2025, 11, 19, 10, 30, 0)): + self.format_and_process( + MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', + subject='Reply to reply1', + extra=f'References: {reply1.message_id} {self.fake_email.message_id}', + ) new_msg = self.test_record.message_ids[0] - self.assertEqual(new_msg.parent_id, self.fake_email, 'Mail: flattening attach to original message') + self.assertEqual(new_msg.parent_id, reply1, 'Newer parent found should be selected') self.assertEqual(new_msg.subtype_id, self.env.ref('mail.mt_comment'), 'Mail: reply to a comment should be a comment') - # ordering should not impact - self.format_and_process( - MAIL_TEMPLATE, self.email_from, 'groups@test.com', - subject='Reply to reply1 (order issue)', - extra=f'References: {self.fake_email.message_id} {reply1.message_id}' - ) + with self.mock_datetime_and_now(datetime(2025, 11, 19, 10, 30, 0)): + self.format_and_process( + MAIL_TEMPLATE, self.email_from, f'test.gateway@{self.alias_domain}', + subject='Reply to reply1_1 (with noise)', + extra=f'References: {reply1_1.message_id} {reply1.message_id} {reply1.message_id}', + ) new_msg = self.test_record.message_ids[0] - self.assertEqual(new_msg.parent_id, self.fake_email, 'Mail: flattening attach to original message') + self.assertEqual(new_msg.parent_id, reply1_1, 'Newer parent found should be selected') + self.assertEqual(new_msg.subtype_id, self.env.ref('mail.mt_note'), 'Mail: reply to a note should be a note') + + # ordering should not impact + with self.mock_datetime_and_now(datetime(2025, 11, 19, 10, 30, 0)): + self.format_and_process( + MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', + subject='Reply to reply1 (order issue)', + extra=f'References: {self.fake_email.message_id} {reply1.message_id}', + ) + new_msg = self.test_record.message_ids[0] + self.assertEqual(new_msg.parent_id, reply1, 'Mail: flattening attach to original message') self.assertEqual(new_msg.subtype_id, self.env.ref('mail.mt_comment'), 'Mail: reply to a comment should be a comment') # history with last one being a note - self.format_and_process( - MAIL_TEMPLATE, self.email_from, 'groups@test.com', - subject='Reply to reply1_1', - extra=f'References: {reply1_1.message_id} {self.fake_email.message_id}' - ) + with self.mock_datetime_and_now(datetime(2025, 11, 19, 10, 30, 0)): + self.format_and_process( + MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', + subject='Reply to reply1_1', + extra=f'References: {reply1_1.message_id} {self.fake_email.message_id}', + ) new_msg = self.test_record.message_ids[0] - self.assertEqual(new_msg.parent_id, self.fake_email, 'Mail: flattening attach to original message') + self.assertEqual(new_msg.parent_id, reply1_1, 'Mail: flattening attach to original message') self.assertEqual(new_msg.subtype_id, self.env.ref('mail.mt_note'), 'Mail: reply to a note should be a note') # messed up history (two child branches): gateway initial parent is newest one - # (then may change with flattening when posting on record) - self.format_and_process( - MAIL_TEMPLATE, self.email_from, 'groups@test.com', - subject='Reply to reply2_1 (with noise)', - extra=f'References: {reply1_1.message_id} {reply2_1.message_id}' - ) - new_msg = self.test_record.message_ids[0] - self.assertEqual(new_msg.parent_id, self.fake_email, 'Mail: flattening attach to original message') - self.assertEqual(new_msg.subtype_id, self.env.ref('mail.mt_comment'), 'Mail: parent should be a comment (before flattening)') + with self.mock_datetime_and_now(datetime(2025, 11, 19, 10, 30, 0)), \ + self.mock_mail_gateway(), self.mock_mail_app(): + self.format_and_process( + MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', + subject='Reply to reply2_1 (with noise)', + date=datetime(2025, 11, 20, 10, 30, 0), + extra=f'References: {reply1_1.message_id} {reply2_1.message_id}', + ) + new_msg = self._new_msgs + self.assertEqual(new_msg, self.test_record.message_ids[0]) + self.assertEqual(new_msg.date, datetime(2025, 11, 20, 10, 30, 0)) + self.assertEqual(new_msg.parent_id, reply2_1, 'Mail: flattening attach to original message') + self.assertEqual(new_msg.subtype_id, self.env.ref('mail.mt_comment'), 'Mail: parent should be a comment') + + # no references: new discussion thread started. Alias allows to post on + # a record without replying, aka without references, which means parent + # set to last email / discussion message + with self.mock_datetime_and_now(datetime(2025, 11, 19, 10, 30, 0)): + old_msg = self._create_gateway_message( + self.test_record, 'old_msg', + date=datetime(2024, 11, 20, 10, 30, 0), + parent_id=reply1.id, + ) + self.assertEqual(old_msg.date, datetime(2024, 11, 20, 10, 30, 0)) + with self.mock_datetime_and_now(datetime(2024, 11, 20, 10, 30, 0)): + old_disturbing_msg = self._create_gateway_message( + self.test_record, 'old_disturbinh_msg', + date=False, + parent_id=reply1.id, + ) + self.assertFalse(old_disturbing_msg.date) + + with self.mock_datetime_and_now(datetime(2025, 11, 19, 10, 30, 0)): + self.format_and_process( + MAIL_TEMPLATE, self.email_from, alias_update.alias_full_name, + subject='New thread', + extra='References:' + ) + last_msg = self.test_record.message_ids[0] + self.assertEqual(last_msg.parent_id, new_msg, 'No free message, attached to last thread comment / email') + self.assertEqual(last_msg.subtype_id, self.env.ref('mail.mt_comment'), 'Mail: parent should be a comment') @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink', 'odoo.addons.mail.models.mail_mail', 'odoo.tests') def test_message_process_references_multi_parent_notflat(self): """ Incoming email with multiple references with ``_mail_flat_thread`` - being False (mail.group/mail.channel behavior like). """ + being False (mail.group/discuss.channel behavior like). """ test_record = self.env['mail.test.gateway.groups'].create({ 'alias_name': 'test.gateway', 'name': 'Test', @@ -1401,48 +1585,19 @@ class TestMailgateway(MailGatewayCommon): reply1 = self._create_gateway_message( test_record, 'reply1', parent_id=first_msg.id, ) - reply2 = self._create_gateway_message( - test_record, 'reply2', parent_id=first_msg.id, - subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), - ) - reply1_1 = self._create_gateway_message( - test_record, 'reply1_1', parent_id=reply1.id, - subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), - ) - reply2_1 = self._create_gateway_message( - test_record, 'reply2_1', parent_id=reply2.id, - ) self.format_and_process( - MAIL_TEMPLATE, self.email_from, 'test.gateway@test.com', + MAIL_TEMPLATE, self.email_from, f'test.gateway@{self.alias_domain}', subject='Reply to reply1', - extra=f'References: {reply1.message_id}' + extra=f'References: {first_msg.id} {reply1.message_id}' ) new_msg = test_record.message_ids[0] - self.assertEqual(new_msg.parent_id, first_msg, 'Mail: pseudo no flattening: getting up one level (reply1 parent)') - self.assertEqual(new_msg.subtype_id, self.env.ref('mail.mt_comment'), 'Mail: parent should be a comment') - - self.format_and_process( - MAIL_TEMPLATE, self.email_from, 'test.gateway@test.com', - subject='Reply to reply1_1 (with noise)', - extra=f'References: {reply1_1.message_id} {reply1.message_id} {reply1.message_id}' - ) - new_msg = test_record.message_ids[0] - self.assertEqual(new_msg.parent_id, reply1, 'Mail: pseudo no flattening: getting up one level (reply1_1 parent)') - self.assertEqual(new_msg.subtype_id, self.env.ref('mail.mt_note'), 'Mail: reply to a note should be a note') - - self.format_and_process( - MAIL_TEMPLATE, self.email_from, 'test.gateway@test.com', - subject='Reply to reply2_1 (with noise)', - extra=f'References: {reply2_1.message_id} {reply1_1.message_id}' - ) - new_msg = test_record.message_ids[0] - self.assertEqual(new_msg.parent_id, reply2, 'Mail: pseudo no flattening: getting up one level (reply2_1 parent') + self.assertEqual(new_msg.parent_id, reply1, 'Mail: pseudo no flattening: getting up one level (reply1 parent)') self.assertEqual(new_msg.subtype_id, self.env.ref('mail.mt_comment'), 'Mail: parent should be a comment') # no references: new discussion thread started self.format_and_process( - MAIL_TEMPLATE, self.email_from, 'test.gateway@test.com', + MAIL_TEMPLATE, self.email_from, f'test.gateway@{self.alias_domain}', subject='New thread', extra='References:' ) @@ -1451,16 +1606,6 @@ class TestMailgateway(MailGatewayCommon): self.assertEqual(new_thread.subject, 'New thread') self.assertEqual(new_thread.subtype_id, self.env.ref('mail.mt_comment'), 'Mail: parent should be a comment') - # mixed up references: newer message wins - self.format_and_process( - MAIL_TEMPLATE, self.email_from, 'test.gateway@test.com', - subject='New thread', - extra=f'References: {new_thread.message_id} {reply1_1.message_id}' - ) - new_msg = test_record.message_ids[0] - self.assertEqual(new_msg.parent_id, new_thread) - self.assertEqual(new_msg.subtype_id, self.env.ref('mail.mt_comment'), 'Mail: parent should be a comment') - @mute_logger('odoo.addons.mail.models.mail_thread') def test_message_process_references_external(self): """ Incoming email being a reply to an external email processed by odoo should update thread accordingly """ @@ -1501,15 +1646,16 @@ class TestMailgateway(MailGatewayCommon): def test_message_process_references_forward(self): """ Incoming email using references but with alias forward should not go into references destination """ self.env['mail.alias'].create({ + 'alias_domain_id': self.mail_alias_domain.id, 'alias_name': 'test.alias', - 'alias_user_id': False, 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, 'alias_contact': 'everyone', }) init_msg_count = len(self.test_record.message_ids) res_test = self.format_and_process( MAIL_TEMPLATE, self.email_from, f'test.alias@{self.alias_domain}', - subject='My Dear Forward', extra=f'References: <2233@a.com>\r\n\t<3edss_dsa@b.com> {self.fake_email.message_id}', + subject='My Dear Forward', + extra=f'References: <2233@a.com>\r\n\t<3edss_dsa@b.com> {self.fake_email.message_id}', target_model='mail.test.container') self.assertEqual(len(self.test_record.message_ids), init_msg_count) @@ -1521,15 +1667,16 @@ class TestMailgateway(MailGatewayCommon): def test_message_process_references_forward_same_model(self): """ Incoming email using references but with alias forward on same model should be considered as a reply """ self.env['mail.alias'].create({ + 'alias_domain_id': self.mail_alias_domain.id, 'alias_name': 'test.alias', - 'alias_user_id': False, 'alias_model_id': self.env['ir.model']._get('mail.test.gateway').id, 'alias_contact': 'everyone', }) init_msg_count = len(self.test_record.message_ids) res_test = self.format_and_process( MAIL_TEMPLATE, self.email_from, f'test.alias@{self.alias_domain}', - subject='My Dear Forward', extra=f'References: <2233@a.com>\r\n\t<3edss_dsa@b.com> {self.fake_email.message_id}', + subject='My Dear Forward', + extra=f'References: <2233@a.com>\r\n\t<3edss_dsa@b.com> {self.fake_email.message_id}', target_model='mail.test.container') self.assertEqual(len(self.test_record.message_ids), init_msg_count + 1) @@ -1540,15 +1687,18 @@ class TestMailgateway(MailGatewayCommon): def test_message_process_references_forward_cc(self): """ Incoming email using references but with alias forward in CC should be considered as a repy (To > Cc) """ self.env['mail.alias'].create({ + 'alias_domain_id': self.mail_alias_domain.id, 'alias_name': 'test.alias', - 'alias_user_id': False, 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, 'alias_contact': 'everyone', }) init_msg_count = len(self.test_record.message_ids) res_test = self.format_and_process( - MAIL_TEMPLATE, self.email_from, 'catchall.test@test.com', cc='test.alias@test.com', - subject='My Dear Forward', extra='References: <2233@a.com>\r\n\t<3edss_dsa@b.com> %s' % self.fake_email.message_id, + MAIL_TEMPLATE, self.email_from, + f'{self.alias_catchall}@{self.alias_domain}', + cc=f'test.alias@{self.alias_domain}', + subject='My Dear Forward', + extra=f'References: <2233@a.com>\r\n\t<3edss_dsa@b.com> {self.fake_email.message_id}', target_model='mail.test.container') self.assertEqual(len(self.test_record.message_ids), init_msg_count + 1) @@ -1564,14 +1714,17 @@ class TestMailgateway(MailGatewayCommon): reply_to_force_new=False, subtype_xmlid='mail.mt_comment', ) - self.assertEqual(record_msg.reply_to, formataddr(('%s %s' % (self.user_employee.company_id.name, first_record.name), '%s@%s' % ('catchall.test', 'test.com')))) + self.assertEqual( + record_msg.reply_to, + formataddr((self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}')) + ) mail_msg = first_record.message_post( subject='Replies to Record', - reply_to='groups@test.com', + reply_to=f'groups@{self.alias_domain}', reply_to_force_new=True, subtype_xmlid='mail.mt_comment', ) - self.assertEqual(mail_msg.reply_to, 'groups@test.com') + self.assertEqual(mail_msg.reply_to, f'groups@{self.alias_domain}') # reply to mail but should be considered as a new mail for alias msgID = '' @@ -1582,7 +1735,7 @@ class TestMailgateway(MailGatewayCommon): incoming_msg = self.env['mail.message'].search([('message_id', '=', msgID)]) self.assertFalse(res_test) self.assertEqual(incoming_msg.model, 'mail.test.simple') - self.assertEqual(incoming_msg.parent_id, first_record.message_ids[-1]) + self.assertEqual(incoming_msg.parent_id, record_msg) self.assertTrue(incoming_msg.res_id == first_record.id) # reply to mail but should be considered as a new mail for alias @@ -1607,6 +1760,7 @@ class TestMailgateway(MailGatewayCommon): """New record with mail that contains base64 inline image.""" target_model = "mail.test.field.type" alias = self.env["mail.alias"].create({ + 'alias_domain_id': self.mail_alias_domain.id, "alias_name": "base64-lover", "alias_model_id": self.env["ir.model"]._get(target_model).id, "alias_defaults": "{}", @@ -1630,6 +1784,7 @@ class TestMailgateway(MailGatewayCommon): coming from alias.""" target_model = "mail.test.field.type" alias = self.env["mail.alias"].create({ + 'alias_domain_id': self.mail_alias_domain.id, "alias_name": "base64-lover", "alias_model_id": self.env["ir.model"]._get(target_model).id, "alias_defaults": "{'type': 'second'}", @@ -1679,19 +1834,19 @@ class TestMailgateway(MailGatewayCommon): self.alias.write({'alias_force_thread_id': self.test_record.id,}) # Post a base message - record = self.format_and_process(MAIL_TEMPLATE, self.email_from, 'groups@test.com', subject='Re: super cats', msg_id='<123?456.diff1@agrolait.com>') + record = self.format_and_process(MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', subject='Re: super cats', msg_id='<123.456.diff1@agrolait.com>') self.assertFalse(record) self.assertEqual(len(self.test_record.message_ids), 2) # Do: due to some issue, same email goes back into the mailgateway record = self.format_and_process( - MAIL_TEMPLATE, self.email_from, 'groups@test.com', subject='Re: news', - msg_id='<123?456.diff1@agrolait.com>', extra='In-Reply-To: <1198923581.41972151344608186799.JavaMail.diff1@agrolait.com>\n') + MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', subject='Re: news', + msg_id='<123.456.diff1@agrolait.com>', extra='In-Reply-To: <1198923581.41972151344608186799.JavaMail.diff1@agrolait.com>\n') self.assertFalse(record) self.assertEqual(len(self.test_record.message_ids), 2) # Test: message_id is still unique - no_of_msg = self.env['mail.message'].search_count([('message_id', 'ilike', '<123?456.diff1@agrolait.com>')]) + no_of_msg = self.env['mail.message'].search_count([('message_id', 'ilike', '<123.456.diff1@agrolait.com>')]) self.assertEqual(no_of_msg, 1, 'message_process: message with already existing message_id should not have been duplicated') @@ -1700,7 +1855,7 @@ class TestMailgateway(MailGatewayCommon): """ Incoming email with model that does not accepts incoming emails must raise """ self.assertRaises(ValueError, self.format_and_process, - MAIL_TEMPLATE, self.email_from, 'noone@test.com', + MAIL_TEMPLATE, self.email_from, f'noone@{self.alias_domain}', subject='spam', extra='', model='res.country') @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') @@ -1708,14 +1863,14 @@ class TestMailgateway(MailGatewayCommon): """ Incoming email without model and without alias must raise """ self.assertRaises(ValueError, self.format_and_process, - MAIL_TEMPLATE, self.email_from, 'noone@test.com', + MAIL_TEMPLATE, self.email_from, f'noone@{self.alias_domain}', subject='spam', extra='') @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') def test_message_process_fallback(self): """ Incoming email with model that accepting incoming emails as fallback """ record = self.format_and_process( - MAIL_TEMPLATE, self.email_from, 'noone@test.com', + MAIL_TEMPLATE, self.email_from, f'noone@{self.alias_domain}', subject='Spammy', extra='', model='mail.test.gateway') self.assertEqual(len(record), 1) self.assertEqual(record.name, 'Spammy') @@ -1725,7 +1880,7 @@ class TestMailgateway(MailGatewayCommon): def test_message_process_file_encoding(self): """ Incoming email with file encoding """ file_content = 'Hello World' - for encoding in ['', 'UTF-8', 'UTF-16LE', 'UTF-32BE']: + for encoding in ['', 'UTF-8', 'UTF-16LE', 'UTF-32BE', 'cp-850']: file_content_b64 = base64.b64encode(file_content.encode(encoding or 'utf-8')).decode() record = self.format_and_process(test_mail_data.MAIL_FILE_ENCODING, self.email_from, f'groups@{self.alias_domain}', @@ -1735,7 +1890,7 @@ class TestMailgateway(MailGatewayCommon): ) attachment = record.message_ids.attachment_ids self.assertEqual(file_content, attachment.raw.decode(encoding or 'utf-8')) - if encoding not in ['', 'UTF-8']: + if encoding not in ['', 'UTF-8', 'cp-850']: self.assertNotEqual(file_content, attachment.raw.decode('utf-8')) def test_message_hebrew_iso8859_8_i(self): @@ -1751,7 +1906,7 @@ class TestMailgateway(MailGatewayCommon): content = "שלום וברוכים הבאים למקרה המבחן הנפלא הזה" encoded_content = base64.b64encode(content.encode(charset)).decode() - with RecordCapturer(self.env['mail.test.gateway'], []) as capture: + with RecordCapturer(self.env['mail.test.gateway']) as capture: mail = test_mail_data.MAIL_FILE_ENCODING.format( msg_id="", subject=encoded_subject, @@ -1773,7 +1928,7 @@ class TestMailgateway(MailGatewayCommon): # python, check that Odoo is still capable of decoding it. # windows-874 is the Microsoft equivalent of cp874. with self.mock_mail_gateway(), \ - RecordCapturer(self.env['mail.test.gateway'], []) as capture: + RecordCapturer(self.env['mail.test.gateway']) as capture: self.env['mail.thread'].message_process('mail.test.gateway', THAI_EMAIL_WINDOWS_874) capture.records.ensure_one() self.assertEqual(capture.records.name, 'เรื่อง') @@ -1788,9 +1943,9 @@ class TestMailgateway(MailGatewayCommon): """ Incoming email containing an xml attachment with unknown characters (�) but an ASCII charset should not raise an Exception. UTF-8 is used as a safe fallback. """ - record = self.format_and_process(test_mail_data.MAIL_MULTIPART_INVALID_ENCODING, self.email_from, 'groups@test.com') + record = self.format_and_process(test_mail_data.MAIL_MULTIPART_INVALID_ENCODING, self.email_from, f'groups@{self.alias_domain}') - self.assertEqual(record.message_main_attachment_id.name, 'bis3_with_error_encoding_address.xml') + self.assertEqual(record.message_ids.attachment_ids.name, 'bis3_with_error_encoding_address.xml') # NB: the xml received by email contains b"Chauss\xef\xbf\xbd\xef\xbf\xbde" with "\xef\xbf\xbd" being the # replacement character � in UTF-8. # When calling `_message_parse_extract_payload`, `part.get_content()` will be called on the attachment part of @@ -1799,25 +1954,25 @@ class TestMailgateway(MailGatewayCommon): # part, i.e: `content.decode('us-ascii', errors='replace')`. So the errors are replaced using the Unicode # replacement marker and the string "Chauss������e" is used to create the attachment. # This explains the multiple "�" in the attachment. - self.assertIn("Chauss������e de Bruxelles", record.message_main_attachment_id.raw.decode()) + self.assertIn("Chauss������e de Bruxelles", record.message_ids.attachment_ids.raw.decode()) @mute_logger('odoo.addons.mail.models.mail_thread') def test_message_process_file_omitted_charset_xml(self): """ For incoming email containing an xml attachment with omitted charset and containing an UTF8 payload we should parse the attachment using UTF-8. """ - record = self.format_and_process(test_mail_data.MAIL_MULTIPART_OMITTED_CHARSET_XML, self.email_from, 'groups@test.com') - self.assertEqual(record.message_main_attachment_id.name, 'bis3.xml') - self.assertEqual("Chaussée de Bruxelles", record.message_main_attachment_id.raw.decode()) + record = self.format_and_process(test_mail_data.MAIL_MULTIPART_OMITTED_CHARSET_XML, self.email_from, f'groups@{self.alias_domain}') + self.assertEqual(record.message_ids.attachment_ids.name, 'bis3.xml') + self.assertEqual("Chaussée de Bruxelles", record.message_ids.attachment_ids.raw.decode()) @mute_logger('odoo.addons.mail.models.mail_thread') def test_message_process_file_omitted_charset_csv(self): """ For incoming email containing a csv attachment with omitted charset and containing an UTF8 payload we should parse the attachment using UTF-8. """ - record = self.format_and_process(test_mail_data.MAIL_MULTIPART_OMITTED_CHARSET_CSV, self.email_from, 'groups@test.com') - self.assertEqual(record.message_main_attachment_id.name, 'bis3.csv') - self.assertEqual("\ufeffAuftraggeber;LieferadresseStraße;", record.message_main_attachment_id.raw.decode()) + record = self.format_and_process(test_mail_data.MAIL_MULTIPART_OMITTED_CHARSET_CSV, self.email_from, f'groups@{self.alias_domain}') + self.assertEqual(record.message_ids.attachment_ids.name, 'bis3.csv') + self.assertEqual("\ufeffAuftraggeber;LieferadresseStraße;", record.message_ids.attachment_ids.raw.decode()) @mute_logger('odoo.addons.mail.models.mail_thread') def test_message_process_file_omitted_charset_txt(self): @@ -1828,9 +1983,9 @@ class TestMailgateway(MailGatewayCommon): "altes Schloss. Über den Dächern sieht man oft Vögel fliegen. Müller und Schröder sind typische deutsche Nachnamen. " "Die Straße, in der ich wohne, heißt „Bachstraße“ und ist sehr ruhig. Überall im Wald wachsen Bäume mit kräftigen Ästen. " "Können wir uns über die Pläne für das nächste Wochenende unterhalten?") - record = self.format_and_process(test_mail_data.MAIL_MULTIPART_OMITTED_CHARSET_TXT, self.email_from, 'groups@test.com') - self.assertEqual(record.message_main_attachment_id.name, 'bis3.txt') - self.assertEqual(test_string, record.message_main_attachment_id.raw.decode()) + record = self.format_and_process(test_mail_data.MAIL_MULTIPART_OMITTED_CHARSET_TXT, self.email_from, f'groups@{self.alias_domain}') + self.assertEqual(record.message_ids.attachment_ids.name, 'bis3.txt') + self.assertEqual(test_string, record.message_ids.attachment_ids.raw.decode()) @mute_logger('odoo.addons.mail.models.mail_thread') def test_message_route_reply_model_none(self): @@ -1847,14 +2002,18 @@ class TestMailgateway(MailGatewayCommon): message = self.env['mail.message'].create({ 'body': '

test

', 'email_from': self.email_from, - 'message_type': 'email', + 'message_type': 'email_outgoing', 'model': None, 'res_id': None, }) - self.env['mail.alias'].create({'alias_name': 'test', 'alias_model_id': self.env['ir.model']._get('mail.test.gateway').id}) + self.env['mail.alias'].create({ + 'alias_domain_id': self.mail_alias_domain.id, + 'alias_name': 'test', + 'alias_model_id': self.env['ir.model']._get('mail.test.gateway').id, + }) record = self.format_and_process( - MAIL_TEMPLATE, self.email_from, 'test@test.com', + MAIL_TEMPLATE, self.email_from, f'test@{self.alias_domain}', subject=message.message_id, extra=f'In-Reply-To:\r\n\t{message.message_id}\n', model=None) @@ -1881,14 +2040,14 @@ class TestMailGatewayLoops(MailGatewayCommon): cls.alias_ticket = cls.env['mail.alias'].create({ 'alias_contact': 'everyone', + 'alias_domain_id': cls.mail_alias_domain.id, 'alias_model_id': cls.env['ir.model']._get_id('mail.test.ticket'), 'alias_name': 'test.ticket', - 'alias_user_id': False, }) cls.alias_other = cls.env['mail.alias'].create({ 'alias_contact': 'everyone', + 'alias_domain_id': cls.mail_alias_domain.id, 'alias_model_id': cls.env['ir.model']._get_id('mail.test.gateway'), - 'alias_user_id': False, 'alias_name': 'test.gateway', }) @@ -1904,7 +2063,7 @@ class TestMailGatewayLoops(MailGatewayCommon): } ]) - @mute_logger('odoo.addons.mail.models.mail_thread') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_thread') @patch.object(Cursor, 'now', lambda *args, **kwargs: datetime(2022, 1, 1, 10, 0, 0)) def test_routing_loop_alias_create(self): """Test the limit on the number of record we can create by alias.""" @@ -1951,7 +2110,7 @@ class TestMailGatewayLoops(MailGatewayCommon): new_record, msg='The loop should have been detected and the record should not have been created') - self.assertSentEmail('"MAILER-DAEMON" ', [exp_to]) + self.assertSentEmail(f'"MAILER-DAEMON" <{self.alias_bounce}@{self.alias_domain}>', [exp_to]) bounce_references = self._mails[0]['references'] self.assertIn('-loop-detection-bounce-email@', bounce_references, msg='The "bounce email" tag must be in the reference') @@ -1993,7 +2152,7 @@ class TestMailGatewayLoops(MailGatewayCommon): records = self.env['mail.test.ticket'].search([('name', 'ilike', 'Whitelist test alias loop %')]) self.assertEqual(len(records), 10, msg='Email whitelisted should not have the restriction') - @mute_logger('odoo.addons.mail.models.mail_thread') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_thread') def test_routing_loop_alias_mix(self): """ Test loop detection in case of multiples routes, just be sure all routes are checked and models checked once. """ @@ -2021,11 +2180,11 @@ class TestMailGatewayLoops(MailGatewayCommon): _original_ticket_sc = MailTestTicket.search_count _original_groups_sc = MailTestGatewayGroups.search_count - _original_rgr = Message._read_group_raw + _original_rgr = MailMessage._read_group with self.mock_mail_gateway(), \ patch.object(MailTestTicket, 'search_count', autospec=True, side_effect=_original_ticket_sc) as mock_ticket_sc, \ patch.object(MailTestGatewayGroups, 'search_count', autospec=True, side_effect=_original_groups_sc) as mock_groups_sc, \ - patch.object(Message, '_read_group_raw', autospec=True, side_effect=_original_rgr) as mock_msg_rgr: + patch.object(MailMessage, '_read_group', autospec=True, side_effect=_original_rgr) as mock_msg_rgr: self.format_and_process( MAIL_TEMPLATE, self.other_partner.email_formatted, @@ -2050,6 +2209,7 @@ class TestMailGatewayLoops(MailGatewayCommon): 'author_id': self.other_partner.id, 'model': test_updates[0]._name, 'res_id': test_updates[0].id, + 'message_type': 'email' } for x in range(4) # 4 + 1 posted before = 5 aka threshold ]) with self.mock_mail_gateway(): @@ -2068,7 +2228,7 @@ class TestMailGatewayLoops(MailGatewayCommon): 'Even if other routes are ok, one looping route is sufficient to block the incoming email' ) - @mute_logger('odoo.addons.mail.models.mail_thread') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_thread') def test_routing_loop_auto_notif(self): """ Test Odoo servers talking to each other """ with self.mock_mail_gateway(): @@ -2081,13 +2241,13 @@ class TestMailGatewayLoops(MailGatewayCommon): target_model='mail.test.ticket', ) self.assertTrue(record) - self.assertEqual(record.message_partner_ids, self.other_partner) for incoming_count in range(6): # threshold + 1 with self.mock_mail_gateway(): record.with_user(self.user_employee).message_post( body='Automatic answer', message_type='auto_comment', + partner_ids=self.other_partner.ids, subtype_xmlid='mail.mt_comment', ) capture_messages = self.gateway_mail_reply_last_email(MAIL_TEMPLATE) @@ -2103,7 +2263,7 @@ class TestMailGatewayLoops(MailGatewayCommon): self.assertIn('loop-detection-bounce-email', msg.mail_ids.references, 'Should be a msg linked to a bounce email with right header') - @mute_logger('odoo.addons.mail.models.mail_thread') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_thread') def test_routing_loop_follower_alias(self): """ Use case: managing follower that are aliases. """ with self.mock_mail_gateway(): @@ -2132,15 +2292,15 @@ class TestMailGatewayLoops(MailGatewayCommon): # simulate this email coming back to the same Odoo server -> msg_id is # a duplicate, hence rejected - with RecordCapturer(self.env['mail.test.ticket'], []) as capture_ticket, \ - RecordCapturer(self.env['mail.test.gateway'], []) as capture_gateway: + with RecordCapturer(self.env['mail.test.ticket']) as capture_ticket, \ + RecordCapturer(self.env['mail.test.gateway']) as capture_gateway: self._reinject() self.assertFalse(capture_ticket.records) self.assertFalse(capture_gateway.records) self.assertNotSentEmail() self.assertFalse(bool(self._new_msgs)) - @mute_logger('odoo.addons.mail.models.mail_thread') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_thread') def test_routing_loop_forward_catchall(self): """ Use case: broad email forward to catchall. Example: customer sends an email to catchall. It bounces: to=customer, return-path=bounce. Autoreply @@ -2163,12 +2323,12 @@ class TestMailGatewayLoops(MailGatewayCommon): original_mail = self._mails # auto-reply: write to bounce = no more bounce - self.gateway_mail_reply_last_email(MAIL_TEMPLATE, force_to=f'{self.alias_bounce}@{self.alias_domain}') + self.gateway_mail_reply_last_email(MAIL_TEMPLATE, force_email_to=f'{self.alias_bounce}@{self.alias_domain}') self.assertNotSentEmail() # auto-reply but forwarded to catchall -> should not bounce again self._mails = original_mail # just to revert state prior to auto reply - self.gateway_mail_reply_last_email(MAIL_TEMPLATE, force_to=f'{self.alias_catchall}@{self.alias_domain}') + self.gateway_mail_reply_last_email(MAIL_TEMPLATE, force_email_to=f'{self.alias_catchall}@{self.alias_domain}') # TDE FIXME: this should not bounce again # self.assertNotSentEmail() self.assertSentEmail( @@ -2177,6 +2337,63 @@ class TestMailGatewayLoops(MailGatewayCommon): subject=f'Re: Re: Re: Should Bounce (initial)') +@tagged('mail_gateway', 'mail_tools') +class TestMailGatewayRecipients(MailGatewayCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.test_partners = cls.env['res.partner'].create([ + { + 'email': '"Test Format" ', + 'name': 'Format', + }, { + 'email': '"Test Multi" , test.multi.2@test.example.com', + 'name': 'Multi', + }, { + 'email': '"Test Case" ', + 'name': 'Case', + }, + ]) + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_gateway_recipients_finding(self): + """ Incoming email: find or create partners. """ + for additional_to, exp_partners in zip( + [ + 'test.format@test.example.com', + 'TEST.FORMAT@test.example.com', + '"Another Name" ', debug_log=True, ) @@ -2315,10 +2532,10 @@ class TestMailGatewayReplies(MailGatewayCommon): 'message_id': reply_2.message_id, 'references': f'{log.message_id} {odooext_msg.message_id} {reply.message_id} {reply_2.message_id}', # should contain reference to OdooExternal message }, - 'fields_values': { + 'mail_mail_values': { 'author_id': self.env['res.partner'], 'notified_partner_ids': self.partner_employee + self.partner_admin, - 'parent_id': log, # log serves as thread ancestor + 'parent_id': reply, }, 'message_type': 'email', 'notif': [ @@ -2349,9 +2566,9 @@ class TestMailGatewayReplies(MailGatewayCommon): 'message_id': reply_3.message_id, 'references': f'{odooext_msg.message_id} {reply.message_id} {reply_2.message_id} {reply_3.message_id}', # should contain reference to OdooExternal message }, - 'fields_values': { + 'mail_mail_values': { 'notified_partner_ids': self.partner_1 + self.partner_admin, - 'parent_id': log, # log serves as thread ancestor + 'parent_id': reply_2, # attached to last comment / email when possible }, 'notif': [ {'partner': self.partner_1, 'type': 'email',}, @@ -2365,17 +2582,17 @@ class TestMailGatewayReplies(MailGatewayCommon): """ Test mass mailing emails when providers rewrite messageID: references should allow to find the original message. """ # send mailing on records using composer, in both reply and force new modes - for reply_to_mode, auto_delete_message in [ - ('new', False), - ('update', False), - ('new', True), # reference is lost, but reply alias should be ok - ('update', True), # reference is lost, hence considered as a reply to catchall, is going to crash (FIXME ?) + for reply_to_mode, auto_delete_keep_log in [ + ('new', True), + ('update', True), + ('new', False), # reference is lost, but reply alias should be ok + ('update', False), # reference is lost, hence considered as a reply to catchall, is going to crash (FIXME ?) ]: - with self.subTest(reply_to_mode=reply_to_mode, auto_delete_message=auto_delete_message): + with self.subTest(reply_to_mode=reply_to_mode, auto_delete_keep_log=auto_delete_keep_log), self.mock_mail_gateway(mail_unlink_sent=True): composer_form = Form(self.env['mail.compose.message'].with_context({ 'active_ids': self.test_records.ids, 'default_auto_delete': True, - 'default_auto_delete_message': auto_delete_message, + 'default_auto_delete_keep_log': auto_delete_keep_log, 'default_composition_mode': 'mass_mail', 'default_email_from': self.user_employee.email_formatted, 'default_model': self.test_records._name, @@ -2386,8 +2603,7 @@ class TestMailGatewayReplies(MailGatewayCommon): if reply_to_mode == 'new': composer_form.reply_to = self.alias.display_name composer = composer_form.save() - with self.mock_mail_gateway(mail_unlink_sent=True): - mails, _msg = composer._action_send_mail() + mails, _msg = composer._action_send_mail() self.assertFalse(mails.exists()) # check reply using references @@ -2400,16 +2616,16 @@ class TestMailGatewayReplies(MailGatewayCommon): # for some reason, provider rewrites message_id, then customer replies outgoing['message_id'] = f'' extra = f'In-Reply-To:{outgoing["message_id"]}\nReferences:{outgoing["message_id"]} {outgoing["references"]}\n' - with RecordCapturer(self.env['mail.message'], []) as capture_messages: + with RecordCapturer(self.env['mail.message']) as capture_messages: gateway_record = self.format_and_process( MAIL_TEMPLATE, outgoing['email_to'][0], outgoing['reply_to'], extra=extra, - subject=f'Re: {outgoing["subject"]} - from {outgoing["email_to"][0]} ({reply_to_mode} {auto_delete_message})', + subject=f'Re: {outgoing["subject"]} - from {outgoing["email_to"][0]} ({reply_to_mode} {auto_delete_keep_log})', debug_log=False, ) new_message = capture_messages.records # as outgoing mail is unlinked with its mail.message -> cannot find parent -> bounce - if reply_to_mode == 'update' and auto_delete_message: + if reply_to_mode == 'update' and not auto_delete_keep_log: self.assertFalse(new_message) self.assertFalse(gateway_record) continue @@ -2429,9 +2645,104 @@ class TestMailGatewayReplies(MailGatewayCommon): 'res_id': gateway_record.id, }) + def test_routing_with_out_of_office(self): + """ Test email exchanges with out-of-office messages activated, to check + gateway support """ + self.user_admin.notification_type = 'email' + + with self.mock_datetime_and_now(datetime(2025, 6, 17, 14, 15, 59)): + self._setup_out_of_office(self.user_employee) + + with self.mock_mail_gateway(), self.mock_mail_app(): + record = self.format_and_process( + MAIL_TEMPLATE, self.email_from, + self.alias.alias_full_name, + subject='Gateway Creation', + ) + record.with_user(self.user_admin).write({ + 'user_id': self.user_employee, + }) + self.assertEqual(len(self._new_msgs), 1) + initial_msg = self._new_msgs + self.assertFalse(initial_msg.author_id) + self.assertEqual(initial_msg.email_from, self.email_from) + + # intenal user email reply + with self.mock_datetime_and_now(datetime(2025, 6, 17, 14, 16, 00)): + with self.mock_mail_gateway(), self.mock_mail_app(): + self.format_and_process( + MAIL_TEMPLATE_EXTRA_HTML, self.user_admin.email_formatted, + self.alias.alias_full_name, + extra=f'In-Reply-To:{initial_msg.message_id}\nReferences:{initial_msg.message_id}\n', + extra_html='Admin reply', + subject='Admin reply', + ) + self.assertEqual(len(self._new_msgs), 2, 'Reply + OOO message') + ooo_message = self._new_msgs[1] + self.assertMailNotifications( + ooo_message, + [{ + 'content': "

Le numéro que vous avez composé n'est plus attribué.

", + 'email_values': { + 'subject': 'Auto: Admin reply', + }, + 'message_type': 'out_of_office', + 'message_values': { + 'author_id': self.partner_employee, + 'email_from': self.partner_employee.email_formatted, + 'model': record._name, + 'partner_ids': self.partner_admin, + 'notified_partner_ids': self.partner_admin, + 'res_id': record.id, + 'subject': 'Auto: Admin reply', + }, + 'notif': [ + {'partner': self.partner_admin, 'type': 'email'}, + ], + 'subtype': 'mail.mt_comment', + }], + ) + + # customer reply + with self.mock_datetime_and_now(datetime(2025, 6, 17, 14, 16, 00)): + with self.mock_mail_gateway(), self.mock_mail_app(): + self.format_and_process( + MAIL_TEMPLATE_EXTRA_HTML, self.email_from, + self.alias.alias_full_name, + extra=f'In-Reply-To:{initial_msg.message_id}\nReferences:{initial_msg.message_id}\n', + extra_html='Customer reply', + subject='Customer reply', + ) + self.assertEqual(len(self._new_msgs), 2, 'Reply + OOO message') + ooo_message = self._new_msgs[1] + self.assertMailNotifications( + ooo_message, + [{ + 'content': "

Le numéro que vous avez composé n'est plus attribué.

", + 'email_values': { + 'subject': 'Auto: Customer reply', + }, + 'message_type': 'out_of_office', + 'message_values': { + 'author_id': self.partner_employee, + 'email_from': self.partner_employee.email_formatted, + 'model': record._name, + 'outgoing_email_to': self.email_from, + 'partner_ids': self.env['res.partner'], + 'notified_partner_ids': self.env['res.partner'], + 'res_id': record.id, + 'subject': 'Auto: Customer reply', + }, + 'notif': [ + {'email_to': [email_normalize(self.email_from)], 'type': 'email'}, + ], + 'subtype': 'mail.mt_comment', + }], + ) + @tagged('mail_gateway', 'mail_thread') -class TestMailThreadCC(TestMailCommon): +class TestMailThreadCC(MailCommon): @classmethod def setUpClass(cls): @@ -2440,14 +2751,14 @@ class TestMailThreadCC(TestMailCommon): cls.email_from = 'Sylvie Lelitre ' cls.alias = cls.env['mail.alias'].create({ 'alias_contact': 'everyone', + 'alias_domain_id': cls.mail_alias_domain.id, 'alias_model_id': cls.env['ir.model']._get('mail.test.cc').id, 'alias_name': 'cc_record', - 'alias_user_id': False, }) @mute_logger('odoo.addons.mail.models.mail_thread') def test_message_cc_new(self): - record = self.format_and_process(MAIL_TEMPLATE, self.email_from, 'cc_record@test.com', + record = self.format_and_process(MAIL_TEMPLATE, self.email_from, f'cc_record@{self.alias_domain}', cc='cc1@example.com, cc2@example.com', target_model='mail.test.cc') cc = email_split_and_format(record.email_cc) self.assertEqual(sorted(cc), ['cc1@example.com', 'cc2@example.com']) @@ -2457,7 +2768,7 @@ class TestMailThreadCC(TestMailCommon): record = self.env['mail.test.cc'].create({'email_cc': 'cc1 , cc2@example.com'}) self.alias.write({'alias_force_thread_id': record.id}) - self.format_and_process(MAIL_TEMPLATE, self.email_from, 'cc_record@test.com', + self.format_and_process(MAIL_TEMPLATE, self.email_from, f'cc_record@{self.alias_domain}', cc='cc2 , cc3@example.com', target_model='mail.test.cc') cc = email_split_and_format(record.email_cc) self.assertEqual(sorted(cc), ['"cc1" ', 'cc2@example.com', 'cc3@example.com'], 'new cc should have been added on record (unique)') @@ -2467,7 +2778,7 @@ class TestMailThreadCC(TestMailCommon): record = self.env['mail.test.cc'].create({}) self.alias.write({'alias_force_thread_id': record.id}) - self.format_and_process(MAIL_TEMPLATE, self.email_from, 'cc_record@test.com', + self.format_and_process(MAIL_TEMPLATE, self.email_from, f'cc_record@{self.alias_domain}', cc='cc2 , cc3@example.com', target_model='mail.test.cc') cc = email_split_and_format(record.email_cc) self.assertEqual(sorted(cc), ['"cc2" ', 'cc3@example.com'], 'new cc should have been added on record (unique)') diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_mail.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_mail.py index fc595bb..049cf34 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_mail.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_mail.py @@ -1,46 +1,41 @@ -# -*- coding: utf-8 -*- + # Part of Odoo. See LICENSE file for full copyright and licensing details. -import psycopg2 import pytz +import re import smtplib +from email import message_from_string from datetime import datetime, timedelta from freezegun import freeze_time +from markupsafe import Markup from OpenSSL.SSL import Error as SSLError from socket import gaierror, timeout -from unittest.mock import call, patch +from unittest.mock import call, patch, PropertyMock -from odoo import api, Command, tools +from odoo import api, Command, fields, SUPERUSER_ID from odoo.addons.base.models.ir_mail_server import MailDeliveryException -from odoo.addons.test_mail.tests.common import TestMailCommon -from odoo.exceptions import AccessError +from odoo.addons.mail.tests.common import MailCommon +from odoo.exceptions import AccessError, LockError from odoo.tests import common, tagged, users -from odoo.tools import mute_logger, DEFAULT_SERVER_DATETIME_FORMAT +from odoo.tools import formataddr, mute_logger @tagged('mail_mail') -class TestMailMail(TestMailCommon): +class TestMailMail(MailCommon): @classmethod def setUpClass(cls): super(TestMailMail, cls).setUpClass() - cls._init_mail_servers() - - cls.server_domain_2 = cls.env['ir.mail_server'].create({ - 'name': 'Server 2', - 'smtp_host': 'test_2.com', - 'from_filter': 'test_2.com', - }) cls.test_record = cls.env['mail.test.gateway'].with_context(cls._test_context).create({ 'name': 'Test', 'email_from': 'ignasse@example.com', }).with_context({}) - cls.test_message = cls.test_record.message_post(body='

Message

', subject='Subject') + cls.test_message = cls.test_record.message_post(body=Markup('

Message

'), subject='Subject') cls.test_mail = cls.env['mail.mail'].create([{ - 'body': '

Body

', + 'body': Markup('

Body

'), 'email_from': False, 'email_to': 'test@example.com', 'is_notification': True, @@ -78,11 +73,13 @@ class TestMailMail(TestMailCommon): ], }) - def _patched_check(self, *args, **kwargs): - if self.env.is_superuser(): - return - if any(attachment.name in ('file 2', 'file 4') for attachment in self): - raise AccessError('No access') + def _patched_check_access(self, *args, **kwargs): + if self.env.su: + return None + inaccessible = self.filtered(lambda att: att.name in ('file 2', 'file 4')) + if inaccessible: + return inaccessible, lambda: AccessError(self.env._("No access")) + return None mail.invalidate_recordset() @@ -91,7 +88,7 @@ class TestMailMail(TestMailCommon): 'datas': 'c2VjcmV0', }) - with patch.object(type(self.env['ir.attachment']), 'check', _patched_check): + with patch.object(self.env.registry['ir.attachment'], '_check_access', _patched_check_access): # Sanity check self.assertEqual(mail.restricted_attachment_count, 2) self.assertEqual(len(mail.unrestricted_attachment_ids), 2) @@ -126,6 +123,35 @@ class TestMailMail(TestMailCommon): self.assertEqual(mail.sudo().restricted_attachment_count, 2) self.assertEqual(len(mail.sudo().unrestricted_attachment_ids), 0) + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_headers(self): + """ Test headers management when set on outgoing mail. """ + # mail without thread-enabled record + base_values = { + 'body_html': '

Test

', + 'email_to': 'test@example.com', + 'headers': {'foo': 'bar'}, + } + + for headers, expected in [ + ({'foo': 'bar'}, {'foo': 'bar'}), + ("{'foo': 'bar'}", {'foo': 'bar'}), + ("{'foo': 'bar', 'baz': '3+2'}", {'foo': 'bar', 'baz': '3+2'}), + (['not_a_dict'], {}), + ('alsonotadict', {}), + ("['not_a_dict']", {}), + ("{'invaliddict'}", {}), + ]: + with self.subTest(headers=headers, expected=expected): + mail = self.env['mail.mail'].create([ + dict(base_values, headers=headers) + ]) + with self.mock_mail_gateway(): + mail.send() + for key, value in expected.items(): + self.assertIn(key, self._mails[0]['headers']) + self.assertEqual(self._mails[0]['headers'][key], value) + @mute_logger('odoo.addons.mail.models.mail_mail') def test_mail_mail_recipients(self): """ Partner_ids is a field used from mail_message, but not from mail_mail. """ @@ -166,9 +192,10 @@ class TestMailMail(TestMailCommon): self.assertSentEmail(mail.env.user.partner_id, ['test.rec.1@example.com', '"Raoul" '], email_cc=['test.cc.1@example.com', '"Herbert" ']) - # Mail: currently cc are put as copy of all sent emails (aka spam) + # don't put CCs as copy of each outgoing email, only the first one (and never + # with partner based recipients as those may receive specific links) self.assertSentEmail(mail.env.user.partner_id, [self.user_employee.email_formatted], - email_cc=['test.cc.1@example.com', '"Herbert" ']) + email_cc=[]) self.assertEqual(len(self._mails), 2) @mute_logger('odoo.addons.mail.models.mail_mail') @@ -187,9 +214,96 @@ class TestMailMail(TestMailCommon): # note that formatting is lost for cc self.assertSentEmail('"Ignasse" ', ['test.rec.1@example.com', '"Raoul" '], - email_cc=['test.cc.1@example.com', '"Herbert" ']) + email_cc=['"Herbert" ', 'test.cc.1@example.com']) self.assertEqual(len(self._mails), 1) + def test_mail_mail_recipients_add_msg_to(self): + """ Test adding recipients in outgoing emails (email Message) without + impacting SMTP recipients. Use case is to have a given recipient + but forge the To of message to allow a reply-all behavior including + "virtual" recipients already mailed using another way. """ + self.maxDiff = None + test_partners = self.env['res.partner'].create([ + { + 'name': name, + 'email': email, + } for name, email in [('Partner1', 'partner.test@test.example.com'), ("Partner2", '')] + ]) + for mail_values, exp_smtp, exp_to, exp_cc in [ + ( # add "To" when having To and partners + { + 'email_to': '"Customer" , user2@test.mycompany.com', + 'recipient_ids': [(4, p.id) for p in test_partners], + 'headers': { + 'X-Msg-To-Add': 'add.1@test.example.com, "Add 2" ', + }, + }, [ + ['customer@test.example.com', 'user2@test.mycompany.com'], + ['partner.test@test.example.com'], ['partner.test.2@test.example.com'], + ], [ + # To + added recipients + ['"Customer" ', 'user2@test.mycompany.com', 'add.1@test.example.com', '"Add 2" '], + # then each partner + added recipients + ['"Partner1" ', 'add.1@test.example.com', '"Add 2" '], + ['"Partner2" ', 'add.1@test.example.com', '"Add 2" '], + ], + [[], [], []], + ), ( # add "To" when having Cc and partners + { + 'email_cc': '"Cc Customer" , customer.cc.2@test.example.com', + 'recipient_ids': [(4, p.id) for p in test_partners], + 'headers': { + 'X-Msg-To-Add': 'add.1@test.example.com, "Add 2" ', + }, + }, [ + ['customer.cc@test.example.com', 'customer.cc.2@test.example.com'], + ['partner.test@test.example.com'], ['partner.test.2@test.example.com'], + ], [ + # Cc as solo + added recipients + ['add.1@test.example.com', '"Add 2" '], + # then each partner + added recipients + ['"Partner1" ', 'add.1@test.example.com', '"Add 2" '], + ['"Partner2" ', 'add.1@test.example.com', '"Add 2" '], + ], + [['"Cc Customer" ', 'customer.cc.2@test.example.com'], [], []], + ), ( # additional "To" when having To + Cc + partners and duplicates and errors in Add To + { + 'email_cc': '"Cc Customer" ', + 'email_to': '"Customer" ', + 'recipient_ids': [(4, p.id) for p in test_partners], + 'headers': { + 'X-Msg-To-Add': 'add.1@test.example.com, "Add 2" , customer@test.example.com, ,wrong', + }, + }, [ + ['customer@test.example.com', 'customer.cc@test.example.com'], # to and cc in same outgoing email + ['partner.test@test.example.com'], ['partner.test.2@test.example.com'], + ], [ + # To + Cc + ['"Customer" ', 'add.1@test.example.com', '"Add 2" '], + # then each partner + added recipients + ['"Partner1" ', 'add.1@test.example.com', '"Add 2" ', 'customer@test.example.com'], + ['"Partner2" ', 'add.1@test.example.com', '"Add 2" ', 'customer@test.example.com'], + ], + [['"Cc Customer" '], [], []], + ), + ]: + with self.subTest(mail_values=mail_values): + # with self.mock_smtplib_connection(): + with self.mock_mail_gateway(): + self.env['mail.mail'].create({ + 'subject': 'Test Recipients', + **mail_values, + }).send() + # self.assertEqual(len(self.emails), len(exp_smtp)) + for exp_smtp_to_lst, exp_msg_to_lst, exp_msg_cc_lst in zip(exp_smtp, exp_to, exp_cc): + self.assertSMTPEmailsSent( + msg_from=f'{self.user_root.name} <{self.default_from}@{self.alias_domain}>', + smtp_from=f'{self.default_from}@{self.alias_domain}', + smtp_to_list=exp_smtp_to_lst, + msg_cc_lst=exp_msg_cc_lst, + msg_to_lst=exp_msg_to_lst, + ) + @mute_logger('odoo.addons.mail.models.mail_mail') def test_mail_mail_return_path(self): # mail without thread-enabled record @@ -222,8 +336,8 @@ class TestMailMail(TestMailCommon): # datetimes (UTC/GMT +10 hours for Australia/Brisbane) now, pytz.timezone('Australia/Brisbane').localize(now), # string - (now - timedelta(days=1)).strftime(DEFAULT_SERVER_DATETIME_FORMAT), - (now + timedelta(days=1)).strftime(DEFAULT_SERVER_DATETIME_FORMAT), + fields.Datetime.to_string(now - timedelta(days=1)), + fields.Datetime.to_string(now + timedelta(days=1)), (now + timedelta(days=1)).strftime("%H:%M:%S %d-%m-%Y"), # tz: is actually 1 hour before now in UTC (now + timedelta(hours=3)).strftime("%H:%M:%S %d-%m-%Y") + " +0400", @@ -257,76 +371,105 @@ class TestMailMail(TestMailCommon): 'Scheduled date: %s should be stored as %s, received %s' % (scheduled_datetime, expected_datetime, mail.scheduled_date)) self.assertEqual(mail.state, 'outgoing') - with freeze_time(now): + with freeze_time(now), self.mock_mail_gateway(): self.env['mail.mail'].process_email_queue() for mail, expected_state in zip(mails, expected_states): self.assertEqual(mail.state, expected_state) + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests') + def test_mail_mail_send_configuration(self): + """ Test configuration and control of email queue """ + self.env['mail.mail'].search([]).unlink() # cleanup queue + + # test 'mail.mail.queue.batch.size': cron fetch size + for queue_batch_size, exp_send_count in [ + (3, 3), + (0, 10), # maximum available + (False, 10), # maximum available + ]: + with self.subTest(queue_batch_size=queue_batch_size), \ + self.mock_mail_gateway(): + self.env['ir.config_parameter'].sudo().set_param('mail.mail.queue.batch.size', queue_batch_size) + mails = self.env['mail.mail'].create([ + { + 'auto_delete': False, + 'body_html': f'Batch Email {idx}', + 'email_from': 'test.from@mycompany.example.com', + 'email_to': 'test.outgoing@test.example.com', + 'state': 'outgoing', + } + for idx in range(10) + ]) + + self.env['mail.mail'].process_email_queue() + self.assertEqual(len(self._mails), exp_send_count) + mails.write({'state': 'sent'}) # avoid conflicts between batch + + # test 'mail.session.batch.size': batch send size + self.env['ir.config_parameter'].sudo().set_param('mail.mail.queue.batch.size', False) + for session_batch_size, exp_call_count in [ + (3, 4), # 10 mails -> 4 iterations of 3 + (0, 1), + (False, 1), + ]: + with self.subTest(session_batch_size=session_batch_size), \ + self.mock_mail_gateway(): + self.env['ir.config_parameter'].sudo().set_param('mail.session.batch.size', session_batch_size) + mails = self.env['mail.mail'].create([ + { + 'auto_delete': False, + 'body_html': f'Batch Email {idx}', + 'email_from': 'test.from@mycompany.example.com', + 'email_to': 'test.outgoing@test.example.com', + 'state': 'outgoing', + } + for idx in range(10) + ]) + + self.env['mail.mail'].process_email_queue() + self.assertEqual(self.mail_mail_private_send_mocked.call_count, exp_call_count) + mails.write({'state': 'sent'}) # avoid conflicts between batch + @mute_logger('odoo.addons.mail.models.mail_mail') def test_mail_mail_send_exceptions_origin(self): """ Test various use case with exceptions and errors and see how they are managed and stored at mail and notification level. """ mail, notification = self.test_mail, self.test_notification - # MailServer.build_email(): invalid from - self.env['ir.config_parameter'].set_param('mail.default.from', '') - self._reset_data() - with self.mock_mail_gateway(), mute_logger('odoo.addons.mail.models.mail_mail'): - mail.send(raise_exception=False) - self.assertFalse(self._mails[0]['email_from']) - self.assertEqual( - mail.failure_reason, - 'You must either provide a sender address explicitly or configure using the combination of `mail.catchall.domain` and `mail.default.from` ICPs, in the server configuration file or with the --email-from startup parameter.') - self.assertFalse(mail.failure_type, 'Mail: void from: no failure type, should be updated') - self.assertEqual(mail.state, 'exception') - self.assertEqual( - notification.failure_reason, - 'You must either provide a sender address explicitly or configure using the combination of `mail.catchall.domain` and `mail.default.from` ICPs, in the server configuration file or with the --email-from startup parameter.') - self.assertEqual(notification.failure_type, 'unknown', 'Mail: void from: unknown failure type, should be updated') - self.assertEqual(notification.notification_status, 'exception') + # MailServer._build_email__(): invalid from (missing) + for default_from in [False, '']: + self.mail_alias_domain.default_from = default_from + self._reset_data() + with self.mock_mail_gateway(), mute_logger('odoo.addons.mail.models.mail_mail'): + mail.send(raise_exception=False) + self.assertEqual(len(self._mails), 0) # email not send at all + self.assertEqual( + mail.failure_reason, + 'You must either provide a sender address explicitly or configure using the combination of `mail.catchall.domain` and `mail.default.from` ICPs, in the server configuration file or with the --email-from startup parameter.') + self.assertEqual(mail.failure_type, 'mail_from_missing') + self.assertEqual(mail.state, 'exception') + self.assertEqual( + notification.failure_reason, + 'You must either provide a sender address explicitly or configure using the combination of `mail.catchall.domain` and `mail.default.from` ICPs, in the server configuration file or with the --email-from startup parameter.') + self.assertEqual(notification.failure_type, 'mail_from_missing') + self.assertEqual(notification.notification_status, 'exception') - # MailServer.send_email(): _prepare_email_message: unexpected ASCII - # Force catchall domain to void otherwise bounce is set to postmaster-odoo@domain - self.env['ir.config_parameter'].set_param('mail.catchall.domain', '') - self._reset_data() - mail.write({'email_from': 'strange@example¢¡.com'}) - with self.mock_mail_gateway(): - mail.send(raise_exception=False) - self.assertEqual(self._mails[0]['email_from'], 'strange@example¢¡.com') - self.assertEqual(mail.failure_reason, "Malformed 'Return-Path' or 'From' address: strange@example¢¡.com - It should contain one valid plain ASCII email") - self.assertFalse(mail.failure_type, 'Mail: bugged from (ascii): no failure type, should be updated') - self.assertEqual(mail.state, 'exception') - self.assertEqual(notification.failure_reason, "Malformed 'Return-Path' or 'From' address: strange@example¢¡.com - It should contain one valid plain ASCII email") - self.assertEqual(notification.failure_type, 'unknown', 'Mail: bugged from (ascii): unknown failure type, should be updated') - self.assertEqual(notification.notification_status, 'exception') - - # MailServer.send_email(): _prepare_email_message: unexpected ASCII based on catchall domain - self.env['ir.config_parameter'].set_param('mail.catchall.domain', 'domain¢¡.com') - self._reset_data() - mail.write({'email_from': 'test.user@example.com'}) - with self.mock_mail_gateway(): - mail.send(raise_exception=False) - self.assertEqual(self._mails[0]['email_from'], 'test.user@example.com') - self.assertIn("Malformed 'Return-Path' or 'From' address: bounce.test@domain¢¡.com", mail.failure_reason) - self.assertFalse(mail.failure_type, 'Mail: bugged catchall domain (ascii): no failure type, should be updated') - self.assertEqual(mail.state, 'exception') - self.assertEqual(notification.failure_reason, "Malformed 'Return-Path' or 'From' address: bounce.test@domain¢¡.com - It should contain one valid plain ASCII email") - self.assertEqual(notification.failure_type, 'unknown', 'Mail: bugged catchall domain (ascii): unknown failure type, should be updated') - self.assertEqual(notification.notification_status, 'exception') - - # MailServer.send_email(): _prepare_email_message: Malformed 'Return-Path' or 'From' address - self.env['ir.config_parameter'].set_param('mail.catchall.domain', '') - self._reset_data() - mail.write({'email_from': 'robert'}) - with self.mock_mail_gateway(): - mail.send(raise_exception=False) - self.assertEqual(self._mails[0]['email_from'], 'robert') - self.assertEqual(mail.failure_reason, "Malformed 'Return-Path' or 'From' address: robert - It should contain one valid plain ASCII email") - self.assertFalse(mail.failure_type, 'Mail: bugged from (ascii): no failure type, should be updated') - self.assertEqual(mail.state, 'exception') - self.assertEqual(notification.failure_reason, "Malformed 'Return-Path' or 'From' address: robert - It should contain one valid plain ASCII email") - self.assertEqual(notification.failure_type, 'unknown', 'Mail: bugged from (ascii): unknown failure type, should be updated') - self.assertEqual(notification.notification_status, 'exception') + # MailServer.send_email(): _prepare_email_message__: unexpected ASCII / Malformed 'Return-Path' or 'From' address + # Force bounce alias to void, will force usage of email_from + self.mail_alias_domain.bounce_alias = False + self.env.company.invalidate_recordset(fnames={'bounce_email', 'bounce_formatted'}) + for email_from in ['strange@example¢¡.com', 'robert']: + self._reset_data() + mail.write({'email_from': email_from}) + with self.mock_mail_gateway(): + mail.send(raise_exception=False) + self.assertEqual(self._mails[0]['email_from'], email_from) + self.assertEqual(mail.failure_reason, f"Malformed 'Return-Path' or 'From' address: {email_from} - It should contain one valid plain ASCII email") + self.assertEqual(mail.failure_type, 'mail_from_invalid') + self.assertEqual(mail.state, 'exception') + self.assertEqual(notification.failure_reason, f"Malformed 'Return-Path' or 'From' address: {email_from} - It should contain one valid plain ASCII email") + self.assertEqual(notification.failure_type, 'mail_from_invalid') + self.assertEqual(notification.notification_status, 'exception') @mute_logger('odoo.addons.mail.models.mail_mail') def test_mail_mail_send_exceptions_recipients_emails(self): @@ -334,65 +477,69 @@ class TestMailMail(TestMailCommon): managed and stored at mail and notification level. """ mail, notification = self.test_mail, self.test_notification - self.env['ir.config_parameter'].set_param('mail.catchall.domain', self.alias_domain) - self.env['ir.config_parameter'].set_param('mail.default.from', self.default_from) - # MailServer.send_email(): _prepare_email_message: missing To for email_to in self.emails_falsy: - self._reset_data() - mail.write({'email_to': email_to}) - with self.mock_mail_gateway(): - mail.send(raise_exception=False) - self.assertEqual(mail.failure_reason, 'Error without exception. Probably due to sending an email without computed recipients.') - self.assertFalse(mail.failure_type, 'Mail: missing email_to: no failure type, should be updated') - self.assertEqual(mail.state, 'exception') - if email_to == ' ': - self.assertFalse(notification.failure_reason, 'Mail: failure reason not propagated') - self.assertEqual(notification.failure_type, 'mail_email_missing') + with self.subTest(email_to=email_to): + self._reset_data() + mail.write({'email_to': email_to}) + with self.mock_mail_gateway(): + mail.send(raise_exception=False) + self.assertEqual(mail.failure_reason, self.env['ir.mail_server'].NO_VALID_RECIPIENT) + self.assertEqual(mail.failure_type, 'mail_email_missing', 'Mail: missing email_to') + self.assertEqual(mail.state, 'exception') + if email_to == ' ': + self.assertEqual(notification.failure_reason, self.env['ir.mail_server'].NO_VALID_RECIPIENT) + self.assertEqual(notification.failure_type, 'mail_email_missing') + self.assertEqual(notification.notification_status, 'exception') + else: + self.assertFalse(notification.failure_reason, 'Mail: failure reason not propagated') + self.assertEqual(notification.failure_type, False, 'Mail: missing email_to: notification is wrongly set as sent') + self.assertEqual(notification.notification_status, 'sent', 'Mail: missing email_to: notification is wrongly set as sent') + + # MailServer.send_email(): _prepare_email_message__: invalid To + for email_to, failure_type in zip( + self.emails_invalid, + ['mail_email_missing', 'mail_email_missing'] + ): + with self.subTest(email_to=email_to): + self._reset_data() + mail.write({'email_to': email_to}) + with self.mock_mail_gateway(): + mail.send(raise_exception=False) + self.assertEqual(mail.failure_reason, self.env['ir.mail_server'].NO_VALID_RECIPIENT) + self.assertEqual(mail.failure_type, failure_type) + self.assertEqual(mail.state, 'exception') + self.assertEqual(notification.failure_reason, self.env['ir.mail_server'].NO_VALID_RECIPIENT) + self.assertEqual(notification.failure_type, failure_type, 'Mail: invalid email_to: missing instead of invalid') self.assertEqual(notification.notification_status, 'exception') - else: - self.assertFalse(notification.failure_reason, 'Mail: failure reason not propagated') - self.assertEqual(notification.failure_type, False, 'Mail: missing email_to: notification is wrongly set as sent') - self.assertEqual(notification.notification_status, 'sent', 'Mail: missing email_to: notification is wrongly set as sent') - # MailServer.send_email(): _prepare_email_message: invalid To - for email_to, failure_type in zip(self.emails_invalid, - ['mail_email_missing', 'mail_email_missing']): - self._reset_data() - mail.write({'email_to': email_to}) - with self.mock_mail_gateway(): - mail.send(raise_exception=False) - self.assertEqual(mail.failure_reason, 'Error without exception. Probably due to sending an email without computed recipients.') - self.assertFalse(mail.failure_type, 'Mail: invalid email_to: no failure type, should be updated') - self.assertEqual(mail.state, 'exception') - self.assertFalse(notification.failure_reason, 'Mail: failure reason not propagated') - self.assertEqual(notification.failure_type, failure_type, 'Mail: invalid email_to: missing instead of invalid') - self.assertEqual(notification.notification_status, 'exception') - - # MailServer.send_email(): _prepare_email_message: invalid To (ascii) + # MailServer.send_email(): _prepare_email_message__: invalid To (ascii) for email_to in self.emails_invalid_ascii: - self._reset_data() - mail.write({'email_to': email_to}) - with self.mock_mail_gateway(): - mail.send(raise_exception=False) - self.assertEqual(mail.failure_reason, 'Error without exception. Probably due to sending an email without computed recipients.') - self.assertFalse(mail.failure_type, 'Mail: invalid (ascii) recipient partner: no failure type, should be updated') - self.assertEqual(mail.state, 'exception') - self.assertEqual(notification.failure_type, 'mail_email_invalid') - self.assertEqual(notification.notification_status, 'exception') + with self.subTest(email_to=email_to): + self._reset_data() + mail.write({'email_to': email_to}) + with self.mock_mail_gateway(): + mail.send(raise_exception=False) + self.assertEqual(mail.failure_reason, self.env['ir.mail_server'].NO_VALID_RECIPIENT) + self.assertEqual(mail.failure_type, 'mail_email_invalid', 'Mail: invalid (ascii) recipient') + self.assertEqual(mail.state, 'exception') + self.assertEqual(notification.failure_reason, self.env['ir.mail_server'].NO_VALID_RECIPIENT) + self.assertEqual(notification.failure_type, 'mail_email_invalid') + self.assertEqual(notification.notification_status, 'exception') - # MailServer.send_email(): _prepare_email_message: ok To (ascii or just ok) + # MailServer.send_email(): _prepare_email_message__: ok To (ascii or just ok) for email_to in self.emails_valid: - self._reset_data() - mail.write({'email_to': email_to}) - with self.mock_mail_gateway(): - mail.send(raise_exception=False) - self.assertFalse(mail.failure_reason) - self.assertFalse(mail.failure_type) - self.assertEqual(mail.state, 'sent') - self.assertFalse(notification.failure_reason) - self.assertFalse(notification.failure_type) - self.assertEqual(notification.notification_status, 'sent') + with self.subTest(email_to=email_to): + self._reset_data() + mail.write({'email_to': email_to}) + with self.mock_mail_gateway(): + mail.send(raise_exception=False) + self.assertFalse(mail.failure_reason) + self.assertFalse(mail.failure_type) + self.assertEqual(mail.state, 'sent') + self.assertFalse(notification.failure_reason) + self.assertFalse(notification.failure_type) + self.assertEqual(notification.notification_status, 'sent') @mute_logger('odoo.addons.mail.models.mail_mail') def test_mail_mail_send_exceptions_recipients_partners(self): @@ -420,58 +567,63 @@ class TestMailMail(TestMailCommon): # void values for partner in partners_falsy: - self._reset_data() - mail.write({'recipient_ids': [(5, 0), (4, partner.id)]}) - notification.write({'res_partner_id': partner.id}) - with self.mock_mail_gateway(): - mail.send(raise_exception=False) - self.assertEqual(mail.failure_reason, 'Error without exception. Probably due to sending an email without computed recipients.') - self.assertFalse(mail.failure_type, 'Mail: void recipient partner: no failure type, should be updated') - self.assertEqual(mail.state, 'exception') - self.assertFalse(notification.failure_reason, 'Mail: failure reason not propagated') - self.assertEqual(notification.failure_type, 'mail_email_invalid', 'Mail: void recipient partner: should be missing, not invalid') - self.assertEqual(notification.notification_status, 'exception') + with self.subTest(partner_email=partner.email): + self._reset_data() + mail.write({'recipient_ids': [(5, 0), (4, partner.id)]}) + notification.write({'res_partner_id': partner.id}) + with self.mock_mail_gateway(): + mail.send(raise_exception=False) + self.assertEqual(mail.failure_reason, self.env['ir.mail_server'].NO_VALID_RECIPIENT) + self.assertEqual(mail.failure_type, 'mail_email_invalid', 'Mail: void recipient partner: should be missing, not invalid') + self.assertEqual(mail.state, 'exception') + self.assertEqual(notification.failure_reason, self.env['ir.mail_server'].NO_VALID_RECIPIENT) + self.assertEqual(notification.failure_type, 'mail_email_invalid', 'Mail: void recipient partner: should be missing, not invalid') + self.assertEqual(notification.notification_status, 'exception') # wrong values for partner in partners_invalid: - self._reset_data() - mail.write({'recipient_ids': [(5, 0), (4, partner.id)]}) - notification.write({'res_partner_id': partner.id}) - with self.mock_mail_gateway(): - mail.send(raise_exception=False) - self.assertEqual(mail.failure_reason, 'Error without exception. Probably due to sending an email without computed recipients.') - self.assertFalse(mail.failure_type, 'Mail: invalid recipient partner: no failure type, should be updated') - self.assertEqual(mail.state, 'exception') - self.assertFalse(notification.failure_reason, 'Mail: failure reason not propagated') - self.assertEqual(notification.failure_type, 'mail_email_invalid') - self.assertEqual(notification.notification_status, 'exception') + with self.subTest(partner_email=partner.email): + self._reset_data() + mail.write({'recipient_ids': [(5, 0), (4, partner.id)]}) + notification.write({'res_partner_id': partner.id}) + with self.mock_mail_gateway(): + mail.send(raise_exception=False) + self.assertEqual(mail.failure_reason, self.env['ir.mail_server'].NO_VALID_RECIPIENT) + self.assertEqual(mail.failure_type, 'mail_email_invalid') + self.assertEqual(mail.state, 'exception') + self.assertEqual(notification.failure_reason, self.env['ir.mail_server'].NO_VALID_RECIPIENT) + self.assertEqual(notification.failure_type, 'mail_email_invalid') + self.assertEqual(notification.notification_status, 'exception') # ascii ko for partner in partners_invalid_ascii: - self._reset_data() - mail.write({'recipient_ids': [(5, 0), (4, partner.id)]}) - notification.write({'res_partner_id': partner.id}) - with self.mock_mail_gateway(): - mail.send(raise_exception=False) - self.assertEqual(mail.failure_reason, 'Error without exception. Probably due to sending an email without computed recipients.') - self.assertFalse(mail.failure_type, 'Mail: invalid (ascii) recipient partner: no failure type, should be updated') - self.assertEqual(mail.state, 'exception') - self.assertEqual(notification.failure_type, 'mail_email_invalid') - self.assertEqual(notification.notification_status, 'exception') + with self.subTest(partner_email=partner.email): + self._reset_data() + mail.write({'recipient_ids': [(5, 0), (4, partner.id)]}) + notification.write({'res_partner_id': partner.id}) + with self.mock_mail_gateway(): + mail.send(raise_exception=False) + self.assertEqual(mail.failure_reason, self.env['ir.mail_server'].NO_VALID_RECIPIENT) + self.assertEqual(mail.failure_type, 'mail_email_invalid') + self.assertEqual(mail.state, 'exception') + self.assertEqual(notification.failure_reason, self.env['ir.mail_server'].NO_VALID_RECIPIENT) + self.assertEqual(notification.failure_type, 'mail_email_invalid') + self.assertEqual(notification.notification_status, 'exception') # ascii ok or just ok for partner in partners_valid: - self._reset_data() - mail.write({'recipient_ids': [(5, 0), (4, partner.id)]}) - notification.write({'res_partner_id': partner.id}) - with self.mock_mail_gateway(): - mail.send(raise_exception=False) - self.assertFalse(mail.failure_reason) - self.assertFalse(mail.failure_type) - self.assertEqual(mail.state, 'sent') - self.assertFalse(notification.failure_reason) - self.assertFalse(notification.failure_type) - self.assertEqual(notification.notification_status, 'sent') + with self.subTest(partner_email=partner.email): + self._reset_data() + mail.write({'recipient_ids': [(5, 0), (4, partner.id)]}) + notification.write({'res_partner_id': partner.id}) + with self.mock_mail_gateway(): + mail.send(raise_exception=False) + self.assertFalse(mail.failure_reason) + self.assertFalse(mail.failure_type) + self.assertEqual(mail.state, 'sent') + self.assertFalse(notification.failure_reason) + self.assertFalse(notification.failure_type) + self.assertEqual(notification.notification_status, 'sent') @mute_logger('odoo.addons.mail.models.mail_mail') def test_mail_mail_send_exceptions_recipients_partners_mixed(self): @@ -503,7 +655,7 @@ class TestMailMail(TestMailCommon): self.assertFalse(mail.failure_reason, 'Mail: at least one valid recipient, mail is sent to avoid send loops and spam') self.assertFalse(mail.failure_type, 'Mail: at least one valid recipient, mail is sent to avoid send loops and spam') self.assertEqual(mail.state, 'sent', 'Mail: at least one valid recipient, mail is sent to avoid send loops and spam') - self.assertFalse(notification.failure_reason, 'Mail: void email considered as invalid') + self.assertEqual(notification.failure_reason, self.env['ir.mail_server'].NO_VALID_RECIPIENT) self.assertEqual(notification.failure_type, 'mail_email_invalid', 'Mail: void email considered as invalid') self.assertEqual(notification.notification_status, 'exception') @@ -520,35 +672,37 @@ class TestMailMail(TestMailCommon): # missing to / invalid to for email_to in self.emails_falsy + self.emails_invalid: - self._reset_data() - notification2.write({'failure_reason': False, 'failure_type': False, 'notification_status': 'ready'}) - mail.write({'email_to': email_to}) - with self.mock_mail_gateway(): - mail.send(raise_exception=False) - self.assertFalse(mail.failure_reason, 'Mail: at least one valid recipient, mail is sent to avoid send loops and spam') - self.assertFalse(mail.failure_type, 'Mail: at least one valid recipient, mail is sent to avoid send loops and spam') - self.assertEqual(mail.state, 'sent', 'Mail: at least one valid recipient, mail is sent to avoid send loops and spam') - self.assertFalse(notification.failure_reason) - self.assertFalse(notification.failure_type) - self.assertEqual(notification.notification_status, 'sent') - self.assertFalse(notification2.failure_reason) - self.assertEqual(notification2.failure_type, 'mail_email_invalid') - self.assertEqual(notification2.notification_status, 'exception') + with self.subTest(email_to=email_to): + self._reset_data() + notification2.write({'failure_reason': False, 'failure_type': False, 'notification_status': 'ready'}) + mail.write({'email_to': email_to}) + with self.mock_mail_gateway(): + mail.send(raise_exception=False) + self.assertFalse(mail.failure_reason, 'Mail: at least one valid recipient, mail is sent to avoid send loops and spam') + self.assertFalse(mail.failure_type, 'Mail: at least one valid recipient, mail is sent to avoid send loops and spam') + self.assertEqual(mail.state, 'sent', 'Mail: at least one valid recipient, mail is sent to avoid send loops and spam') + self.assertFalse(notification.failure_reason) + self.assertFalse(notification.failure_type) + self.assertEqual(notification.notification_status, 'sent') + self.assertEqual(notification2.failure_reason, self.env['ir.mail_server'].NO_VALID_RECIPIENT) + self.assertEqual(notification2.failure_type, 'mail_email_invalid') + self.assertEqual(notification2.notification_status, 'exception') # buggy to (ascii) for email_to in self.emails_invalid_ascii: - self._reset_data() - notification2.write({'failure_reason': False, 'failure_type': False, 'notification_status': 'ready'}) - mail.write({'email_to': email_to}) - with self.mock_mail_gateway(): - mail.send(raise_exception=False) + with self.subTest(email_to=email_to): + self._reset_data() + notification2.write({'failure_reason': False, 'failure_type': False, 'notification_status': 'ready'}) + mail.write({'email_to': email_to}) + with self.mock_mail_gateway(): + mail.send(raise_exception=False) - self.assertFalse(mail.failure_type, 'Mail: at least one valid recipient, mail is sent to avoid send loops and spam') - self.assertEqual(mail.state, 'sent') - self.assertFalse(notification.failure_type) - self.assertEqual(notification.notification_status, 'sent') - self.assertEqual(notification2.failure_type, 'mail_email_invalid') - self.assertEqual(notification2.notification_status, 'exception') + self.assertFalse(mail.failure_type, 'Mail: at least one valid recipient, mail is sent to avoid send loops and spam') + self.assertEqual(mail.state, 'sent') + self.assertFalse(notification.failure_type) + self.assertEqual(notification.notification_status, 'sent') + self.assertEqual(notification2.failure_type, 'mail_email_invalid') + self.assertEqual(notification2.notification_status, 'exception') @mute_logger('odoo.addons.mail.models.mail_mail') def test_mail_mail_send_exceptions_raise_management(self): @@ -589,6 +743,8 @@ class TestMailMail(TestMailCommon): # SMTP sending issues with self.mock_mail_gateway(): _send_current = self.send_email_mocked.side_effect + self.addCleanup(setattr, self.send_email_mocked, 'side_effect', _send_current) + self._reset_data() mail.write({'email_to': 'test@example.com'}) @@ -596,9 +752,7 @@ class TestMailMail(TestMailCommon): for error, error_class in [ (smtplib.SMTPServerDisconnected("Some exception"), smtplib.SMTPServerDisconnected), (MemoryError("Some exception"), MemoryError)]: - def _send_email(*args, **kwargs): - raise error - self.send_email_mocked.side_effect = _send_email + self.send_email_mocked.side_effect = error with self.assertRaises(error_class): mail.send(raise_exception=False) @@ -611,23 +765,42 @@ class TestMailMail(TestMailCommon): # MailDeliveryException: should be catched; other issues are sub-catched under # a MailDeliveryException and are catched - for error, msg in [ - (MailDeliveryException("Some exception"), 'Some exception'), - (ValueError("Unexpected issue"), 'Unexpected issue')]: - def _send_email(*args, **kwargs): - raise error - self.send_email_mocked.side_effect = _send_email + for error, msg, failure_type in [ + (MailDeliveryException("Some exception"), 'Some exception', 'unknown'), + (MailDeliveryException("OutboundSpamException"), 'OutboundSpamException', 'mail_spam'), + (ValueError("Unexpected issue"), 'Unexpected issue', 'unknown')]: + self.send_email_mocked.side_effect = error self._reset_data() mail.send(raise_exception=False) self.assertEqual(mail.failure_reason, msg) - self.assertFalse(mail.failure_type, 'Mail: unlogged failure type to fix') + self.assertEqual(mail.failure_type, failure_type) self.assertEqual(mail.state, 'exception') self.assertEqual(notification.failure_reason, msg) - self.assertEqual(notification.failure_type, 'unknown', 'Mail: generic failure type') + self.assertEqual(notification.failure_type, failure_type) self.assertEqual(notification.notification_status, 'exception') - self.send_email_mocked.side_effect = _send_current + def test_mail_mail_values_misc(self): + """ Test various values on mail.mail, notably default values """ + msg = self.env['mail.mail'].create({}) + self.assertEqual(msg.message_type, 'email_outgoing', 'Mails should have outgoing email type by default') + +@tagged('mail_mail', 'mail_server') +class TestMailMailServer(MailCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.mail_server_domain_2 = cls.env['ir.mail_server'].create({ + 'from_filter': 'test_2.com', + 'name': 'Server 2', + 'smtp_host': 'test_2.com', + }) + cls.test_record = cls.env['mail.test.gateway'].with_context(cls._test_context).create({ + 'name': 'Test', + 'email_from': 'ignasse@example.com', + }).with_context({}) @mute_logger('odoo.addons.mail.models.mail_mail') def test_mail_mail_send_server(self): @@ -635,7 +808,10 @@ class TestMailMail(TestMailCommon): Batch are defined by the mail server and the email from field. """ - self.assertEqual(self.env['ir.mail_server']._get_default_from_address(), 'notifications@test.com') + self.assertEqual( + self.env['ir.mail_server']._get_default_from_address(), + f'{self.default_from}@{self.alias_domain}' + ) mail_values = { 'body_html': '

Test

', @@ -654,19 +830,19 @@ class TestMailMail(TestMailCommon): # Should use the test_2 mail server # Once with "user_1@test_2.com" as login # Once with "user_2@test_2.com" as login - mails |= self.env['mail.mail'].create([{ + mails += self.env['mail.mail'].create([{ **mail_values, 'email_from': 'user_1@test_2.com', - } for _ in range(5)]) | self.env['mail.mail'].create([{ + } for _ in range(5)]) + self.env['mail.mail'].create([{ **mail_values, 'email_from': 'user_2@test_2.com', } for _ in range(5)]) # Mail server is forced - mails |= self.env['mail.mail'].create([{ + mails += self.env['mail.mail'].create([{ **mail_values, 'email_from': 'user_1@test_2.com', - 'mail_server_id': self.server_domain.id, + 'mail_server_id': self.mail_server_domain.id, } for _ in range(5)]) with self.mock_smtplib_connection(): @@ -680,21 +856,21 @@ class TestMailMail(TestMailCommon): self.assertEqual(self.connect_mocked.call_count, 4, 'Must be called once per batch which share the same mail server and the same smtp from') self.connect_mocked.assert_has_calls( calls=[ - call(smtp_from='notifications@test.com', mail_server_id=self.server_notification.id), - call(smtp_from='user_1@test_2.com', mail_server_id=self.server_domain_2.id), - call(smtp_from='user_2@test_2.com', mail_server_id=self.server_domain_2.id), - call(smtp_from='user_1@test_2.com', mail_server_id=self.server_domain.id), + call(smtp_from=f'{self.default_from}@{self.alias_domain}', mail_server_id=self.mail_server_notification.id), + call(smtp_from='user_1@test_2.com', mail_server_id=self.mail_server_domain_2.id), + call(smtp_from='user_2@test_2.com', mail_server_id=self.mail_server_domain_2.id), + call(smtp_from='user_1@test_2.com', mail_server_id=self.mail_server_domain.id), ], any_order=True, ) - self.assert_email_sent_smtp(message_from='"test" ', - emails_count=5, from_filter=self.server_notification.from_filter) - self.assert_email_sent_smtp(message_from='"test_2" ', - emails_count=5, from_filter=self.server_notification.from_filter) - self.assert_email_sent_smtp(message_from='user_1@test_2.com', emails_count=5, from_filter=self.server_domain_2.from_filter) - self.assert_email_sent_smtp(message_from='user_2@test_2.com', emails_count=5, from_filter=self.server_domain_2.from_filter) - self.assert_email_sent_smtp(message_from='user_1@test_2.com', emails_count=5, from_filter=self.server_domain.from_filter) + self.assertSMTPEmailsSent(message_from=f'"test" <{self.default_from}@{self.alias_domain}>', + emails_count=5, from_filter=self.mail_server_notification.from_filter) + self.assertSMTPEmailsSent(message_from=f'"test_2" <{self.default_from}@{self.alias_domain}>', + emails_count=5, from_filter=self.mail_server_notification.from_filter) + self.assertSMTPEmailsSent(message_from='user_1@test_2.com', emails_count=5, mail_server=self.mail_server_domain_2) + self.assertSMTPEmailsSent(message_from='user_2@test_2.com', emails_count=5, mail_server=self.mail_server_domain_2) + self.assertSMTPEmailsSent(message_from='user_1@test_2.com', emails_count=5, mail_server=self.mail_server_domain) @mute_logger('odoo.addons.mail.models.mail_mail') def test_mail_mail_values_email_formatted(self): @@ -715,15 +891,15 @@ class TestMailMail(TestMailCommon): self.assertEqual( sorted(sorted(_mail['email_to']) for _mail in self._mails), sorted([sorted(['"Raoul, le Grand" ', '"Micheline, l\'immense" ']), - [tools.formataddr((self.user_employee.name, self.user_employee.email_normalized))], - [tools.formataddr(("Tony Customer", 'tony.customer@test.example.com'))] + [formataddr((self.user_employee.name, self.user_employee.email_normalized))], + [formataddr(("Tony Customer", 'tony.customer@test.example.com'))] ]), 'Mail: formatting issues should have been removed as much as possible' ) - # Currently broken: CC are added to ALL emails (spammy) + # CC are added to first email self.assertEqual( [_mail['email_cc'] for _mail in self._mails], - [['"Ignasse, le Poilu" ']] * 3, + [['"Ignasse, le Poilu" '], [], []], 'Mail: currently always removing formatting in email_cc' ) @@ -747,17 +923,17 @@ class TestMailMail(TestMailCommon): self.assertEqual( sorted(sorted(_mail['email_to']) for _mail in self._mails), sorted([sorted(['test.email.1@test.example.com', 'test.email.2@test.example.com']), - [tools.formataddr((self.user_employee.name, self.user_employee.email_normalized))], - sorted([tools.formataddr(("Tony Customer", 'tony.customer@test.example.com')), - tools.formataddr(("Tony Customer", 'norbert.customer@test.example.com'))]), + [formataddr((self.user_employee.name, self.user_employee.email_normalized))], + sorted([formataddr(("Tony Customer", 'tony.customer@test.example.com')), + formataddr(("Tony Customer", 'norbert.customer@test.example.com'))]), ]), 'Mail: formatting issues should have been removed as much as possible (multi emails in a single address are managed ' 'like separate emails when sending with recipient_ids' ) - # Currently broken: CC are added to ALL emails (spammy) + # CC are added to first email self.assertEqual( [_mail['email_cc'] for _mail in self._mails], - [['test.cc.1@test.example.com', 'test.cc.2@test.example.com']] * 3, + [['test.cc.1@test.example.com', 'test.cc.2@test.example.com'], [], []], ) # Multi + formatting @@ -777,17 +953,17 @@ class TestMailMail(TestMailCommon): self.assertEqual( sorted(sorted(_mail['email_to']) for _mail in self._mails), sorted([sorted(['test.email.1@test.example.com', 'test.email.2@test.example.com']), - [tools.formataddr((self.user_employee.name, self.user_employee.email_normalized))], - sorted([tools.formataddr(("Tony Customer", 'tony.customer@test.example.com')), - tools.formataddr(("Tony Customer", 'norbert.customer@test.example.com'))]), + [formataddr((self.user_employee.name, self.user_employee.email_normalized))], + sorted([formataddr(("Tony Customer", 'tony.customer@test.example.com')), + formataddr(("Tony Customer", 'norbert.customer@test.example.com'))]), ]), 'Mail: formatting issues should have been removed as much as possible (multi emails in a single address are managed ' 'like separate emails when sending with recipient_ids (and partner name is always used as name part)' ) - # Currently broken: CC are added to ALL emails (spammy) + # CC are added to first email self.assertEqual( [_mail['email_cc'] for _mail in self._mails], - [['test.cc.1@test.example.com', 'test.cc.2@test.example.com']] * 3, + [['test.cc.1@test.example.com', 'test.cc.2@test.example.com'], [], []], ) @mute_logger('odoo.addons.mail.models.mail_mail') @@ -805,6 +981,7 @@ class TestMailMail(TestMailCommon): self.assertEqual(self._mails[0]['email_to'], ['test.😊@example.com']) @users('admin') + @mute_logger('odoo.addons.mail.models.mail_mail') def test_mail_mail_values_email_uppercase(self): """ Test uppercase support when comparing emails, notably due to 'send_validated_to' introduction that checks emails before sending them. """ @@ -812,15 +989,19 @@ class TestMailMail(TestMailCommon): 'name': 'Uppercase Partner', 'email': 'Uppercase.Partner.youpie@example.gov.uni', }) - for recipient_values, (exp_to, exp_cc) in zip( + for recipient_values, exp_recipients in zip( [ {'email_to': 'Uppercase.Customer.to@example.gov.uni'}, - {'email_to': '"Formatted Customer" '}, - {'recipient_ids': [(4, customer.id)], 'email_cc': 'Uppercase.Customer.cc@example.gov.uni'}, + {'email_to': '"Formatted Customer" ', 'email_cc': '"UpCc" '}, + {'recipient_ids': [(4, customer.id)], 'email_cc': '"UpCc" '}, ], [ - (['uppercase.customer.to@example.gov.uni'], []), - (['"Formatted Customer" '], []), - (['"Uppercase Partner" '], ['uppercase.customer.cc@example.gov.uni']), + [(['uppercase.customer.to@example.gov.uni'], [])], + [(['"Formatted Customer" '], ['"UpCc" '])], + # partner-based recipients are not mixed with emails-only, even if only CC + [ + (['"Uppercase Partner" '], []), + ([], ['"UpCc" ']), + ], ] ): with self.subTest(values=recipient_values): @@ -831,19 +1012,119 @@ class TestMailMail(TestMailCommon): }) with self.mock_mail_gateway(): mail.send() - self.assertSentEmail('"Forced From" ', exp_to, email_cc=exp_cc) + for exp_to, exp_cc in exp_recipients: + self.assertSentEmail('"Forced From" ', exp_to, email_cc=exp_cc) + + @mute_logger('odoo.addons.mail.models.mail_mail') + @patch('odoo.addons.base.models.ir_attachment.IrAttachment.file_size', new_callable=PropertyMock) + def test_mail_mail_send_server_attachment_to_download_link(self, mock_attachment_file_size): + """ Test that when the mail size exceeds the max email size limit, + attachments are turned into download links added at the end of the + email content. + + The feature is tested in the following conditions: + - using a specified server or the default one (to test command ICP parameter) + - in batch mode + - with mail that exceed (with one or more attachments) or not the limit + - with attachment owned by a business record or not: attachments not owned by a + business record are never turned into links because their lifespans are not + controlled by the user (might even be deleted right after the message is sent). + """ + def count_attachments(message): + if isinstance(message, str): + return 0 + elif message.is_multipart(): + return sum(count_attachments(part) for part in message.get_payload()) + elif 'attachment' in message.get('Content-Disposition', ''): + return 1 + return 0 + + mock_attachment_file_size.return_value = 1024 * 128 + # Define some constant to ease the understanding of the test + test_mail_server = self.mail_server_domain_2 + max_size_always_exceed = 0.1 + max_size_never_exceed = 10 + + for n_attachment, mail_server, business_attachment, expected_is_links in ( + # 1 attachment which doesn't exceed max size + (1, self.env['ir.mail_server'], True, False), + # 3 attachment: exceed max size + (3, self.env['ir.mail_server'], True, True), + # 1 attachment: exceed max size + (1, self.env['ir.mail_server'], True, True), + # Same as above with a specific server. Note that the default and server max_email size are reversed. + (1, test_mail_server, True, False), + (3, test_mail_server, True, True), + (1, test_mail_server, True, True), + # Attachments not linked to a business record are never turned to link + (3, self.env['ir.mail_server'], False, False), + (1, test_mail_server, False, False), + ): + # Setup max email size to check that the right maximum is used (default or mail server one) + if expected_is_links: + max_size_test_succeed = max_size_always_exceed * n_attachment + max_size_test_fail = max_size_never_exceed + else: + max_size_test_succeed = max_size_never_exceed + max_size_test_fail = max_size_always_exceed * n_attachment + if mail_server: + self.env['ir.config_parameter'].sudo().set_param('base.default_max_email_size', max_size_test_fail) + mail_server.max_email_size = max_size_test_succeed + else: + self.env['ir.config_parameter'].sudo().set_param('base.default_max_email_size', max_size_test_succeed) + + attachments = self.env['ir.attachment'].sudo().create([{ + 'name': f'attachment{idx_attachment}', + 'res_name': 'test', + 'res_model': self.test_record._name if business_attachment else 'mail.message', + 'res_id': self.test_record.id if business_attachment else 0, + 'datas': 'IA==', # a non-empty base64 content. We mock attachment file_size to simulate bigger size. + } for idx_attachment in range(n_attachment)]) + with self.mock_smtplib_connection(): + mails = self.env['mail.mail'].create([{ + 'attachment_ids': attachments.ids, + 'body_html': '

Test

', + 'email_from': 'test@test_2.com', + 'email_to': f'mail_{mail_idx}@test.com', + } for mail_idx in range(2)]) + mails._send(mail_server=mail_server) + + self.assertEqual(len(self.emails), 2) + for outgoing_email in self.emails: + message_raw = outgoing_email['message'] + message_parsed = message_from_string(message_raw) + message_cleaned = re.sub(r'[\s=]', '', message_raw) + with self.subTest(n_attachment=n_attachment, mail_server=mail_server, + business_attachment=business_attachment, expected_is_links=expected_is_links): + if expected_is_links: + self.assertEqual(count_attachments(message_parsed), 0, + 'Attachments should have been removed (replaced by download links)') + self.assertTrue(all(attachment.access_token for attachment in attachments), + 'Original attachment should have been modified (access_token added)') + self.assertTrue(all(attachment.access_token in message_cleaned for attachment in attachments), + 'All attachments should have been turned into download links') + else: + self.assertEqual(count_attachments(message_parsed), n_attachment, + 'All attachments should be present') + self.assertEqual(message_cleaned.count('access_token'), 0, + 'Attachments should not have been turned into download links') + self.assertTrue(all(not attachment.access_token for attachment in attachments), + 'Original attachment should not have been modified (access_token not added)') @tagged('mail_mail') -class TestMailMailRace(common.TransactionCase): +class TestMailMailRace(MailCommon): @mute_logger('odoo.addons.mail.models.mail_mail') def test_mail_bounce_during_send(self): - self.partner = self.env['res.partner'].create({ + cr = self.registry.cursor() + env = api.Environment(cr, SUPERUSER_ID, {}) + + self.partner = env['res.partner'].create({ 'name': 'Ernest Partner', }) # we need to simulate a mail sent by the cron task, first create mail, message and notification by hand - mail = self.env['mail.mail'].sudo().create({ + mail = env['mail.mail'].sudo().create({ 'body_html': '

Test

', 'is_notification': True, 'state': 'outgoing', @@ -851,7 +1132,7 @@ class TestMailMailRace(common.TransactionCase): }) mail_message = mail.mail_message_id - message = self.env['mail.message'].create({ + message = env['mail.message'].create({ 'subject': 'S', 'body': 'B', 'subtype_id': self.ref('mail.mt_comment'), @@ -863,9 +1144,10 @@ class TestMailMailRace(common.TransactionCase): 'notification_status': 'ready', })], }) - notif = self.env['mail.notification'].search([('res_partner_id', '=', self.partner.id)]) + notif = env['mail.notification'].search([('res_partner_id', '=', self.partner.id)]) + notif.ensure_one() # for patched method # we need to commit transaction or cr will keep the lock on notif - self.cr.commit() + cr.commit() # patch send_email in order to create a concurent update and check the notif is already locked by _send() this = self # coding in javascript ruinned my life @@ -875,8 +1157,8 @@ class TestMailMailRace(common.TransactionCase): with this.registry.cursor() as cr, mute_logger('odoo.sql_db'): try: # try ro aquire lock (no wait) on notification (should fail) - cr.execute("SELECT notification_status FROM mail_notification WHERE id = %s FOR UPDATE NOWAIT", [notif.id]) - except psycopg2.OperationalError: + notif.with_env(notif.env(cr=cr)).lock_for_update() + except LockError: # record already locked by send, all good bounce_deferred.append(True) else: @@ -886,22 +1168,19 @@ class TestMailMailRace(common.TransactionCase): # In practice, the update will wait the end of the send() transaction and set the notif as bounce, as expeced cr.execute("UPDATE mail_notification SET notification_status='bounce' WHERE id = %s", [notif.id]) return message['Message-Id'] - self.env['ir.mail_server']._patch_method('send_email', send_email) - mail.send() + with self.mock_mail_gateway(): + self.patch(self.registry['ir.mail_server'], 'send_email', send_email) + mail.send() self.assertTrue(bounce_deferred, "The bounce should have been deferred") self.assertEqual(notif.notification_status, 'sent') # some cleaning since we commited the cr - self.env['ir.mail_server']._revert_method('send_email') notif.unlink() mail.unlink() (mail_message | message).unlink() self.partner.unlink() - self.env.cr.commit() - - # because we committed the cursor, the savepoint of the test method is - # gone, and this would break TransactionCase cleanups - self.cr.execute('SAVEPOINT test_%d' % self._savepoint_id) + cr.commit() + cr.close() diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_management.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_management.py index c80f57e..eddeaff 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_management.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_management.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients +from odoo.addons.mail.tests.common import MailCommon +from odoo.addons.test_mail.tests.common import TestRecipients from odoo.tests import tagged @tagged('mail_management') -class TestMailManagement(TestMailCommon, TestRecipients): +class TestMailManagement(MailCommon, TestRecipients): @classmethod def setUpClass(cls): diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_message.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_message.py index 8001be4..6433bd0 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_message.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_message.py @@ -1,14 +1,162 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.addons.test_mail.tests.common import TestMailCommon +import contextlib + +from markupsafe import Markup + +from odoo.addons.base.models.ir_mail_server import MailDeliveryException +from odoo.addons.mail.tests.common import mail_new_test_user, MailCommon +from odoo.addons.mail.tools.discuss import Store from odoo.exceptions import UserError +from odoo.tests.common import tagged, users, HttpCase from odoo.tools import is_html_empty, mute_logger, formataddr -from odoo.tests import tagged, users -@tagged('mail_message') -class TestMessageValues(TestMailCommon): +@tagged('mail_message', 'mail_controller', 'post_install', '-at_install') +class TestMessageHelpersRobustness(MailCommon, HttpCase): + """ Test message helpers robustness, currently mainly linked to records + being removed from DB due to cascading deletion, which let side records + alive in DB. """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.user_employee_2 = mail_new_test_user( + cls.env, + email='eglantine@example.com', + groups='base.group_user', + login='employee2', + notification_type='email', + name='Eglantine Employee', + ) + cls.partner_employee_2 = cls.user_employee_2.partner_id + + cls.test_records_simple, _partners = cls._create_records_for_batch( + 'mail.test.simple', 3, + ) + + def setUp(self): + super().setUp() + # cleanup db + self.env['mail.notification'].search([('author_id', '=', self.partner_employee.id)]).unlink() + + # handy shortcut variables + self.deleted_record = self.test_records_simple[2] + + # generate crashed notifications + with mute_logger('odoo.addons.mail.models.mail_mail'), self.mock_mail_gateway(): + def _send_email(*args, **kwargs): + raise MailDeliveryException("Some exception") + self.send_email_mocked.side_effect = _send_email + + for record in self.test_records_simple.with_user(self.user_employee): + record.message_post( + body="Setup", + message_type='comment', + partner_ids=self.partner_employee_2.ids, + subtype_id=self.env.ref('mail.mt_comment').id, + ) + + # In the mean time, some FK deletes the record where the message is + # # scheduled, skipping its unlink() override + self.env.cr.execute( + f"DELETE FROM {self.test_records_simple._table} WHERE id = %s", (self.deleted_record.id,) + ) + self.env.invalidate_all() + + def test_assert_initial_values(self): + notifs_by_employee = self.env['mail.notification'].search([('author_id', '=', self.partner_employee.id)]) + self.assertEqual( + set(notifs_by_employee.mapped('mail_message_id.res_id')), + set(self.test_records_simple.ids) + ) + self.assertEqual(len(notifs_by_employee), 3) + self.assertTrue(all(notif.notification_status == 'exception' for notif in notifs_by_employee)) + self.assertTrue(all(notif.res_partner_id == self.partner_employee_2 for notif in notifs_by_employee)) + + def test_load_message_failures(self): + self.authenticate(self.user_employee.login, self.user_employee.login) + with contextlib.suppress(Exception), mute_logger('odoo.http', 'odoo.sql_db'): # suppress logged error due to readonly route doing an update + result = self.make_jsonrpc_request("/mail/data", {"fetch_params": ["failures"]}) + self.assertEqual(sorted(r['thread']['id'] for r in result['mail.message']), sorted(self.test_records_simple[:2].ids)) + self.assertEqual( + sorted(self.env['mail.notification'].search([('author_id', '=', self.partner_employee.id)]).mapped('mail_message_id.res_id')), + sorted((self.test_records_simple - self.deleted_record).ids), + 'Should have cleaned notifications linked to unexisting records' + ) + + def test_load_message_failures_use_display_name(self): + test_record = self.env['mail.test.simple.unnamed'].create({'description': 'Some description'}) + test_record.message_subscribe(partner_ids=self.partner_employee_2.ids) + + self.authenticate(self.user_employee.login, self.user_employee.password) + msg = test_record.message_post(body='Some body', author_id=self.partner_employee.id) + # simulate failure + self.env['mail.notification'].create({ + 'author_id': msg.author_id.id, + 'mail_message_id': msg.id, + 'res_partner_id': self.partner_employee_2.id, + 'notification_type': 'email', + 'notification_status': 'exception', + 'failure_type': 'mail_email_invalid', + }) + with contextlib.suppress(Exception), mute_logger('odoo.http', 'odoo.sql_db'): # suppress logged error due to readonly route doing an update + res = self.make_jsonrpc_request("/mail/data", {"fetch_params": ["failures"]}) + self.assertEqual( + sorted(t["name"] for t in res["mail.thread"]), + sorted(['Some description'] + (self.test_records_simple - self.deleted_record).mapped('display_name')) + ) + + def test_message_fetch(self): + # set notifications to unread, so that we can simulate inbox usage + p2_notifications = self.env['mail.notification'].search([('res_partner_id', '=', self.partner_employee_2.id)]) + p2_notifications.is_read = False + + self.authenticate(self.user_employee_2.login, self.user_employee_2.login) + result = self.make_jsonrpc_request("/mail/inbox/messages", {})['data'] + self.assertEqual( + {r['thread']['id'] if r['thread'] else False for r in result['mail.message']}, + set((self.test_records_simple - self.deleted_record).ids + [False]), + 'Currently reading message on missing record, crash avoided, void thread for missing record' + ) + p2_notifications.with_user(self.user_employee_2).mail_message_id.set_message_done() + + result = self.make_jsonrpc_request("/mail/history/messages", {})['data'] + self.assertEqual( + {r['thread']['id'] if r['thread'] else False for r in result['mail.message']}, + set((self.test_records_simple - self.deleted_record).ids + [False]), + 'Currently reading message on missing record, crash avoided' + ) + + def test_message_link_by_employee(self): + record = self.test_records_simple[0] + thread_message = record.message_post(body='Thread Message', message_type='comment') + deleted_message = record.message_post(body='', message_type='comment') + self.authenticate(self.user_employee.login, self.user_employee.login) + with self.subTest(thread_message=thread_message): + expected_url = self.base_url() + f'/odoo/{thread_message.model}/{thread_message.res_id}?highlight_message_id={thread_message.id}' + res = self.url_open(f'/mail/message/{thread_message.id}') + self.assertEqual(res.url, expected_url) + with self.subTest(deleted_message=deleted_message): + res = self.url_open(f'/mail/message/{deleted_message.id}') + + def test_notify_cancel_by_type(self): + """ Test canceling notifications, notably when having missing records. """ + self.env.invalidate_all() + notifs_by_employee = self.env['mail.notification'].search([('author_id', '=', self.partner_employee.id)]) + + # do not crash even if removed record + self.test_records_simple.with_user(self.user_employee).notify_cancel_by_type('email') + self.env.invalidate_all() + + notifs_by_employee = notifs_by_employee.exists() + self.assertEqual(len(notifs_by_employee), 3, 'Currently keep notifications for missing records') + self.assertTrue(all(notif.notification_status == 'canceled' for notif in notifs_by_employee)) + + +@tagged("mail_message", "post_install", "-at_install") +class TestMessageValues(MailCommon): @classmethod def setUpClass(cls): @@ -60,24 +208,28 @@ class TestMessageValues(TestMailCommon): self.assertFalse(message.sudo().tracking_value_ids) # Reset body case - record._message_update_content(message, '


', attachment_ids=message.attachment_ids.ids) + record._message_update_content( + message, + body=Markup("


"), + attachment_ids=message.attachment_ids.ids, + ) self.assertTrue(is_html_empty(message.body)) self.assertFalse(message.sudo()._filter_empty(), 'Still having attachments') # Subtype content note_subtype.sudo().write({'description': 'Very important discussions'}) - record._message_update_content(message, '', []) + record._message_update_content(message, body="", attachment_ids=[]) self.assertFalse(message.attachment_ids) self.assertEqual(message.notified_partner_ids, self.partner_admin) self.assertEqual(message.starred_partner_ids, self.partner_admin) self.assertFalse(message.sudo()._filter_empty(), 'Subtype with description') - # Completely void now + # Completely emptied now note_subtype.sudo().write({'description': ''}) self.assertEqual(message.sudo()._filter_empty(), message) - record._message_update_content(message, '', []) - self.assertFalse(message.notified_partner_ids) - self.assertFalse(message.starred_partner_ids) + record._message_update_content(message.sudo(), body="", attachment_ids=[]) + self.assertEqual(message.notified_partner_ids, self.partner_admin) # message still notified (albeit content is removed) + self.assertEqual(message.starred_partner_ids, self.partner_admin) # starred messages stay (albeit content is removed) # test tracking values record.write({'user_id': self.user_admin.id}) @@ -88,13 +240,13 @@ class TestMessageValues(TestMailCommon): self.assertFalse(tracking_message.subtype_id.description) self.assertFalse(tracking_message.sudo()._filter_empty(), 'Has tracking values') with self.assertRaises(UserError, msg='Tracking values prevent from updating content'): - record._message_update_content(tracking_message, '', []) + record._message_update_content(tracking_message, body="", attachment_ids=[]) @mute_logger('odoo.models.unlink') - def test_mail_message_format_access(self): + def test_mail_message_to_store_access(self): """ User that doesn't have access to a record should still be able to fetch - the record_name inside message_format. + the record_name inside message _to_store. """ company_2 = self.env['res.company'].create({'name': 'Second Test Company'}) record1 = self.env['mail.test.multi.company'].create({ @@ -104,25 +256,78 @@ class TestMessageValues(TestMailCommon): message = record1.message_post(body='', partner_ids=[self.user_employee.partner_id.id]) # We need to flush and invalidate the ORM cache since the record_name # is already cached from the creation. Otherwise it will leak inside - # message_format. + # message _to_store. self.env.flush_all() self.env.invalidate_all() - res = message.with_user(self.user_employee).message_format() - self.assertEqual(res[0].get('record_name'), 'Test1') + res = Store().add(message.with_user(self.user_employee)).get_result() + self.assertEqual(res["mail.message"][0].get("record_name"), "Test1") record1.write({"name": "Test2"}) - res = message.with_user(self.user_employee).message_format() - self.assertEqual(res[0].get('record_name'), 'Test2') + self.env.flush_all() + self.env.invalidate_all() + res = Store().add(message.with_user(self.user_employee)).get_result() + self.assertEqual(res["mail.message"][0].get('record_name'), 'Test2') + + # check model not inheriting from mail.thread -> should not crash + record_nothread = self.env['mail.test.nothread'].create({'name': 'NoThread'}) + message = self.env['mail.message'].create({ + 'model': record_nothread._name, + 'res_id': record_nothread.id, + }) + formatted = Store().add(message).get_result()["mail.message"][0] + self.assertEqual(formatted['record_name'], record_nothread.name) + + def test_records_by_message(self): + record1 = self.env["mail.test.simple"].create({"name": "Test1"}) + record2 = self.env["mail.test.simple"].create({"name": "Test1"}) + record3 = self.env["mail.test.nothread"].create({"name": "Test2"}) + messages = self.env["mail.message"].create( + [ + { + "model": record._name, + "res_id": record.id, + } + for record in [record1, record2, record3] + ] + ) + # methods called on batch of message + records_by_model_name = messages._records_by_model_name() + test_simple_records = records_by_model_name["mail.test.simple"] + self.assertEqual(test_simple_records, record1 + record2) + self.assertEqual(test_simple_records._prefetch_ids, tuple((record1 + record2).ids)) + test_no_thread_records = records_by_model_name["mail.test.nothread"] + self.assertEqual(test_no_thread_records, record3) + self.assertEqual(test_no_thread_records._prefetch_ids, tuple(record3.ids)) + record_by_message = messages._record_by_message() + m0_records = record_by_message[messages[0]] + self.assertEqual(m0_records, record1) + self.assertEqual(m0_records._prefetch_ids, tuple((record1 + record2).ids)) + m1_records = record_by_message[messages[1]] + self.assertEqual(m1_records, record2) + self.assertEqual(m1_records._prefetch_ids, tuple((record1 + record2).ids)) + m2_records = record_by_message[messages[2]] + self.assertEqual(m2_records, record3) + self.assertEqual(m2_records._prefetch_ids, tuple(record3.ids)) + # methods called on individual message from a batch: prefetch from batch is kept + records_by_model_name = next(iter(messages))._records_by_model_name() + test_simple_records = records_by_model_name["mail.test.simple"] + self.assertEqual(test_simple_records, record1) + self.assertEqual(test_simple_records._prefetch_ids, tuple((record1 + record2).ids)) + record_by_message = next(iter(messages))._record_by_message() + m0_records = record_by_message[messages[0]] + self.assertEqual(m0_records, record1) + self.assertEqual(m0_records._prefetch_ids, tuple((record1 + record2).ids)) def test_mail_message_values_body_base64_image(self): msg = self.env['mail.message'].with_user(self.user_employee).create({ 'body': 'taratata ', }) self.assertEqual(len(msg.attachment_ids), 1) + attachment = msg.attachment_ids[0] self.assertEqual( msg.body, - '

taratata image0 ' - 'image0

'.format(attachment=msg.attachment_ids[0]) + f'

taratata image0 ' + f'image0

' ) @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.models') @@ -134,7 +339,7 @@ class TestMessageValues(TestMailCommon): + commit linked to this test). """ # name would make it blow up: keep only email test_record = self.env['mail.test.container'].browse(self.alias_record.ids) - test_record.write({ + self.user_employee.write({ 'name': 'Super Long Name That People May Enter "Even with an internal quoting of stuff"' }) msg = self.env['mail.message'].create({ @@ -145,40 +350,11 @@ class TestMessageValues(TestMailCommon): self.assertEqual(msg.reply_to, reply_to_email, 'Reply-To: use only email when formataddr > 68 chars') - # name + company_name would make it blow up: keep record_name in formatting - self.company_admin.name = "Company name being about 33 chars" - test_record.write({'name': 'Name that would be more than 68 with company name'}) - msg = self.env['mail.message'].create({ - 'model': test_record._name, - 'res_id': test_record.id - }) - self.assertEqual(msg.reply_to, formataddr((test_record.name, reply_to_email)), - 'Reply-To: use recordname as name in format if recordname + company > 68 chars') - - # no record_name: keep company_name in formatting if ok - test_record.write({'name': ''}) - msg = self.env['mail.message'].create({ - 'model': test_record._name, - 'res_id': test_record.id - }) - self.assertEqual(msg.reply_to, formataddr((self.env.user.company_id.name, reply_to_email)), - 'Reply-To: use company as name in format when no record name and still < 68 chars') - - # no record_name and company_name make it blow up: keep only email - self.env.user.company_id.write({'name': 'Super Long Name That People May Enter "Even with an internal quoting of stuff"'}) - msg = self.env['mail.message'].create({ - 'model': test_record._name, - 'res_id': test_record.id - }) - self.assertEqual(msg.reply_to, reply_to_email, - 'Reply-To: use only email when formataddr > 68 chars') - # whatever the record and company names, email is too long: keep only email test_record.write({ 'alias_name': 'Waaaay too long alias name that should make any reply-to blow the 68 characters limit', 'name': 'Short', }) - self.env.user.company_id.write({'name': 'Comp'}) sanitized_alias_name = 'waaaay-too-long-alias-name-that-should-make-any-reply-to-blow-the-68-characters-limit' msg = self.env['mail.message'].create({ 'model': test_record._name, @@ -187,6 +363,7 @@ class TestMessageValues(TestMailCommon): self.assertEqual(msg.reply_to, f"{sanitized_alias_name}@{self.alias_domain}", 'Reply-To: even a long email is ok as only formataddr is problematic') + @users('employee') @mute_logger('odoo.models.unlink') def test_mail_message_values_fromto_no_document_values(self): msg = self.Message.create({ @@ -197,32 +374,26 @@ class TestMessageValues(TestMailCommon): self.assertEqual(msg.reply_to, 'test.reply@example.com') self.assertEqual(msg.email_from, 'test.from@example.com') + @users('employee') @mute_logger('odoo.models.unlink') def test_mail_message_values_fromto_no_document(self): msg = self.Message.create({}) self.assertIn('-private', msg.message_id.split('@')[0], 'mail_message: message_id for a void message should be a "private" one') - reply_to_name = self.env.user.company_id.name + reply_to_name = self.user_employee.name reply_to_email = '%s@%s' % (self.alias_catchall, self.alias_domain) self.assertEqual(msg.reply_to, formataddr((reply_to_name, reply_to_email))) self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email))) # no alias domain -> author - self.env['ir.config_parameter'].search([('key', '=', 'mail.catchall.domain')]).unlink() - - msg = self.Message.create({}) - self.assertIn('-private', msg.message_id.split('@')[0], 'mail_message: message_id for a void message should be a "private" one') - self.assertEqual(msg.reply_to, formataddr((self.user_employee.name, self.user_employee.email))) - self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email))) - - # no alias catchall, no alias -> author - self.env['ir.config_parameter'].set_param('mail.catchall.domain', self.alias_domain) - self.env['ir.config_parameter'].search([('key', '=', 'mail.catchall.alias')]).unlink() + self.env.company.sudo().alias_domain_id = False + self.assertFalse(self.env.company.catchall_email) msg = self.Message.create({}) self.assertIn('-private', msg.message_id.split('@')[0], 'mail_message: message_id for a void message should be a "private" one') self.assertEqual(msg.reply_to, formataddr((self.user_employee.name, self.user_employee.email))) self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email))) + @users('employee') @mute_logger('odoo.models.unlink') def test_mail_message_values_fromto_document_alias(self): msg = self.Message.create({ @@ -230,13 +401,15 @@ class TestMessageValues(TestMailCommon): 'res_id': self.alias_record.id }) self.assertIn('-openerp-%d-mail.test' % self.alias_record.id, msg.message_id.split('@')[0]) - reply_to_name = '%s %s' % (self.env.user.company_id.name, self.alias_record.name) + reply_to_name = self.user_employee.name reply_to_email = '%s@%s' % (self.alias_record.alias_name, self.alias_domain) self.assertEqual(msg.reply_to, formataddr((reply_to_name, reply_to_email))) self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email))) - # no alias domain -> author - self.env['ir.config_parameter'].search([('key', '=', 'mail.catchall.domain')]).unlink() + # no alias domain, no company catchall -> author + self.alias_record.alias_domain_id = False + self.env.company.sudo().alias_domain_id = False + self.assertFalse(self.env.company.catchall_email) msg = self.Message.create({ 'model': 'mail.test.container', @@ -246,20 +419,18 @@ class TestMessageValues(TestMailCommon): self.assertEqual(msg.reply_to, formataddr((self.user_employee.name, self.user_employee.email))) self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email))) - # no catchall -> don't care, alias - self.env['ir.config_parameter'].set_param('mail.catchall.domain', self.alias_domain) - self.env['ir.config_parameter'].search([('key', '=', 'mail.catchall.alias')]).unlink() + # alias wins over company, hence no catchall is not an issue + self.alias_record.alias_domain_id = self.mail_alias_domain msg = self.Message.create({ 'model': 'mail.test.container', 'res_id': self.alias_record.id }) self.assertIn('-openerp-%d-mail.test' % self.alias_record.id, msg.message_id.split('@')[0]) - reply_to_name = '%s %s' % (self.env.company.name, self.alias_record.name) - reply_to_email = '%s@%s' % (self.alias_record.alias_name, self.alias_domain) self.assertEqual(msg.reply_to, formataddr((reply_to_name, reply_to_email))) self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email))) + @users('employee') @mute_logger('odoo.models.unlink') def test_mail_message_values_fromto_document_no_alias(self): test_record = self.env['mail.test.simple'].create({'name': 'Test', 'email_from': 'ignasse@example.com'}) @@ -269,17 +440,17 @@ class TestMessageValues(TestMailCommon): 'res_id': test_record.id }) self.assertIn('-openerp-%d-mail.test.simple' % test_record.id, msg.message_id.split('@')[0]) - reply_to_name = '%s %s' % (self.env.user.company_id.name, test_record.name) + reply_to_name = self.user_employee.name reply_to_email = '%s@%s' % (self.alias_catchall, self.alias_domain) self.assertEqual(msg.reply_to, formataddr((reply_to_name, reply_to_email))) self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email))) + @users('employee') @mute_logger('odoo.models.unlink') def test_mail_message_values_fromto_document_manual_alias(self): test_record = self.env['mail.test.simple'].create({'name': 'Test', 'email_from': 'ignasse@example.com'}) - alias = self.env['mail.alias'].create({ + alias = self.env['mail.alias'].sudo().create({ 'alias_name': 'MegaLias', - 'alias_user_id': False, 'alias_model_id': self.env['ir.model']._get('mail.test.simple').id, 'alias_parent_model_id': self.env['ir.model']._get('mail.test.simple').id, 'alias_parent_thread_id': test_record.id, @@ -291,11 +462,12 @@ class TestMessageValues(TestMailCommon): }) self.assertIn('-openerp-%d-mail.test.simple' % test_record.id, msg.message_id.split('@')[0]) - reply_to_name = '%s %s' % (self.env.user.company_id.name, test_record.name) + reply_to_name = self.user_employee.name reply_to_email = '%s@%s' % (alias.alias_name, self.alias_domain) self.assertEqual(msg.reply_to, formataddr((reply_to_name, reply_to_email))) self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email))) + @users('employee') def test_mail_message_values_fromto_reply_to_force_new(self): msg = self.Message.create({ 'model': 'mail.test.container', @@ -305,3 +477,8 @@ class TestMessageValues(TestMailCommon): self.assertIn('reply_to', msg.message_id.split('@')[0]) self.assertNotIn('mail.test.container', msg.message_id.split('@')[0]) self.assertNotIn('-%d-' % self.alias_record.id, msg.message_id.split('@')[0]) + + def test_mail_message_values_misc(self): + """ Test various values on mail.message, notably default values """ + msg = self.env['mail.message'].create({'model': self.alias_record._name, 'res_id': self.alias_record.id}) + self.assertEqual(msg.message_type, 'comment', 'Message should be comments by default') diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_message_security.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_message_security.py index f15516d..72dbfc6 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_message_security.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_message_security.py @@ -1,17 +1,18 @@ import base64 +from markupsafe import Markup from unittest.mock import patch -from odoo.addons.mail.tests.common import mail_new_test_user +from odoo import SUPERUSER_ID +from odoo.addons.mail.tests.common import mail_new_test_user, MailCommon from odoo.addons.test_mail.models.mail_test_access import MailTestAccess -from odoo.addons.test_mail.tests.common import TestMailCommon from odoo.addons.test_mail.models.test_mail_models import MailTestSimple from odoo.exceptions import AccessError from odoo.tools import mute_logger -from odoo.tests import tagged +from odoo.tests import HttpCase, tagged -class MessageAccessCommon(TestMailCommon): +class MessageAccessCommon(MailCommon, HttpCase): @classmethod def setUpClass(cls): @@ -36,6 +37,13 @@ class MessageAccessCommon(TestMailCommon): name='Chell Gladys', ) + cls.test_subtype_access_internal = cls.env['mail.message.subtype'].create([ + { + 'internal': True, + 'name': 'Test Internal', + }, + ]) + ( cls.record_public, cls.record_portal, cls.record_portal_ro, cls.record_followers, @@ -133,7 +141,7 @@ class TestMailMessageAccess(MessageAccessCommon): # - Criterions # - "private message" (no model, no res_id) -> deprecated # - follower of document - # - document-based (write or create, using '_get_mail_message_access' + # - document-based (write or create, using '_mail_get_operation_for_mail_message_operation' # hence '_mail_post_access' by default) # - notified of parent message # ------------------------------------------------------------ @@ -142,7 +150,7 @@ class TestMailMessageAccess(MessageAccessCommon): def test_access_create(self): """ Test 'group_user' creation rules """ # prepare 'notified of parent' condition - admin_msg = self.record_admin.message_ids[0] + admin_msg = self.record_admin.message_ids[-1] admin_msg.write({'partner_ids': [(4, self.user_employee.partner_id.id)]}) # prepare 'followers' condition @@ -157,6 +165,7 @@ class TestMailMessageAccess(MessageAccessCommon): (self.env["mail.test.access"], {}, False, 'Private message like is ok'), # document based (self.record_internal, {}, False, 'W Access on record'), + (self.record_internal, {'message_type': 'notification'}, False, 'W Access on record, notification does not change anything'), (self.record_internal_ro, {}, True, 'No W Access on record'), (self.record_admin, {}, True, 'No access on record (and not notified on first message)'), (record_admin_fol, { @@ -168,26 +177,30 @@ class TestMailMessageAccess(MessageAccessCommon): }, False, 'No access on record but reply to notified parent'), ]: with self.subTest(record=record, msg_vals=msg_vals, reason=reason): + final_vals = dict( + { + 'body': 'Test', + 'message_type': 'comment', + 'subtype_id': self.env.ref('mail.mt_comment').id, + }, **msg_vals + ) if should_crash: with self.assertRaises(AccessError): self.env['mail.message'].with_user(self.user_employee).create({ 'model': record._name if record else False, 'res_id': record.id if record else False, - 'body': 'Test', - **msg_vals, + **final_vals, }) if record: with self.assertRaises(AccessError): record.with_user(self.user_employee).message_post( - body='Test', - subtype_id=self.env.ref('mail.mt_comment').id, + **final_vals, ) else: _message = self.env['mail.message'].with_user(self.user_employee).create({ 'model': record._name if record else False, 'res_id': record.id if record else False, - 'body': 'Test', - **msg_vals, + **final_vals, }) if record: # TDE note: due to parent_id flattening, doing message_post @@ -197,27 +210,57 @@ class TestMailMessageAccess(MessageAccessCommon): if record == self.record_admin and 'parent_id' in msg_vals: continue record.with_user(self.user_employee).message_post( - body='Test', - subtype_id=self.env.ref('mail.mt_comment').id, - **msg_vals, + **final_vals, ) def test_access_create_customized(self): - """ Test '_get_mail_message_access' support """ + """ Test '_mail_get_operation_for_mail_message_operation' support """ record = self.env['mail.test.access.custo'].with_user(self.user_employee).create({'name': 'Open'}) for user in self.user_employee + self.user_portal: - _message = record.message_post( - body='A message', - subtype_id=self.env.ref('mail.mt_comment').id, - ) - # lock -> see '_get_mail_message_access' + with self.subTest(user_name=user.name): + _message = record.with_user(user).message_post( + # attachments=[('Attachment', b'My attachment')], # FIXME + body='A message', + subtype_id=self.env.ref('mail.mt_comment').id, + ) + # lock -> see '_mail_get_operation_for_mail_message_operation' record.write({'is_locked': True}) + record.message_unsubscribe(partner_ids=self.partner_employee.ids) # avoid acl conflict with those follower-based + record.invalidate_model() for user in self.user_employee + self.user_portal: - with self.assertRaises(AccessError): - _message_portal = record.with_user(self.user_portal).message_post( + with self.subTest(user_name=user.name): + with self.assertRaises(AccessError): + _message = record.with_user(user).message_post( + body='Another portal message', + subtype_id=self.env.ref('mail.mt_comment').id, + ) + # readonly -> "read" access sufficient on unlocked records, see '_mail_get_operation_for_mail_message_operation' + record.sudo().write({'is_locked': False, 'is_readonly': True}) + record.invalidate_model() + for user in self.user_employee + self.user_portal: + with self.subTest(user_name=user.name): + # cannot write + with self.assertRaises(AccessError): + record.with_user(user).write({'name': 'Can Update'}) + # can post + _message = record.with_user(user).message_post( + # attachments=[('Attachment', b'My attachment')], # FIXME body='Another portal message', subtype_id=self.env.ref('mail.mt_comment').id, ) + # controller check + self.authenticate(user.login, user.login) + res = self.make_jsonrpc_request( + route="/mail/message/post", + params={ + 'thread_model': record._name, + 'thread_id': record.id, + 'post_data': { + 'body': "Test", + }, + }, + )['store_data'] + self.assertEqual(len(res['mail.message']), 1) def test_access_create_mail_post_access(self): """ Test 'mail_post_access' support that allows creating a message with @@ -270,6 +313,13 @@ class TestMailMessageAccess(MessageAccessCommon): (self.record_admin, { 'parent_id': admin_msg.id, }, False, 'No access on record but reply to notified parent'), + # internal = forbidden (internal users only) + (self.record_portal, {'is_internal': True}, True, 'Internal subtype always forbidden'), + (self.record_portal, {'is_internal': True, 'message_type': 'notification'}, False, 'Automatic log accepted'), + (self.record_portal, {'subtype_id': self.env.ref('mail.mt_note').id}, True, 'Internal flag always forbidden'), + (self.record_portal, {'subtype_id': self.test_subtype_access_internal.id}, True, 'Internal flag (custom subtype) always forbidden'), + (self.record_portal, {'message_type': 'notification', 'subtype_id': self.test_subtype_access_internal.id}, False, 'Automatic log accepted'), + (self.record_portal, {'subtype_id': False}, True, 'No subtype = internal = always forbidden'), ]: with self.subTest(record=record, msg_vals=msg_vals, reason=reason): if should_crash: @@ -278,6 +328,8 @@ class TestMailMessageAccess(MessageAccessCommon): 'model': record._name if record else False, 'res_id': record.id if record else False, 'body': 'Test', + 'message_type': 'comment', + 'subtype_id': self.env.ref('mail.mt_comment').id, **msg_vals, }) else: @@ -285,6 +337,8 @@ class TestMailMessageAccess(MessageAccessCommon): 'model': record._name if record else False, 'res_id': record.id if record else False, 'body': 'Test', + 'message_type': 'comment', + 'subtype_id': self.env.ref('mail.mt_comment').id, **msg_vals, }) @@ -294,6 +348,7 @@ class TestMailMessageAccess(MessageAccessCommon): 'model': self.record_portal._name, 'res_id': self.record_portal.id, 'body': 'Test', + 'subtype_id': self.env.ref('mail.mt_comment').id, }) @mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule') @@ -329,18 +384,18 @@ class TestMailMessageAccess(MessageAccessCommon): test_record.message_subscribe((partner_1 | self.user_admin.partner_id).ids) message = test_record.message_post( - body='

This is First Message

', + body=Markup('

This is First Message

'), message_type='comment', subject='Subject', subtype_xmlid='mail.mt_note', ) # portal user have no rights to read the message with self.assertRaises(AccessError): - message.with_user(self.user_portal).read(['subject, body']) + message.with_user(self.user_portal).read(['subject', 'body']) - with patch.object(MailTestSimple, 'check_access_rights', return_value=True): + with patch.object(MailTestSimple, '_check_access', return_value=None): with self.assertRaises(AccessError): - message.with_user(self.user_portal).read(['subject, body']) + message.with_user(self.user_portal).read(['subject', 'body']) # parent message is accessible to references notification mail values # for _notify method and portal user have no rights to send the message for this model @@ -368,8 +423,9 @@ class TestMailMessageAccess(MessageAccessCommon): # READ # - Criterions # - author + # - creator (might post on behalf of someone else) # - recipients / notified - # - document-based: read, using '_get_mail_message_access' + # - document-based: read, using '_mail_get_operation_for_mail_message_operation' # - share users: limited to 'not internal' (flag or subtype) # ------------------------------------------------------------ @@ -384,6 +440,9 @@ class TestMailMessageAccess(MessageAccessCommon): (self.record_admin.message_ids[0], { 'author_id': self.user_employee.partner_id.id, }, False, 'Author > no access on record'), + (self.record_admin.message_ids[0], { + 'create_uid': self.user_employee.id, + }, False, 'Creator > no access on record'), # notified (self.record_admin.message_ids[0], { 'notification_ids': [(0, 0, { @@ -400,7 +459,12 @@ class TestMailMessageAccess(MessageAccessCommon): 'parent_id': msg.parent_id.id, } with self.subTest(msg=msg, reason=reason): - if msg_vals: + if 'create_uid' in msg_vals: + self.patch(self.env.registry, 'ready', False) + msg.with_user(SUPERUSER_ID).write(msg_vals) + self.patch(self.env.registry, 'ready', True) + self.assertEqual(msg.create_uid.id, msg_vals['create_uid']) + elif msg_vals: msg.write(msg_vals) if should_crash: with self.assertRaises(AccessError): @@ -410,6 +474,35 @@ class TestMailMessageAccess(MessageAccessCommon): if msg_vals: msg.write(original_vals) + def test_access_read_customized(self): + """ Test '_mail_get_operation_for_mail_message_operation' support """ + records = self.env['mail.test.access.custo'].with_user(self.user_admin).create([ + {'name': 'Open'}, + {'name': 'Open RO', 'is_readonly': True}, + {'is_locked': True, 'name': 'Locked'}, + ]) + messages_all = self.env['mail.message'] + for record in records: + messages_all += record.with_user(self.user_admin).message_post( + body=f'AnchorForTest / A message from {self.user_admin.name} on {record.name}', + subtype_id=self.env.ref('mail.mt_comment').id, + ) + # lock -> see '_mail_get_operation_for_mail_message_operation', cannot read locked message + # without write access, with is not granted for employees + with self.assertRaises(AccessError): # write access not granted on locked -> cannot read message + messages_all[2].with_user(self.user_employee).read(['subject']) + with self.assertRaises(AccessError): # also working in case of batch ok / not ok + messages_all.with_user(self.user_employee).read(['subject']) + messages_all[0].with_user(self.user_employee).read(['subject']) + messages_all[1].with_user(self.user_employee).read(['subject']) # can read message of readonly + + with self.assertRaises(AccessError): # fetch should be symmetric to read + _message = messages_all[2].with_user(self.user_employee).copy_data() + + with self.assertRaises(AccessError): # no write access at all + messages_all.with_user(self.user_portal).read(['subject']) + messages_all.with_user(self.user_admin).read(['subject']) + def test_access_read_portal(self): """ Read access check for portal users """ for msg, msg_vals, should_crash, reason in [ @@ -426,17 +519,36 @@ class TestMailMessageAccess(MessageAccessCommon): 'res_partner_id': self.user_portal.partner_id.id, })], }, False, 'Notified > no access on record'), - # forbidden + # forbidden: internal (subtype / message) (self.record_portal.message_ids[0], { 'subtype_id': self.env.ref('mail.mt_note').id, - }, True, 'Note cannot be read by portal users'), + }, True, 'Note (comment) cannot be read by portal users'), + (self.record_portal.message_ids[0], { + 'subtype_id': self.test_subtype_access_internal.id, + }, True, 'Internal subtype (comment) cannot be read by portal users'), + (self.record_portal.message_ids[0], { + 'message_type': 'email_outgoing', + 'subtype_id': self.env.ref('mail.mt_note').id, + }, False, 'Note (email_outgoing) can be read by portal users'), + (self.record_portal.message_ids[0], { + 'subtype_id': False, + }, True, 'Pure log (no subtype, even comment) cannot be read by portal users'), (self.record_portal.message_ids[0], { 'is_internal': True, - }, True, 'Internal message cannot be read by portal users'), + }, True, 'Internal message (comment) cannot be read by portal users'), + (self.record_portal.message_ids[0], { + 'is_internal': True, + 'message_type': 'notification', + }, False, 'Internal message (notification) can be read by portal users'), + # forbidden: other + (self.record_portal.message_ids[0], { + 'message_type': 'user_notification', + }, True, 'User notifications for other people can never be read by portal users'), ]: original_vals = { 'author_id': msg.author_id.id, 'is_internal': False, + 'message_type': msg.message_type, 'notification_ids': [(6, 0, {})], 'parent_id': msg.parent_id.id, 'subtype_id': self.env.ref('mail.mt_comment').id, @@ -444,6 +556,8 @@ class TestMailMessageAccess(MessageAccessCommon): with self.subTest(msg=msg, reason=reason): if msg_vals: msg.write(msg_vals) + + self.env.invalidate_all() if should_crash: with self.assertRaises(AccessError): msg.with_user(self.user_portal).read(['body']) @@ -451,6 +565,7 @@ class TestMailMessageAccess(MessageAccessCommon): msg.with_user(self.user_portal).read(['body']) if msg_vals: msg.write(original_vals) + self.env.invalidate_all() def test_access_read_public(self): """ Read access check for public users """ @@ -479,6 +594,7 @@ class TestMailMessageAccess(MessageAccessCommon): original_vals = { 'author_id': msg.author_id.id, 'is_internal': False, + 'message_type': msg.message_type, 'notification_ids': [(6, 0, {})], 'parent_id': msg.parent_id.id, 'subtype_id': self.env.ref('mail.mt_comment').id, @@ -486,6 +602,8 @@ class TestMailMessageAccess(MessageAccessCommon): with self.subTest(msg=msg, reason=reason): if msg_vals: msg.write(msg_vals) + + self.env.invalidate_all() if should_crash: with self.assertRaises(AccessError): msg.with_user(self.user_public).read(['body']) @@ -493,10 +611,11 @@ class TestMailMessageAccess(MessageAccessCommon): msg.with_user(self.user_public).read(['body']) if msg_vals: msg.write(original_vals) + self.env.invalidate_all() # ------------------------------------------------------------ # UNLINK - # - Criterion: document-based (write or create), using '_get_mail_message_access' + # - Criterion: document-based (write or create), using '_mail_get_operation_for_mail_message_operation' # ------------------------------------------------------------ def test_access_unlink(self): @@ -548,7 +667,7 @@ class TestMailMessageAccess(MessageAccessCommon): # - Criterions # - author # - recipients / notified - # - document-based (write or create), using '_get_mail_message_access' + # - document-based (write or create), using '_mail_get_operation_for_mail_message_operation' # ------------------------------------------------------------ def test_access_write(self): @@ -590,13 +709,12 @@ class TestMailMessageAccess(MessageAccessCommon): """ Test updating message envelope require some privileges """ message = self.record_internal.with_user(self.user_employee).message_ids[0] message.write({'body': 'Update Me'}) - # To change in 18+ - message.write({'model': 'res.partner'}) - message.sudo().write({'model': self.record_internal._name}) # back to original model + with self.assertRaises(AccessError): + message.write({'model': 'res.partner'}) # To change in 18+ message.write({'partner_ids': [(4, self.user_portal_2.partner_id.id)]}) - # To change in 18+ - message.write({'res_id': self.record_public.id}) + with self.assertRaises(AccessError): + message.write({'res_id': self.record_public.id}) # To change in 18+ message.write({'notification_ids': [ (0, 0, {'res_partner_id': self.user_portal_2.partner_id.id}) @@ -672,12 +790,26 @@ class TestMailMessageAccess(MessageAccessCommon): res_id=self.record_portal.id, subtype_id=self.ref('mail.mt_comment'), )) + msg_record_portal_internal = self.env['mail.message'].create(dict(base_msg_vals, + body='Internal Comment on Portal', + is_internal=True, + model=self.record_portal._name, + res_id=self.record_portal.id, + subtype_id=self.ref('mail.mt_comment'), + )) msg_record_public = self.env['mail.message'].create(dict(base_msg_vals, body='Public Comment', model=self.record_public._name, res_id=self.record_public.id, subtype_id=self.ref('mail.mt_comment'), )) + msg_record_public_internal = self.env['mail.message'].create(dict(base_msg_vals, + body='Internal Comment on Public', + is_internal=True, + model=self.record_public._name, + res_id=self.record_public.id, + subtype_id=self.ref('mail.mt_comment'), + )) for (test_user, add_domain), exp_messages in zip([ (self.user_public, []), @@ -686,16 +818,60 @@ class TestMailMessageAccess(MessageAccessCommon): (self.user_employee, [('body', 'ilike', 'Internal')]), (self.user_admin, []), ], [ + # public: record with access msg_record_public, + # portal: mentionned + record with access, if published msgs[0] + msgs[3] + msg_record_portal + msg_record_public, - msgs[1:6] + msg_record_portal + msg_record_public, - msgs[1:6], - msgs[1:] + msg_record_admin + msg_record_portal + msg_record_public + # employee + msgs[1:6] + msg_record_portal + msg_record_portal_internal + msg_record_public + msg_record_public_internal, + msgs[1:6] + msg_record_portal_internal + msg_record_public_internal, + msgs[1:] + msg_record_admin + msg_record_portal + msg_record_portal_internal + msg_record_public + msg_record_public_internal, ]): with self.subTest(test_user=test_user.name, add_domain=add_domain): + self.env.invalidate_all() domain = [('subject', 'like', '_ZTest')] + add_domain self.assertEqual(self.env['mail.message'].with_user(test_user).search(domain), exp_messages) + def test_search_customized(self): + """ Test '_mail_get_operation_for_mail_message_operation' support in search """ + records = self.env['mail.test.access.custo'].with_user(self.user_admin).create([ + {'name': 'Open'}, + {'name': 'Open RO', 'is_readonly': True}, # internal can read thus search + {'name': 'Soonish Locked'}, + ]) + messages_all = self.env['mail.message'].sudo() + for user in self.user_employee + self.user_portal: + for record in records: + new = record.message_post( + body=f'AnchorForSearch / A message from {user.name}', + subtype_id=self.env.ref('mail.mt_comment').id, + ) + messages_all += new.sudo() + + found_emp = self.env['mail.message'].with_user(self.user_employee).search([ + ('body', 'ilike', 'AnchorForSearch') + ]) + self.assertEqual(found_emp, messages_all) + found_por = self.env['mail.message'].with_user(self.user_portal).search([ + ('body', 'ilike', 'AnchorForSearch') + ]) + self.assertEqual(found_por, messages_all) + + # lock -> locked records need 'write' access, as defined in '_mail_get_operation_for_mail_message_operation' + # hence messages are out of search, symmetrical to reading therm + records[2].write({'is_locked': True, 'name': 'Locked !'}) + records[2].flush_recordset() + found_emp = self.env['mail.message'].with_user(self.user_employee).search([ + ('body', 'ilike', 'AnchorForSearch') + ]) + self.assertEqual(found_emp, messages_all.filtered(lambda m: m.res_id != records[2].id), 'Should filter like read') + found_emp.read(['subject']) + found_por = self.env['mail.message'].with_user(self.user_portal).search([ + ('body', 'ilike', 'AnchorForSearch') + ]) + self.assertEqual(found_por, messages_all.filtered(lambda m: m.res_id != records[2].id), 'Should filter like read') + found_por.read(['subject']) + @tagged('mail_message', 'security', 'post_install', '-at_install') class TestMessageSubModelAccess(MessageAccessCommon): @@ -762,9 +938,11 @@ class TestMessageSubModelAccess(MessageAccessCommon): with self.assertRaises(AccessError): notif_own.write({'res_partner_id': self.user_admin.partner_id.id}) + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule') def test_mail_notification_portal(self): """ In any case, portal should not modify notifications """ - self.assertFalse(self.env['mail.notification'].with_user(self.user_portal).check_access_rights('write', raise_exception=False)) + with self.assertRaises(AccessError): + self.assertFalse(self.env['mail.notification'].with_user(self.user_portal).check_access('write')) portal_record = self.record_portal.with_user(self.user_portal) message = portal_record.message_post( body='Hello People', @@ -776,3 +954,17 @@ class TestMessageSubModelAccess(MessageAccessCommon): self.assertEqual(len(notifications), 2) self.assertTrue(bool(notifications.read(['is_read'])), 'Portal can read') self.assertEqual(notifications.res_partner_id, self.user_portal_2.partner_id + self.user_employee.partner_id) + + internal_record = self.record_internal.with_user(self.user_admin) + message = internal_record.message_post( + body='Hello People', + message_type='comment', + partner_ids=self.user_employee.partner_id.ids, + subtype_id=self.env.ref('mail.mt_comment').id, + ) + notifications = message.notification_ids.with_user(self.user_portal) + with self.assertRaises( + AccessError, + msg="Portal cannot read notifications unless they are the recipient or the author" + ): + notifications.read(['is_read']) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_multicompany.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_multicompany.py index be78d53..2205c9a 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_multicompany.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_multicompany.py @@ -1,28 +1,28 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 -import json import socket from itertools import product +from freezegun import freeze_time from unittest.mock import patch -from werkzeug.urls import url_parse, url_decode +from werkzeug.urls import url_parse -from odoo.addons.mail.models.mail_message import Message -from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients -from odoo.exceptions import AccessError, UserError +from odoo.addons.mail.models.mail_message import MailMessage +from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user +from odoo.addons.test_mail.models.test_mail_corner_case_models import MailTestMultiCompanyWithActivity +from odoo.addons.test_mail.tests.common import TestRecipients +from odoo.exceptions import AccessError from odoo.tests import tagged, users, HttpCase -from odoo.tools import formataddr, mute_logger +from odoo.tests.common import JsonRpcException +from odoo.tools import mute_logger -@tagged('multi_company') -class TestMultiCompanySetup(TestMailCommon, TestRecipients): +class TestMailMCCommon(MailCommon, TestRecipients): @classmethod def setUpClass(cls): - super(TestMultiCompanySetup, cls).setUpClass() - cls._activate_multi_company() + super().setUpClass() cls.test_model = cls.env['ir.model']._get('mail.test.gateway') cls.email_from = '"Sylvie Lelitre" ' @@ -38,17 +38,16 @@ class TestMultiCompanySetup(TestMailCommon, TestRecipients): 'company_id': cls.user_employee_c2.company_id.id}, ]) - cls.company_3 = cls.env['res.company'].create({'name': 'ELIT'}) cls.partner_1 = cls.env['res.partner'].with_context(cls._test_context).create({ 'name': 'Valid Lelitre', 'email': 'valid.lelitre@agrolait.com', }) # groups@.. will cause the creation of new mail.test.gateway - cls.alias = cls.env['mail.alias'].create({ - 'alias_name': 'groups', - 'alias_user_id': False, + cls.mail_alias = cls.env['mail.alias'].create({ + 'alias_contact': 'everyone', 'alias_model_id': cls.test_model.id, - 'alias_contact': 'everyone'}) + 'alias_name': 'groups', + }) # Set a first message on public group to test update and hierarchy cls.fake_email = cls.env['mail.message'].create({ @@ -61,11 +60,23 @@ class TestMultiCompanySetup(TestMailCommon, TestRecipients): 'message_id': '<123456-openerp-%s-mail.test.gateway@%s>' % (cls.test_record.id, socket.gethostname()), }) + cls._create_portal_user() + cls.user_portal_c2 = mail_new_test_user( + cls.env, + groups='base.group_portal', + login='portal_user_c2', + company_id=cls.company_2.id, + name="Portal User C2", + ) + def setUp(self): - super(TestMultiCompanySetup, self).setUp() + super().setUp() # patch registry to simulate a ready environment self.patch(self.env.registry, 'ready', True) - self.flush_tracking() + + +@tagged('multi_company') +class TestMultiCompanySetup(TestMailMCCommon, HttpCase): @users('employee_c2') @mute_logger('odoo.addons.base.models.ir_rule') @@ -85,26 +96,35 @@ class TestMultiCompanySetup(TestMailCommon, TestRecipients): with self.assertRaises(AccessError): test_record_c1.write({'name': 'Cannot Write'}) + first_attachment = self.env['ir.attachment'].create({ + 'company_id': self.user_employee_c2.company_id.id, + 'datas': base64.b64encode(b'First attachment'), + 'mimetype': 'text/plain', + 'name': 'TestAttachmentIDS.txt', + 'res_model': 'mail.compose.message', + 'res_id': 0, + }) + message = test_record_c1.message_post( - attachments=[('testAttachment', b'Test attachment')], + attachments=[('testAttachment', b'First attachment')], + attachment_ids=first_attachment.ids, body='My Body', message_type='comment', subtype_xmlid='mail.mt_comment', ) - self.assertEqual(message.attachment_ids.mapped('name'), ['testAttachment']) - first_attachment = message.attachment_ids + self.assertTrue('testAttachment' in message.attachment_ids.mapped('name')) self.assertEqual(test_record_c1.message_main_attachment_id, first_attachment) new_attach = self.env['ir.attachment'].create({ 'company_id': self.user_employee_c2.company_id.id, - 'datas': base64.b64encode(b'Test attachment'), + 'datas': base64.b64encode(b'Second attachment'), 'mimetype': 'text/plain', 'name': 'TestAttachmentIDS.txt', 'res_model': 'mail.compose.message', 'res_id': 0, }) message = test_record_c1.message_post( - attachments=[('testAttachment', b'Test attachment')], + attachments=[('testAttachment', b'Second attachment')], attachment_ids=new_attach.ids, body='My Body', message_type='comment', @@ -129,19 +149,19 @@ class TestMultiCompanySetup(TestMailCommon, TestRecipients): # Other company (no access) # ------------------------------------------------------------ - _original_car = Message.check_access_rule - with patch.object(Message, 'check_access_rule', + _original_car = MailMessage._check_access + with patch.object(MailMessage, '_check_access', autospec=True, side_effect=_original_car) as mock_msg_car: with self.assertRaises(AccessError): test_records_mc_c1.message_post( body='

Hello

', + force_record_name='CustomName', # avoid ACL on display_name message_type='comment', - record_name='CustomName', # avoid ACL on display_name reply_to='custom.reply.to@test.example.com', # avoid ACL in notify_get_reply_to subtype_xmlid='mail.mt_comment', ) - self.assertEqual(mock_msg_car.call_count, 1, - 'Purpose is to raise at msg check access level') + self.assertEqual(mock_msg_car.call_count, 2, + 'Check at model level succeeds and check at record level fails') with self.assertRaises(AccessError): _name = test_records_mc_c1.name @@ -163,26 +183,17 @@ class TestMultiCompanySetup(TestMailCommon, TestRecipients): # now able to post as was notified of parent message test_records_mc_c1.message_post( body='

Hello

', + force_record_name='CustomName', # avoid ACL on display_name message_type='comment', parent_id=initial_message.id, - record_name='CustomName', # avoid ACL on display_name reply_to='custom.reply.to@test.example.com', # avoid ACL in notify_get_reply_to subtype_xmlid='mail.mt_comment', ) - # now able to post as was notified of parent message - attachments = self.env['ir.attachment'].create( - self._generate_attachments_data( - 2, 'mail.compose.message', 0, - prefix='Other' - ) - ) # record_name and reply_to may generate ACLs issues when computed by # 'message_post' but should not, hence not specifying them to be sure # testing the complete flow test_records_mc_c1.message_post( - attachments=attachments_data, - attachment_ids=attachments.ids, body='

Hello

', message_type='comment', parent_id=initial_message.id, @@ -204,8 +215,8 @@ class TestMultiCompanySetup(TestMailCommon, TestRecipients): attachments=attachments_data, attachment_ids=attachments.ids, body='

Hello

', + force_record_name='CustomName', # avoid ACL on display_name message_type='comment', - record_name='CustomName', # avoid ACL on display_name reply_to='custom.reply.to@test.example.com', # avoid ACL in notify_get_reply_to subtype_xmlid='mail.mt_comment', ) @@ -238,71 +249,106 @@ class TestMultiCompanySetup(TestMailCommon, TestRecipients): attachments=attachments_data, attachment_ids=attachments.ids, body='

Hello

', + force_record_name='CustomName', # avoid ACL on display_name message_type='comment', - record_name='CustomName', # avoid ACL on display_name reply_to='custom.reply.to@test.example.com', # avoid ACL in notify_get_reply_to subtype_xmlid='mail.mt_comment', ) - def test_systray_get_activities(self): - self.env["mail.activity"].search([]).unlink() - user_admin = self.user_admin.with_user(self.user_admin) - test_records = self.env["mail.test.multi.company.with.activity"].create( - [ - {"name": "Test1", "company_id": user_admin.company_id.id}, - {"name": "Test2", "company_id": self.company_2.id}, - ] - ) - test_records[0].activity_schedule("test_mail.mail_act_test_todo", user_id=user_admin.id) - test_records[1].activity_schedule("test_mail.mail_act_test_todo", user_id=user_admin.id) - test_activity = next( - a for a in user_admin.systray_get_activities() - if a['model'] == 'mail.test.multi.company.with.activity' - ) - self.assertEqual( - test_activity, - { - "actions": [{"icon": "fa-clock-o", "name": "Summary"}], - "icon": "/base/static/description/icon.png", - "id": self.env["ir.model"]._get_id("mail.test.multi.company.with.activity"), - "model": "mail.test.multi.company.with.activity", - "name": "Test Multi Company Mail With Activity", - "overdue_count": 0, - "planned_count": 0, - "today_count": 2, - "total_count": 2, - "type": "activity", - } - ) - - test_activity = next( - a for a in user_admin.with_context(allowed_company_ids=[self.company_2.id]).systray_get_activities() - if a['model'] == 'mail.test.multi.company.with.activity' - ) - self.assertEqual( - test_activity, - { - "actions": [{"icon": "fa-clock-o", "name": "Summary"}], - "icon": "/base/static/description/icon.png", - "id": self.env["ir.model"]._get_id("mail.test.multi.company.with.activity"), - "model": "mail.test.multi.company.with.activity", - "name": "Test Multi Company Mail With Activity", - "overdue_count": 0, - "planned_count": 0, - "today_count": 1, - "total_count": 1, - "type": "activity", - } - ) + def test_recipients_multi_company(self): + """Test mentioning a partner with no common company.""" + test_records_mc_c2 = self.test_records_mc[1] + with self.assertBus([(self.cr.dbname, "res.partner", self.user_employee_c3.partner_id.id)]): + test_records_mc_c2.with_user(self.user_employee_c2).with_context( + allowed_company_ids=self.company_2.ids + ).message_post( + body="Hello @Freudenbergerg", + message_type="comment", + partner_ids=self.user_employee_c3.partner_id.ids, + subtype_xmlid="mail.mt_comment", + ) @tagged('-at_install', 'post_install', 'multi_company', 'mail_controller') -class TestMultiCompanyRedirect(TestMailCommon, HttpCase): +class TestMultiCompanyControllers(TestMailMCCommon, HttpCase): - @classmethod - def setUpClass(cls): - super(TestMultiCompanyRedirect, cls).setUpClass() - cls._activate_multi_company() + @mute_logger('odoo.http') + def test_mail_thread_data(self): + """ Test returned thread data, in MC environment, to test notably MC + access issues on partner, ACL support, ... """ + customer_c3 = self.env["res.partner"].create({ + "company_id": self.company_3.id, + "name": "C3 Customer", + }) + record = self.env["mail.test.multi.company.read"].with_user(self.user_employee_c2).create({ + "company_id": self.user_employee_c2.company_id.id, + "name": "Multi Company Record", + }) + self.assertEqual(record.company_id, self.company_2) + + record.message_subscribe(partner_ids=customer_c3.ids) + with self.assertRaises(AccessError): + customer_c3.with_user(self.user_employee_c2).check_access("read") + + self.authenticate(self.user_employee_c2.login, self.user_employee_c2.login) + result = self.make_jsonrpc_request( + "/mail/data", {"fetch_params": [["mail.thread", { + "thread_id": record.id, + "thread_model": record._name, + "request_list": ["followers"], + }]]}, + ) + self.assertEqual(len(result["mail.followers"]), 2) + self.assertEqual(result["mail.followers"][0]["partner_id"], customer_c3.id) + self.assertEqual(result["mail.thread"][0]["followersCount"], 2) + self.assertTrue(result["mail.thread"][0]["hasWriteAccess"]) + self.assertTrue(result["mail.thread"][0]["hasReadAccess"]) + self.assertTrue(result["mail.thread"][0]["canPostOnReadonly"]) + + # check read / write / post access info + for test_user, (has_w, has_r, can_post) in zip( + (self.user_portal, self.user_portal_c2, self.user_employee, self.user_admin), + ( + (False, True, True), # currently not really supported actually, should go through portal controllers + (False, True, True), # currently not really supported actually, should go through portal controllers + (False, True, True), + (True, True, True), + ), + ): + with self.subTest(user_name=test_user.name): + self.authenticate(test_user.login, test_user.login) + # crash if calling using portal users -> dedicated portal routes currently + if test_user in self.user_portal + self.user_portal_c2: + with self.assertRaises(JsonRpcException): + result = self.make_jsonrpc_request( + "/mail/data", {"fetch_params": [["mail.thread", { + "thread_id": record.id, + "thread_model": record._name, + "request_list": ["followers"], + }]]}, + ) + else: + result = self.make_jsonrpc_request( + "/mail/data", {"fetch_params": [["mail.thread", { + "thread_id": record.id, + "thread_model": record._name, + "request_list": ["followers"], + }]]}, + ) + self.assertEqual(result["mail.thread"][0]["followersCount"], 2) + self.assertEqual(result["mail.thread"][0]["hasWriteAccess"], has_w) + self.assertEqual(result["mail.thread"][0]["hasReadAccess"], has_r) + self.assertEqual(result["mail.thread"][0]["canPostOnReadonly"], can_post) + + record.with_user(self.user_admin).message_post( + body='Hello!', + message_type='comment', + subtype_xmlid='mail.mt_comment', + partner_ids=[self.partner_employee_c2.id, customer_c3.id], + ) + self.authenticate(self.user_employee_c2.login, self.user_employee_c2.login) + messages = self.make_jsonrpc_request("/mail/inbox/messages") + self.assertEqual(len(messages['data']['mail.message']), 1) def test_redirect_to_records(self): """ Test mail/view redirection in MC environment, notably cids being @@ -336,8 +382,7 @@ class TestMultiCompanyRedirect(TestMailCommon, HttpCase): if not login: path = url_parse(response.url).path self.assertEqual(path, '/web/login') - decoded_fragment = url_decode(url_parse(response.url).fragment) - self.assertNotIn("cids", decoded_fragment) + self.assertNotIn('cids', response.request._cookies) else: user = self.env['res.users'].browse(self.session.uid) self.assertEqual(user.login, login) @@ -346,19 +391,46 @@ class TestMultiCompanyRedirect(TestMailCommon, HttpCase): # Logged into company main, try accessing record in other # company -> _redirect_to_record should redirect to # messaging as the user doesn't have any access - fragment = url_parse(response.url).fragment - action = url_decode(fragment)['action'] - self.assertEqual(action, 'mail.action_discuss') + parsed_url = url_parse(response.url) + self.assertEqual(parsed_url.path, '/odoo/action-mail.action_discuss') else: # Logged into company main, try accessing record in same # company -> _redirect_to_record should add company in # allowed_company_ids - fragment = url_parse(response.url).fragment - cids = url_decode(fragment)['cids'] + cids = response.request._cookies.get('cids') if mc_record.company_id == user.company_id: self.assertEqual(cids, f'{mc_record.company_id.id}') else: - self.assertEqual(cids, f'{user.company_id.id},{mc_record.company_id.id}') + self.assertEqual(cids, f'{user.company_id.id}-{mc_record.company_id.id}') + + def test_multi_redirect_to_records(self): + """ Test mail/view redirection in MC environment, notably test a user that is + redirected multiple times, the cids needed to access the record are being added + recursivelly when in redirect.""" + mc_records = self.env['mail.test.multi.company'].create([ + { + 'company_id': self.user_employee.company_id.id, + 'name': 'Multi Company Record', + }, + { + 'company_id': self.user_employee_c2.company_id.id, + 'name': 'Multi Company Record', + } + ]) + + self.authenticate(self.user_admin.login, self.user_admin.login) + companies = [] + for mc_record in mc_records: + with self.subTest(mc_record=mc_record): + response = self.url_open( + f'/mail/view?model={mc_record._name}&res_id={mc_record.id}', + timeout=15 + ) + self.assertEqual(response.status_code, 200) + + cids = response.request._cookies.get('cids') + companies.append(str(mc_record.company_id.id)) + self.assertEqual(cids, '-'.join(companies)) def test_redirect_to_records_nothread(self): """ Test no thread models and redirection """ @@ -372,10 +444,10 @@ class TestMultiCompanyRedirect(TestMailCommon, HttpCase): # when being logged, cids should be based on current user's company unless # there is an access issue (not tested here, see 'test_redirect_to_records') - self.authenticate(self.user_admin.login, self.user_admin.login) for test_record in nothreads: for user_company in self.company_admin, self.company_2: with self.subTest(record_name=test_record.name, user_company=user_company): + self.authenticate(self.user_admin.login, self.user_admin.login) self.user_admin.write({'company_id': user_company.id}) response = self.url_open( f'/mail/view?model={test_record._name}&res_id={test_record.id}', @@ -383,9 +455,8 @@ class TestMultiCompanyRedirect(TestMailCommon, HttpCase): ) self.assertEqual(response.status_code, 200) - decoded_fragment = url_decode(url_parse(response.url).fragment) - self.assertTrue("cids" in decoded_fragment) - self.assertEqual(decoded_fragment['cids'], str(user_company.id)) + self.assertTrue('cids' in response.request._cookies) + self.assertEqual(response.request._cookies.get('cids'), str(user_company.id)) # when being not logged, cids should not be added as redirection after # logging will be 'mail/view' again @@ -397,40 +468,41 @@ class TestMultiCompanyRedirect(TestMailCommon, HttpCase): timeout=15 ) self.assertEqual(response.status_code, 200) - decoded_fragment = url_decode(url_parse(response.url).fragment) - self.assertNotIn('cids', decoded_fragment) + self.assertNotIn('cids', response.request._cookies) + def test_mail_message_post_other_company_with_cids(self): + """ + Ensure that a user can post a message on a thread belonging to another + company when: -@tagged("-at_install", "post_install", "multi_company", "mail_controller") -class TestMultiCompanyThreadData(TestMailCommon, HttpCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls._activate_multi_company() + - The user has access to both companies via `company_ids`. + - The active company context only includes the other company. + - The target record belongs to a different company than the active one. - def test_mail_thread_data_follower(self): - partner_portal = self.env["res.partner"].create( - {"company_id": self.company_3.id, "name": "portal partner"} - ) - record = self.env["mail.test.multi.company"].create({"name": "Multi Company Record"}) - record.message_subscribe(partner_ids=partner_portal.ids) - with self.assertRaises(UserError): - partner_portal.with_user(self.user_employee_c2).check_access_rule("read") - self.authenticate(self.user_employee_c2.login, self.user_employee_c2.login) - response = self.url_open( - url="/mail/thread/data", - headers={"Content-Type": "application/json"}, - data=json.dumps( - { - "params": { - "thread_id": record.id, - "thread_model": "mail.test.multi.company", - "request_list": ["followers"], - } - }, - ), - ) - self.assertEqual(response.status_code, 200) - followers = json.loads(response.content)["result"]["followers"] - self.assertEqual(len(followers), 1) - self.assertEqual(followers[0]["partner"]["id"], partner_portal.id) + This reproduces the scenario where a user receives a notification from a + record in Company A while being active in Company B, and attempts to reply + from the inbox. + """ + self.user_employee_c2.write({'company_ids': [(6, 0, [self.user_employee.company_id.id, self.company_2.id])]}) + record_c1 = self.env["mail.test.multi.company"].sudo().create({ + "name": "Thread in C1", + "company_id": self.user_employee.company_id.id, # company 1 + }) + self.authenticate('employee_c2', 'employee_c2') + self.opener.cookies.set('cids', str(self.company_2.id)) + payload = { + "thread_model": record_c1._name, + "thread_id": record_c1.id, + "post_data": { + "body": "

Reply from inbox

", + "message_type": "comment", + "subtype_xmlid": "mail.mt_comment", + }, + "context": { + "allowed_company_ids": self.company_2.ids, + } + } + result = self.make_jsonrpc_request("/mail/message/post", payload) + message_data = result["store_data"]["mail.message"][0] + self.assertEqual(message_data["body"], ["markup", "

Reply from inbox

"]) + self.assertTrue(record_c1.message_ids.filtered(lambda m: m.id == message_data["id"])) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_push.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_push.py new file mode 100644 index 0000000..8c8d619 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_push.py @@ -0,0 +1,708 @@ +import json +import socket + +from datetime import datetime, timedelta + +import odoo + +from odoo.tools.misc import mute_logger +from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.mail.tools.jwt import InvalidVapidError +from odoo.addons.mail.tools.web_push import ENCRYPTION_BLOCK_OVERHEAD, ENCRYPTION_HEADER_SIZE +from odoo.addons.sms.tests.common import SMSCommon +from odoo.addons.test_mail.data.test_mail_data import MAIL_TEMPLATE +from odoo.tests import tagged +from markupsafe import Markup +from unittest.mock import patch +from types import SimpleNamespace + + +@tagged('post_install', '-at_install', 'mail_push') +class TestWebPushNotification(SMSCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user_email = cls.user_employee + cls.user_email.notification_type = 'email' + + cls.user_inbox = mail_new_test_user( + cls.env, login='user_inbox', groups='base.group_user', name='User Inbox', + notification_type='inbox' + ) + + cls.record_simple = cls.env['mail.test.simple'].with_context(cls._test_context).create({ + 'name': 'Test', + 'email_from': 'ignasse@example.com' + }) + cls.record_simple.message_subscribe(partner_ids=[ + cls.user_email.partner_id.id, + cls.user_inbox.partner_id.id, + ]) + cls.alias_gateway = cls.env['mail.alias'].create({ + 'alias_contact': 'everyone', + 'alias_domain': cls.mail_alias_domain.id, + 'alias_model_id': cls.env['ir.model']._get_id('mail.test.gateway.company'), + 'alias_name': 'alias.gateway', + }) + + # generate keys and devices + cls.vapid_public_key = cls.env['mail.push.device'].get_web_push_vapid_public_key() + cls.env['mail.push.device'].sudo().create([ + { + 'endpoint': f'https://test.odoo.com/webpush/user{(idx + 1)}', + 'expiration_time': None, + 'keys': json.dumps({ + 'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A', + 'auth': 'DJFdtAgZwrT6yYkUMgUqow' + }), + 'partner_id': user.partner_id.id, + } for idx, user in enumerate(cls.user_email + cls.user_inbox) + ]) + + def _trigger_cron_job(self): + self.env.ref('mail.ir_cron_web_push_notification').method_direct_trigger() + + def _assert_notification_count_for_cron(self, number_of_notification): + notification_count = self.env['mail.push'].search_count([]) + self.assertEqual(notification_count, number_of_notification) + + @patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point') + @mute_logger('odoo.tests') + def test_notify_by_push(self, push_to_end_point): + """ When posting a comment, notify both inbox and people outside of Odoo + aka email """ + self.record_simple.with_user(self.user_admin).message_post( + body=Markup('

Hello

'), + message_type='comment', + partner_ids=(self.user_email + self.user_inbox).partner_id.ids, + subtype_xmlid='mail.mt_comment', + ) + # not using cron, as max 1 push notif -> direct send + self._assert_notification_count_for_cron(0) + # two recipients, comment notifies both inbox and email people + self.assertEqual(push_to_end_point.call_count, 2) + + @patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point') + def test_notify_by_push_channel(self, push_to_end_point): + """ Test various use case with discuss.channel. Chat and group channels + sends push notifications, channel not. """ + chat_channel, channel_channel, group_channel = self.env['discuss.channel'].with_user(self.user_email).create([ + { + 'channel_partner_ids': [ + (4, self.user_email.partner_id.id), + (4, self.user_inbox.partner_id.id), + ], + 'channel_type': channel_type, + 'name': f'{channel_type} Message' if channel_type != 'group' else '', + } for channel_type in ['chat', 'channel', 'group'] + ]) + group_channel._add_members(guests=self.guest) + + for channel, sender, notification_count in zip( + (chat_channel + channel_channel + group_channel + group_channel), + (self.user_email, self.user_email, self.user_email, self.guest), + (1, 0, 1, 2), + ): + with self.subTest(channel_type=channel.channel_type): + if sender == self.guest: + channel_as_sender = channel.with_user(self.env.ref('base.public_user')).with_context(guest=sender) + else: + channel_as_sender = channel.with_user(self.user_email) + # sudo: discuss.channel - guest can post as sudo in a test (simulating RPC without using network) + channel_as_sender.sudo().message_post( + body='Test Push', + message_type='comment', + subtype_xmlid='mail.mt_comment', + ) + self.assertEqual(push_to_end_point.call_count, notification_count) + if notification_count > 0: + payload_value = json.loads(push_to_end_point.call_args.kwargs['payload']) + if channel.channel_type == 'chat': + self.assertEqual(payload_value['title'], f'{self.user_email.name}') + elif channel.channel_type == 'group': + self.assertIn(self.user_email.name, payload_value['title']) + self.assertIn(self.user_inbox.name, payload_value['title']) + self.assertIn(self.guest.name, payload_value['title']) + self.assertNotIn("False", payload_value['title']) + else: + self.assertEqual(payload_value['title'], f'#{channel.name}') + icon = ( + '/web/static/img/odoo-icon-192x192.png' + if sender == self.guest + else f'/web/image/res.partner/{self.user_email.partner_id.id}/avatar_128' + ) + self.assertEqual(payload_value['options']['icon'], icon) + self.assertEqual(payload_value['options']['body'], 'Test Push') + self.assertEqual(payload_value['options']['data']['res_id'], channel.id) + self.assertEqual(payload_value['options']['data']['model'], channel._name) + self.assertEqual(push_to_end_point.call_args.kwargs['device']['endpoint'], 'https://test.odoo.com/webpush/user2') + push_to_end_point.reset_mock() + + # Test Direct Message with channel muted -> should skip push notif + now = datetime.now() + self.env['discuss.channel.member'].search([ + ('partner_id', 'in', (self.user_email.partner_id + self.user_inbox.partner_id).ids), + ('channel_id', 'in', (chat_channel + channel_channel + group_channel).ids), + ]).write({ + 'mute_until_dt': now + timedelta(days=5) + }) + chat_channel.with_user(self.user_email).message_post( + body='Test', + message_type='comment', + subtype_xmlid='mail.mt_comment', + ) + push_to_end_point.assert_not_called() + push_to_end_point.reset_mock() + + self.env["discuss.channel.member"].search([ + ("partner_id", "in", (self.user_email.partner_id + self.user_inbox.partner_id).ids), + ("channel_id", "in", (chat_channel + channel_channel + group_channel).ids), + ]).write({ + "mute_until_dt": False, + }) + + # Test Channel Message + group_channel.with_user(self.user_email).message_post( + body='Test', + partner_ids=self.user_inbox.partner_id.ids, + message_type='comment', + subtype_xmlid='mail.mt_comment', + ) + push_to_end_point.assert_called_once() + + @patch.object(odoo.addons.mail.models.mail_thread, "push_to_end_point") + def test_notify_by_push_channel_with_channel_notifications_settings(self, push_to_end_point): + """ Test various use case with the channel notification settings.""" + all_test_user = mail_new_test_user( + self.env, + login="all", + name="all", + email="all@example.com", + notification_type="inbox", + groups="base.group_user", + ) + mentions_test_user = mail_new_test_user( + self.env, + login="mentions", + name="mentions", + email="mentions@example.com", + notification_type="inbox", + groups="base.group_user", + ) + nothing_test_user = mail_new_test_user( + self.env, + login="nothing", + name="nothing", + email="nothing@example.com", + notification_type="inbox", + groups="base.group_user", + ) + all_test_user.res_users_settings_ids.write({"channel_notifications": "all"}) + nothing_test_user.res_users_settings_ids.write({"channel_notifications": "no_notif"}) + + # generate devices + self.env["mail.push.device"].sudo().create( + [ + { + "endpoint": f"https://test.odoo.com/webpush/user{(idx + 20)}", + "expiration_time": None, + "keys": json.dumps( + { + "p256dh": "BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A", + "auth": "DJFdtAgZwrT6yYkUMgUqow", + } + ), + "partner_id": user.partner_id.id, + } + for idx, user in enumerate(all_test_user + mentions_test_user + nothing_test_user) + ] + ) + + channel_channel = self.env["discuss.channel"].with_user(self.user_email).create( + [ + { + "channel_partner_ids": [ + (4, self.user_email.partner_id.id), + (4, all_test_user.partner_id.id), + (4, mentions_test_user.partner_id.id), + (4, nothing_test_user.partner_id.id), + ], + "channel_type": "channel", + "name": "channel", + } + ] + ) + # normal messages in channel + channel_channel.with_user(self.user_email).message_post( + body="Test Push", + message_type="comment", + subtype_xmlid="mail.mt_comment", + ) + push_to_end_point.assert_called_once() + # all_test_user should be notified + self.assertEqual(push_to_end_point.call_args.kwargs["device"]["endpoint"], "https://test.odoo.com/webpush/user20") + push_to_end_point.reset_mock() + + # mention messages in channel + channel_channel.with_user(self.user_email).message_post( + body="Test Push @mentions", + message_type="comment", + partner_ids=(all_test_user + mentions_test_user + nothing_test_user).partner_id.ids, + subtype_xmlid="mail.mt_comment", + ) + self.assertEqual(push_to_end_point.call_count, 2) + # all_test_user and mentions_test_user should be notified + self.assertEqual(push_to_end_point.call_args_list[0].kwargs["device"]["endpoint"], "https://test.odoo.com/webpush/user20") + self.assertEqual(push_to_end_point.call_args_list[1].kwargs["device"]["endpoint"], "https://test.odoo.com/webpush/user21") + push_to_end_point.reset_mock() + + # muted channel + now = datetime.now() + self.env["discuss.channel.member"].search( + [ + ("partner_id", "in", (all_test_user.partner_id + mentions_test_user.partner_id + nothing_test_user.partner_id).ids), + ] + ).write( + { + "mute_until_dt": now + timedelta(days=5), + } + ) + # normal messages in channel + channel_channel.with_user(self.user_email).message_post( + body="Test Push", + message_type="comment", + subtype_xmlid="mail.mt_comment", + ) + push_to_end_point.assert_not_called() + # mention messages in channel + channel_channel.with_user(self.user_email).message_post( + body="Test Push", + message_type="comment", + subtype_xmlid="mail.mt_comment", + ) + push_to_end_point.assert_not_called() + + @mute_logger('odoo.addons.mail.models.mail_thread') + def test_notify_by_push_mail_gateway(self): + """ Check mail gateway push notifications """ + with self.mock_mail_gateway(): + test_record = self.format_and_process( + MAIL_TEMPLATE, self.user_email.email_formatted, + f'{self.alias_gateway.display_name}, {self.user_inbox.email_formatted}', + subject='Test Record Creation', + target_model='mail.test.gateway.company', + ) + self.assertEqual(len(test_record.message_ids), 1) + self.assertEqual(test_record.message_partner_ids, self.user_email.partner_id) + test_record.message_subscribe(partner_ids=[self.user_inbox.partner_id.id]) + + for include_as_external, has_notif in ((False, True), (True, False)): + with self.mock_mail_gateway(): + to = f'{self.alias_gateway.display_name}' + if include_as_external: + to += f', {self.user_inbox.email_formatted}' + self.format_and_process( + MAIL_TEMPLATE, self.user_email.email_formatted, to, + subject='Repy By Email', + extra=f'In-Reply-To:\r\n\t{test_record.message_ids[-1].message_id}\n', + ) + if has_notif: + # user_inbox is notified by Odoo, hence receives a push notification + self.assertPushNotification( + mail_push_count=0, title_content=self.user_email.name, + body_content='Please call me as soon as possible this afternoon!\n\n--\nSylvie', + ) + else: + self.assertNoPushNotification() + + @mute_logger('odoo.tests') + def test_notify_by_push_message_notify(self): + """ In case of notification, only inbox users are notified """ + for recipient, has_notification in [(self.user_email, False), (self.user_inbox, True)]: + with self.subTest(recipient=recipient): + with self.mock_mail_gateway(): + self.record_simple.with_user(self.user_admin).message_notify( + body='Test Push Body', + partner_ids=recipient.partner_id.ids, + subject='Test Push Notification', + ) + # not using cron, as max 1 push notif -> direct send + self._assert_notification_count_for_cron(0) + if has_notification: + self.assertPushNotification( + mail_push_count=0, + endpoint='https://test.odoo.com/webpush/user2', keys=('vapid_private_key', 'vapid_public_key'), + title=f'{self.user_admin.name}: {self.record_simple.display_name}', + body_content='Test Push Body', + options={ + 'data': {'model': self.record_simple._name, 'res_id': self.record_simple.id,}, + }, + ) + else: + self.assertNoPushNotification() + + @patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point') + @mute_logger('odoo.tests') + def test_notify_call_invitation(self, push_to_end_point): + inviting_user = self.env['res.users'].sudo().create({'name': "Test User", 'login': 'test'}) + channel = self.env['discuss.channel'].with_user(inviting_user)._get_or_create_chat( + partners_to=[self.user_email.partner_id.id]) + inviting_channel_member = channel.sudo().channel_member_ids.filtered( + lambda channel_member: channel_member.partner_id == inviting_user.partner_id) + + inviting_channel_member._rtc_join_call() + push_to_end_point.assert_called_once() + payload_value = json.loads(push_to_end_point.call_args.kwargs['payload']) + self.assertEqual( + payload_value['title'], + "Incoming call", + ) + options = payload_value['options'] + self.assertTrue(options['requireInteraction']) + self.assertEqual(options['body'], f"Conference: {channel.name}") + self.assertEqual(options['actions'], [ + { + "action": "DECLINE", + "type": "button", + "title": "Decline", + }, + { + "action": "ACCEPT", + "type": "button", + "title": "Accept", + }, + ]) + data = options['data'] + self.assertEqual(data['type'], "CALL") + self.assertEqual(data['res_id'], channel.id) + self.assertEqual(data['model'], "discuss.channel") + push_to_end_point.reset_mock() + + inviting_channel_member._rtc_leave_call() + push_to_end_point.assert_called_once() + payload_value = json.loads(push_to_end_point.call_args.kwargs['payload']) + self.assertEqual(payload_value['options']['data']['type'], "CANCEL") + push_to_end_point.reset_mock() + + @patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point') + def test_notify_by_push_tracking(self, push_to_end_point): + """ Test tracking message included in push notifications """ + container_update_subtype = self.env.ref('test_mail.st_mail_test_ticket_container_upd') + ticket = self.env['mail.test.ticket'].with_user(self.user_email).create({ + 'name': 'Test', + }) + ticket.message_subscribe( + partner_ids=[self.user_email.partner_id.id], + subtype_ids=[container_update_subtype.id], + ) + + container = self.env['mail.test.container'].create({'name': 'Container'}) + ticket.write({ + 'name': 'Test2', + 'email_from': 'noone@example.com', + 'container_id': container.id, + }) + self.flush_tracking() + self._assert_notification_count_for_cron(0) + push_to_end_point.assert_not_called() + + container2 = self.env['mail.test.container'].create({'name': 'Container Two'}) + ticket.message_subscribe( + partner_ids=[self.user_inbox.partner_id.id], + subtype_ids=[container_update_subtype.id], + ) + ticket.write({ + 'name': 'Test3', + 'email_from': 'noone@example.com', + 'container_id': container2.id, + }) + self.flush_tracking() + self._assert_notification_count_for_cron(0) + push_to_end_point.assert_called_once() + payload_value = json.loads(push_to_end_point.call_args.kwargs['payload']) + self.assertIn( + f'{container_update_subtype.description}\nContainer: {container.name} → {container2.name}', + payload_value['options']['body'], + 'Tracking changes should be included in push notif payload' + ) + + @patch.object(odoo.addons.mail.models.mail_push, 'push_to_end_point') + def test_push_notifications_cron(self, push_to_end_point): + # Add 4 more devices to force sending via cron queue + for index in range(10, 14): + self.env['mail.push.device'].sudo().create([{ + 'endpoint': 'https://test.odoo.com/webpush/user%d' % index, + 'expiration_time': None, + 'keys': json.dumps({ + 'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A', + 'auth': 'DJFdtAgZwrT6yYkUMgUqow' + }), + 'partner_id': self.user_inbox.partner_id.id, + }]) + + self.record_simple.with_user(self.user_email).message_notify( + partner_ids=self.user_inbox.partner_id.ids, + body='Test message send via Web Push', + subject='Test Activity', + ) + + self._assert_notification_count_for_cron(5) + # Force the execution of the cron + self._trigger_cron_job() + self.assertEqual(push_to_end_point.call_count, 5) + + @patch.object(odoo.addons.mail.models.mail_thread.Session, 'post', + return_value=SimpleNamespace(**{'status_code': 404, 'text': 'Device Unreachable'})) + def test_push_notifications_error_device_unreachable(self, post): + with mute_logger('odoo.addons.mail.tools.web_push'): + self.record_simple.with_user(self.user_email).message_notify( + partner_ids=self.user_inbox.partner_id.ids, + body='Test message send via Web Push', + subject='Test Activity', + ) + + self._assert_notification_count_for_cron(0) + post.assert_called_once() + # Test that the unreachable device is deleted from the DB + notification_count = self.env['mail.push.device'].search_count([('endpoint', '=', 'https://test.odoo.com/webpush/user2')]) + self.assertEqual(notification_count, 0) + + @patch.object(odoo.addons.mail.models.mail_thread.Session, 'post', + return_value=SimpleNamespace(**{'status_code': 201, 'text': 'Ok'})) + def test_push_notifications_error_encryption_simple(self, post): + """ Test to see if all parameters sent to the endpoint are present. + This test doesn't test if the cryptographic values are correct. """ + self.record_simple.with_user(self.user_email).message_notify( + partner_ids=self.user_inbox.partner_id.ids, + body='Test message send via Web Push', + subject='Test Activity', + ) + + self._assert_notification_count_for_cron(0) + post.assert_called_once() + self.assertEqual(post.call_args.args[0], 'https://test.odoo.com/webpush/user2') + self.assertIn('headers', post.call_args.kwargs) + self.assertIn('vapid', post.call_args.kwargs['headers']['Authorization']) + self.assertIn('t=', post.call_args.kwargs['headers']['Authorization']) + self.assertIn('k=', post.call_args.kwargs['headers']['Authorization']) + self.assertEqual('aes128gcm', post.call_args.kwargs['headers']['Content-Encoding']) + self.assertEqual('60', post.call_args.kwargs['headers']['TTL']) + self.assertIn('data', post.call_args.kwargs) + self.assertIn('timeout', post.call_args.kwargs) + + @patch.object(odoo.addons.mail.models.mail_thread.Session, 'post', + return_value=SimpleNamespace(status_code=201, text='Ok')) + def test_push_notifications_device_invalid_tld_domain(self, post): + self.env['mail.push.device'].sudo().create([{ + 'endpoint': 'https://test.odoo.invalid/webpush/user', + 'expiration_time': None, + 'keys': json.dumps({ + 'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A', + 'auth': 'DJFdtAgZwrT6yYkUMgUqow' + }), + 'partner_id': self.user_inbox.partner_id.id, + }]) + + device_count = self.env['mail.push.device'].search_count([('endpoint', '=', 'https://test.odoo.invalid/webpush/user')]) + self.assertEqual(device_count, 1) + + self.record_simple.with_user(self.user_email).message_notify( + partner_ids=self.user_inbox.partner_id.ids, + body='Test message send via Web Push', + subject='Test Activity', + ) + + self._assert_notification_count_for_cron(0) + post.assert_called_once() + # Test that the device with the invalid TLD is deleted from the DB + device_count = self.env['mail.push.device'].search_count([('endpoint', '=', 'https://test.odoo.invalid/webpush/user')]) + self.assertEqual(device_count, 0) + + @patch.object(odoo.addons.mail.models.mail_thread.Session, 'post', side_effect=ConnectionError("Oops, network error")) + def test_push_notifications_device_raise_exception(self, post): + # Add 4 more devices to force sending via cron queue + for index in range(10, 14): + self.env['mail.push.device'].sudo().create([{ + 'endpoint': 'https://test.odoo.com/webpush/user%d' % index, + 'expiration_time': None, + 'keys': json.dumps({ + 'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A', + 'auth': 'DJFdtAgZwrT6yYkUMgUqow' + }), + 'partner_id': self.user_inbox.partner_id.id, + }]) + + self.record_simple.with_user(self.user_email).message_notify( + partner_ids=self.user_inbox.partner_id.ids, + body='Test message send via Web Push', + subject='Test Activity', + ) + + with self.assertLogs('odoo.addons.mail.models.mail_push', level="ERROR") as capture: + self._assert_notification_count_for_cron(5) + self._trigger_cron_job() + self.assertEqual(capture.output, [ + 'ERROR:odoo.addons.mail.models.mail_push:An error occurred while trying to send web push: Oops, network error', + ] * 5) + + def test_push_notification_regenerate_vapid_keys(self): + ir_params_sudo = self.env['ir.config_parameter'].sudo() + ir_params_sudo.search([('key', 'in', [ + 'mail.web_push_vapid_private_key', + 'mail.web_push_vapid_public_key' + ])]).unlink() + new_vapid_public_key = self.env['mail.push.device'].get_web_push_vapid_public_key() + self.assertNotEqual(self.vapid_public_key, new_vapid_public_key) + with self.assertRaises(InvalidVapidError): + self.env['mail.push.device'].register_devices( + endpoint='https://test.odoo.com/webpush/user1', + expiration_time=None, + keys=json.dumps({ + 'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A', + 'auth': 'DJFdtAgZwrT6yYkUMgUqow' + }), + partner_id=self.user_email.partner_id.id, + vapid_public_key=self.vapid_public_key, + ) + + @patch.object( + odoo.addons.mail.models.mail_thread.Session, 'post', return_value=SimpleNamespace(status_code=201, text='Ok') + ) + @patch.object( + odoo.addons.mail.models.mail_thread, 'push_to_end_point', + wraps=odoo.addons.mail.tools.web_push.push_to_end_point, + ) + def test_push_notifications_truncate_payload(self, thread_push_mock, session_post_mock): + """Ensure that when we send large bodies with various character types, + the final encrypted data (post-encryption) never exceeds 4096 bytes. + + This test checks the behavior for the current size limits and encryption overhead. + See below test for a more illustrative example. + See MailThread._truncate_payload for a more thorough explanation. + + Test scenarios include: + - ASCII characters (X) + - UTF-8 characters (Ø), at various offsets + """ + # compute the size of an empty notification with these parameters + # this could change based on the id of record_simple for example + # but is otherwise constant for any notification sent with the same parameters + self.record_simple.with_user(self.user_email).message_notify( + partner_ids=self.user_inbox.partner_id.ids, + body='', + subject='Test Payload', + ) + base_payload_size = len(thread_push_mock.call_args.kwargs['payload'].encode()) + effective_payload_size_limit = self.env['mail.thread']._truncate_payload_get_max_payload_length() + # this is just a sanity check that the value makes sense, feel free to update as needed + self.assertEqual(effective_payload_size_limit, 3993, "Payload limit should come out to 3990.") + body_size_limit = effective_payload_size_limit - base_payload_size + encryption_overhead = ENCRYPTION_HEADER_SIZE + ENCRYPTION_BLOCK_OVERHEAD + + test_cases = [ + # (description, body) + ('empty string', '', 0, 0), + ('1-byte ASCII characters (below limit)', 'X' * (body_size_limit - 1), body_size_limit - 1, body_size_limit - 1), + ('1-byte ASCII characters (at limit)', 'X' * body_size_limit, body_size_limit, body_size_limit), + ('1-byte ASCII characters (past limit)', 'X' * (body_size_limit + 1), body_size_limit, body_size_limit), + ('1-byte ASCII characters (way past limit)', 'X' * 5000, body_size_limit, body_size_limit), + ] + [ # \u00d8 check that it can be cut anywhere by offsetting the string by 1 byte each time + ( + f'2-bytes UTF-8 characters (near limit + {offset}-byte offset)', + ('+' * offset) + ('Ø' * (body_size_limit // 6)), + offset + ((body_size_limit - offset) // 6), # length truncated to nearest full character (\u00f8) + offset * 1 + ((body_size_limit - offset) // 6) * 6, + ) + for offset in range(0, 8) + ] + + for description, body, expected_body_length, expected_body_size in test_cases: + with self.subTest(description): + self.record_simple.with_user(self.user_email).message_notify( + partner_ids=self.user_inbox.partner_id.ids, + body=body, + subject='Test Payload', + ) + + encrypted_payload = session_post_mock.call_args.kwargs['data'] + payload_before_encryption = thread_push_mock.call_args.kwargs['payload'] + self.assertLessEqual( + len(encrypted_payload), 4096, 'Final encrypted payload should not exceed 4096 bytes' + ) + self.assertEqual( + len(json.loads(payload_before_encryption)['options']['body']), expected_body_length + ) + self.assertEqual( + len(encrypted_payload), + base_payload_size + expected_body_size + encryption_overhead, + 'Encrypted size should be exactly the base payload size + body size + encryption overhead.' + ) + + @patch.object( + odoo.addons.mail.models.mail_thread.Session, 'post', return_value=SimpleNamespace(status_code=201, text='Ok') + ) + @patch.object( + odoo.addons.mail.models.mail_thread, 'push_to_end_point', + wraps=odoo.addons.mail.tools.web_push.push_to_end_point, + ) + @patch.object( + odoo.addons.mail.tools.web_push, '_encrypt_payload', + wraps=odoo.addons.mail.tools.web_push._encrypt_payload, + ) + def test_push_notifications_truncate_payload_mocked_size_limit(self, web_push_encrypt_payload_mock, thread_push_mock, session_post_mock): + """Illustrative test for text contents truncation. + + We want to ensure we truncate utf-8 values properly based on maximum payload size. + Here max payload size is mocked, so that we can test on the same body each time to ease reading. + + See MailThread._truncate_payload for a more thorough explanation. + """ + self.record_simple.with_user(self.user_email).message_notify( + partner_ids=self.user_inbox.partner_id.ids, + body="", + subject='Test Payload', + ) + base_payload = thread_push_mock.call_args.kwargs['payload'].encode() + base_payload_size = len(base_payload) + encryption_overhead = ENCRYPTION_HEADER_SIZE + ENCRYPTION_BLOCK_OVERHEAD + + body = "BØDY" + body_json = json.dumps(body)[1:-1] + for size_limit, expected_body in [ + (base_payload_size + len(body_json), "BØDY"), + (base_payload_size + len(body_json) - 1, "BØD"), + (base_payload_size + len(body_json) - 2, "BØ"), + ] + [ # truncating anywhere in \u00d8 (Ø) should truncate to the nearest full character (B) + (base_payload_size + len(body_json) - n, "B") + for n in range(3, 9) + ] + [ + (base_payload_size + len(body_json) - 9, ""), + (base_payload_size + len(body_json) - 10, ""), # should still work even if it would still be too big after truncate + ]: + with self.subTest(size_limit=size_limit), patch.object( + odoo.addons.mail.models.mail_thread.MailThread, '_truncate_payload_get_max_payload_length', + return_value=size_limit, + ): + self.record_simple.with_user(self.user_email).message_notify( + partner_ids=self.user_inbox.partner_id.ids, + body=body, + subject='Test Payload', + ) + payload_at_push = thread_push_mock.call_args.kwargs['payload'] + payload_before_encrypt = web_push_encrypt_payload_mock.call_args.args[0] + encrypted_payload = session_post_mock.call_args.kwargs['data'] + self.assertEqual(payload_before_encrypt.decode(), payload_at_push, "Payload should not change between encryption and push call.") + self.assertEqual(len(payload_before_encrypt), len(payload_at_push), "Encoded body should be same size as decoded.") + self.assertEqual( + len(encrypted_payload), len(payload_before_encrypt) + encryption_overhead, + 'Final encrypted payload should just be the size of the unencrypted payload + the size of encryption overhead.' + ) + self.assertEqual( + json.loads(payload_at_push)['options']['body'], expected_body + ) + if not expected_body: + self.assertEqual( + payload_before_encrypt, base_payload, + "Only the contents of the body should be truncated, not the rest of the payload." + ) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_scheduled_message.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_scheduled_message.py new file mode 100644 index 0000000..e76c98a --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_scheduled_message.py @@ -0,0 +1,239 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.base.tests.test_ir_cron import CronMixinCase +from odoo.addons.mail.tests.common import MailCommon +from odoo.addons.test_mail.models.mail_test_lead import MailTestTLead +from odoo.addons.test_mail.tests.common import TestRecipients +from odoo.exceptions import AccessError, UserError, ValidationError +from odoo.fields import Datetime as FieldDatetime +from odoo.tests import tagged, users +from odoo.tools import mute_logger +from unittest.mock import patch + + +@tagged('mail_scheduled_message') +class TestScheduledMessage(MailCommon, TestRecipients): + """ Test Scheduled Message internals """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # force 'now' to ease test about schedulers + cls.reference_now = FieldDatetime.to_datetime('2022-12-24 12:00:00') + + with cls.mock_datetime_and_now(cls, cls.reference_now): + cls.test_record = cls.env['mail.test.ticket'].with_context(cls._test_context).create([{ + 'name': 'Test Record', + 'customer_id': cls.partner_1.id, + 'user_id': cls.user_employee.id, + }]) + cls.private_record = cls.env['mail.test.access'].create({ + 'access': 'admin', + 'name': 'Private Record', + }) + cls.hidden_scheduled_message, cls.visible_scheduled_message = cls.env['mail.scheduled.message'].create([ + { + 'author_id': cls.partner_admin.id, + 'model': cls.private_record._name, + 'res_id': cls.private_record.id, + 'body': 'Hidden Scheduled Message', + 'scheduled_date': '2022-12-24 15:00:00', + }, + { + 'author_id': cls.partner_admin.id, + 'model': cls.test_record._name, + 'res_id': cls.test_record.id, + 'body': 'Visible Scheduled Message', + 'scheduled_date': '2022-12-24 15:00:00', + }, + ]).with_user(cls.user_employee) + + def schedule_message(self, target_record=None, author_id=None, **kwargs): + with self.mock_datetime_and_now(self.reference_now): + return self.env['mail.scheduled.message'].create({ + 'author_id': author_id or self.env.user.partner_id.id, + 'model': target_record._name if target_record else kwargs.pop('model'), + 'res_id': target_record.id if target_record else kwargs.pop('res_id'), + 'body': kwargs.pop('body', 'Test Body'), + 'scheduled_date': kwargs.pop('scheduled_date', '2022-12-24 15:00:00'), + **kwargs, + }) + + +class TestScheduledMessageAccess(TestScheduledMessage): + + @users('employee') + def test_scheduled_message_model_without_post_right(self): + # creation on a record that the user cannot post to + with self.assertRaises(AccessError): + self.schedule_message(self.private_record) + # read a message scheduled on a record the user can't post to + with self.assertRaises(AccessError): + self.hidden_scheduled_message.read() + # search a message scheduled on a record the user can't post to + self.assertFalse(self.env['mail.scheduled.message'].search([['id', '=', self.hidden_scheduled_message.id]])) + # write on a message scheduled on a record the user can't post to + with self.assertRaises(AccessError): + self.hidden_scheduled_message.write({'body': 'boum'}) + # post a message scheduled on a record the user can't post to + with self.assertRaises(AccessError): + self.hidden_scheduled_message.post_message() + # unlink a message scheduled on a record the user can't post to + with self.assertRaises(AccessError): + self.hidden_scheduled_message.unlink() + + @users('employee') + def test_scheduled_message_model_with_post_right(self): + # read a message scheduled by another user on a record the user can post to + self.visible_scheduled_message.read() + # search a message scheduled by another user on a record the user can post to + self.assertEqual(self.env['mail.scheduled.message'].search([['id', '=', self.visible_scheduled_message.id]]), self.visible_scheduled_message) + # write on a message scheduled by another user on a record the user can post to + with self.assertRaises(AccessError): + self.visible_scheduled_message.write({'body': 'boum'}) + # post a message scheduled on a record the user can post to + with self.assertRaises(UserError): + self.visible_scheduled_message.post_message() + # unlink a message scheduled on a record the user can post to + self.visible_scheduled_message.unlink() + + @users('employee') + def test_own_scheduled_message(self): + # create a scheduled message on a record the user can post to + scheduled_message = self.schedule_message(self.test_record) + # read own scheduled message + scheduled_message.read() + # search own scheduled message + self.assertEqual(self.env['mail.scheduled.message'].search([['id', '=', scheduled_message.id]]), scheduled_message) + # write on own scheduled message + scheduled_message.write({'body': 'Hello!'}) + # unlink own scheduled message + scheduled_message.unlink() + + +class TestScheduledMessageBusiness(TestScheduledMessage, CronMixinCase): + + @users('employee') + def test_scheduled_message_restrictions(self): + # cannot schedule a message in the past + with self.assertRaises(ValidationError): + self.schedule_message(self.test_record, scheduled_date='2022-12-24 10:00:00') + # cannot schedule a message on a model without thread + # with admin as employee does not have write access on res.users) + with self.with_user("admin"), self.assertRaises(ValidationError): + self.schedule_message(self.user_employee) + scheduled_message = self.schedule_message(self.test_record) + # cannot reschedule a message in the past + with self.assertRaises(ValidationError): + scheduled_message.write({'scheduled_date': '2022-12-24 14:00:00'}) + # cannot change target record of scheduled message + with self.assertRaises(UserError): + scheduled_message.write({'res_id': 2}) + with self.assertRaises(UserError): + scheduled_message.write({'model': 'mail.test.track'}) + # unlink the test record should also unlink the test message + self.test_record.sudo().unlink() + self.assertFalse(scheduled_message.exists()) + + @users('employee') + def test_scheduled_message_posting(self): + schedule_cron_id = self.env.ref('mail.ir_cron_post_scheduled_message').id + test_lead = self.env["mail.test.lead"].create({}) + with self.mock_mail_gateway(), \ + self.mock_mail_app(), \ + self.capture_triggers(schedule_cron_id) as capt: + scheduled_message_id = self.schedule_message( + self.test_record, + scheduled_date='2022-12-24 14:00:00', + partner_ids=self.test_record.customer_id, + body="success", + send_context={"mail_post_autofollow": True}, + subject="Test subject", + ).id + # cron should be triggered at scheduled date + self.assertEqual(capt.records['call_at'], FieldDatetime.to_datetime('2022-12-24 14:00:00')) + # no message created or mail sent + self.assertFalse(self.test_record.message_ids) + self.assertFalse(self._new_mails) + + # add a scheduled message that will fail to check that it won't block the cron + failing_schedueld_message_id = self.schedule_message( + test_lead, + scheduled_date='2022-12-24 14:00:00', + partner_ids=self.test_record.customer_id, + body="fail", + ).id + + def _message_post_after_hook(self, message, values): + raise Exception("Boum!") + + with self.mock_datetime_and_now('2022-12-24 14:00:00'),\ + patch.object(MailTestTLead, '_message_post_after_hook', _message_post_after_hook),\ + mute_logger('odoo.addons.mail.models.mail_scheduled_message'): + self.env['mail.scheduled.message'].with_user(self.user_root)._post_messages_cron() + # one scheduled message failed, only one mail should be sent + self.assertEqual(len(self._new_mails), 1) + # user should be notified about the failed posting + self.assertMailNotifications( + self._new_msgs.filtered(lambda m: not m.model), + [{ + 'content': f"

The message scheduled on {test_lead._name}({test_lead.id}) with" + " the following content could not be sent:
-----

fail


-----
", + 'message_type': 'user_notification', + 'subtype': 'mail.mt_note', + 'message_values': { + 'author_id': self.partner_root, + 'model': False, + 'res_id': False, + 'subject': "A scheduled message could not be sent", + }, + 'notif': [ + {'partner': self.partner_employee, 'type': 'inbox'} + ] + }]) + # other message should be posted and mail should be sent + self.assertMailNotifications( + self._new_msgs.filtered(lambda m: m.model == self.test_record._name), + [{ + 'content': "

success

", + 'message_type': 'notification', + 'message_values': { + 'author_id': self.partner_employee, + 'model': self.test_record._name, + 'res_id': self.test_record.id, + 'subject': "Test subject", + }, + 'notif': [ + {'partner': self.test_record.customer_id, 'type': 'email'} + ] + }] + ) + self.assertEqual(self._new_mails[0].state, 'sent') + # customer should be a follower of the thread (mail_post_autofollow context key) + self.assertIn(self.test_record.customer_id, self.test_record.message_partner_ids) + # scheduled messages shouldn't exist anymore + self.assertFalse(self.env['mail.scheduled.message'].search([['id', 'in', [scheduled_message_id, failing_schedueld_message_id]]])) + + @users('employee') + def test_scheduled_message_posting_on_scheduled_time(self): + """ Ensure scheduled message is posted and sent at the scheduled time. """ + self.test_record.message_subscribe(partner_ids=[self.partner_1.id]) + + self.schedule_message( + self.test_record, + scheduled_date=FieldDatetime.to_string(self.reference_now), + ) + + with self.mock_mail_gateway(), self.mock_datetime_and_now(self.reference_now), self.enter_registry_test_mode(): + # Needed to get force_send disabled due to mail_notify_force_send in the context + self.env.ref('mail.ir_cron_post_scheduled_message').with_user(self.user_admin).method_direct_trigger() + + # Message is posted and mail is sent on time + self.assertEqual(len(self._new_mails), 1) + self.assertMailMailWRecord( + self.test_record, + [self.partner_1], + 'sent', + author=self.env.user.partner_id, + ) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_security.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_security.py index cf345c5..8b8bfde 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_security.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_security.py @@ -1,12 +1,11 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.addons.mail.tests.common import mail_new_test_user -from odoo.addons.test_mail.tests.common import TestMailCommon +from odoo.addons.mail.tests.common import mail_new_test_user, MailCommon from odoo.exceptions import AccessError -class TestSubtypeAccess(TestMailCommon): +class TestSubtypeAccess(MailCommon): def test_subtype_access(self): """ diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_template.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_template.py index a842621..42326aa 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_template.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_template.py @@ -7,25 +7,22 @@ import datetime from freezegun import freeze_time from unittest.mock import patch -from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients -from odoo.tests import tagged, users +from odoo.addons.mail.tests.common import MailCommon +from odoo.addons.test_mail.tests.common import TestRecipients +from odoo.tests import tagged, users, warmup from odoo.tools import mute_logger, safe_eval -class TestMailTemplateCommon(TestMailCommon, TestRecipients): +class TestMailTemplateCommon(MailCommon, TestRecipients): @classmethod def setUpClass(cls): - super(TestMailTemplateCommon, cls).setUpClass() + super().setUpClass() cls.test_record = cls.env['mail.test.lang'].with_context(cls._test_context).create({ 'email_from': 'ignasse@example.com', 'name': 'Test', }) - cls.user_employee.write({ - 'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)], - }) - cls._attachments = [{ 'name': 'first.txt', 'datas': base64.b64encode(b'My first attachment'), @@ -51,6 +48,7 @@ class TestMailTemplateCommon(TestMailCommon, TestRecipients): 'email_cc': '%s' % cls.email_3, 'partner_to': '%s,%s' % (cls.partner_2.id, cls.user_admin.partner_id.id), 'subject': 'EnglishSubject for {{ object.name }}', + 'use_default_to': False, }) # activate translations @@ -64,6 +62,17 @@ class TestMailTemplateCommon(TestMailCommon, TestRecipients): # Force the attachments of the template to be in the natural order. cls.test_template.invalidate_recordset(['attachment_ids']) + # dynamic reports + cls.test_report = cls.env['ir.actions.report'].create([ + { + 'name': 'Test Report 3 with variable data on Mail Test Ticket', + 'model': 'mail.test.ticket.mc', + 'print_report_name': "'TestReport3 for %s' % object.name", + 'report_type': 'qweb-pdf', + 'report_name': 'test_mail.mail_test_ticket_test_variable_template', + }, + ]) + @tagged('mail_template') class TestMailTemplate(TestMailTemplateCommon): @@ -79,6 +88,19 @@ class TestMailTemplate(TestMailTemplateCommon): self.assertEqual(action.name, 'Send Mail (%s)' % self.test_template.name) self.assertEqual(action.binding_model_id.model, 'mail.test.lang') + def test_template_fields(self): + """ Test computed fields """ + # has_dynamic_reports: based on ir.actions.report + test_template_lang = self.test_template.with_user(self.user_employee) + self.assertFalse(test_template_lang.has_dynamic_reports) + test_template_ticket_mc = self.env['mail.template'].with_user(self.user_employee).create({ + 'model_id': self.env['ir.model']._get_id('mail.test.ticket.mc'), + }) + self.assertTrue(test_template_ticket_mc.has_dynamic_reports) + # has_mail_server: based on ir.mail_server available + self.assertTrue(test_template_lang.has_mail_server) + self.assertTrue(test_template_ticket_mc.has_mail_server) + @mute_logger('odoo.addons.mail.models.mail_mail') @users('employee') def test_template_schedule_email(self): @@ -119,48 +141,275 @@ class TestMailTemplate(TestMailTemplateCommon): self.assertFalse(mail.scheduled_date) self.assertEqual(mail.state, 'outgoing') - -@tagged('mail_template', 'multi_lang') -class TestMailTemplateLanguages(TestMailTemplateCommon): - @mute_logger('odoo.addons.mail.models.mail_mail') - def test_template_send_email(self): + def test_template_send_mail_body(self): + """ Test that the body and body_html is set correctly in 'mail.mail' + when sending an email from mail.template """ mail_id = self.test_template.send_mail(self.test_record.id) mail = self.env['mail.mail'].sudo().browse(mail_id) + body_result = '

EnglishBody for %s

' % self.test_record.name + + self.assertEqual(mail.body_html, body_result) + self.assertEqual(mail.body, body_result) + + +@tagged('mail_template', 'multi_lang', 'mail_performance', 'post_install', '-at_install') +class TestMailTemplateLanguages(TestMailTemplateCommon): + + @classmethod + def setUpClass(cls): + """ Create lang-based records and templates, to test batch and performances + with language involved. """ + super().setUpClass() + + # use test notification layout + cls.test_template.write({ + 'email_layout_xmlid': 'mail.test_layout', + }) + + # double record, one in each lang + cls.test_records = cls.test_record + cls.env['mail.test.lang'].create({ + 'email_from': 'ignasse.es@example.com', + 'lang': 'es_ES', + 'name': 'Test Record 2', + }) + + # pure batch, 100 records + cls.test_records_batch, test_partners = cls._create_records_for_batch( + 'mail.test.lang', 100, + ) + test_partners[:50].lang = 'es_ES' + + # have a template with dynamic templates to check impact + cls.test_template_wreports = cls.test_template.copy({ + 'email_layout_xmlid': 'mail.test_layout', + }) + cls.test_reports = cls.env['ir.actions.report'].create([ + { + 'name': f'Test Report on {cls.test_record._name}', + 'model': cls.test_record._name, + 'print_report_name': "f'TestReport for {object.name}'", + 'report_type': 'qweb-pdf', + 'report_name': 'test_mail.mail_test_ticket_test_template', + }, { + 'name': f'Test Report 2 on {cls.test_record._name}', + 'model': cls.test_record._name, + 'print_report_name': "f'TestReport2 for {object.name}'", + 'report_type': 'qweb-pdf', + 'report_name': 'test_mail.mail_test_ticket_test_template_2', + } + ]) + cls.test_template_wreports.report_template_ids = cls.test_reports + + cls.env.flush_all() + + def setUp(self): + super().setUp() + # warm up group access cache: 5 queries + 1 query per user + self.user_employee.has_group('base.group_user') + # we don't use mock_mail_gateway thus want to mock smtp to test the stack + self._mock_smtplib_connection() + + @mute_logger('odoo.addons.mail.models.mail_mail') + @warmup + def test_template_send_email(self): + """ Test 'send_email' on template on a given record, used notably as + contextual action. """ + self.env.invalidate_all() + with self.with_user(self.user_employee.login), self.assertQueryCount(13): + mail_id = self.test_template.with_env(self.env).send_mail(self.test_record.id) + mail = self.env['mail.mail'].sudo().browse(mail_id) + + self.assertEqual(sorted(mail.attachment_ids.mapped('name')), ['first.txt', 'second.txt']) + self.assertEqual(mail.body_html, + f'

EnglishBody for {self.test_record.name}

English Layout for Lang Chatter Model') self.assertEqual(mail.email_cc, self.test_template.email_cc) self.assertEqual(mail.email_to, self.test_template.email_to) self.assertEqual(mail.recipient_ids, self.partner_2 | self.user_admin.partner_id) - self.assertEqual(mail.subject, 'EnglishSubject for %s' % self.test_record.name) + self.assertEqual(mail.subject, f'EnglishSubject for {self.test_record.name}') + + @mute_logger('odoo.addons.mail.models.mail_mail') + @warmup + def test_template_send_email_nolayout(self): + """ Test without layout, just to check impact """ + self.test_template.email_layout_xmlid = False + self.env.invalidate_all() + with self.with_user(self.user_employee.login), self.assertQueryCount(12): + mail_id = self.test_template.with_env(self.env).send_mail(self.test_record.id) + mail = self.env['mail.mail'].sudo().browse(mail_id) + + self.assertEqual(sorted(mail.attachment_ids.mapped('name')), ['first.txt', 'second.txt']) + self.assertEqual(mail.body_html, + f'

EnglishBody for {self.test_record.name}

') + self.assertEqual(mail.email_cc, self.test_template.email_cc) + self.assertEqual(mail.email_to, self.test_template.email_to) + self.assertEqual(mail.recipient_ids, self.partner_2 | self.user_admin.partner_id) + self.assertEqual(mail.subject, f'EnglishSubject for {self.test_record.name}') + + @mute_logger('odoo.addons.mail.models.mail_mail') + @warmup + def test_template_send_email_batch(self): + """ Test 'send_email' on template in batch """ + self.env.invalidate_all() + with self.with_user(self.user_employee.login), self.assertQueryCount(25): + template = self.test_template.with_env(self.env) + mails_sudo = template.send_mail_batch(self.test_records_batch.ids) + + self.assertEqual(len(mails_sudo), 100) + for idx, (mail, record) in enumerate(zip(mails_sudo, self.test_records_batch)): + self.assertEqual(sorted(mail.attachment_ids.mapped('name')), ['first.txt', 'second.txt']) + self.assertEqual(mail.attachment_ids.mapped("res_id"), [template.id] * 2) + self.assertEqual(mail.attachment_ids.mapped("res_model"), [template._name] * 2) + self.assertEqual(mail.email_cc, template.email_cc) + self.assertEqual(mail.email_to, template.email_to) + self.assertEqual(mail.recipient_ids, self.partner_2 | self.user_admin.partner_id) + if idx >= 50: + self.assertEqual(mail.subject, f'EnglishSubject for {record.name}') + else: + self.assertEqual(mail.subject, f'SpanishSubject for {record.name}') + + @mute_logger('odoo.addons.mail.models.mail_mail') + @warmup + def test_template_send_email_wreport(self): + """ Test 'send_email' on template on a given record, used notably as + contextual action, with dynamic reports involved """ + self.env.invalidate_all() + # tm: 22, nightly: +1 + with self.with_user(self.user_employee.login), self.assertQueryCount(21): + mail_id = self.test_template_wreports.with_env(self.env).send_mail(self.test_record.id) + mail = self.env['mail.mail'].sudo().browse(mail_id) + + self.assertEqual( + sorted(mail.attachment_ids.mapped('name')), + [f'TestReport for {self.test_record.name}.html', f'TestReport2 for {self.test_record.name}.html', 'first.txt', 'second.txt'] + ) + self.assertEqual(mail.recipient_ids, self.partner_2 | self.user_admin.partner_id) + self.assertEqual(mail.subject, f'EnglishSubject for {self.test_record.name}') + + @mute_logger('odoo.addons.mail.models.mail_mail') + @warmup + def test_template_send_email_wreport_batch(self): + """ Test 'send_email' on template in batch with dynamic reports """ + self.env.invalidate_all() + # tm: 233, nightly: +1 + with self.with_user(self.user_employee.login), self.assertQueryCount(232): + template = self.test_template_wreports.with_env(self.env) + mails_sudo = template.send_mail_batch(self.test_records_batch.ids) + + self.assertEqual(len(mails_sudo), 100) + for idx, (mail, record) in enumerate(zip(mails_sudo, self.test_records_batch)): + self.assertEqual( + sorted(mail.attachment_ids.mapped('name')), + [f'TestReport for {record.name}.html', f'TestReport2 for {record.name}.html', 'first.txt', 'second.txt'] + ) + self.assertEqual( + sorted(mail.attachment_ids.mapped("res_id")), + sorted([self.test_template_wreports.id] * 2 + [mail.mail_message_id.id] * 2), + "Attachments: attachment_ids -> linked to template, attachments -> to mail.message" + ) + self.assertEqual( + sorted(mail.attachment_ids.mapped("res_model")), + sorted([template._name] * 2 + ["mail.message"] * 2), + "Attachments: attachment_ids -> linked to template, attachments -> to mail.message" + ) + self.assertEqual(mail.email_cc, self.test_template.email_cc) + self.assertEqual(mail.email_to, self.test_template.email_to) + self.assertEqual(mail.recipient_ids, self.partner_2 | self.user_admin.partner_id) + if idx >= 50: + self.assertEqual(mail.subject, f'EnglishSubject for {record.name}') + self.assertEqual(mail.body_html, + f'

EnglishBody for {record.name}

English Layout for Lang Chatter Model') + else: + self.assertEqual(mail.subject, f'SpanishSubject for {record.name}') + self.assertEqual(mail.body_html, + f'

SpanishBody for {record.name}

Spanish Layout para Spanish Model Description') + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_template_send_email_wreport_batch_scalability(self): + """ Test 'send_email' on template in batch, using configuration parameter + for batch rendering. """ + for batch_size, exp_mail_create_count in [ + (False, 2), # unset, default is 50 + (0, 2), # 0: fallbacks on default + (30, 4), # 100 / 30 -> 4 iterations + ]: + with self.subTest(batch_size=batch_size): + self.env['ir.config_parameter'].sudo().set_param( + "mail.batch_size", batch_size + ) + with self.with_user(self.user_employee.login), \ + self.mock_mail_gateway(): + template = self.test_template_wreports.with_env(self.env) + mails_sudo = template.send_mail_batch(self.test_records_batch.ids) + + self.assertEqual(self.mail_mail_create_mocked.call_count, exp_mail_create_count) + self.assertEqual(len(mails_sudo), 100) + for idx, (mail, record) in enumerate(zip(mails_sudo, self.test_records_batch)): + self.assertEqual( + sorted(mail.attachment_ids.mapped('name')), + [f'TestReport for {record.name}.html', f'TestReport2 for {record.name}.html', 'first.txt', 'second.txt'] + ) + self.assertEqual( + sorted(mail.attachment_ids.mapped("res_id")), + sorted([self.test_template_wreports.id] * 2 + [mail.mail_message_id.id] * 2), + "Attachments: attachment_ids -> linked to template, attachments -> to mail.message" + ) + self.assertEqual( + sorted(mail.attachment_ids.mapped("res_model")), + sorted([template._name] * 2 + ["mail.message"] * 2), + "Attachments: attachment_ids -> linked to template, attachments -> to mail.message" + ) + self.assertEqual(mail.email_cc, self.test_template.email_cc) + self.assertEqual(mail.email_to, self.test_template.email_to) + self.assertEqual(mail.recipient_ids, self.partner_2 | self.user_admin.partner_id) + if idx >= 50: + self.assertEqual(mail.subject, f'EnglishSubject for {record.name}') + else: + self.assertEqual(mail.subject, f'SpanishSubject for {record.name}') @mute_logger('odoo.addons.mail.models.mail_mail') def test_template_translation_lang(self): - test_record = self.env['mail.test.lang'].browse(self.test_record.ids) + """ Test template rendering using lang defined directly on the record """ + test_record = self.test_record.with_env(self.env) test_record.write({ 'lang': 'es_ES', }) - test_template = self.env['mail.template'].browse(self.test_template.ids) + test_template = self.test_template.with_env(self.env) - mail_id = test_template.send_mail(test_record.id, email_layout_xmlid='mail.test_layout') + mail_id = test_template.send_mail(test_record.id) mail = self.env['mail.mail'].sudo().browse(mail_id) self.assertEqual(mail.body_html, - '

SpanishBody for %s

Spanish Layout para Spanish Model Description' % self.test_record.name) - self.assertEqual(mail.subject, 'SpanishSubject for %s' % self.test_record.name) + f'

SpanishBody for {self.test_record.name}

Spanish Layout para Spanish Model Description') + self.assertEqual(mail.subject, f'SpanishSubject for {self.test_record.name}') @mute_logger('odoo.addons.mail.models.mail_mail') + @warmup def test_template_translation_partner_lang(self): - test_record = self.env['mail.test.lang'].browse(self.test_record.ids) - customer = self.env['res.partner'].create({ - 'email': 'robert.carlos@test.example.com', - 'lang': 'es_ES', - 'name': 'Roberto Carlos', - }) - test_record.write({ - 'customer_id': customer.id, - }) - test_template = self.env['mail.template'].browse(self.test_template.ids) + """ Test template rendering using lang defined on a sub-record aka + 'partner_id.lang' """ + test_records = self.env['mail.test.lang'].browse(self.test_records.ids) + customers = self.env['res.partner'].create([ + { + 'email': 'roberto.carlos@test.example.com', + 'lang': 'es_ES', + 'name': 'Roberto Carlos', + }, { + 'email': 'rob.charly@test.example.com', + 'lang': 'en_US', + 'name': 'Rob Charly', + } + ]) + test_records[0].write({'customer_id': customers[0].id}) + test_records[1].write({'customer_id': customers[1].id}) - mail_id = test_template.send_mail(test_record.id, email_layout_xmlid='mail.test_layout') - mail = self.env['mail.mail'].sudo().browse(mail_id) - self.assertEqual(mail.body_html, - '

SpanishBody for %s

Spanish Layout para Spanish Model Description' % self.test_record.name) - self.assertEqual(mail.subject, 'SpanishSubject for %s' % self.test_record.name) + self.env.invalidate_all() + with self.with_user(self.user_employee.login), self.assertQueryCount(18): + template = self.test_template.with_env(self.env) + mails_sudo = template.send_mail_batch(self.test_records.ids, email_layout_xmlid='mail.test_layout') + + self.assertEqual(mails_sudo[0].body_html, + f'

SpanishBody for {test_records[0].name}

Spanish Layout para Spanish Model Description') + self.assertEqual(mails_sudo[0].subject, f'SpanishSubject for {test_records[0].name}') + self.assertEqual(mails_sudo[1].body_html, + f'

EnglishBody for {test_records[1].name}

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

EnglishBody for {test_record.name}

') + self.assertFalse(preview.attachment_ids, 'Reports should not be listed in attachments') + def test_mail_template_preview_force_lang(self): test_record = self.env['mail.test.lang'].browse(self.test_record.ids) test_record.write({ diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_thread_internals.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_thread_internals.py index 71677f4..fa2ac30 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_thread_internals.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_thread_internals.py @@ -1,27 +1,930 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - +from markupsafe import Markup from unittest.mock import patch from unittest.mock import DEFAULT +import base64 -from odoo import exceptions +from odoo import exceptions, tools +from odoo.addons.mail.tests.common import mail_new_test_user, MailCommon from odoo.addons.test_mail.models.test_mail_models import MailTestSimple -from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients -from odoo.tests.common import tagged, users +from odoo.addons.test_mail.tests.common import TestRecipients +from odoo.addons.mail.tools.discuss import Store +from odoo.tests import Form, users, warmup, tagged from odoo.tools import mute_logger -@tagged('mail_thread') -class TestAPI(TestMailCommon, TestRecipients): +class ThreadRecipients(MailCommon, TestRecipients): @classmethod def setUpClass(cls): - super(TestAPI, cls).setUpClass() - cls.ticket_record = cls.env['mail.test.ticket'].with_context(cls._test_context).create({ + super().setUpClass() + cls.user_portal = cls._create_portal_user() + cls.test_partner, cls.test_partner_archived = cls.env['res.partner'].create([ + { + 'email': '"Test External" ', + 'phone': '+32455001122', + 'name': 'Name External', + }, { + 'active': False, + 'email': '"Test Archived" ', + 'phone': '+32455221100', + 'name': 'Name Archived', + }, + ]) + cls.user_employee_2 = mail_new_test_user( + cls.env, + email='eglantine@example.com', + groups='base.group_user', + login='employee2', + name='Eglantine Employee', + notification_type='email', + signature='--\nEglantine', + ) + cls.partner_employee_2 = cls.user_employee_2.partner_id + cls.user_employee_archived = mail_new_test_user( + cls.env, + email='albert@example.com', + groups='base.group_user', + login='albert', + name='Albert Alemployee', + notification_type='email', + signature='--\nAlbert', + ) + cls.user_employee_archived.active = False + cls.partner_employee_archived = cls.user_employee_archived.partner_id + + cls.test_aliases = cls.env['mail.alias'].create([ + { + 'alias_domain_id': cls.mail_alias_domain.id, + 'alias_model_id': cls.env['ir.model']._get_id('mail.test.ticket.mc'), + 'alias_name': 'test.alias.free', + }, { + 'alias_domain_id': cls.mail_alias_domain.id, + 'alias_model_id': cls.env['ir.model']._get_id('mail.test.ticket.mc'), + 'alias_name': 'test.alias.partner', + }, { + 'alias_domain_id': cls.mail_alias_domain.id, + 'alias_incoming_local': True, + 'alias_model_id': cls.env['ir.model']._get_id('mail.test.ticket.mc'), + 'alias_name': 'test.alias.free.local', + } + ]) + cls.test_partner_alias = cls.env['res.partner'].create({ + 'email': f'"Do not do this" <{cls.test_aliases[1].alias_full_name}>', + 'name': 'Someone created a partner with email=alias', + }) + cls.test_partner_catchall = cls.env['res.partner'].create({ + 'email': f'"Do not do this neither" <{cls.mail_alias_domain.catchall_email}>', + 'name': 'Someone created a partner with email=catchall', + }) + + +@tagged('mail_thread', 'mail_thread_api', 'mail_tools') +class TestAPI(ThreadRecipients): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.ticket_record = cls.env['mail.test.ticket.mc'].create({ + 'company_id': cls.user_employee.company_id.id, 'email_from': '"Paulette Vachette" ', + 'phone_number': '+32455998877', 'name': 'Test', 'user_id': cls.user_employee.id, }) + cls.ticket_records = cls.ticket_record + cls.env['mail.test.ticket.mc'].create([ + { + 'email_from': '"Maybe Paulette" ', + 'name': 'Duplicate email', + }, { + 'email_from': '"Multi Customer" , "Multi 2" ', + 'name': 'Multi Email', + }, { + 'email_from': 'wrong', + 'phone_number': '+32455000001', + 'name': 'Wrong email', + }, { + 'email_from': 'wrong', + 'name': 'Duplicate Wrong email', + }, { + 'email_from': False, + 'name': 'Falsy email', + }, { + 'email_from': f'"Other Name" <{cls.test_partner.email_normalized}>', + 'name': 'Test Partner Email', + }, { + 'customer_id': cls.user_public.partner_id.id, + 'name': 'Publicly Created', + }, + ]) + + def test_assert_initial_values(self): + """ Just be sure of what we test """ + self.assertFalse(self.user_employee_archived.active) + self.assertTrue(self.partner_employee_archived.active) + + @users('employee') + def test_body_escape(self): + """ Test various use cases involving HTML encoding / escaping """ + ticket_record = self.ticket_record.with_env(self.env) + attachments = self.env['ir.attachment'].create( + self._generate_attachments_data(2, 'mail.compose.message', 0) + ) + self.assertFalse(self.env['ir.attachment'].sudo().search([('name', '=', 'test_image.jpeg')])) + + # attachments processing through CID, rewrites body (if escaped) + body = '
test_image.jpegZboing
' + for with_markup in [False, True]: + with self.subTest(with_markup=with_markup): + test_body = Markup(body) if with_markup else body + message = ticket_record.message_post( + attachments=[("test_image.jpeg", "b", {"cid": "ii_lps7a8sm0"})], + attachment_ids=attachments.ids, + body=test_body, + message_type="comment", + partner_ids=self.partner_1.ids, + ) + new_attachment = self.env['ir.attachment'].sudo().search([('name', '=', 'test_image.jpeg')]) + self.assertEqual(new_attachment.res_id, ticket_record.id) + if with_markup: + expected_body = Markup( + f'
Zboing
' + ) + else: + expected_body = Markup('

<div class="ltr"><img src="cid:ii_lps7a8sm0" alt="test_image.jpeg" width="542" height="253">Zboing</div>

') + self.assertEqual(message.attachment_ids, attachments + new_attachment) + self.assertEqual(message.body, expected_body) + new_attachment.unlink() + + # internals of attachment processing, in case it is called for other addons + for with_markup in [False, True]: + with self.subTest(with_markup=with_markup): + message_values = { + 'body': Markup(body) if with_markup else body, + 'model': ticket_record._name, + 'res_id': ticket_record.id, + } + processed_values = self.env['mail.thread']._process_attachments_for_post( + [("test_image.jpeg", "b", {"cid": "ii_lps7a8sm0"})], attachments.ids, message_values, + ) + if not with_markup: + self.assertFalse('body' in processed_values, 'Mail: escaped html does not contain tags to handle anymore') + else: + self.assertTrue(isinstance(processed_values['body'], Markup)) + + # html is escaped in main API methods + content = 'I am "Robert "' + expected = Markup('

I am "Robert <robert@poilvache.com>"

') # enclosed in p to make valid html + message = ticket_record._message_log( + body=content, + ) + self.assertEqual(message.body, expected) + message = ticket_record.message_notify( + body=content, + partner_ids=self.partner_1.ids, + ) + self.assertEqual(message.body, expected) + message = ticket_record.message_post( + body=content, + message_type="comment", + partner_ids=self.partner_1.ids, + ) + self.assertEqual(message.body, expected) + ticket_record._message_update_content(message, body="Hello ") + self.assertEqual(message.body, Markup('

Hello <R&D/>

')) + + @users('employee') + def test_mail_partner_find_from_emails(self): + """ Test '_partner_find_from_emails'. Multi mode is mainly targeting + finding or creating partners based on record information or message + history. """ + existing_partners = self.env['res.partner'].sudo().search([]) + tickets = self.ticket_records.with_user(self.env.user) + self.assertEqual(len(tickets), 8) + res = tickets._partner_find_from_emails({ticket: [ticket.email_from] for ticket in tickets}, no_create=False) + self.assertEqual(len(tickets), len(res)) + + # fetch partners that should have been created + new = self.env['res.partner'].search([('email_normalized', '=', 'paulette@test.example.com')]) + self.assertEqual(len(new), 1, 'Should have created once the customer, even if found in various duplicates') + self.assertNotIn(new, existing_partners) + new_wrong = self.env['res.partner'].search([('email', '=', 'wrong')]) + self.assertEqual(len(new_wrong), 1, 'Should have created once the wrong email') + self.assertNotIn(new, new_wrong) + new_multi = self.env['res.partner'].search([('email_normalized', '=', 'multi@test.example.com')]) + self.assertEqual(len(new_multi), 1, 'Should have created a based for multi email, using the first found email') + self.assertNotIn(new, new_multi) + + # assert results: found / create partners and their values (if applies) + record_customer_values = { + 'company_id': self.user_employee.company_id, + 'email': 'paulette@test.example.com', + 'name': 'Paulette Vachette', + 'phone': '+32455998877', + } + expected_all = [ + (new, [record_customer_values]), + (new, [record_customer_values]), + (new_multi, [{ # not the actual record customer hence no mobile / phone, see _get_customer_information + 'company_id': self.user_employee.company_id, + 'email': 'multi@test.example.com', + 'name': 'Multi Customer', + 'phone': False, + }]), + (new_wrong, [{ # invalid email but can be fixed afterwards -> matches a potential customer + 'company_id': self.user_employee.company_id, + 'email': 'wrong', + 'name': 'wrong', + 'phone': '+32455000001', + }]), + (new_wrong, [{ # invalid email but can be fixed afterwards -> matches a potential customer + 'company_id': self.user_employee.company_id, + 'email': 'wrong', + 'name': 'wrong', + 'phone': '+32455000001', + }]), + (self.env['res.partner'], []), + (self.test_partner, [{}]), + (self.env['res.partner'], []), + ] + for ticket, (exp_partners, exp_values_list) in zip(tickets, expected_all): + partners = res[ticket.id] + with self.subTest(ticket_name=ticket.name): + self.assertEqual(partners, exp_partners, f'Found {partners.name} instead of {exp_partners.name}') + for partner, exp_values in zip(partners, exp_values_list, strict=True): + for fname, fvalue in exp_values.items(): + self.assertEqual(partners[fname], fvalue) + + @users('employee') + def test_mail_partner_find_from_emails_ordering(self): + """ Test '_partner_find_from_emails' on a single record, to test notably + ordering and filtering. """ + self.user_employee.write({'company_ids': [(4, self.company_2.id)]}) + # create a mess, mix of portal / internal users + customer, to test ordering + portal_user, internal_user = self.env['res.users'].sudo().create([ + { + 'company_id': self.env.user.company_id.id, + 'email': 'test.ordering@test.example.com', + 'group_ids': [(4, self.env.ref('base.group_portal').id)], + 'login': 'order_portal', + 'name': 'Portal Test User for ordering', + }, { + 'company_id': self.env.user.company_id.id, + 'email': 'test.ordering@test.example.com', + 'group_ids': [(4, self.env.ref('base.group_user').id)], + 'login': 'order_internal', + 'name': 'Zuper Internal Test User for ordering', # name based: after portal + } + ]) + dupe_partners = self.env['res.partner'].create([ + { + 'company_id': self.company_2.id, + 'email': 'test.ordering@test.example.com', + 'name': 'Dupe Partner (C2)', + }, { + 'company_id': False, + 'email': 'test.ordering@test.example.com', + 'name': 'Dupe Partner (NoC)', + }, { + 'company_id': self.env.user.company_id.id, + 'email': 'test.ordering@test.example.com', + 'name': 'Dupe Partner (C1)', + }, { + 'company_id': False, + 'email': '"ID ordering check" ', + 'name': 'A Dupe Partner (NoC)', # name based: before other, but newest, check ID order + }, + ]) + all_partners = portal_user.partner_id + internal_user.partner_id + dupe_partners + self.assertTrue(portal_user.partner_id.id < internal_user.partner_id.id) + self.assertTrue(internal_user.partner_id.id < dupe_partners[0].id) + + for active_partners, followers, expected in [ + # nothing to find + (self.env['res.partner'], self.env['res.partner'], self.env['res.partner']), + # one result, easy yay + (dupe_partners[3], self.env['res.partner'], dupe_partners[3]), + # various partners: should be id ASC, not name-based + (dupe_partners[1] + dupe_partners[3], self.env['res.partner'], dupe_partners[1]), + # involving matching company check: matching company wins + (dupe_partners, self.env['res.partner'], dupe_partners[2]), + # users > partner + (portal_user.partner_id + dupe_partners, self.env['res.partner'], portal_user.partner_id), + # internal user > any other user + (portal_user.partner_id + internal_user.partner_id + dupe_partners, self.env['res.partner'], internal_user.partner_id), + # follower > any other thing + (internal_user.partner_id + dupe_partners, dupe_partners[0], dupe_partners[0]), + ]: + with self.subTest(names=active_partners.mapped('name'), followers=followers.mapped('name')): + # removes (through deactivating) some partners to check ordering + (portal_user + internal_user).filtered(lambda u: u.partner_id not in active_partners).active = False + (all_partners - active_partners).active = False + self.ticket_record.message_subscribe(followers.ids) + + ticket = self.ticket_record.with_user(self.env.user) + partners = ticket._partner_find_from_emails( + {ticket: [ticket.email_from, 'test.ordering@test.example.com']}, + no_create=True, + )[ticket.id] + + # should find just one partner, the other one is not linked to any partner + self.assertEqual(partners, expected, f'Found {partners.name} instead of {expected.name}') + + all_partners.active = True + (portal_user + internal_user).active = True + self.ticket_record.message_unsubscribe(followers.ids) + + @users('employee') + def test_mail_partner_find_from_emails_record(self): + """ On a given record, give several emails and check it is effectively + based on record information. """ + ticket = self.ticket_record.with_user(self.env.user) + partners = ticket._partner_find_from_emails( + {ticket: [ + 'raoul@test.example.com', + ticket.email_from, + self.test_partner.email, + ]}, + no_create=False, + )[ticket.id] + + # new - extra email + other = partners[0] + self.assertEqual(other.company_id, self.user_employee.company_id) + self.assertEqual(other.email, "raoul@test.example.com") + self.assertEqual(other.name, "raoul@test.example.com") + # new - linked to record + customer = partners[1] + self.assertEqual(customer.company_id, self.user_employee.company_id) + self.assertEqual(customer.email, "paulette@test.example.com") + self.assertEqual(customer.phone, "+32455998877", "Should come from record, see '_get_customer_information'") + self.assertEqual(customer.name, "Paulette Vachette") + # found + self.assertEqual(partners[2], self.test_partner) + + @users('employee') + def test_mail_partner_find_from_emails_tweaks(self): + """ Misc tweaks of '_partner_find_from_emails' """ + ticket = self.ticket_record.with_user(self.env.user) + partner = ticket._partner_find_from_emails_single( + [ticket.email_from], + additional_values={'paulette@test.example.com': {'name': 'Forced Name', 'company_id': False}}, + no_create=False) + self.assertFalse(partner.company_id, 'Forced by additional values') + self.assertEqual(partner.email, 'paulette@test.example.com') + self.assertEqual(partner.name, 'Forced Name', 'Forced by additional values') + self.assertEqual(partner.phone, '+32455998877') + + @users('employee') + @warmup + def test_message_get_default_recipients(self): + void_partner = self.env['res.partner'].sudo().create({'name': 'No Email'}) + test_records = self.env['mail.test.recipients'].create([ + { + 'customer_id': self.partner_1.id, + 'contact_ids': [(4, self.partner_2.id), (4, self.partner_1.id)], + 'name': 'Lots of partners', + }, { + 'customer_id': self.partner_1.id, + 'customer_email': '"Forced" ', + 'email_cc': '"CC" ', + 'name': 'Email Forced + CC', + }, { + 'customer_id': self.partner_1.id, + 'customer_email': False, + 'name': 'No email but partner', + }, { + 'customer_email': '"Unknown" ', + 'name': 'Email only', + }, { + 'email_cc': '"CC" ', + 'name': 'CC only', + }, { + 'customer_id': void_partner.id, + 'name': 'No info (void partner)', + }, { + 'name': 'No info at all', + }, { + 'customer_id': self.user_public.partner_id.id, + } + ]) + self.assertFalse(test_records[2].customer_email) + self.flush_tracking() + + # test default computation of recipients + self.env.invalidate_all() + with self.assertQueryCount(14): + defaults_withcc = test_records.with_context()._message_get_default_recipients(with_cc=True) + defaults_withoutcc = test_records.with_context()._message_get_default_recipients() + for record, expected in zip(test_records, [ + { + # customer_id first for partner_ids; partner > email + 'email_cc': '', 'email_to': '', + 'partner_ids': (self.partner_1 + self.partner_2).ids, + }, { + # partner > email + 'email_cc': '"CC" ', 'email_to': '', 'partner_ids': self.partner_1.ids, + }, { + # partner > email + 'email_cc': '', 'email_to': '', 'partner_ids': self.partner_1.ids, + }, { + 'email_cc': '', 'email_to': '"Unknown" ', 'partner_ids': [], + }, { + 'email_cc': '"CC" ', 'email_to': '', 'partner_ids': [], + }, { + 'email_cc': '', 'email_to': '', 'partner_ids': void_partner.ids, + }, { + 'email_cc': '', 'email_to': '', 'partner_ids': [], + }, { # public user should not be proposed + 'email_cc': '', 'email_to': '', 'partner_ids': [], + }, + ], strict=True): + with self.subTest(name=record.name): + self.assertEqual(defaults_withcc[record.id], expected) + self.assertEqual(defaults_withoutcc[record.id], dict(expected, email_cc='')) + + # test default computation of recipients with email prioritized + with patch.object(type(self.env["mail.test.recipients"]), "_mail_defaults_to_email", True): + self.assertEqual( + test_records[1]._message_get_default_recipients()[test_records[1].id], + {'email_cc': '', 'email_to': '"Forced" ', 'partner_ids': []}, + 'Mail: prioritize email should not return partner if email is found' + ) + self.assertEqual( + test_records[2]._message_get_default_recipients()[test_records[2].id], + {'email_cc': '', 'email_to': '', 'partner_ids': self.partner_1.ids}, + 'Mail: prioritize email should not return partner if email is found' + ) + + @users('employee') + def test_message_get_default_recipients_banned(self): + """ Test defensive behavior to avoid contacting critical emails like + aliases, public users, ... """ + tickets = self.env['mail.test.ticket.mc'].create([ + # do not propose public partners + { + 'customer_id': self.user_public.partner_id.id, + 'name': 'Public', + }, + # do not propose root + { + 'customer_id': self.user_root.partner_id.id, + 'name': 'Root', + }, + # do not propose alias domain emails + { + 'email_from': self.mail_alias_domain.catchall_email, + 'name': 'Alias domain email', + }, + # do not propose when partner = alias + { + 'customer_id': self.test_partner_alias.id, + 'name': 'Partner = Alias', + }, + # do not propose alias email + { + 'email_from': self.test_aliases[0].alias_full_name, + 'name': 'Alias email', + }, + # do not propose alias email (left-part pre-17 support) + { + 'email_from': f'{self.test_aliases[2].alias_name}@other.domain', + 'name': 'Alias email (left-part compat)', + }, + # do not propose alias email (even if linked to a partner) + { + 'email_from': self.test_aliases[1].alias_full_name, + 'name': 'Alias email, existing partner', + }, + # propose archived + { + 'customer_id': self.test_partner_archived.id, + 'name': 'Archived partner', + }, + # propose active based on archived user + { + 'customer_id': self.partner_employee_archived.id, + 'name': 'Archived partner', + }, + ]) + expected_all = [ + # nobody to suggest (no public !) + {'email_cc': '', 'email_to': '', 'partner_ids': []}, + # should be nobody to suggest (no root !) + {'email_cc': '', 'email_to': '', 'partner_ids': []}, + # alias domain email is not ok + {'email_cc': '', 'email_to': '', 'partner_ids': []}, + # partner with alias email is not ok + {'email_cc': '', 'email_to': '', 'partner_ids': []}, + # alias email is not ok + {'email_cc': '', 'email_to': '', 'partner_ids': []}, + # left-part compat alias email is not ok + {'email_cc': '', 'email_to': '', 'partner_ids': []}, + # alias email is not ok even if linked to partner + {'email_cc': '', 'email_to': '', 'partner_ids': []}, + # archived is ok, customer + {'email_cc': '', 'email_to': '', 'partner_ids': [self.test_partner_archived.id]}, + # active based on archived user is ok, customer + {'email_cc': '', 'email_to': '', 'partner_ids': [self.partner_employee_archived.id]}, + ] + defaults = tickets._message_get_default_recipients() + for ticket, expected in zip(tickets, expected_all, strict=True): + with self.subTest(ticket_name=ticket.name): + self.assertDictEqual(defaults[ticket.id], expected) + + @users("employee") + def test_message_get_suggested_recipients(self): + """ Test default creation values returned for suggested recipient. """ + ticket = self.ticket_record.with_user(self.env.user) + ticket.message_unsubscribe(ticket.user_id.partner_id.ids) + suggestions = ticket._message_get_suggested_recipients(no_create=True) + self.assertEqual(len(suggestions), 2) + for suggestion, expected in zip(suggestions, [{ + 'create_values': {}, + 'email': self.user_employee.email_normalized, + 'name': self.user_employee.name, + 'partner_id': self.partner_employee.id, + }, { + 'create_values': { + 'company_id': self.env.user.company_id.id, + 'phone': '+32455998877', + }, + 'email': 'paulette@test.example.com', + 'name': 'Paulette Vachette', + 'partner_id': False, + }], strict=True): + self.assertDictEqual(suggestion, expected) + + # existing partner not linked -> should propose it + ticket_partner_email = self.env['mail.test.ticket.mc'].create({ + 'customer_id': False, + 'email_from': self.test_partner.email_formatted, + 'name': 'Partner email', + 'phone_number': '+33199001015', + 'user_id': self.env.user.id, # should not be proposed, already follower + }) + # existing partner -> should propose it + ticket_partner = self.env['mail.test.ticket.mc'].create({ + 'customer_id': self.test_partner.id, + 'email_from': self.test_partner.email_formatted, + 'name': 'Partner', + }) + # existing partner in followers -> should not propose it + ticket_partner_fol = self.env['mail.test.ticket.mc'].create({ + 'customer_id': self.test_partner.id, + 'email_from': self.test_partner.email_formatted, + 'name': 'Partner follower', + }) + # existing partner in followers -> should not propose it + ticket_partner_fol_user = self.env['mail.test.ticket.mc'].create({ + 'customer_id': self.partner_employee.id, + 'email_from': self.partner_employee.email_formatted, + 'name': 'Partner follower (user)', + }) + # existing partner with multiple emails -> should propose only the first one + partner_multiemail = self.test_partner.copy({'email': 'test1.external@example.com,test2.external@example.com'}) + ticket_partner_multiemail = self.env['mail.test.ticket.mc'].create({ + 'customer_id': partner_multiemail.id, + 'email_from': partner_multiemail.email_formatted, + 'name': 'Partner Multi-Emails', + }) + ticket_partner_fol.message_subscribe(partner_ids=self.test_partner.ids) + ticket_partner_fol.message_subscribe(partner_ids=self.partner_employee.ids) + for ticket, sugg_partner in zip( + ticket_partner_email + ticket_partner + ticket_partner_fol + ticket_partner_fol_user + ticket_partner_multiemail, + (self.test_partner, self.test_partner, self.test_partner, False, partner_multiemail), + strict=True, + ): + with self.subTest(ticket=ticket.name): + suggestions = ticket._message_get_suggested_recipients(no_create=True) + if sugg_partner: + self.assertEqual(len(suggestions), 1) + self.assertDictEqual( + suggestions[0], + { + 'create_values': {}, + 'email': sugg_partner.email_normalized, + 'name': sugg_partner.name, + 'partner_id': sugg_partner.id, + } + ) + else: + self.assertEqual(len(suggestions), 0) + + @users("employee") + def test_message_get_suggested_recipients_banned(self): + """ Ban list: public partners, aliases, alias domains """ + domains = self.env['mail.alias.domain'].sudo().search([]) + domains_cc_list = [] + for domain in domains: + domains_cc_list += [ + f'"Bounce {domain.name}" <{domain.bounce_email}>', + f'"Catchall {domain.name}" <{domain.catchall_email}>', + f'"Default {domain.name}" <{domain.default_from_email}>', + ] + tickets = self.env['mail.test.ticket.mc'].create([ + # do not propose public partners + { + 'customer_id': self.user_public.partner_id.id, + 'name': 'Public', + }, + # do not propose root + { + 'customer_id': self.user_root.partner_id.id, + 'name': 'Root', + }, + # valid, but with message containing alias domain emails + { + 'customer_id': self.test_partner.id, + 'name': 'Valid partner + invalid domain emails in discussion', + }, + # valid, but with message containing alias emails or partners + { + 'customer_id': self.test_partner_archived.id, + 'name': 'Valid partner archived + invalid in discussion', + }, + ]) + tickets[2].message_post( + author_id=self.user_root.partner_id.id, + body='Message with lots of invalid emails', + incoming_email_cc=', '.join(domains_cc_list), + message_type='email', + subtype_id=self.env.ref('mail.mt_comment').id, + ) + tickets[3].message_post( + author_id=False, + email_from=self.mail_alias_domain.bounce_email, + body='Message with alias emails and partners', + message_type='email', + incoming_email_to=f'"Alias" <{self.test_aliases[0].alias_full_name}>', + partner_ids=(self.test_partner_alias + self.test_partner_catchall).ids, + subtype_id=self.env.ref('mail.mt_comment').id, + ) + expected_all = [ + # nobody to suggest (no public !) + [], + #nobody to suggest (no root !) + [], + # only valid is the customer + [ + { + 'create_values': {}, + 'email': self.test_partner.email_normalized, + 'name': self.test_partner.name, + 'partner_id': self.test_partner.id, + }, + ], + # only valid is the customer (and not aliases nor partner with alias email) + [ + { + 'create_values': {}, + 'email': self.test_partner_archived.email_normalized, + 'name': self.test_partner_archived.name, + 'partner_id': self.test_partner_archived.id, + }, + ], + ] + suggested_all = tickets._message_get_suggested_recipients_batch(no_create=True, reply_discussion=True) + for ticket, expected in zip(tickets, expected_all, strict=True): + with self.subTest(ticket_name=ticket.name): + suggested = suggested_all[ticket.id] + for suggestion, expected_sugg in zip(suggested, expected, strict=True): + self.assertDictEqual(suggestion, expected_sugg) + + @users("employee") + def test_message_get_suggested_recipients_conversation(self): + """ Test suggested recipients in a conversation based on discussion + history: email_{cc/to} of previous messages, ... """ + test_cc_tuples = [ + ('Test Record Cc', 'test.record.cc@test.example.com'), + ('Test Msg Cc', 'test.msg.cc@test.example.com'), + ('Test Msg Cc 2', 'test.msg.cc.2@test.example.com'), + ] + test_to_tuples = [ + ('Test Msg To', 'test.msg.to@test.example.com'), + ('Test Msg To 2', 'test.msg.to.2@test.example.com'), + ] + test_emails = [x[1] for x in test_cc_tuples + test_to_tuples] + self.assertFalse(self.env['res.partner'].search([('email_normalized', 'in', test_emails)])) + + test_record = self.env['mail.test.recipients'].create({ + 'email_cc': tools.mail.formataddr(test_cc_tuples[0]), + 'name': 'Test Recipients', + }) + messages = self.env['mail.message'] + for user, post_values in [ + (self.user_root, { + 'author_id': self.user_portal.partner_id.id, + 'body': 'First incoming email', + 'email_from': self.user_portal.email_formatted, + 'incoming_email_cc': tools.mail.formataddr(test_cc_tuples[1]), + 'incoming_email_to': tools.mail.formataddr(test_to_tuples[0]), + 'message_type': 'email', + 'subtype_id': self.env.ref('mail.mt_comment').id, + }), + (self.user_root, { + 'body': 'Some automated email', + 'message_type': 'email_outgoing', + 'partner_ids': self.user_portal.partner_id.ids, + 'subtype_id': self.env.ref('mail.mt_comment').id, + }), + (self.user_employee, { + 'body': 'Salesman reply by email', + 'incoming_email_cc': tools.mail.formataddr(test_cc_tuples[2]), + 'incoming_email_to': tools.mail.formataddr(test_to_tuples[1]), + 'message_type': 'email', + 'subtype_id': self.env.ref('mail.mt_comment').id, + }), + ]: + messages += test_record.with_user(user).message_post(**post_values) + self.assertEqual(test_record.message_partner_ids, self.user_employee.partner_id) + + recipients = test_record._message_get_suggested_recipients(reply_message=messages[0], no_create=True) + for recipient, expected in zip(recipients, [ + { # partner first: author of message + 'create_values': {}, + 'email': self.user_portal.email_normalized, + 'name': self.user_portal.name, + 'partner_id': self.user_portal.partner_id.id, + }, { # override of model for email_cc + 'create_values': {}, + 'email': test_cc_tuples[0][1], + 'name': test_cc_tuples[0][0], + 'partner_id': False, + }, { # replying message to + 'create_values': {}, + 'email': test_to_tuples[0][1], + 'name': test_to_tuples[0][0], + 'partner_id': False, + }, { # replying message cc + 'create_values': {}, + 'email': test_cc_tuples[1][1], + 'name': test_cc_tuples[1][0], + 'partner_id': False, + }, + ], strict=True): + with self.subTest(): + self.assertDictEqual(recipient, expected) + + recipients = test_record._message_get_suggested_recipients(reply_message=messages[1], no_create=True) + for recipient, expected in zip(recipients, [ + { # partner first: recipient of message + 'create_values': {}, + 'email': self.user_portal.email_normalized, + 'name': self.user_portal.name, + 'partner_id': self.user_portal.partner_id.id, + }, { # override of model for email_cc + 'create_values': {}, + 'email': test_cc_tuples[0][1], + 'name': test_cc_tuples[0][0], + 'partner_id': False, + }, # and not author, as it is odoobot's email + ], strict=True): + with self.subTest(): + self.assertDictEqual(recipient, expected) + + # discussion: should be last message + recipients = test_record._message_get_suggested_recipients(reply_discussion=True, no_create=True) + for recipient, expected in zip(recipients, [ + { # override of model for email_cc + 'create_values': {}, + 'email': test_cc_tuples[0][1], + 'name': test_cc_tuples[0][0], + 'partner_id': False, + }, { # replying message to + 'create_values': {}, + 'email': test_to_tuples[1][1], + 'name': test_to_tuples[1][0], + 'partner_id': False, + }, { # replying message cc + 'create_values': {}, + 'email': test_cc_tuples[2][1], + 'name': test_cc_tuples[2][0], + 'partner_id': False, + }, # and not author as he is already follower + ], strict=True): + with self.subTest(): + self.assertDictEqual(recipient, expected) + + # check with partner creation + recipients = test_record._message_get_suggested_recipients(reply_message=messages[0], no_create=False) + new_partners = self.env['res.partner'].search([('email_normalized', 'in', test_emails)], order='id ASC') + self.assertEqual(len(new_partners), 3, 'Find or create should have created 3 partners, one / email') + new_to, new_cc_0, new_cc_1 = new_partners + for recipient, expected in zip(recipients, [ + { # partner first: author of message + 'create_values': {}, + 'email': self.user_portal.email_normalized, + 'name': self.user_portal.name, + 'partner_id': self.user_portal.partner_id.id, + }, { # override of model for email_cc + 'email': test_cc_tuples[0][1], + 'name': test_cc_tuples[0][0], + 'partner_id': new_to.id, + 'create_values': {}, + }, { # replying message to + 'email': test_to_tuples[0][1], + 'name': test_to_tuples[0][0], + 'partner_id': new_cc_0.id, + 'create_values': {}, + }, { # replying message cc + 'email': test_cc_tuples[1][1], + 'name': test_cc_tuples[1][0], + 'partner_id': new_cc_1.id, + 'create_values': {}, + }, + ], strict=True): + with self.subTest(): + self.assertDictEqual(recipient, expected) + + @users("employee") + def test_message_get_suggested_recipients_conversation_filter(self): + """ Test sorting of messages when suggested is used in reply-all based + on last message. """ + test_record = self.env['mail.test.recipients'].create({ + 'email_cc': '"Test Cc" ', + 'name': 'Test Recipients', + }) + base_expected = [{ + 'create_values': {}, + 'email': 'test.cc.1@test.example.com', + 'name': 'Test Cc', + 'partner_id': False, + }] + for user, post_values, expected_add in [ + ( + self.user_employee, + { + 'body': 'Note with pings, to ignore', + 'message_type': 'comment', + 'subtype_id': self.env.ref('mail.mt_note').id, + }, + [] + ), ( + self.user_root, + { + 'author_id': False, + 'email_from': '"Outdated" ', + 'body': 'Incoming (old) email', + 'message_type': 'email', + 'subtype_id': self.env.ref('mail.mt_comment').id, + }, + [{ + 'create_values': {}, + 'email': 'outdated@test.example.com', + 'name': 'Outdated', + 'partner_id': False, + }], + ), ( + self.user_employee, + { + 'body': 'Some discussion', + 'message_type': 'comment', + 'partner_ids': self.user_portal.partner_id.ids, + 'subtype_id': self.env.ref('mail.mt_comment').id, + }, + [{ + 'create_values': {}, + 'email': self.user_portal.email_normalized, + 'name': self.user_portal.name, + 'partner_id': self.user_portal.partner_id.id, + }, { + 'create_values': {}, + 'email': self.user_employee.email_normalized, + 'name': self.user_employee.name, + 'partner_id': self.user_employee.partner_id.id, + }], + ), ( + self.user_root, + { + 'author_id': self.partner_employee_2.id, + 'body': 'Some marketing email', + 'message_type': 'email_outgoing', + 'subtype_id': self.env.ref('mail.mt_note').id, + }, + [{ + 'create_values': {}, + 'email': self.user_portal.email_normalized, + 'name': self.user_portal.name, + 'partner_id': self.user_portal.partner_id.id, + }, { + 'create_values': {}, + 'email': self.user_employee.email_normalized, + 'name': self.user_employee.name, + 'partner_id': self.user_employee.partner_id.id, + }], + ), + ]: + test_record.with_user(user).message_post(**post_values) + test_record.message_unsubscribe(partner_ids=test_record.message_partner_ids.ids) + suggested = test_record._message_get_suggested_recipients(reply_discussion=True, no_create=True) + expected = base_expected + expected_add + # as we can't use sorted directly, reorder manually, hey + expected.sort(key=lambda item: item['partner_id'], reverse=True) + with self.subTest(message=post_values['body']): + for sugg, expected_sugg in zip(suggested, expected, strict=True): + self.assertDictEqual(sugg, expected_sugg) @mute_logger('openerp.addons.mail.models.mail_mail') @users('employee') @@ -35,7 +938,7 @@ class TestAPI(TestMailCommon, TestRecipients): # post a note message = ticket_record.message_post( attachment_ids=attachments.ids, - body="

Initial Body

", + body=Markup("

Initial Body

"), message_type="comment", partner_ids=self.partner_1.ids, ) @@ -45,27 +948,39 @@ class TestAPI(TestMailCommon, TestRecipients): self.assertEqual(message.body, "

Initial Body

") self.assertEqual(message.subtype_id, self.env.ref('mail.mt_note')) + # clear the content when having attachments should show edit label + ticket_record._message_update_content(message, body="") + self.assertEqual(message.attachment_ids, attachments) + self.assertEqual(message.body, Markup('')) # update the content with new attachments new_attachments = self.env['ir.attachment'].create( self._generate_attachments_data(2, 'mail.compose.message', 0) ) ticket_record._message_update_content( - message, "

New Body

", - attachment_ids=new_attachments.ids + message, + body=Markup("
New Body
"), + attachment_ids=new_attachments.ids, ) self.assertEqual(message.attachment_ids, attachments + new_attachments) self.assertEqual(set(message.mapped('attachment_ids.res_id')), set(ticket_record.ids)) self.assertEqual(set(message.mapped('attachment_ids.res_model')), set([ticket_record._name])) - self.assertEqual(message.body, "

New Body

") + self.assertEqual(message.body, Markup('
New Body
')) # void attachments ticket_record._message_update_content( - message, "

Another Body, void attachments

", - attachment_ids=[] + message, + body=Markup("

Another Body, void attachments

"), + attachment_ids=[], ) self.assertFalse(message.attachment_ids) self.assertFalse((attachments + new_attachments).exists()) - self.assertEqual(message.body, "

Another Body, void attachments

") + self.assertEqual(message.body, Markup('

Another Body, void attachments

')) + + ticket_record._message_update_content( + message, + body=Markup("line1
edit
line2
line3"), + ) + self.assertEqual(message.body, Markup('

line1
edit
line2
line3

')) @mute_logger('openerp.addons.mail.models.mail_mail') @users('employee') @@ -73,59 +988,103 @@ class TestAPI(TestMailCommon, TestRecipients): """ Test cases where updating content should be prevented """ ticket_record = self.ticket_record.with_env(self.env) - # cannot edit user comments (subtype) message = ticket_record.message_post( body="

Initial Body

", message_type="comment", subtype_id=self.env.ref('mail.mt_comment').id, ) - with self.assertRaises(exceptions.UserError): - ticket_record._message_update_content( - message, "

New Body

" - ) + ticket_record._message_update_content(message, body="

New Body 1

") message.sudo().write({'subtype_id': self.env.ref('mail.mt_note')}) - ticket_record._message_update_content( - message, "

New Body

" - ) + ticket_record._message_update_content(message, body="

New Body 2

") # cannot edit notifications - for message_type in ['notification', 'user_notification', 'email']: + for message_type in ['notification', 'user_notification', 'email', 'email_outgoing', 'auto_comment']: message.sudo().write({'message_type': message_type}) with self.assertRaises(exceptions.UserError): - ticket_record._message_update_content( - message, "

New Body

" - ) + ticket_record._message_update_content(message, body="

New Body

") @tagged('mail_thread') -class TestChatterTweaks(TestMailCommon, TestRecipients): +class TestChatterTweaks(ThreadRecipients): @classmethod def setUpClass(cls): super(TestChatterTweaks, cls).setUpClass() cls.test_record = cls.env['mail.test.simple'].with_context(cls._test_context).create({'name': 'Test', 'email_from': 'ignasse@example.com'}) + @users('employee') + def test_post_headers_recipients_limit(self): + test_record = self.test_record.with_env(self.env) + + for recipients_limit, has_header in ( + (0, False), + (2, False), # zut alors, 2 recipients is the limit ! + (10, True), + ): + MailTestSimple._CUSTOMER_HEADERS_LIMIT_COUNT = recipients_limit + with self.mock_mail_gateway(mail_unlink_sent=False), \ + self.mock_mail_app(): + message = test_record.message_post( + body='With To Headers', + partner_ids=(self.test_partner + self.test_partner_catchall).ids, + ) + + headers = { + 'Return-Path': f'{self.mail_alias_domain.bounce_email}', + 'X-Custom': 'Done', # model override + 'X-Odoo-Objects': f'{test_record._name}-{test_record.id}', + } + if has_header: + headers['X-Msg-To-Add'] = f'{self.test_partner.email_formatted},{self.test_partner_catchall.email_formatted}' + for recipient in self.test_partner + self.test_partner_catchall: + self.assertMailMail( + recipient, + 'sent', + author=self.partner_employee, + mail_message=message, + email_values={ + 'headers': headers, + }, + fields_values={ + 'headers': headers, + }, + ) + def test_post_no_subscribe_author(self): original = self.test_record.message_follower_ids - self.test_record.with_user(self.user_employee).with_context({'mail_create_nosubscribe': True}).message_post( + self.test_record.with_user(self.user_employee).with_context({'mail_post_autofollow_author_skip': True}).message_post( body='Test Body', message_type='comment', subtype_xmlid='mail.mt_comment') self.assertEqual(self.test_record.message_follower_ids.mapped('partner_id'), original.mapped('partner_id')) @mute_logger('odoo.addons.mail.models.mail_mail') def test_post_no_subscribe_recipients(self): original = self.test_record.message_follower_ids - self.test_record.with_user(self.user_employee).with_context({'mail_create_nosubscribe': True}).message_post( + self.test_record.with_user(self.user_employee).with_context({'mail_post_autofollow_author_skip': True}).message_post( body='Test Body', message_type='comment', subtype_xmlid='mail.mt_comment', partner_ids=[self.partner_1.id, self.partner_2.id]) self.assertEqual(self.test_record.message_follower_ids.mapped('partner_id'), original.mapped('partner_id')) @mute_logger('odoo.addons.mail.models.mail_mail') def test_post_subscribe_recipients(self): original = self.test_record.message_follower_ids - self.test_record.with_user(self.user_employee).with_context({'mail_create_nosubscribe': True, 'mail_post_autofollow': True}).message_post( + self.test_record.with_user(self.user_employee).with_context({'mail_post_autofollow_author_skip': True, 'mail_post_autofollow': True}).message_post( body='Test Body', message_type='comment', subtype_xmlid='mail.mt_comment', partner_ids=[self.partner_1.id, self.partner_2.id]) self.assertEqual(self.test_record.message_follower_ids.mapped('partner_id'), original.mapped('partner_id') | self.partner_1 | self.partner_2) + # check _mail_thread_customer class attribute + new_record = self.env['mail.test.thread.customer'].create({ + 'customer_id': self.partner_1.id, + }) + self.assertFalse(new_record.message_partner_ids) + msg = new_record.with_user(self.user_employee).with_context(mail_post_autofollow_author_skip=True).message_post( + body='Test Body', message_type='comment', + partner_ids=(self.partner_1 + self.partner_2).ids, + subtype_id=self.env.ref('mail.mt_comment').id, + ) + self.assertEqual(msg.notified_partner_ids, self.partner_1 + self.partner_2) + self.assertEqual(new_record.message_partner_ids, self.partner_1, + 'Customer was found and added as follower automatically when pinged') + @mute_logger('odoo.addons.mail.models.mail_mail') def test_chatter_context_cleaning(self): """ Test default keys are not propagated to message creation as it may @@ -199,7 +1158,7 @@ class TestChatterTweaks(TestMailCommon, TestRecipients): @tagged('mail_thread') -class TestDiscuss(TestMailCommon, TestRecipients): +class TestDiscuss(MailCommon, TestRecipients): @classmethod def setUpClass(cls): @@ -211,16 +1170,13 @@ class TestDiscuss(TestMailCommon, TestRecipients): @mute_logger('openerp.addons.mail.models.mail_mail') def test_mark_all_as_read(self): - def _employee_crash(*args, **kwargs): + def _employee_crash(recordset, operation): """ If employee is test employee, consider they have no access on document """ - recordset = args[0] if recordset.env.uid == self.user_employee.id and not recordset.env.su: - if kwargs.get('raise_exception', True): - raise exceptions.AccessError('Hop hop hop Ernest, please step back.') - return False + return recordset, lambda: exceptions.AccessError('Hop hop hop Ernest, please step back.') return DEFAULT - with patch.object(MailTestSimple, 'check_access_rights', autospec=True, side_effect=_employee_crash): + with patch.object(MailTestSimple, '_check_access', autospec=True, side_effect=_employee_crash): with self.assertRaises(exceptions.AccessError): self.env['mail.test.simple'].with_user(self.user_employee).browse(self.test_record.ids).read(['name']) @@ -228,7 +1184,6 @@ class TestDiscuss(TestMailCommon, TestRecipients): # mark all as read clear needactions msg1 = self.test_record.message_post(body='Test', message_type='comment', subtype_xmlid='mail.mt_comment', partner_ids=[employee_partner.id]) - self._reset_bus() with self.assertBus( [(self.cr.dbname, 'res.partner', employee_partner.id)], message_items=[{ @@ -255,7 +1210,6 @@ class TestDiscuss(TestMailCommon, TestRecipients): na_count = employee_partner._get_needaction_count() self.assertEqual(na_count, 1, "message not accessible is currently still counted") - self._reset_bus() with self.assertBus( [(self.cr.dbname, 'res.partner', employee_partner.id)], message_items=[{ @@ -276,36 +1230,8 @@ class TestDiscuss(TestMailCommon, TestRecipients): partner_ids=[self.user_employee.partner_id.id]) message.with_user(self.user_employee).set_message_done() self.assertMailNotifications(message, [{'notif': [{'partner': self.partner_employee, 'type': 'inbox', 'is_read': True}]}]) - # TDE TODO: it seems bus notifications could be checked - def test_set_star(self): - msg = self.test_record.with_user(self.user_admin).message_post(body='My Body', subject='1') - msg_emp = self.env['mail.message'].with_user(self.user_employee).browse(msg.id) - - # Admin set as starred - msg.toggle_message_starred() - self.assertTrue(msg.starred) - - # Employee set as starred - msg_emp.toggle_message_starred() - self.assertTrue(msg_emp.starred) - - # Do: Admin unstars msg - msg.toggle_message_starred() - self.assertFalse(msg.starred) - self.assertTrue(msg_emp.starred) - - @mute_logger('odoo.addons.mail.models.mail_mail') - def test_mail_cc_recipient_suggestion(self): - record = self.env['mail.test.cc'].create({'email_cc': 'cc1@example.com, cc2@example.com, cc3 '}) - suggestions = record._message_get_suggested_recipients()[record.id] - self.assertEqual(sorted(suggestions), [ - (False, '"cc3" ', None, 'CC Email'), - (False, 'cc1@example.com', None, 'CC Email'), - (False, 'cc2@example.com', None, 'CC Email'), - ], 'cc should be in suggestions') - - def test_inbox_message_fetch_needaction(self): + def test_message_fetch_needaction(self): user1 = self.env['res.users'].create({'login': 'user1', 'name': 'User 1'}) user1.notification_type = 'inbox' user2 = self.env['res.users'].create({'login': 'user2', 'name': 'User 2'}) @@ -314,18 +1240,40 @@ class TestDiscuss(TestMailCommon, TestRecipients): message2 = self.test_record.with_user(self.user_admin).message_post(body='Message 2', partner_ids=[user1.partner_id.id, user2.partner_id.id]) # both notified users should have the 2 messages in Inbox initially - messages = self.env['mail.message'].with_user(user1)._message_fetch(domain=[['needaction', '=', True]]) - self.assertEqual(len(messages), 2) - messages = self.env['mail.message'].with_user(user2)._message_fetch(domain=[['needaction', '=', True]]) - self.assertEqual(len(messages), 2) + res = self.env['mail.message'].with_user(user1)._message_fetch(domain=[['needaction', '=', True]]) + self.assertEqual(len(res["messages"]), 2) + res = self.env['mail.message'].with_user(user2)._message_fetch(domain=[['needaction', '=', True]]) + self.assertEqual(len(res["messages"]), 2) # first user is marking one message as done: the other message is still Inbox, while the other user still has the 2 messages in Inbox message1.with_user(user1).set_message_done() - messages = self.env['mail.message'].with_user(user1)._message_fetch(domain=[['needaction', '=', True]]) - self.assertEqual(len(messages), 1) - self.assertEqual(messages[0].id, message2.id) - messages = self.env['mail.message'].with_user(user2)._message_fetch(domain=[['needaction', '=', True]]) - self.assertEqual(len(messages), 2) + res = self.env['mail.message'].with_user(user1)._message_fetch(domain=[['needaction', '=', True]]) + self.assertEqual(len(res["messages"]), 1) + self.assertEqual(res["messages"][0].id, message2.id) + res = self.env['mail.message'].with_user(user2)._message_fetch(domain=[['needaction', '=', True]]) + self.assertEqual(len(res["messages"]), 2) + + @users("employee") + def test_unlink_notification_message(self): + message = self.test_record.with_user(self.user_admin).message_notify( + body='test', + partner_ids=[self.partner_2.id], + ) + self.assertEqual(len(message), 1, "Test message should have been posted") + self.test_record.unlink() + self.assertFalse(message.exists(), "Test message should have been deleted") + + +@tagged('mail_thread') +class TestNotification(MailCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.test_record = cls.env['mail.test.simple'].create({ + 'name': 'Test', + 'email_from': 'ignasse@example.com' + }) def test_notification_has_error_filter(self): """Ensure message_has_error filter is only returning threads for which @@ -355,40 +1303,219 @@ class TestDiscuss(TestMailCommon, TestRecipients): threads_admin = self.test_record.with_user(self.user_admin).search([('message_has_error', '=', True)]) self.assertEqual(len(threads_admin), 0) - @users("employee") - def test_unlink_notification_message(self): - channel = self.env['mail.channel'].create({'name': 'testChannel'}) - notification_msg = channel.with_user(self.user_admin).message_notify( - body='test', - message_type='user_notification', - partner_ids=[self.partner_2.id], + +@tagged('mail_thread', 'mail_nothread') +class TestNoThread(MailCommon, TestRecipients): + """ Specific tests for cross models thread features """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.test_record_nothread = cls.env['mail.test.nothread'].with_user(cls.user_employee).create({ + 'customer_id': cls.partner_1.id, + 'name': 'Not A Thread', + }) + cls.test_template = cls.env['mail.template'].create({ + 'body_html': 'Hello ', + 'model_id': cls.env['ir.model']._get_id('mail.test.nothread'), + 'subject': 'Subject {{ object.name }}', + 'use_default_to': True, + }) + cls.test_attachment = cls.env['ir.attachment'].with_user(cls.user_employee).create({ + 'name': 'Test Attachment', + 'datas': base64.b64encode(b'This is test attachment content'), + 'res_model': cls.test_record_nothread._name, + 'res_id': cls.test_record_nothread.id, + 'mimetype': 'text/plain', + }) + + @users('employee') + def test_mail_composer_comment_with_template(self): + """ This test simulates using a template, opening a composer and posting + a message to a non-thread record, which transforms into a user notification. + Check recipients computation works in non-thread mode. """ + record = self.test_record_nothread.with_env(self.env) + template = self.test_template.with_env(self.env) + mail_compose_message = self.env['mail.compose.message'].create({ + 'attachment_ids': [(6, 0, [self.test_attachment.id])], + 'composition_mode': 'comment', + 'model': record._name, + 'template_id': template.id, + 'res_ids': record.ids, + }) + with self.mock_mail_gateway(): + _mail, message = mail_compose_message._action_send_mail() + self.assertMailNotifications( + message, + [{ + 'content': f'Hello {record.name}', + # not mail.thread -> automatically transformed using message_notify + 'message_type': 'user_notification', + 'notif': [{'partner': self.partner_1, 'type': 'email',}], + }], ) - with self.assertRaises(exceptions.AccessError): - notification_msg.with_env(self.env)._message_format(['id', 'body', 'date', 'author_id', 'email_from']) + @users('employee') + def test_mail_composer_mail_with_template(self): + """ This test simulates scenarios where a required method called `_process_attachments_for_post` is missing, + in such case composer should fallback to the method implementation in mail.thread. """ + record = self.test_record_nothread.with_env(self.env) + template = self.test_template.with_env(self.env) + mail_compose_message = self.env['mail.compose.message'].create({ + 'composition_mode': 'mass_mail', + 'model': 'mail.test.nothread', + 'template_id': template.id, + 'res_ids': record.ids, + 'attachment_ids': [(6, 0, [self.test_attachment.id])] + }) + with self.mock_mail_gateway(): + mail_compose_message.action_send_mail() + self.assertEqual(self._new_mails.attachment_ids['datas'], base64.b64encode(b'This is test attachment content'), + "The attachment was not included correctly in the sent message") - channel_message = self.env['mail.message'].sudo().search([('model', '=', 'mail.channel'), ('res_id', 'in', channel.ids)]) - self.assertEqual(len(channel_message), 1, "Test message should have been posted") + @users('employee') + def test_mail_template_send_mail(self): + template = self.test_template.with_env(self.env) + test_record = self.test_record_nothread.with_env(self.env) + with self.mock_mail_gateway(): + template.send_mail( + test_record.id, + email_layout_xmlid='mail.mail_notification_light', + ) + self.assertMailMail( + self.partner_1, + 'outgoing', + ) - channel.sudo().unlink() - remaining_message = channel_message.exists() - self.assertEqual(len(remaining_message), 0, "Test message should have been deleted") + @users('employee') + def test_message_to_store(self): + """ Test formatting of messages when linked to non-thread models. + Format could be asked notably if an inbox notification due to a + 'message_notify' happens. """ + test_record = self.test_record_nothread.with_env(self.env) + message = self.env['mail.message'].create({ + 'model': test_record._name, + 'res_id': test_record.id, + }) + formatted = Store().add(message).get_result()["mail.message"][0] + self.assertEqual(formatted['default_subject'], test_record.name) + self.assertEqual(formatted['record_name'], test_record.name) -@tagged('mail_thread') -class TestNoThread(TestMailCommon, TestRecipients): - """ Specific tests for cross models thread features """ + test_record.write({'name': 'Just Test'}) + message.invalidate_recordset(['record_name']) + formatted = Store().add(message).get_result()["mail.message"][0] + self.assertEqual(formatted['default_subject'], 'Just Test') + self.assertEqual(formatted['record_name'], 'Just Test') @users('employee') def test_message_notify(self): - test_record = self.env['mail.test.nothread'].create({ - 'customer_id': self.partner_1.id, - 'name': 'Not A Thread', + """ Test notifying on non-thread models, using MailThread as an abstract + class with model and res_id giving the record used for notification. + + Test default subject computation is also tested. """ + test_record = self.test_record_nothread.with_env(self.env) + + for subject in ["Test Notify", False]: + with self.subTest(): + with self.assertPostNotifications([{ + 'content': 'Hello Paulo', + 'email_values': { + 'reply_to': tools.mail.formataddr(( + self.partner_employee.name, + self.company_admin.catchall_email, + )), + }, + 'message_type': 'user_notification', + 'notif': [{ + 'check_send': True, + 'is_read': True, + 'partner': self.partner_2, + 'status': 'sent', + 'type': 'email', + }], + 'subtype': 'mail.mt_note', + }]): + _message = self.env['mail.thread'].message_notify( + body='

Hello Paulo

', + model=test_record._name, + partner_ids=self.partner_2.ids, + res_id=test_record.id, + subject=subject, + ) + + @users('employee') + def test_message_notify_composer(self): + """ Test comment mode on composer which triggers a notify when model + does not inherit from mail thread. """ + test_records, _test_partners = self._create_records_for_batch('mail.test.nothread', 2) + + test_reports = self.env['ir.actions.report'].sudo().create([ + { + 'name': 'Test Report on Mail Test Ticket', + 'model': test_records._name, + 'print_report_name': "'TestReport for %s' % object.name", + 'report_type': 'qweb-pdf', + 'report_name': 'test_mail.mail_test_ticket_test_template', + }, { + 'name': 'Test Report 2 on Mail Test Ticket', + 'model': test_records._name, + 'print_report_name': "'TestReport2 for %s' % object.name", + 'report_type': 'qweb-pdf', + 'report_name': 'test_mail.mail_test_ticket_test_template_2', + } + ]) + test_template = self.env['mail.template'].create({ + 'auto_delete': True, + 'body_html': '

TemplateBody

', + 'email_from': '{{ (user.email_formatted) }}', + 'email_to': '', + 'mail_server_id': self.mail_server_domain.id, + 'partner_to': '{{ object.customer_id.id if object.customer_id else "" }}', + 'name': 'TestTemplate', + 'model_id': self.env['ir.model']._get(test_records._name).id, + 'reply_to': '{{ ctx.get("custom_reply_to") or "info@test.example.com" }}', + 'report_template_ids': [(6, 0, test_reports.ids)], + 'scheduled_date': '{{ (object.create_date or datetime.datetime(2022, 12, 26, 18, 0, 0)) + datetime.timedelta(days=2) }}', + 'subject': 'TemplateSubject {{ object.name }}', }) + attachment_data = self._generate_attachments_data(2, test_template._name, test_template.id) + test_template.write({'attachment_ids': [(0, 0, a) for a in attachment_data]}) + + ctx = { + 'default_composition_mode': 'comment', + 'default_model': test_records._name, + 'default_res_domain': [('id', 'in', test_records.ids)], + 'default_template_id': test_template.id, + } + # open a composer and run it in comment mode + composer_form = Form(self.env['mail.compose.message'].with_context(ctx)) + composer = composer_form.save() + + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + _, messages = composer._action_send_mail() + + self.assertEqual(len(messages), 2) + for record, message in zip(test_records, messages, strict=True): + self.assertEqual( + sorted(message.mapped('attachment_ids.name')), + sorted(['AttFileName_00.txt', 'AttFileName_01.txt', + f'TestReport2 for {record.name}.html', + f'TestReport for {record.name}.html']) + ) + self.assertEqual(len(messages.attachment_ids), 8, 'No attachments should be shared') + + @users('employee') + def test_message_notify_norecord(self): + """ Test notifying on no record, just using the abstract model itself. """ with self.assertPostNotifications([{ 'content': 'Hello Paulo', 'email_values': { - 'reply_to': self.company_admin.catchall_formatted, + 'reply_to': tools.mail.formataddr(( + self.partner_employee.name, + self.company_admin.catchall_email, + )), + 'subject': 'Test Notify', }, 'message_type': 'user_notification', 'notif': [{ @@ -401,9 +1528,7 @@ class TestNoThread(TestMailCommon, TestRecipients): 'subtype': 'mail.mt_note', }]): _message = self.env['mail.thread'].message_notify( - body='

Hello Paulo

', - model=test_record._name, - res_id=test_record.id, + body=Markup('

Hello Paulo

'), + partner_ids=self.partner_2.ids, subject='Test Notify', - partner_ids=self.partner_2.ids ) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_thread_mixins.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_thread_mixins.py index fb2c2e2..07b70e1 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_thread_mixins.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_thread_mixins.py @@ -1,14 +1,187 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import datetime from odoo import exceptions, tools -from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients -from odoo.tests.common import tagged +from odoo.addons.mail.tests.common import MailCommon +from odoo.addons.mail.tests.common_tracking import MailTrackingDurationMixinCase +from odoo.addons.test_mail.tests.common import TestRecipients +from odoo.tests.common import tagged, users from odoo.tools import mute_logger +@tagged('mail_thread', 'mail_track', 'is_query_count') +class TestMailTrackingDurationMixin(MailTrackingDurationMixinCase): + + @classmethod + def setUpClass(cls): + super().setUpClass('mail.test.track.duration.mixin') + + def test_mail_tracking_duration(self): + self._test_record_duration_tracking() + + def test_mail_tracking_duration_batch(self): + self._test_record_duration_tracking_batch() + + def test_queries_batch_mail_tracking_duration(self): + self._test_queries_batch_duration_tracking() + + +@tagged('mail_thread', 'mail_track') +class TestMailThreadRottingMixin(MailTrackingDurationMixinCase): + + @classmethod + def setUpClass(cls): + super().setUpClass('mail.test.rotting.resource') + + [cls.stage_new, cls.stage_qualification, cls.stage_finished] = cls.env['mail.test.rotting.stage'].create([ + { + 'name': 'stage_new', + 'rotting_threshold_days': 3, + }, { + 'name': 'stage_qualification', + 'rotting_threshold_days': 5, + }, { + 'name': 'stage_finished', + 'rotting_threshold_days': 1, + 'no_rot': True, + }, + ]) + + def test_resource_rotting(self): + # create dates for the test + jan1 = datetime(2025, 1, 1) + jan5 = datetime(2025, 1, 5) + jan7 = datetime(2025, 1, 7) + jan12 = datetime(2025, 1, 12) + jan28 = datetime(2025, 1, 28) + + # create resources for the test, created on jan 1 + with self.mock_datetime_and_now(jan1): + items = [item1, item2, item3, item_done, item_won] = self.env['mail.test.rotting.resource'].create([ + { + 'name': 'item1', + 'stage_id': self.stage_new.id, + }, { + 'name': 'item2', + 'stage_id': self.stage_qualification.id, + }, { + 'name': 'item3', + 'stage_id': self.stage_new.id, + }, { + 'name': 'item_done', + 'stage_id': self.stage_qualification.id, + 'done': True, + }, { + 'name': 'item_wonStage', + 'stage_id': self.stage_finished.id, + }, + ]) + items.flush_recordset(['date_last_stage_update']) # precalculate stage update() + + with self.mock_datetime_and_now(jan5): + # need to invalidate on date change to ensure rotting computations + items.invalidate_recordset(['is_rotting']) + for item in [item1, item3]: + self.assertTrue( + item.is_rotting, + 'on jan 5: it\'s been four days, so only items in stage_new should be rotting', + ) + self.assertEqual(item.rotting_days, 4) + for item in [item2, item_done, item_won]: + self.assertFalse( + item.is_rotting, + 'on jan 5: it\'s been four days, so only items in stage_new should be rotting', + ) + self.assertEqual(item.rotting_days, 0) + + item3.name = 'item3 edited' + self.assertTrue( + item3.is_rotting, + 'writing to an item doesn\'t affect its rotting status', + ) + + with self.mock_datetime_and_now(jan7): + items.invalidate_recordset(['is_rotting']) + self.assertTrue( + item2.is_rotting, + 'on jan 7: items belonging to stage_qualification should be rotting, except if their state forbids it', + ) + self.assertEqual(item2.rotting_days, 6) + self.assertFalse( + item_done.is_rotting, + 'item_done is marked as done, it should not be able to rot', + ) + + self.assertTrue(item1.is_rotting) + item1.message_post(body='Message received', message_type='email') + self.assertTrue( + item1.is_rotting, + 'Receiving an email should not remove rotting', + ) + + item1.message_post(body='Message sent', message_type='email_outgoing') + self.assertTrue( + item1.is_rotting, + 'Nor should sending an email', + ) + + self.assertFalse( + item_won.is_rotting, + 'Items in stage_finished cannot rot', + ) + self.stage_finished.no_rot = False + self.assertTrue( + item_won.is_rotting, + 'However if the stage no longer disallows rotting, then all items in the stage may once more rot', + ) + + self.stage_finished.no_rot = True + self.assertFalse( + item_won.is_rotting, + 'Disallowing rotting once again should disable rotting once more', + ) + + with self.mock_datetime_and_now(jan12): + items.invalidate_recordset(['rotting_days', 'is_rotting']) + + self.assertTrue(item3.is_rotting) + self.stage_new.rotting_threshold_days = 40 + self.assertFalse( + item3.is_rotting, + 'Changing the threshold should affect the status immediately)', + ) + + self.stage_new.rotting_threshold_days = 1 + + item3.stage_id = self.stage_qualification + self.assertFalse( + item3.is_rotting, + 'Changing stages always removes rotting', + ) + + self.stage_qualification.rotting_threshold_days = 0 + self.assertFalse( + item2.is_rotting, + 'Setting rotting_threshold_days at 0 on a stage immediately disables rotting for the stage', + ) + + with self.mock_datetime_and_now(jan28): + items.invalidate_recordset(['rotting_days', 'is_rotting']) + # After a significant amount of time has passed: + self.assertTrue( + item1.is_rotting, + 'Items that are not done or won are rotting', + ) + for item in [item2, item3, item_done, item_won]: + self.assertFalse( + item.is_rotting, + 'Items that are not done, won, or in a disabled rotting stage are not rotting', + ) + + @tagged('mail_thread', 'mail_blacklist') -class TestMailThread(TestMailCommon, TestRecipients): +class TestMailThread(MailCommon, TestRecipients): @mute_logger('odoo.models.unlink') def test_blacklist_mixin_email_normalized(self): @@ -58,3 +231,31 @@ class TestMailThread(TestMailCommon, TestRecipients): self.assertTrue(new_record.is_blacklisted) bl_record.unlink() + + +@tagged('mail_thread', 'mail_thread_cc', 'mail_tools') +class TestMailThreadCC(MailCommon): + + @users("employee") + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_suggested_recipients_mail_cc(self): + """ MailThreadCC mixin adds its own suggested recipients management + coming from CC (carbon copy) management. """ + record = self.env['mail.test.cc'].create({ + 'email_cc': 'cc1@example.com, cc2@example.com, cc3 ', + }) + suggestions = record._message_get_suggested_recipients(no_create=True) + expected_list = [ + { + 'name': '', 'email': 'cc1@example.com', + 'partner_id': False, 'create_values': {}, + }, { + 'name': '', 'email': 'cc2@example.com', + 'partner_id': False, 'create_values': {}, + }, { + 'name': 'cc3', 'email': 'cc3@example.com', + 'partner_id': False, 'create_values': {}, + }] + self.assertEqual(len(suggestions), len(expected_list)) + for suggestion, expected in zip(suggestions, expected_list): + self.assertDictEqual(suggestion, expected) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_message_management.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_message_management.py deleted file mode 100644 index e1e5ab2..0000000 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_message_management.py +++ /dev/null @@ -1,130 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo.addons.mail.tests.common import mail_new_test_user -from odoo.addons.test_mail.tests.common import TestMailCommon -from odoo.tests import tagged -from odoo.tools import mute_logger - - -@tagged('mail_wizards') -class TestMailResend(TestMailCommon): - - @classmethod - def setUpClass(cls): - super(TestMailResend, cls).setUpClass() - cls.test_record = cls.env['mail.test.simple'].with_context(cls._test_context).create({'name': 'Test', 'email_from': 'ignasse@example.com'}) - - #Two users - cls.user1 = mail_new_test_user(cls.env, login='e1', groups='base.group_user', name='Employee 1', notification_type='email', email='e1') # invalid email - cls.user2 = mail_new_test_user(cls.env, login='e2', groups='base.group_portal', name='Employee 2', notification_type='email', email='e2@example.com') - #Two partner - cls.partner1 = cls.env['res.partner'].with_context(cls._test_context).create({ - 'name': 'Partner 1', - 'email': 'p1' # invalid email - }) - cls.partner2 = cls.env['res.partner'].with_context(cls._test_context).create({ - 'name': 'Partner 2', - 'email': 'p2@example.com' - }) - cls.partners = cls.env['res.partner'].concat(cls.user1.partner_id, cls.user2.partner_id, cls.partner1, cls.partner2) - cls.invalid_email_partners = cls.env['res.partner'].concat(cls.user1.partner_id, cls.partner1) - - # @mute_logger('odoo.addons.mail.models.mail_mail') - def test_mail_resend_workflow(self): - with self.assertSinglePostNotifications( - [{'partner': partner, 'type': 'email', 'status': 'exception'} for partner in self.partners], - message_info={'message_type': 'notification'}): - def _connect(*args, **kwargs): - raise Exception("Some exception") - self.connect_mocked.side_effect = _connect - message = self.test_record.with_user(self.user_admin).message_post(partner_ids=self.partners.ids, subtype_xmlid='mail.mt_comment', message_type='notification') - - wizard = self.env['mail.resend.message'].with_context({'mail_message_to_resend': message.id}).create({}) - self.assertEqual(wizard.notification_ids.mapped('res_partner_id'), self.partners, "wizard should manage notifications for each failed partner") - - # three more failure sent on bus, one for each mail in failure and one for resend - self._reset_bus() - expected_bus_notifications = [ - (self.cr.dbname, 'res.partner', self.partner_admin.id), - (self.cr.dbname, 'res.partner', self.env.user.partner_id.id), - ] - with self.mock_mail_gateway(), self.assertBus(expected_bus_notifications * 3): - wizard.resend_mail_action() - done_msgs, done_notifs = self.assertMailNotifications(message, [ - {'content': '', 'message_type': 'notification', - 'notif': [{'partner': partner, 'type': 'email', 'status': 'exception' if partner in self.user1.partner_id | self.partner1 else 'sent'} for partner in self.partners]}] - ) - self.assertEqual(wizard.notification_ids, done_notifs) - self.assertEqual(done_msgs, message) - - self.user1.write({"email": 'u1@example.com'}) - - # two more failure update sent on bus, one for failed mail and one for resend - self._reset_bus() - with self.mock_mail_gateway(), self.assertBus(expected_bus_notifications * 2): - self.env['mail.resend.message'].with_context({'mail_message_to_resend': message.id}).create({}).resend_mail_action() - done_msgs, done_notifs = self.assertMailNotifications(message, [ - {'content': '', 'message_type': 'notification', - 'notif': [{'partner': partner, 'type': 'email', 'status': 'exception' if partner == self.partner1 else 'sent', 'check_send': partner == self.partner1} for partner in self.partners]}] - ) - self.assertEqual(wizard.notification_ids, done_notifs) - self.assertEqual(done_msgs, message) - - self.partner1.write({"email": 'p1@example.com'}) - - # A success update should be sent on bus once the email has no more failure - self._reset_bus() - with self.mock_mail_gateway(), self.assertBus(expected_bus_notifications): - self.env['mail.resend.message'].with_context({'mail_message_to_resend': message.id}).create({}).resend_mail_action() - self.assertMailNotifications(message, [ - {'content': '', 'message_type': 'notification', - 'notif': [{'partner': partner, 'type': 'email', 'status': 'sent', 'check_send': partner == self.partner1} for partner in self.partners]}] - ) - - @mute_logger('odoo.addons.mail.models.mail_mail') - def test_remove_mail_become_canceled(self): - # two failure sent on bus, one for each mail - self._reset_bus() - with self.mock_mail_gateway(), self.assertBus([(self.cr.dbname, 'res.partner', self.partner_admin.id)] * 2): - message = self.test_record.with_user(self.user_admin).message_post(partner_ids=self.partners.ids, subtype_xmlid='mail.mt_comment', message_type='notification') - - self.assertMailNotifications(message, [ - {'content': '', 'message_type': 'notification', - 'notif': [{'partner': partner, 'type': 'email', 'status': 'exception' if partner in self.user1.partner_id | self.partner1 else 'sent'} for partner in self.partners]}] - ) - - wizard = self.env['mail.resend.message'].with_context({'mail_message_to_resend': message.id}).create({}) - partners = wizard.partner_ids.mapped("partner_id") - self.assertEqual(self.invalid_email_partners, partners) - wizard.partner_ids.filtered(lambda p: p.partner_id == self.partner1).write({"resend": False}) - wizard.resend_mail_action() - - self.assertMailNotifications(message, [ - {'content': '', 'message_type': 'notification', - 'notif': [{'partner': partner, 'type': 'email', - 'status': (partner == self.user1.partner_id and 'exception') or (partner == self.partner1 and 'canceled') or 'sent'} for partner in self.partners]}] - ) - - @mute_logger('odoo.addons.mail.models.mail_mail') - def test_cancel_all(self): - self._reset_bus() - with self.mock_mail_gateway(), self.assertBus([(self.cr.dbname, 'res.partner', self.partner_admin.id)] * 2): - message = self.test_record.with_user(self.user_admin).message_post(partner_ids=self.partners.ids, subtype_xmlid='mail.mt_comment', message_type='notification') - - wizard = self.env['mail.resend.message'].with_context({'mail_message_to_resend': message.id}).create({}) - # one update for cancell - self._reset_bus() - expected_bus_notifications = [ - (self.cr.dbname, 'res.partner', self.partner_admin.id), - (self.cr.dbname, 'res.partner', self.env.user.partner_id.id), - ] - with self.mock_mail_gateway(), self.assertBus(expected_bus_notifications): - wizard.cancel_mail_action() - - self.assertMailNotifications(message, [ - {'content': '', 'message_type': 'notification', - 'notif': [{'partner': partner, 'type': 'email', - 'check_send': partner in self.user1.partner_id | self.partner1, - 'status': 'canceled' if partner in self.user1.partner_id | self.partner1 else 'sent'} for partner in self.partners]}] - ) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_message_post.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_message_post.py index fec97e2..1fe4655 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_message_post.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_message_post.py @@ -6,27 +6,27 @@ import base64 from datetime import datetime, timedelta from freezegun import freeze_time from itertools import product -from markupsafe import escape +from markupsafe import escape, Markup from unittest.mock import patch from odoo import tools from odoo.addons.base.tests.test_ir_cron import CronMixinCase -from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.mail.tests.common import mail_new_test_user, MailCommon from odoo.addons.test_mail.data.test_mail_data import MAIL_TEMPLATE_PLAINTEXT from odoo.addons.test_mail.models.test_mail_models import MailTestSimple -from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients -from odoo.api import call_kw +from odoo.addons.test_mail.tests.common import TestRecipients +from odoo.service.model import call_kw from odoo.exceptions import AccessError from odoo.tests import tagged from odoo.tools import mute_logger, formataddr from odoo.tests.common import users -class TestMessagePostCommon(TestMailCommon, TestRecipients): +class TestMessagePostCommon(MailCommon, TestRecipients): @classmethod def setUpClass(cls): - super(TestMessagePostCommon, cls).setUpClass() + super().setUpClass() # portal user, notably for ACLS / notifications cls.user_portal = cls._create_portal_user() @@ -49,6 +49,9 @@ class TestMessagePostCommon(TestMailCommon, TestRecipients): 'name': 'Test', 'email_from': 'ignasse@example.com' }) + cls.test_records_simple, _partners = cls._create_records_for_batch( + 'mail.test.simple', 3, + ) cls.test_record_container = cls.env['mail.test.container.mc'].create({ 'name': 'MC Container', }) @@ -63,10 +66,9 @@ class TestMessagePostCommon(TestMailCommon, TestRecipients): 'body': '

Notify Body Woop Woop

', 'email_from': cls.partner_employee.email_formatted, 'is_internal': False, - 'message_id': tools.generate_tracking_message_id('dummy-generate'), + 'message_id': tools.mail.generate_tracking_message_id('dummy-generate'), 'message_type': 'comment', 'model': cls.test_record._name, - 'record_name': False, 'reply_to': 'wrong.alias@test.example.com', 'subtype_id': cls.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), 'subject': 'Notify Test', @@ -74,27 +76,45 @@ class TestMessagePostCommon(TestMailCommon, TestRecipients): cls.user_admin.write({'notification_type': 'email'}) def setUp(self): - super(TestMessagePostCommon, self).setUp() - # send tracking and messages + patch registry to simulate a ready environment - # purpose is to avoid nondeterministic tests, notably because tracking is - # accumulated and sent at flush -> we want to test only the result of a - # given test, not setup + test - self.flush_tracking() - # see ``_message_auto_subscribe_notify`` + super().setUp() + # patch registry to simulate a ready environment; see ``_message_auto_subscribe_notify`` self.patch(self.env.registry, 'ready', True) -@tagged('mail_post') +@tagged('mail_post', 'mail_notify') class TestMailNotifyAPI(TestMessagePostCommon): @classmethod def setUpClass(cls): super().setUpClass() - cls._activate_multi_company() + cls.test_lang_records = cls.env['mail.test.lang'].create([ + { + 'customer_id': False, + 'email_from': 'test.record.1@test.customer.com', + 'lang': 'es_ES', + 'name': 'TestRecord1', + }, { + 'customer_id': cls.partner_2.id, + 'email_from': 'valid.other@gmail.com', + 'name': 'TestRecord2', + }, + ]) + cls.test_lang_template = cls.env['mail.template'].create({ + 'auto_delete': True, + 'body_html': '

EnglishBody for

', + 'email_from': '{{ user.email_formatted }}', + 'email_layout_xmlid': 'mail.test_layout', # created during '_activate_multi_lang' + 'lang': '{{ object.customer_id.lang or object.lang }}', + 'model_id': cls.env['ir.model']._get('mail.test.lang').id, + 'name': 'TestTemplate', + 'subject': 'EnglishSubject for {{ object.name }}', + 'use_default_to': True, + }) + cls._activate_multi_lang(test_record=cls.test_lang_records[0], test_template=cls.test_lang_template) @mute_logger('odoo.models.unlink') @users('employee') - def test_email_notifiction_layouts(self): + def test_email_notification_layouts(self): self.user_employee.write({'notification_type': 'email'}) test_record = self.env['mail.test.simple'].browse(self.test_record.ids) test_message = self.env['mail.message'].browse(self.test_message.ids) @@ -109,7 +129,7 @@ class TestMailNotifyAPI(TestMessagePostCommon): test_record._notify_thread_by_email( test_message, recipients_data, - force_send=False + force_send=False, ) self.assertEqual(len(self._new_mails), 2, 'Should have 2 emails: one for customers, one for internal users') @@ -121,6 +141,93 @@ class TestMailNotifyAPI(TestMessagePostCommon): user_email = self._new_mails.filtered(lambda mail: mail.recipient_ids == self.partner_employee) self.assertTrue(user_email) + @mute_logger('odoo.models.unlink') + @users('employee') + def test_email_notification_layouts_header_footer(self): + """ Test tweaks for header / footer + + Basic behavior + * header shown + * if having an access button (aka sth to show), unless 'email_notification_allow_header' + ctx key is set to False; + * 'email_notification_force_header' ctx key allows to force + * footer shown + * if having a header, if the author is internal, and if 'email_notification_allow_footer' + ctx key is set (defaults to False); + * 'email_notification_force_footer' ctx key allows to force + """ + (self.user_employee + self.user_employee_c2).write({'notification_type': 'email'}) + test_lang_record = self.env['mail.test.lang'].browse(self.test_lang_records[0].ids) + test_lang_record.message_subscribe(partner_ids=(self.partner_1 + self.partner_employee_c2).ids) + test_classic_record = self.env['mail.test.simple'].browse(self.test_record.ids) + test_classic_record.message_subscribe(partner_ids=(self.partner_1 + self.partner_employee_c2).ids) + + for record, add_ctx, exp_header_for, exp_footer_for, exp_unfollow_for in [ + # for 'lang'-like model: _notify_get_recipients_groups is overriden + # # so that customers / followers have access button + ( + test_lang_record, {}, + self.partner_1 + self.partner_2 + self.partner_employee_c2, + self.env['res.partner'], # footer is now disabled by default + self.env['res.partner'], # no footer, no unfollow + ), + ( + test_lang_record, {'email_notification_allow_footer': True}, + self.partner_1 + self.partner_2 + self.partner_employee_c2, + self.partner_1 + self.partner_2 + self.partner_employee_c2, # footer allowed if header + self.partner_employee_c2, # unfollow for internal + ), + # classic record, access button is for internal only + ( + test_classic_record, {}, + self.partner_employee_c2, # based on access_button, aka internal only + self.env['res.partner'], # footer is now disabled by default + self.env['res.partner'], # no footer, no unfollow + ), + ( + test_classic_record, {'email_notification_force_header': True}, + self.partner_1 + self.partner_2 + self.partner_employee_c2, # forced + self.env['res.partner'], # footer is now disabled by default + self.env['res.partner'], # no footer, no unfollow + ), + ( + test_classic_record, {'email_notification_force_header': True, 'email_notification_allow_footer': True}, + self.partner_1 + self.partner_2 + self.partner_employee_c2, # forced + self.partner_1 + self.partner_2 + self.partner_employee_c2, # footer allowed if header + self.partner_employee_c2, # unfollow for internal + ), + ( + test_classic_record, {'email_notification_force_footer': True}, + self.partner_employee_c2, # based on access_button, aka internal only + self.partner_1 + self.partner_2 + self.partner_employee_c2, # footer is forced + self.partner_employee_c2, # unfollow for internal + ), + ]: + with self.subTest(record_name=record.name, add_ctx=add_ctx): + with self.mock_mail_gateway(): + _message = record.with_context(**add_ctx).message_post( + body='Test Layout / Tweak', + email_layout_xmlid='mail.test_layout', + partner_ids=self.partner_2.ids, + message_type='comment', + subtype_id=self.env.ref('mail.mt_comment').id, + ) + + for partner in self.partner_1 + self.partner_2 + self.partner_employee_c2: + found_email = self._find_sent_email(self.env.user.email_formatted, [partner.email_formatted]) + if partner in exp_header_for: + self.assertIn('HEADER', found_email['body']) + else: + self.assertNotIn('HEADER', found_email['body']) + if partner in exp_footer_for: + self.assertIn(f'Sent by {self.env.company.name}', found_email['body']) + else: + self.assertNotIn(f'Sent by {self.env.company.name}', found_email['body']) + if partner in exp_unfollow_for: + self.assertIn(f'mail/unfollow?model={record._name}&pid={partner.id}&res_id={record.id}', found_email['body']) + else: + self.assertNotIn('mail/unfollow', found_email['body']) + @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') def test_notify_by_mail_add_signature(self): @@ -168,6 +275,9 @@ class TestMailNotifyAPI(TestMessagePostCommon): 'name': 'Steve', }).id }) + # TOFIX: the test is actually broken because test_message cannot be + # read; this populates the cache to make it work, but that's cheating... + test_message.sudo().email_add_signature template_values = test_record._notify_by_email_prepare_rendering_context(test_message, {}) self.assertNotEqual(escape(template_values['signature']), escape('

--
Steve

')) @@ -212,12 +322,14 @@ class TestMailNotifyAPI(TestMessagePostCommon): template_values = test_record._notify_by_email_prepare_rendering_context(test_record.message_ids, {}) self.assertEqual(template_values.get('company').id, main_company.id) + @users('employee') def test_notify_recipients_internals(self): + base_record = self.test_record.with_env(self.env) pdata = self._generate_notify_recipients(self.partner_1 | self.partner_employee) msg_vals = { 'body': 'Message body', - 'model': self.test_record._name, - 'res_id': self.test_record.id, + 'model': base_record._name, + 'res_id': base_record.id, 'subject': 'Message subject', } link_vals = { @@ -227,112 +339,70 @@ class TestMailNotifyAPI(TestMessagePostCommon): 'auth_login': 'auth_login_val', } notify_msg_vals = dict(msg_vals, **link_vals) - classify_res = self.env[self.test_record._name]._notify_get_recipients_classify(pdata, 'My Custom Model Name', msg_vals=notify_msg_vals) - # find back information for each recipients - partner_info = next(item for item in classify_res if item['recipients'] == self.partner_1.ids) - emp_info = next(item for item in classify_res if item['recipients'] == self.partner_employee.ids) + # test notifying the class (void recordset) + classify_res = self.env[base_record._name]._notify_get_recipients_classify( + self.env['mail.message'], pdata, 'My Custom Model Name', + msg_vals=notify_msg_vals, + ) + # find back information for each recipients + partner_info = next(item for item in classify_res if item['recipients_ids'] == self.partner_1.ids) + emp_info = next(item for item in classify_res if item['recipients_ids'] == self.partner_employee.ids) # partner: no access button self.assertFalse(partner_info['has_button_access']) - # employee: access button and link self.assertTrue(emp_info['has_button_access']) for param, value in link_vals.items(): self.assertIn(f'{param}={value}', emp_info['button_access']['url']) - self.assertIn(f'model={self.test_record._name}', emp_info['button_access']['url']) - self.assertIn(f'res_id={self.test_record.id}', emp_info['button_access']['url']) + self.assertIn(f'model={base_record._name}', emp_info['button_access']['url']) + self.assertIn(f'res_id={base_record.id}', emp_info['button_access']['url']) self.assertNotIn('body', emp_info['button_access']['url']) self.assertNotIn('subject', emp_info['button_access']['url']) # test when notifying on non-records (e.g. MailThread._message_notify()) - for model, res_id in ((self.test_record._name, False), - (self.test_record._name, 0), # browse(0) does not return a valid recordset - (False, self.test_record.id), - (False, False), + for model, res_id in ((base_record._name, False), + (base_record._name, 0), # browse(0) does not return a valid recordset ('mail.thread', False), - ('mail.thread', self.test_record.id)): + ('mail.thread', base_record.id)): with self.subTest(model=model, res_id=res_id): - msg_vals.update({ + notify_msg_vals.update({ 'model': model, 'res_id': res_id, }) - # note that msg_vals wins over record on which method is called - notify_msg_vals = dict(msg_vals, **link_vals) - classify_res = self.test_record._notify_get_recipients_classify( - pdata, 'Test', msg_vals=notify_msg_vals) + classify_res = self.env[model].browse(res_id)._notify_get_recipients_classify( + self.env['mail.message'], pdata, 'Test', + msg_vals=notify_msg_vals, + ) # find back information for partner - partner_info = next(item for item in classify_res if item['recipients'] == self.partner_1.ids) - emp_info = next(item for item in classify_res if item['recipients'] == self.partner_employee.ids) + partner_info = next(item for item in classify_res if item['recipients_ids'] == self.partner_1.ids) + emp_info = next(item for item in classify_res if item['recipients_ids'] == self.partner_employee.ids) # check there is no access button self.assertFalse(partner_info['has_button_access']) self.assertFalse(emp_info['has_button_access']) - # test on falsy records (False model cannot be browsed, skipped) - if model: - record_falsy = self.env[model].browse(res_id) - classify_res = record_falsy._notify_get_recipients_classify( - pdata, 'Test', msg_vals=notify_msg_vals) - # find back information for partner - partner_info = next(item for item in classify_res if item['recipients'] == self.partner_1.ids) - emp_info = next(item for item in classify_res if item['recipients'] == self.partner_employee.ids) - # check there is no access button - self.assertFalse(partner_info['has_button_access']) - self.assertFalse(emp_info['has_button_access']) - - @users('employee_c2') - def test_notify_reply_to_computation_mc(self): - """ Test reply-to computation in multi company mode. Add notably tests - depending on user and records company_id / company_ids. """ - - # Test1: no company_id field: depends on current user browsing - test_record = self.test_record.with_env(self.env) - self.assertEqual( - test_record._notify_get_reply_to()[test_record.id], - formataddr(( - f"{self.user_employee_c2.company_id.name} {test_record.name}", - f"{self.alias_catchall}@{self.alias_domain}")) - ) - test_record_c1 = test_record.with_user(self.user_employee) - self.assertEqual( - test_record_c1._notify_get_reply_to()[test_record_c1.id], - formataddr(( - f"{self.user_employee.company_id.name} {test_record_c1.name}", - f"{self.alias_catchall}@{self.alias_domain}")) - ) - - # Test2: MC environment get default value from env - self.user_employee_c2.write({'company_ids': [(4, self.user_employee.company_id.id)]}) - test_records = self.env['mail.test.multi.company'].create([ - {'name': 'Test', - 'company_id': self.user_employee.company_id.id}, - {'name': 'Test', - 'company_id': self.user_employee_c2.company_id.id}, - ]) - res = test_records._notify_get_reply_to() - for test_record in test_records: - self.assertEqual( - res[test_record.id], - formataddr(( - f"{self.user_employee_c2.company_id.name} {test_record.name}", - f"{self.alias_catchall}@{self.alias_domain}")) - ) - - # Test3: get company from record (company_id field) - self.user_employee_c2.write({'company_ids': [(4, self.company_3.id)]}) - test_records = self.env['mail.test.multi.company'].create([ - {'name': 'Test1', - 'company_id': self.company_3.id}, - {'name': 'Test2', - 'company_id': self.company_3.id}, - ]) - res = test_records._notify_get_reply_to() - for test_record in test_records: - self.assertEqual( - res[test_record.id], - formataddr(( - f"{self.company_3.name} {test_record.name}", - f"{self.alias_catchall}@{self.alias_domain}")) - ) + # test when notifying based a valid record, but asking for a falsy record in msg_vals + for model, res_id in ((base_record._name, False), + (base_record._name, 0), # browse(0) does not return a valid recordset + (False, base_record.id), + (False, False), + ('mail.thread', False), + ('mail.thread', base_record.id)): + with self.subTest(model=model, res_id=res_id): + # note that msg_vals wins over record on which method is called + notify_msg_vals.update({ + 'model': model, + 'res_id': res_id, + }) + classify_res = base_record._notify_get_recipients_classify( + self.env['mail.message'], pdata, 'Test', + msg_vals=notify_msg_vals, + ) + # find back information for partner + partner_info = next(item for item in classify_res if item['recipients_ids'] == self.partner_1.ids) + emp_info = next(item for item in classify_res if item['recipients_ids'] == self.partner_employee.ids) + # check there is no access button + self.assertFalse(partner_info['has_button_access']) + self.assertFalse(emp_info['has_button_access']) @tagged('mail_post', 'mail_notify') @@ -364,7 +434,7 @@ class TestMessageNotify(TestMessagePostCommon): }, ): new_notification = test_record.message_notify( - body='

You have received a notification

', + body=Markup('

You have received a notification

'), partner_ids=[self.partner_1.id, self.partner_admin.id, self.partner_employee_2.id], subject='This should be a subject', ) @@ -389,6 +459,49 @@ class TestMessageNotify(TestMessagePostCommon): partner_mail_body = partner_mails[0].get('body') self.assertNotIn('/mail/view?model=', partner_mail_body, 'The email sent to customer should not contain an access link') + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_notify_author(self): + """ Author is added in notified people by default, unless asked not to + using the 'notify_author' parameter or context key. """ + test_record = self.env['mail.test.simple'].browse(self.test_record.ids) + + with self.mock_mail_gateway(): + new_notification = test_record.message_notify( + body=Markup('

You have received a notification

'), + notify_author_mention=False, + partner_ids=(self.partner_1 + self.partner_employee).ids, + subject='This should be a subject', + ) + + self.assertEqual(new_notification.notified_partner_ids, self.partner_1) + + with self.mock_mail_gateway(): + new_notification = test_record.message_notify( + body=Markup('

You have received a notification

'), + partner_ids=(self.partner_1 + self.partner_employee).ids, + subject='This should be a subject', + ) + + self.assertEqual( + new_notification.notified_partner_ids, + self.partner_1 + self.partner_employee, + 'Notify: notify_author parameter skips the author restriction' + ) + + with self.mock_mail_gateway(): + new_notification = test_record.with_context(mail_notify_author=True).message_notify( + body=Markup('

You have received a notification

'), + partner_ids=(self.partner_1 + self.partner_employee).ids, + subject='This should be a subject', + ) + + self.assertEqual( + new_notification.notified_partner_ids, + self.partner_1 + self.partner_employee, + 'Notify: mail_notify_author context key skips the author restriction' + ) + @users('employee') def test_notify_batch(self): """ Test notify in batch. Currently not supported. """ @@ -396,7 +509,7 @@ class TestMessageNotify(TestMessagePostCommon): with self.assertRaises(ValueError): test_records.message_notify( - body='

Nice notification content

', + body=Markup('

Nice notification content

'), partner_ids=self.partner_employee_2.ids, subject='Notify Subject', ) @@ -447,6 +560,40 @@ class TestMessageNotify(TestMessagePostCommon): }], ) + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') + def test_notify_parameters(self): + """ Test usage of parameters in notify, both for unwanted side effects + and magic parameters. """ + test_record = self.test_record.with_env(self.env) + + for parameters in [ + {'message_type': 'comment'}, + {'child_ids': []}, + {'mail_ids': []}, + {'notification_ids': []}, + {'notified_partner_ids': []}, + {'reaction_ids': []}, + {'starred_partner_ids': []}, + ]: + with self.subTest(parameters=parameters), \ + self.mock_mail_gateway(), \ + self.assertRaises(ValueError): + _new_message = test_record.message_notify( + body=Markup('

You will not receive a notification

'), + partner_ids=self.partner_1.ids, + subject='This should not be accepted', + **parameters + ) + + # support of subtype xml id + new_message = test_record.message_notify( + body=Markup('

You will not receive a notification

'), + partner_ids=self.partner_1.ids, + subtype_xmlid='mail.mt_note', + ) + self.assertEqual(new_message.subtype_id, self.env.ref('mail.mt_note')) + @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') def test_notify_thread(self): @@ -454,7 +601,7 @@ class TestMessageNotify(TestMessagePostCommon): people without having a document. """ with self.mock_mail_gateway(): new_notification = self.env['mail.thread'].message_notify( - body='

You have received a notification

', + body=Markup('

You have received a notification

'), partner_ids=[self.partner_1.id, self.partner_admin.id, self.partner_employee_2.id], subject='This should be a subject', ) @@ -488,12 +635,7 @@ class TestMessageLog(TestMessagePostCommon): @classmethod def setUpClass(cls): - super(TestMessageLog, cls).setUpClass() - # ensure employee can create partners, necessary for templates - cls.user_employee.write({ - 'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)], - }) - + super().setUpClass() cls.test_records, cls.test_partners = cls._create_records_for_batch( 'mail.test.ticket', 10, @@ -506,7 +648,7 @@ class TestMessageLog(TestMessagePostCommon): with self.mock_mail_gateway(): new_note = test_record._message_log( - body='

Labrador

', + body=Markup('

Labrador

'), ) self.assertMailNotifications( new_note, @@ -521,7 +663,7 @@ class TestMessageLog(TestMessagePostCommon): 'model': test_record._name, 'notified_partner_ids': self.env['res.partner'], 'partner_ids': self.env['res.partner'], - 'reply_to': formataddr((self.company_admin.name, f'{self.alias_catchall}@{self.alias_domain}')), + 'reply_to': formataddr((self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}')), 'res_id': test_record.id, }, 'notif': [], @@ -537,7 +679,7 @@ class TestMessageLog(TestMessagePostCommon): with self.mock_mail_gateway(): new_notes = test_records._message_log_batch( bodies={ - test_record.id: '

Test _message_log_batch

' + test_record.id: Markup('

Test _message_log_batch

') for test_record in test_records }, ) @@ -555,7 +697,44 @@ class TestMessageLog(TestMessagePostCommon): 'model': test_record._name, 'notified_partner_ids': self.env['res.partner'], 'partner_ids': self.env['res.partner'], - 'reply_to': formataddr((self.company_admin.name, f'{self.alias_catchall}@{self.alias_domain}')), + 'reply_to': formataddr((self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}')), + 'res_id': test_record.id, + }, + 'notif': [], + 'subtype': 'mail.mt_note', + }], + ) + + @users('employee') + def test_message_log_batch_with_partners(self): + """ Partners can be given to log, but this should not generate any + notification. """ + test_records = self.test_records.with_env(self.env) + test_records.message_subscribe(self.partner_employee_2.ids) + + with self.mock_mail_gateway(): + new_notes = test_records._message_log_batch( + bodies={ + test_record.id: Markup('

Test _message_log_batch

') + for test_record in test_records + }, + partner_ids=self.test_partners[:5].ids, + ) + for test_record, new_note in zip(test_records, new_notes): + self.assertMailNotifications( + new_note, + [{ + 'content': '

Test _message_log_batch

', + 'message_type': 'notification', + 'message_values': { + 'author_id': self.partner_employee, + 'body': '

Test _message_log_batch

', + 'email_from': formataddr((self.partner_employee.name, self.partner_employee.email_normalized)), + 'is_internal': True, + 'model': test_record._name, + 'notified_partner_ids': self.env['res.partner'], + 'partner_ids': self.test_partners[:5], + 'reply_to': formataddr((self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}')), 'res_id': test_record.id, }, 'notif': [], @@ -571,7 +750,7 @@ class TestMessageLog(TestMessagePostCommon): with self.mock_mail_gateway(): new_notes = test_records._message_log_with_view( 'test_mail.mail_template_simple_test', - values={'partner': self.user_employee.partner_id} + render_values={'partner': self.user_employee.partner_id} ) for test_record, new_note in zip(test_records, new_notes): self.assertMailNotifications( @@ -586,7 +765,7 @@ class TestMessageLog(TestMessagePostCommon): 'is_internal': True, 'model': test_record._name, 'notified_partner_ids': self.env['res.partner'], - 'reply_to': formataddr((self.company_admin.name, f'{self.alias_catchall}@{self.alias_domain}')), + 'reply_to': formataddr((self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}')), 'res_id': test_record.id, }, 'notif': [], @@ -595,14 +774,9 @@ class TestMessageLog(TestMessagePostCommon): ) -@tagged('mail_post') +@tagged('mail_post', 'post_install', '-at_install') class TestMessagePost(TestMessagePostCommon, CronMixinCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls._activate_multi_company() - def test_assert_initial_values(self): """ Be sure of what we are testing """ self.assertFalse(self.test_record.message_ids) @@ -619,7 +793,7 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): subject='This should be a subject', body='

You have received a notification

', partner_ids=[self.partner_1.id], - message_type='user_notification', + subtype_xmlid='mail.mt_note', force_send=False ) @@ -635,27 +809,35 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): def test_message_post(self): self.user_employee_2.write({'notification_type': 'inbox'}) test_record = self.env['mail.test.simple'].browse(self.test_record.ids) + additional_to = '"Michel Boitaclous" ' with self.assertSinglePostNotifications( - [{'partner': self.partner_employee_2, 'type': 'inbox'}], + [ + {'partner': self.partner_employee_2, 'type': 'inbox'}, + ], message_info={ 'content': 'Body', 'message_values': { 'author_id': self.partner_employee, 'body': '

Body

', 'email_from': formataddr((self.partner_employee.name, self.partner_employee.email_normalized)), + # incoming_email_cc/_to are informative and do not trigger any notification + 'incoming_email_cc': '"Leo Pol" , fab@test.example.com', + 'incoming_email_to': '"Gaby Tlair" , ted@test.example.com', 'is_internal': False, 'message_type': 'comment', 'model': test_record._name, 'notified_partner_ids': self.partner_employee_2, - 'reply_to': formataddr((f'{self.company_admin.name} {test_record.name}', f'{self.alias_catchall}@{self.alias_domain}')), + 'reply_to': formataddr((self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}')), 'res_id': test_record.id, 'subtype_id': self.env.ref('mail.mt_comment'), }, - } + }, ): - new_message = test_record.message_post( + _new_message = test_record.message_post( body='Body', + incoming_email_cc='"Leo Pol" , fab@test.example.com', + incoming_email_to='"Gaby Tlair" , ted@test.example.com', message_type='comment', subtype_xmlid='mail.mt_comment', partner_ids=[self.partner_employee_2.id], @@ -664,60 +846,148 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): # subscribe partner_1, check notifications test_record.message_subscribe(self.partner_1.ids) + exp_headers = { + 'Return-Path': f'{self.alias_bounce}@{self.alias_domain}', + 'X-Custom': 'Done', # mail.test.simple override + # contains external people: partner_1 (follower) and asked outgoing email + 'X-Msg-To-Add': f'{additional_to},{self.partner_1.email_formatted}', + 'X-Odoo-Objects': f'{test_record._name}-{test_record.id}', + } with self.assertSinglePostNotifications( - [{'partner': self.partner_employee_2, 'type': 'inbox'}, - {'partner': self.partner_1, 'type': 'email'}], + [ + {'partner': self.partner_employee_2, 'type': 'inbox'}, + {'partner': self.partner_1, 'type': 'email'}, + {'email_to': ['michel@boitaclous.fr'], 'partner': self.env['res.partner'], 'type': 'email'}, + ], message_info={ 'content': 'NewBody', + 'mail_mail_values': { + 'headers': exp_headers, + }, 'email_values': { - 'headers': { - 'Return-Path': f'{self.alias_bounce}@{self.alias_domain}', - }, + 'headers': exp_headers, }, 'message_values': { 'notified_partner_ids': self.partner_1 + self.partner_employee_2, + 'outgoing_email_to': additional_to, }, }, - mail_unlink_sent=True + mail_unlink_sent=False, ): - new_message = test_record.message_post( + _new_message = test_record.message_post( body='NewBody', message_type='comment', + outgoing_email_to=additional_to, subtype_xmlid='mail.mt_comment', partner_ids=[self.partner_employee_2.id], ) - # notifications emails should have been deleted - self.assertFalse(self.env['mail.mail'].sudo().search_count([('mail_message_id', '=', new_message.id)])) - with self.assertSinglePostNotifications( - [{'partner': self.partner_1, 'type': 'email'}, - {'partner': self.partner_portal, 'type': 'email'}], + [ + {'partner': self.partner_1, 'type': 'email'}, + {'partner': self.partner_portal, 'type': 'email'}, + ], message_info={ 'content': 'ToPortal', - } + }, + mail_unlink_sent=True, # check notification are unlinked ): - test_record.message_post( + last_message = test_record.message_post( body='ToPortal', message_type='comment', subtype_xmlid='mail.mt_comment', partner_ids=self.partner_portal.ids, ) + # notifications emails should have been deleted + self.assertFalse(self.env['mail.mail'].sudo().search_count([('mail_message_id', '=', last_message.id)])) + + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') + @users('employee') + def test_message_post_author(self): + """ Test author recognition """ + test_record = self.test_record.with_env(self.env) + + # when a user spoofs the author: the actual author is the current user + # and not the message author + with self.assertSinglePostNotifications( + [{'partner': self.partner_admin, 'type': 'email'}], + message_info={ + 'content': 'Body', + 'mail_mail_values': { + 'author_id': self.partner_employee_2, + 'email_from': formataddr((self.partner_employee_2.name, self.partner_employee_2.email_normalized)), + }, + 'message_values': { + 'author_id': self.partner_employee_2, + 'email_from': formataddr((self.partner_employee_2.name, self.partner_employee_2.email_normalized)), + 'message_type': 'comment', + 'notified_partner_ids': self.partner_admin, + 'subtype_id': self.env.ref('mail.mt_comment'), + }, + }, + ): + _new_message = test_record.message_post( + author_id=self.partner_employee_2.id, + body='Body', + message_type='comment', + subtype_xmlid='mail.mt_comment', + partner_ids=[self.partner_admin.id], + ) + self.assertEqual(test_record.message_partner_ids, self.partner_employee, + 'Real author is added in followers, not message author') + + # should be skipped with notifications + test_record.message_unsubscribe(partner_ids=self.partner_employee.ids) + _new_message = test_record.message_post( + author_id=self.partner_employee_2.id, + body='Body', + message_type='notification', + subtype_xmlid='mail.mt_comment', + partner_ids=[self.partner_admin.id], + ) + self.assertFalse(test_record.message_partner_ids, 'Notification should not add author in followers') + + # inactive users are not considered as authors + self.env.user.with_user(self.user_admin).active = False + _new_message = test_record.message_post( + author_id=self.partner_employee_2.id, + body='Body', + message_type='comment', + subtype_xmlid='mail.mt_comment', + partner_ids=[self.partner_admin.id], + ) + self.assertEqual(test_record.message_partner_ids, self.partner_employee_2, + 'Author is the message author when user is inactive, and shoud be added in followers') @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') @users('employee') def test_message_post_defaults(self): """ Test default values when posting a classic message. """ + _original_compute_subject = MailTestSimple._message_compute_subject + _original_notify_headers = MailTestSimple._notify_by_email_get_headers + _original_notify_mailvals = MailTestSimple._notify_by_email_get_final_mail_values test_record = self.env['mail.test.simple'].create([{'name': 'Defaults'}]) creation_msg = test_record.message_ids self.assertEqual(len(creation_msg), 1) - with self.mock_mail_gateway(), self.mock_mail_app(): + with patch.object(MailTestSimple, '_message_compute_subject', + autospec=True, side_effect=_original_compute_subject) as mock_compute_subject, \ + patch.object(MailTestSimple, '_notify_by_email_get_headers', + autospec=True, side_effect=_original_notify_headers) as mock_notify_headers, \ + patch.object(MailTestSimple, '_notify_by_email_get_final_mail_values', + autospec=True, side_effect=_original_notify_mailvals) as mock_notify_mailvals, \ + self.mock_mail_gateway(), self.mock_mail_app(): new_message = test_record.message_post( body='Body', partner_ids=[self.partner_employee_2.id], ) + self.assertEqual(mock_compute_subject.call_count, 1, + 'Should call model-based subject computation for outgoing emails') + self.assertEqual(mock_notify_headers.call_count, 1, + 'Should call model-based headers computation for outgoing emails') + self.assertEqual(mock_notify_mailvals.call_count, 1, + 'Should call model-based headers computation for outgoing emails') self.assertMailNotifications( new_message, [{ @@ -731,10 +1001,9 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): 'model': test_record._name, 'notified_partner_ids': self.partner_employee_2, 'parent_id': creation_msg, - 'record_name': test_record.name, - 'reply_to': formataddr((f'{self.company_admin.name} {test_record.name}', f'{self.alias_catchall}@{self.alias_domain}')), + 'reply_to': formataddr((self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}')), 'res_id': test_record.id, - 'subject': f'Re: {test_record.name}', + 'subject': test_record.name, }, 'notif': [ {'partner': self.partner_employee_2, 'type': 'email',}, @@ -795,7 +1064,10 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): }, ]) expected_companies = [self.company_2, self.company_admin, self.company_2] - for record, expected_company in zip(records, expected_companies): + expected_alias_domains = [self.mail_alias_domain_c2, self.mail_alias_domain, self.mail_alias_domain_c2] + for record, expected_company, expected_alias_domain in zip( + records, expected_companies, expected_alias_domains + ): with self.subTest(record=record): with self.assertSinglePostNotifications( [{'partner': self.partner_employee_2, 'type': 'email'}], @@ -803,13 +1075,14 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): 'content': 'Body', 'email_values': { 'headers': { - 'Return-Path': f'{self.alias_bounce}@{self.alias_domain}', + 'Return-Path': f'{expected_alias_domain.bounce_alias}@{expected_alias_domain.name}', }, }, 'mail_mail_values': { - 'headers': repr({ + 'headers': { + 'Return-Path': f'{expected_alias_domain.bounce_alias}@{expected_alias_domain.name}', 'X-Odoo-Objects': f'{record._name}-{record.id}', - }), + }, }, 'message_values': { 'author_id': self.user_erp_manager.partner_id, @@ -817,7 +1090,10 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): 'is_internal': False, 'notified_partner_ids': self.partner_employee_2, 'reply_to': formataddr( - (f'{expected_company.name} {record.name}', f'{self.alias_catchall}@{self.alias_domain}') + ( + self.user_erp_manager.name, + f'{expected_alias_domain.catchall_alias}@{expected_alias_domain.name}' + ) ), }, } @@ -870,6 +1146,47 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): email_to=expected_to, ) + @users('employee') + def test_message_post_recipients_to_email_address(self): + """ Test support of posting with emails, not only partners. """ + test_record = self.test_record.with_env(self.env) + email_to_lst = [ + '"Dade" ', + '"Dide" ', + ] + email_to_normalized_lst = [ + 'das.deboulonneur@fleurus.example.com', + 'die.deboulonneur@fleurus.example.com', + ] + self.assertFalse(self.env['res.partner'].search([('email_normalized', 'in', email_to_normalized_lst)])) + + for partner_ids, exp_partner_mail in [ + (self.partner_1.ids, self.partner_1), + ([], self.env['res.partner']), + ]: + with self.subTest(partner_ids=partner_ids): + with self.mock_mail_gateway(): + test_record.message_post( + body='Test with email recipients', + message_type='comment', + partner_ids=partner_ids, + outgoing_email_to=','.join(email_to_lst), + subject='Email recipients', + subtype_xmlid='mt_comment', + ) + for partner in exp_partner_mail: + # one mail for the asked recipient + self.assertMailMail( + partner, 'sent', + author=self.partner_employee, + ) + # one mail to all emails + self.assertMailMail( + self.env['res.partner'], 'sent', + author=self.partner_employee, + email_to_all=email_to_normalized_lst, + ) + @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_message_schedule', 'odoo.models.unlink') def test_message_post_schedule(self): @@ -879,43 +1196,69 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): scheduled_datetime = now + timedelta(days=5) self.user_admin.write({'notification_type': 'inbox'}) - test_record = self.test_record.with_env(self.env) - test_record.message_subscribe((self.partner_1 | self.partner_admin).ids) + test_records = self.test_records_simple.with_env(self.env) + test_records.message_subscribe((self.partner_1 | self.partner_admin).ids) - with freeze_time(now), \ + # handy shortcut variables + deleted_record = test_records[2] + remaining_records = test_records - deleted_record + + messages = self.env['mail.message'] + with self.mock_datetime_and_now(now), \ self.assertMsgWithoutNotifications(), \ self.capture_triggers(cron_id) as capt: - msg = test_record.message_post( - body='

Test

', - message_type='comment', - subject='Subject', - subtype_xmlid='mail.mt_comment', - scheduled_date=scheduled_datetime, - ) - self.assertEqual(capt.records.call_at, scheduled_datetime, - msg='Should have created a cron trigger for the scheduled sending') + for test_record in test_records: + messages += test_record.message_post( + body=f'

Test on {test_record.name}

', + message_type='comment', + subject=f'Subject for {test_record.name}', + subtype_xmlid='mail.mt_comment', + scheduled_date=scheduled_datetime, + ) + self.assertEqual( + capt.records.mapped('call_at'), [scheduled_datetime] * 3, + msg='Should have created a cron trigger / scheduled post') self.assertFalse(self._new_mails) self.assertFalse(self._mails) - schedules = self.env['mail.message.schedule'].sudo().search([('mail_message_id', '=', msg.id)]) - self.assertEqual(len(schedules), 1, msg='Should have scheduled the message') - self.assertEqual(schedules.scheduled_datetime, scheduled_datetime) + schedules = self.env['mail.message.schedule'].sudo().search([('mail_message_id', 'in', messages.ids)]) + self.assertEqual(len(schedules), 3, msg='Should have one scheduled record / message to post') + self.assertEqual(schedules.mapped('scheduled_datetime'), [scheduled_datetime] * 3) # trigger cron now -> should not sent as in future - with freeze_time(now): + with self.mock_datetime_and_now(now): self.env['mail.message.schedule'].sudo()._send_notifications_cron() - self.assertTrue(schedules.exists(), msg='Should not have sent the message') + self.assertTrue(schedules.exists(), msg='Should not have sent the messages') + + # In the mean time, some FK deletes the record where the message is + # # scheduled, skipping its unlink() override + test_record_names = test_records.mapped('name') + self.env.cr.execute( + f"DELETE FROM {test_records._table} WHERE id = %s", (deleted_record.id,) + ) + test_records.invalidate_recordset() # Send the scheduled message from the cron at right date - with freeze_time(now + timedelta(days=5)), self.mock_mail_gateway(mail_unlink_sent=True): + with self.mock_datetime_and_now(now + timedelta(days=5)), self.mock_mail_gateway(mail_unlink_sent=True): self.env['mail.message.schedule'].sudo()._send_notifications_cron() - self.assertFalse(schedules.exists(), msg='Should have sent the message') + self.assertFalse(schedules.exists(), msg='Should have sent the messages') + # check notifications have been sent - recipients_info = [{'content': '

Test

', 'notif': [ - {'partner': self.partner_admin, 'type': 'inbox'}, - {'partner': self.partner_1, 'type': 'email'}, - ]}] - self.assertMailNotifications(msg, recipients_info) + for msg, test_record, test_record_name in zip(messages, test_records, test_record_names): + with self.subTest(test_record_name=test_record_name): + if test_record != deleted_record: + # unlinked record -> skip notification + self.assertMailNotifications(msg, [{ + 'content': f'Test on {test_record_name}', + 'email_values': { + 'subject': f'Subject for {test_record_name}', + }, + 'notif': [ + {'partner': self.partner_admin, 'type': 'inbox'}, + {'partner': self.partner_1, 'type': 'email'}, + ], + }]) + self.assertEqual(len(self._new_mails), len(remaining_records), 'Should have skipped unlinked record') # manually create a new schedule date, resend it -> should not crash (aka # don't create duplicate notifications, ...) @@ -925,15 +1268,15 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): }) # Send the scheduled message from the CRON - with freeze_time(now + timedelta(days=5)), self.assertNoNotifications(): + with self.mock_datetime_and_now(now + timedelta(days=5)), self.assertNoNotifications(): self.env['mail.message.schedule'].sudo()._send_notifications_cron() # schedule in the past = send when posting - with freeze_time(now), \ + with self.mock_datetime_and_now(now), \ self.mock_mail_gateway(mail_unlink_sent=False), \ self.capture_triggers(cron_id) as capt: - msg = test_record.message_post( - body='

Test

', + msg = test_records[0].message_post( + body=Markup('

Test

'), message_type='comment', subject='Subject', subtype_xmlid='mail.mt_comment', @@ -961,7 +1304,7 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): with freeze_time(now), \ self.assertMsgWithoutNotifications(): msg = test_record.message_post( - body='

Test

', + body=Markup('

Test

'), message_type='comment', subject='Subject', subtype_xmlid='mail.mt_comment', @@ -992,9 +1335,80 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): self.assertFalse(self.env['mail.message.schedule'].sudo()._update_message_scheduled_datetime(msg, now - timedelta(hours=1)), 'Mail scheduler: should return False when no schedule is found') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_message_schedule') + def test_message_post_w_attachments_filtering(self): + """ + Test the message_main_attachment heuristics with an emphasis on the XML/Octet/PDF types. + -> we don't want XML nor Octet-Stream files to be set as message_main_attachment + """ + xml_attachment, octet_attachment, pdf_attachment = ( + [('List1', b'My xml attachment')], + [('List2', b'\x00\x01My octet-stream attachment\x03\x04')], + [('List3', b'%PDF My pdf attachment')]) + + xml_attachment_data, octet_attachment_data, pdf_attachment_data = self.env['ir.attachment'].create( + self._generate_attachments_data(3, 'mail.compose.message', 0) + ) + xml_attachment_data.write({'mimetype': 'application/xml'}) + octet_attachment_data.write({'mimetype': 'application/octet-stream'}) + pdf_attachment_data.write({'mimetype': 'application/pdf'}) + + test_record = self.env['mail.test.simple.main.attachment'].with_context(self._test_context).create({ + 'name': 'Test', + 'email_from': 'ignasse@example.com', + }) + self.assertFalse(test_record.message_main_attachment_id) + + # test with xml attachment + with self.mock_mail_gateway(): + test_record.message_post( + attachments=xml_attachment, + attachment_ids=xml_attachment_data.ids, + body='Post XML', + message_type='comment', + partner_ids=[self.partner_1.id], + subject='Test', + subtype_xmlid='mail.mt_comment', + ) + self.assertFalse(test_record.message_main_attachment_id, + 'MailThread: main attachment should not be set with an XML') + + # test with octet attachment + with self.mock_mail_gateway(): + test_record.message_post( + attachments=octet_attachment, + attachment_ids=octet_attachment_data.ids, + body='Post Octet-Stream', + message_type='comment', + partner_ids=[self.partner_1.id], + subject='Test', + subtype_xmlid='mail.mt_comment', + ) + self.assertFalse(test_record.message_main_attachment_id, + 'MailThread: main attachment should not be set with an Octet-Stream') + # test with pdf attachment + with self.mock_mail_gateway(): + test_record.message_post( + attachments=pdf_attachment, + attachment_ids=pdf_attachment_data.ids, + body='Post PDF', + message_type='comment', + partner_ids=[self.partner_1.id], + subject='Test', + subtype_xmlid='mail.mt_comment', + ) + self.assertEqual(test_record.message_main_attachment_id, pdf_attachment_data, + 'MailThread: main attachment should be set to application/pdf') + @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_message_schedule') - def test_message_post_w_attachments(self): + def test_message_post_w_attachments_on_main_attachment_model(self): + """ Test posting a message with attachments on a model inheriting from + the mixin mail.thread.main.attachment. + + As the mixin inherits from mail.thread, we test mainly features from + mail.thread but with the ones added of the main attachment mixin. + """ _attachments = [ ('List1', b'My first attachment'), ('List2', b'My second attachment'), @@ -1004,7 +1418,12 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): ) _attachment_records[1].write({'mimetype': 'image/png'}) # to test message_main_attachment heuristic - test_record = self.env['mail.test.simple'].browse(self.test_record.ids) + test_record = self.env['mail.test.simple.main.attachment'].with_context(self._test_context).create({ + 'name': 'Test', + 'email_from': 'ignasse@example.com', + }) + self._reset_mail_context(test_record) + self.test_message.model = test_record._name self.assertFalse(test_record.message_main_attachment_id) with self.mock_mail_gateway(): @@ -1024,8 +1443,8 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): # message attachments self.assertEqual(len(msg.attachment_ids), 5) - self.assertEqual(set(msg.attachment_ids.mapped('res_model')), set([self.test_record._name])) - self.assertEqual(set(msg.attachment_ids.mapped('res_id')), set([self.test_record.id])) + self.assertEqual(set(msg.attachment_ids.mapped('res_model')), {test_record._name}) + self.assertEqual(set(msg.attachment_ids.mapped('res_id')), {test_record.id}) self.assertEqual(set(base64.b64decode(x) for x in msg.attachment_ids.mapped('datas')), set([b'AttContent_00', b'AttContent_01', b'AttContent_02', _attachments[0][1], _attachments[1][1]])) self.assertTrue(set(_attachment_records.ids).issubset(msg.attachment_ids.ids), @@ -1035,59 +1454,14 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): self.assertEqual(len(self._mails), 1) self.assertSentEmail( self.user_employee.partner_id, [self.partner_1], - attachments=[('List1', b'My first attachment', 'application/octet-stream'), - ('List2', b'My second attachment', 'application/octet-stream'), + attachments=[('List1', b'My first attachment', 'text/plain'), + ('List2', b'My second attachment', 'text/plain'), ('AttFileName_00.txt', b'AttContent_00', 'text/plain'), ('AttFileName_01.txt', b'AttContent_01', 'image/png'), ('AttFileName_02.txt', b'AttContent_02', 'text/plain'), ] ) - @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_message_schedule') - def test_message_post_w_attachments_filtering(self): - """ - Test the message_main_attachment heuristics with an emphasis on the XML/PDF types. - -> we don't want XML files to be set as message_main_attachment - """ - xml_attachment, pdf_attachment = ('List1', b'My xml attachment'), ('List2', b'My pdf attachment') - - xml_attachment_data, pdf_attachment_data = self.env['ir.attachment'].create( - self._generate_attachments_data(2, 'mail.compose.message', 0) - ) - xml_attachment_data.write({'mimetype': 'application/xml'}) - pdf_attachment_data.write({'mimetype': 'application/pdf'}) - - test_record = self.env['mail.test.simple'].browse(self.test_record.ids) - self.assertFalse(test_record.message_main_attachment_id) - - with self.mock_mail_gateway(): - test_record.message_post( - attachments=xml_attachment, - attachment_ids=xml_attachment_data.ids, - body='Post XML', - message_type='comment', - partner_ids=[self.partner_1.id], - subject='Test', - subtype_xmlid='mail.mt_comment', - ) - - self.assertFalse(test_record.message_main_attachment_id, - 'MailThread: main attachment should not be set with an XML') - - with self.mock_mail_gateway(): - test_record.message_post( - attachments=pdf_attachment, - attachment_ids=pdf_attachment_data.ids, - body='Post PDF', - message_type='comment', - partner_ids=[self.partner_1.id], - subject='Test', - subtype_xmlid='mail.mt_comment', - ) - - self.assertEqual(test_record.message_main_attachment_id, pdf_attachment_data, - 'MailThread: main attachment should be set to application/pdf') - @mute_logger('odoo.addons.mail.models.mail_mail') def test_multiline_subject(self): with self.mock_mail_gateway(): @@ -1098,7 +1472,7 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): ) self.assertEqual(msg.subject, '1st line 2nd line') - @mute_logger('odoo.addons.mail.models.mail_mail') + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.mail.models.mail_mail') def test_portal_acls(self): self.test_record.message_subscribe((self.partner_1 | self.user_employee.partner_id).ids) @@ -1107,9 +1481,9 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): {'partner': self.partner_employee, 'type': 'inbox'}, {'partner': self.partner_1, 'type': 'email'}]} ] - ), patch.object(MailTestSimple, 'check_access_rights', return_value=True): + ), patch.object(MailTestSimple, '_check_access', return_value=None): new_msg = self.test_record.with_user(self.user_portal).message_post( - body='

Test

', + body=Markup('

Test

'), message_type='comment', subject='Subject', subtype_xmlid='mail.mt_comment', @@ -1118,7 +1492,7 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): with self.assertRaises(AccessError): self.test_record.with_user(self.user_portal).message_post( - body='

Test

', + body=Markup('

Test

'), message_type='comment', subject='Subject', subtype_xmlid='mail.mt_comment', @@ -1137,7 +1511,7 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): test_record = self.test_record_ticket.with_env(self.env).copy() self.assertEqual(len(test_record.message_ids), 1) initial_msg = test_record.message_ids - self.assertEqual(initial_msg.reply_to, formataddr((f'{self.env.company.name} {test_record.name}', f'{self.alias_catchall}@{self.alias_domain}'))) + self.assertEqual(initial_msg.reply_to, formataddr((f'{self.user_employee.name}', f'{self.alias_catchall}@{self.alias_domain}'))) self.assertEqual(initial_msg.subtype_id, self.env.ref('test_mail.st_mail_test_ticket_container_mc_upd')) # for the sake of testing various use case, force update subtype initial_msg.sudo().write({'subtype_id': subtype.id}) @@ -1145,7 +1519,7 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): # post a tracking message with self.mock_mail_gateway(): log_msg = test_record._message_log( - body='

Blabla fake tracking

', + body=Markup('

Blabla fake tracking

'), message_type='notification', ) self.assertFalse(log_msg.parent_id, 'FIXME: logs have no parent, strange but funny (somehow)') @@ -1154,12 +1528,12 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): # post an internal tracking/custom message with self.mock_mail_gateway(): internal_msg = test_record.message_post( - body='

Blabla internal

', + body=Markup('

Blabla internal

'), message_type='notification', subtype_id=self.env.ref('test_mail.st_mail_test_ticket_internal').id, partner_ids=self.user_admin.partner_id.ids, ) - self.assertEqual(internal_msg.parent_id, initial_msg) + self.assertEqual(internal_msg.parent_id, log_msg, 'No email/comment, attached to last message') if subtype: references = f'{initial_msg.message_id} {log_msg.message_id} {internal_msg.message_id}' else: # no subtype = pure log = not in references @@ -1167,9 +1541,9 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): self.assertSentEmail( self.user_employee.partner_id, [self.user_admin.partner_id], - body_content='

Blabla internal

', + body_content=Markup('

Blabla internal

'), reply_to=initial_msg.reply_to, - subject='Re: %s' % self.test_record_ticket.name, + subject=f'Ticket for {test_record.name} on {test_record.datetime.strftime("%m/%d/%Y, %H:%M:%S")}', # references contain even 'internal' messages, to help thread formation references=references, ) @@ -1179,13 +1553,13 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): [{'content': '

Test Answer

', 'notif': [{'partner': self.partner_1, 'type': 'email'}]}] ): msg = test_record.message_post( - body='

Test Answer

', + body=Markup('

Test Answer

'), message_type='comment', partner_ids=[self.partner_1.id], subject='Welcome', subtype_xmlid='mail.mt_comment', ) - self.assertEqual(msg.parent_id, initial_msg) + self.assertEqual(msg.parent_id, internal_msg, 'No email/comment, attached to last message') self.assertEqual(msg.partner_ids, self.partner_1) self.assertFalse(initial_msg.partner_ids) if subtype: @@ -1206,20 +1580,20 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): top_msg = log_msg with self.mock_mail_gateway(): new_msg = test_record.message_post( - body='

Test Answer Bis

', + body=Markup('

Test Answer Bis

'), message_type='comment', parent_id=msg.id, subtype_xmlid='mail.mt_comment', partner_ids=[self.partner_2.id], ) - self.assertEqual(new_msg.parent_id, initial_msg, 'message_post: flatten error') + self.assertEqual(new_msg.parent_id, msg) self.assertEqual(new_msg.partner_ids, self.partner_2) self.assertSentEmail( self.user_employee.partner_id, [self.partner_2], body_content='

Test Answer Bis

', reply_to=msg.reply_to, - subject='Re: %s' % self.test_record_ticket.name, + subject=f'Ticket for {test_record.name} on {test_record.datetime.strftime("%m/%d/%Y, %H:%M:%S")}', # references contain mainly 'public', then fill up with internal references=f'{top_msg.message_id} {internal_msg.message_id} {msg.message_id} {new_msg.message_id}', ) @@ -1254,21 +1628,136 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): self.assertEqual(reply.parent_id, msg) self.assertEqual(reply.subtype_id, self.env.ref('mail.mt_note')) - @users('employee') - @mute_logger('odoo.addons.mail.models.mail_mail') - def test_message_post_with_view_no_message_log(self): - """ Test posting on documents based on a view is forced to be a message posted and not a note """ - - test_record = self.test_record.with_user(self.env.user) + def test_post_parameters(self): + """ Test limitations / support of notification and post parameters """ + portal_record = self.env['mail.test.access'].create({ + 'access': 'logged', + 'name': 'Portal enabled', + }) with self.mock_mail_gateway(): - test_record.message_post_with_view( - 'test_mail.mail_template_simple_test', - values={'partner': self.user_employee.partner_id}, - partner_ids=self.partner_1.ids, - message_log=True, + # headers not allowed for portal users + with self.assertRaises(ValueError): + _msg = portal_record.with_user(self.user_portal).message_post( + body='My Body', + mail_headers={ + 'X-Portal': 'myself', + }, + message_type='comment', + subject='My Subject', + subtype_xmlid='mail.mt_comment', + ) + + @users('employee') + def test_post_with_out_of_office(self): + """ Test out of office support. Test setup : + * record followers: user_employee_c2 + * OOO users: user_admin, user_employee_c2, user_portal + """ + test_record = self.env['mail.test.simple'].browse(self.test_record.ids) + test_record.message_subscribe(self.user_employee_c2.partner_id.ids) + # post history with partner_admin, should not prevent first OOO message to be generated + with self.mock_datetime_and_now(datetime(2025, 6, 17, 11, 10, 0)): + test_record.with_user(self.user_admin).message_post( + body='Posting before leaving on holidays', + message_type='comment', + subtype_id=self.env.ref('mail.mt_comment').id, ) - self.assertSentEmail(self.user_employee.partner_id, [self.partner_1]) - self.assertEqual(test_record.message_ids[0].subtype_id, self.env.ref('mail.mt_comment')) + test_record.message_unsubscribe(self.partner_admin.ids) + + # note that even if somehow portal achieved to be OOO we don't care + self._setup_out_of_office(self.user_admin + self.user_employee_c2 + self.user_portal) + self.user_employee.notification_type = 'email' # potential limitation of from, to check + self.user_admin.notification_type = 'email' # potential limitation of from, to check + + for user in self.user_admin + self.user_employee_c2: + self.assertTrue(user.is_out_of_office) + for user in self.user_employee + self.user_employee_c3 + self.user_portal: + self.assertFalse(user.is_out_of_office, 'Unset or portal') + + for msg, post_dt, author_user, recipients, exp_ooo_authors in [ + ( + 'partner_admin should not OOO himself when replying to its own message', + datetime(2025, 6, 17, 14, 16, 5), + self.user_admin, + self.user_admin.partner_id, + self.env['res.partner'], + ), + ( + 'Portal user should not generate OOO messages, admin should as original message author', + datetime(2025, 6, 17, 14, 15, 59), + self.user_employee, + self.user_portal.partner_id, + self.partner_admin, + ), + ( + 'partner_admin and user_employee_2 are in direct recipients and OOO, but admin already sent it', + datetime(2025, 6, 17, 14, 15, 59), + self.user_employee, + (self.user_admin + self.user_employee_c2 + self.user_employee_c3 + self.user_portal).partner_id, + self.partner_employee_c2, + ), + ( + 'Do not send multiple OOO with same author/recipient in a 4 days timeframe', + datetime(2025, 6, 18, 14, 15, 59), + self.user_employee, + (self.user_admin + self.user_employee_c2 + self.user_employee_c3 + self.user_portal).partner_id, + self.env['res.partner'], + ), + ( + 'multiple OOO, more than 4 days after last OOO -> done', + datetime(2025, 6, 22, 14, 16, 0), + self.user_employee, + (self.user_admin + self.user_employee_c2 + self.user_employee_c3 + self.user_portal).partner_id, + self.partner_admin + self.partner_employee_c2, + ), + ]: + with self.subTest(msg=msg, post_dt=post_dt, recipients=recipients): + with self.mock_mail_gateway(), self.mock_mail_app(), self.mock_datetime_and_now(post_dt): + # avoid subscribing author, eases tests in successive order + message = test_record.with_user(author_user).with_context(mail_post_autofollow_author_skip=True).message_post( + body="We need admin NOW !", + message_type='email', + partner_ids=recipients.ids, + subtype_id=self.env.ref('mail.mt_comment').id, + ) + # classic post + self.assertEqual( + message.notified_partner_ids, + recipients - author_user.partner_id + self.user_employee_c2.partner_id, + ) + # OOO messages: from: OOO recipient to message author + self.assertEqual(len(self._new_msgs), 1 + len(exp_ooo_authors), 'Posted message + OOO from expected authors') + ooo_messages = self._new_msgs[1:] + self.assertEqual(ooo_messages.author_id, exp_ooo_authors) + for ooo_author in exp_ooo_authors: + ooo_message = ooo_messages.filtered(lambda m: m.author_id == ooo_author) + self.assertMailNotifications( + ooo_message, + [{ + 'content': "

Le numéro que vous avez composé n'est plus attribué.

", + 'email_values': { + 'headers': { + 'Auto-Submitted': 'auto-replied', + 'X-Auto-Response-Suppress': 'All', + }, + 'subject': f'Auto: {test_record.name}', + }, + 'message_type': 'out_of_office', + 'message_values': { + 'author_id': ooo_author, + 'email_from': ooo_author.email_formatted, + 'model': test_record._name, + 'partner_ids': author_user.partner_id, + 'notified_partner_ids': author_user.partner_id, + 'res_id': test_record.id, + 'subject': f'Auto: {test_record.name}', + }, + 'notif': [ + {'partner': author_user.partner_id, 'type': 'email'}, + ], + 'subtype': 'mail.mt_comment', + }], + ) @tagged('mail_post') @@ -1276,16 +1765,7 @@ class TestMessagePostHelpers(TestMessagePostCommon): @classmethod def setUpClass(cls): - super(TestMessagePostHelpers, cls).setUpClass() - # ensure employee can create partners, necessary for templates - cls.user_employee.write({ - 'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)], - }) - - cls.user_employee.write({ - 'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)], - }) - + super().setUpClass() cls.test_records, cls.test_partners = cls._create_records_for_batch( 'mail.test.ticket', 10, @@ -1302,11 +1782,36 @@ class TestMessagePostHelpers(TestMessagePostCommon): 'email_cc': cls.partner_1.email, 'email_to': f'{cls.email_1}, {cls.email_2}', 'partner_to': '{{ object.customer_id.id }},%s' % cls.partner_2.id, + 'use_default_to': False, }) cls.test_template.attachment_ids.write({'res_id': cls.test_template.id}) # Force the attachments of the template to be in the natural order. cls.test_template.invalidate_recordset(['attachment_ids']) + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_message_helpers_source_ref(self): + """ Test various sources (record or xml id) to ensure source_ref right + computation. """ + test_records = self.test_records.with_env(self.env) + template = self.test_template.with_env(self.env) + view = self.env.ref('test_mail.mail_template_simple_test') + + for source_ref in ('test_mail.mail_test_ticket_tracking_tpl', template, + 'test_mail.mail_template_simple_test', view): + with self.subTest(source_ref=source_ref), self.mock_mail_gateway(): + _new_mails = test_records.with_user(self.user_employee).message_mail_with_source( + source_ref, + render_values={'partner': self.user_employee.partner_id}, + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + ) + + _new_messages = test_records.with_user(self.user_employee).message_post_with_source( + source_ref, + render_values={'partner': self.user_employee.partner_id}, + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + ) + @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') def test_message_mail_with_template(self): @@ -1314,9 +1819,9 @@ class TestMessagePostHelpers(TestMessagePostCommon): test_records = self.test_records.with_env(self.env) template = self.test_template.with_env(self.env) with self.mock_mail_gateway(): - _new_mails, _new_messages = test_records.with_user(self.user_employee).message_post_with_template( - template.id, - composition_mode='mass_mail', + _new_mails = test_records.with_user(self.user_employee).message_mail_with_source( + template, + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), ) # created partners from inline email addresses @@ -1326,8 +1831,9 @@ class TestMessagePostHelpers(TestMessagePostCommon): # sent emails (mass mail mode) for test_record in test_records: + all_partners = new_partners + self.partner_1 + self.partner_2 + test_record.customer_id self.assertMailMail( - new_partners + self.partner_1 + self.partner_2 + test_record.customer_id, + all_partners, 'sent', author=self.user_employee.partner_id, email_values={ @@ -1339,14 +1845,16 @@ class TestMessagePostHelpers(TestMessagePostCommon): 'body_content': f'Body for: {test_record.name}', }, fields_values={ + 'author_id': self.partner_employee, 'auto_delete': True, + 'email_from': self.partner_employee.email_formatted, 'is_internal': False, - 'is_notification': True, # not auto_delete_message -> keep underlying mail.message - 'message_type': 'email', + 'is_notification': True, # auto_delete_keep_log -> keep underlying mail.message + 'message_type': 'email_outgoing', 'model': test_record._name, - 'notified_partner_ids': self.env['res.partner'], + 'notified_partner_ids': all_partners, 'subtype_id': self.env['mail.message.subtype'], - 'reply_to': formataddr((f'{self.company_admin.name} {test_record.name}', f'{self.alias_catchall}@{self.alias_domain}')), + 'reply_to': formataddr((self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}')), 'res_id': test_record.id, } ) @@ -1360,13 +1868,13 @@ class TestMessagePostHelpers(TestMessagePostCommon): test_record.message_subscribe(test_record.customer_id.ids) with self.mock_mail_gateway(): - new_messages = test_records.message_post_with_view( + new_mails = test_records.message_mail_with_source( 'test_mail.mail_template_simple_test', - values={'partner': self.user_employee.partner_id}, - composition_mode='mass_mail', + render_values={'partner': self.user_employee.partner_id}, subject='About mass mailing', + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), ) - self.assertEqual(len(new_messages), 0) + self.assertEqual(len(new_mails), 10) self.assertEqual(len(self._new_mails), 10) # sent emails (mass mail mode) @@ -1379,19 +1887,34 @@ class TestMessagePostHelpers(TestMessagePostCommon): 'subject': 'About mass mailing', }, fields_values={ + 'author_id': self.partner_employee, 'auto_delete': False, + 'email_from': self.partner_employee.email_formatted, 'is_internal': False, - 'is_notification': True, # not auto_delete_message -> keep underlying mail.message - 'message_type': 'email', + 'is_notification': True, # no to_delete -> notification created + 'message_type': 'email_outgoing', 'model': test_record._name, - 'notified_partner_ids': self.env['res.partner'], + 'notified_partner_ids': test_record.customer_id, 'recipient_ids': test_record.customer_id, 'subtype_id': self.env['mail.message.subtype'], - 'reply_to': formataddr((f'{self.company_admin.name} {test_record.name}', f'{self.alias_catchall}@{self.alias_domain}')), + 'reply_to': formataddr((self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}')), 'res_id': test_record.id, } ) + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_message_post_with_source_subtype(self): + """ Test subtype tweaks when posting with a source """ + test_record = self.test_records.with_env(self.env)[0] + test_template = self.test_template.with_env(self.env) + with self.mock_mail_gateway(): + new_message = test_record.with_user(self.user_employee).message_post_with_source( + test_template, + subtype_xmlid='mail.mt_activities', + ) + self.assertEqual(new_message.subtype_id, self.env.ref("mail.mt_activities")) + @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') def test_message_post_with_template(self): @@ -1400,9 +1923,8 @@ class TestMessagePostHelpers(TestMessagePostCommon): test_record.message_subscribe(test_record.customer_id.ids) test_template = self.test_template.with_env(self.env) with self.mock_mail_gateway(): - _new_mail, new_message = test_record.with_user(self.user_employee).message_post_with_template( - test_template.id, - composition_mode='comment', + new_message = test_record.with_user(self.user_employee).message_post_with_source( + test_template, message_type='comment', subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), ) @@ -1423,7 +1945,7 @@ class TestMessagePostHelpers(TestMessagePostCommon): 'email_from': formataddr((self.partner_employee.name, self.partner_employee.email_normalized)), 'is_internal': False, 'model': test_record._name, - 'reply_to': formataddr((f'{self.company_admin.name} {test_record.name}', f'{self.alias_catchall}@{self.alias_domain}')), + 'reply_to': formataddr((self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}')), 'res_id': test_record.id, }, 'notif': [ @@ -1438,15 +1960,15 @@ class TestMessagePostHelpers(TestMessagePostCommon): ) @users('employee') - @mute_logger('odoo.addons.mail.models.mail') + @mute_logger('odoo.addons.mail.models.mail_mail') def test_message_post_with_template_defaults(self): """ Test default values, notably subtype being a comment """ test_record = self.test_records.with_env(self.env)[0] test_record.message_subscribe(test_record.customer_id.ids) test_template = self.test_template.with_env(self.env) with self.mock_mail_gateway(): - _new_mail, new_message = test_record.with_user(self.user_employee).message_post_with_template( - test_template.id, + new_message = test_record.with_user(self.user_employee).message_post_with_source( + test_template, ) # created partners from inline email addresses @@ -1463,7 +1985,7 @@ class TestMessagePostHelpers(TestMessagePostCommon): 'email_from': formataddr((self.partner_employee.name, self.partner_employee.email_normalized)), 'is_internal': False, 'model': test_record._name, - 'reply_to': formataddr((f'{self.company_admin.name} {test_record.name}', f'{self.alias_catchall}@{self.alias_domain}')), + 'reply_to': formataddr((self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}')), 'res_id': test_record.id, }, 'notif': [ @@ -1473,7 +1995,7 @@ class TestMessagePostHelpers(TestMessagePostCommon): {'partner': new_partners[1], 'type': 'email'}, {'partner': test_record.customer_id, 'type': 'email'}, ], - 'subtype': 'mail.mt_comment', + 'subtype': 'mail.mt_note', }]) @users('employee') @@ -1484,10 +2006,10 @@ class TestMessagePostHelpers(TestMessagePostCommon): test_record.message_subscribe(test_record.customer_id.ids) with self.mock_mail_gateway(): - new_message = test_record.message_post_with_view( + new_message = test_record.message_post_with_source( 'test_mail.mail_template_simple_test', message_type='comment', - values={'partner': self.user_employee.partner_id}, + render_values={'partner': self.user_employee.partner_id}, subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), ) @@ -1501,7 +2023,7 @@ class TestMessagePostHelpers(TestMessagePostCommon): 'is_internal': False, 'message_type': 'comment', 'model': test_record._name, - 'reply_to': formataddr((f'{self.company_admin.name} {test_record.name}', f'{self.alias_catchall}@{self.alias_domain}')), + 'reply_to': formataddr((self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}')), 'res_id': test_record.id, }, 'notif': [ @@ -1519,9 +2041,9 @@ class TestMessagePostHelpers(TestMessagePostCommon): # defaults is a note, take into account specified recipients with self.mock_mail_gateway(): - new_message = test_record.message_post_with_view( + new_message = test_record.message_post_with_source( 'test_mail.mail_template_simple_test', - values={'partner': self.user_employee.partner_id}, + render_values={'partner': self.user_employee.partner_id}, partner_ids=test_record.customer_id.ids, ) @@ -1535,13 +2057,13 @@ class TestMessagePostHelpers(TestMessagePostCommon): 'is_internal': False, 'message_type': 'notification', 'model': test_record._name, - 'reply_to': formataddr((f'{self.company_admin.name} {test_record.name}', f'{self.alias_catchall}@{self.alias_domain}')), + 'reply_to': formataddr((self.partner_employee.name, f'{self.alias_catchall}@{self.alias_domain}')), 'res_id': test_record.id, }, 'notif': [ {'partner': test_record.customer_id, 'type': 'email'}, ], - 'subtype': 'mail.mt_comment', + 'subtype': 'mail.mt_note', }]) @@ -1550,23 +2072,24 @@ class TestMessagePostGlobal(TestMessagePostCommon): @users('employee') def test_message_post_return(self): - """ Ensures calling message_post through RPC always return an ID. """ + """ Ensures calling message_post through RPC always return a list with one ID. """ test_record = self.env['mail.test.simple'].browse(self.test_record.ids) # Use call_kw as shortcut to simulate a RPC call. - message_id = call_kw(self.env['mail.test.simple'], - 'message_post', - [test_record.id], - {'body': 'test'}) - self.assertTrue(isinstance(message_id, int)) + result = call_kw( + self.env['mail.test.simple'], + 'message_post', + [test_record.id], + {'body': 'test'}) + self.assertTrue(tools.misc.has_list_types(result, (int,))) @tagged('mail_post', 'multi_lang') -class TestMessagePostLang(TestMailCommon, TestRecipients): +class TestMessagePostLang(MailCommon, TestRecipients): @classmethod def setUpClass(cls): - super(TestMessagePostLang, cls).setUpClass() + super().setUpClass() cls.test_records = cls.env['mail.test.lang'].create([ {'customer_id': False, @@ -1590,12 +2113,9 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): 'name': 'TestTemplate', 'partner_to': '{{ object.customer_id.id if object.customer_id else "" }}', 'subject': 'EnglishSubject for {{ object.name }}', - }) - cls.user_employee.write({ # add group to create contacts, necessary for templates - 'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)], + 'use_default_to': False, }) - cls._activate_multi_company() cls._activate_multi_lang(test_record=cls.test_records[0], test_template=cls.test_template) cls.partner_2.write({'lang': 'es_ES'}) @@ -1619,30 +2139,31 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): @mute_logger('odoo.addons.mail.models.mail_mail') def test_composer_lang_template_comment(self): """ When posting in comment mode, content is rendered using the lang - field of template. Notification layout lang is partly coming from - template, partly from environment (to be improved). """ + field of template. Notification layout lang is the one from the + customer to personalize the context. When not found it fallbacks + on rendered template lang or environment lang. """ test_record = self.test_records[0].with_user(self.env.user) test_template = self.test_template.with_user(self.env.user) - for partner, exp_content_lang, exp_notif_lang in zip( - (self.env['res.partner'], self.partner_1, self.partner_2), - ('es_ES', 'en_US', 'es_ES'), # content: coming from template definition - ('en_US', 'en_US', 'es_ES'), # layout: coming from customer - ): - with self.subTest( - partner=partner, - exp_content_lang=exp_content_lang, - exp_notif_lang=exp_notif_lang - ): - test_record.write({'customer_id': partner.id}) + for partner in self.env['res.partner'] + self.partner_1 + self.partner_2: + with self.subTest(partner=partner): + test_record.write({ + 'customer_id': partner.id, + }) with self.mock_mail_gateway(): - test_record.message_post_with_template( - test_template.id, + test_record.message_post_with_source( + test_template, email_layout_xmlid='mail.test_layout', message_type='comment', subtype_id=self.env.ref('mail.mt_comment').id, ) + # expected languages: content depend on template (lang field) aka + # customer.lang or record.lang (see template); notif lang is + # partner lang or default DB lang + exp_content_lang = partner.lang if partner.lang else 'es_ES' + exp_notif_lang = partner.lang if partner.lang else 'en_US' + if partner: customer = partner else: @@ -1650,7 +2171,7 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): self.assertTrue(customer, 'Template usage should have created a contact based on record email') self.assertEqual(customer.lang, exp_notif_lang) - customer_email = self._find_sent_mail_wemail(customer.email_formatted) + customer_email = self._find_sent_email_wemail(customer.email_formatted) self.assertTrue(customer_email) body = customer_email['body'] # check content: depends on object.lang / object.customer_id.lang @@ -1667,9 +2188,8 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): else: self.assertEqual(f'SpanishSubject for {test_record.name}', customer_email['subject'], 'Subject based on template should be translated') - # check notification layout content: currently partly translated - # based only on template definition - if exp_content_lang == 'en_US': + # check notification layout content: depends on customer lang + if exp_notif_lang == 'en_US': self.assertNotIn('Spanish Layout para', body, 'Layout translation failed') self.assertIn('English Layout for Lang Chatter Model', body, 'Layout / model translation failed') @@ -1679,24 +2199,16 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): '"View document" translation failed') self.assertIn(f'View {test_record._description}', body, '"View document" translation failed') - self.assertNotIn('SpanishButtonTitle', body, - 'Groups-based action names translation failed') - self.assertIn('NotificationButtonTitle', body, - 'Groups-based action names translation failed') else: - self.assertIn('Spanish Layout para', body, 'Layout content should be translated') - self.assertNotIn('English Layout for', body) - self.assertIn('Spanish Layout para Spanish Model Description', body, 'Model name should be translated') + self.assertNotIn('English Layout for', body, 'Layout translation failed') + self.assertIn('Spanish Layout para Spanish Model Description', body, + 'Layout / model translation failed') + self.assertNotIn('Lang Chatter Model', body, 'Model translation failed') # check notification layout strings - self.assertIn('View Lang Chatter Model', body, - 'Fixme: "View document" should be translated') - # self.assertIn('SpanishView Spanish Model Description', body, - # '"View document" should be translated') - # self.assertNotIn(f'View {test_record._description}', body, - # '"View document" should be translated') - # self.assertIn('SpanishButtonTitle', body, - # 'Groups-based action names should be translated') - self.assertIn('NotificationButtonTitle', body, 'Fixme: Groups-based action names should be translated') + self.assertIn('SpanishView Spanish Model Description', body, + '"View document" translation failed') + self.assertNotIn(f'View {test_record._description}', body, + '"View document" translation failed') @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') @@ -1705,31 +2217,26 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): test_template = self.test_template.with_user(self.env.user) with self.mock_mail_gateway(): - test_records.message_post_with_template( - test_template.id, - composition_mode='mass_mail', + test_records.message_mail_with_source( + test_template, email_layout_xmlid='mail.test_layout', message_type='comment', - subtype_id=self.env.ref('mail.mt_comment').id, + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), ) record0_customer = self.env['res.partner'].search([('email_normalized', '=', 'test.record.1@test.customer.com')], limit=1) self.assertTrue(record0_customer, 'Template usage should have created a contact based on record email') for record, customer in zip(test_records, record0_customer + self.partner_2): - customer_email = self._find_sent_mail_wemail(customer.email_formatted) + customer_email = self._find_sent_email_wemail(customer.email_formatted) self.assertTrue(customer_email) body = customer_email['body'] # check content - # self.assertIn(f'SpanishBody for {record.name}', body, - # 'Body based on template should be translated') - self.assertIn(f'EnglishBody for {record.name}', body, - 'Fixme: Body based on template should be translated') + self.assertIn(f'SpanishBody for {record.name}', body, + 'Body based on template should be translated') # check subject - # self.assertEqual(f'SpanishSubject for {record.name}', customer_email['subject'], - # 'Subject based on template should be translated') - self.assertEqual(f'EnglishSubject for {record.name}', customer_email['subject'], - 'Fixme: Subject based on template should be translated') + self.assertEqual(f'SpanishSubject for {record.name}', customer_email['subject'], + 'Subject based on template should be translated') @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') @@ -1739,14 +2246,14 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): with self.mock_mail_gateway(): test_records[1].message_post( - body='

Hello

', + body=Markup('

Hello

'), email_layout_xmlid='mail.test_layout', message_type='comment', subject='Subject', subtype_xmlid='mail.mt_comment', ) - customer_email = self._find_sent_mail_wemail(self.partner_2.email_formatted) + customer_email = self._find_sent_email_wemail(self.partner_2.email_formatted) self.assertTrue(customer_email) body = customer_email['body'] # check content @@ -1760,32 +2267,35 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): # check notification layout strings self.assertIn('SpanishView Spanish Model Description', body, '"View document" should be translated') - self.assertNotIn(f'View {test_records[1]._description}', body) - self.assertIn('SpanishButtonTitle', body, 'Groups-based action names should be translated') - self.assertNotIn('NotificationButtonTitle', body) + self.assertNotIn(f'View {test_records[1]._description}', body, + '"View document" should be translated') @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') def test_layout_email_lang_template(self): """ Test language support when posting in batch using a template. - Content and layout are is translated based on template definition. """ + Content is translated based on template definition, layout based on + customer lang. """ test_records = self.test_records.with_user(self.env.user) test_template = self.test_template.with_user(self.env.user) with self.mock_mail_gateway(): - for test_record in test_records: - test_record.message_post_with_template( - test_template.id, - email_layout_xmlid='mail.test_layout', - message_type='comment', - subtype_id=self.env.ref('mail.mt_comment').id, - ) + test_records.message_post_with_source( + test_template, + email_layout_xmlid='mail.test_layout', + message_type='comment', + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), + ) record0_customer = self.env['res.partner'].search([('email_normalized', '=', 'test.record.1@test.customer.com')], limit=1) self.assertTrue(record0_customer, 'Template usage should have created a contact based on record email') - for record, customer in zip(test_records, record0_customer + self.partner_2): - customer_email = self._find_sent_mail_wemail(customer.email_formatted) + for record, customer, exp_notif_lang in zip( + test_records, + record0_customer + self.partner_2, + ('en_US', 'es_ES') # new customer is en_US, partner_2 is es_ES + ): + customer_email = self._find_sent_email_wemail(customer.email_formatted) self.assertTrue(customer_email) # body and layouting are translated partly based on template. Bits @@ -1799,19 +2309,67 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): self.assertEqual(f'SpanishSubject for {record.name}', customer_email['subject'], 'Subject based on template should be translated') # check notification layout translation - self.assertIn('Spanish Layout para', body, - 'Layout content should be translated') - self.assertNotIn('English Layout for', body) - self.assertIn('Spanish Layout para Spanish Model Description', body, - 'Model name should be translated') - # self.assertIn('SpanishView Spanish Model Description', body, - # '"View document" should be translated') - self.assertIn(f'View {test_records[1]._description}', body, - 'Fixme: "View document" should be translated') - # self.assertIn('NotificationButtonTitle', body, - # 'Groups-based action names should be translated') - self.assertIn('NotificationButtonTitle', body, - 'Fixme: groups-based action names should be translated') + if exp_notif_lang == 'en_US': + self.assertNotIn('Spanish Layout para', body, + 'Layout content should be translated') + self.assertIn('English Layout for', body) + self.assertNotIn('Spanish Layout para Spanish Model Description', body, + 'Model name should be translated') + self.assertNotIn('SpanishView Spanish Model Description', body, + '"View document" should be translated') + self.assertIn(f'View {test_records[1]._description}', body, + '"View document" should be translated') + else: + self.assertIn('Spanish Layout para', body, + 'Layout content should be translated') + self.assertNotIn('English Layout for', body) + self.assertIn('Spanish Layout para Spanish Model Description', body, + 'Model name should be translated') + self.assertIn('SpanishView Spanish Model Description', body, + '"View document" should be translated') + self.assertNotIn(f'View {test_records[1]._description}', body, + '"View document" should be translated') + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_post_multi_lang_inactive(self): + """ Test posting using an inactive lang, due do some data in DB. It + should not crash when trying to search for translated terms / fetch + lang bits. """ + installed = self.env['res.lang'].get_installed() + self.assertNotIn('fr_FR', [code for code, _name in installed]) + test_records = self.test_records.with_env(self.env) + customer_inactive_lang = self.env['res.partner'].create({ + 'email': 'test.partner.fr@test.example.com', + 'lang': 'fr_FR', + 'name': 'French Inactive Customer', + }) + test_records.message_subscribe(partner_ids=customer_inactive_lang.ids) + + for record in test_records: + with self.subTest(record=record.name): + with self.mock_mail_gateway(mail_unlink_sent=False), \ + self.mock_mail_app(): + record.message_post( + body=Markup('

Hi there

'), + email_layout_xmlid='mail.test_layout', + message_type='comment', + subject='TeDeum', + subtype_xmlid='mail.mt_comment', + ) + message = record.message_ids[0] + self.assertEqual(message.notified_partner_ids, customer_inactive_lang) + + email = self._find_sent_email( + self.partner_employee.email_formatted, + [customer_inactive_lang.email_formatted] + ) + self.assertTrue(bool(email), 'Email not found, check recipients') + + exp_layout_content_en = 'English Layout for Lang Chatter Model' + exp_button_en = 'View Lang Chatter Model' + self.assertIn(exp_layout_content_en, email['body']) + self.assertIn(exp_button_en, email['body']) @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') @@ -1842,7 +2400,7 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): with self.mock_mail_gateway(mail_unlink_sent=False), \ self.mock_mail_app(): record.message_post( - body='

Hi there

', + body=Markup('

Hi there

'), email_layout_xmlid=email_layout_xmlid, message_type='comment', subject='TeDeum', @@ -1854,51 +2412,49 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): ) # check created mail.mail and outgoing emails. One email - # is generated for 'partner_1' and 'partner_2' (same email - # as same configuration (aka all customers)). - _mail = self.assertMailMail( - self.partner_1 + self.partner_2, 'sent', - mail_message=message, - author=self.partner_employee, - email_values={ - 'body_content': '

Hi there

', - 'email_from': self.partner_employee.email_formatted, - 'subject': 'TeDeum', - }, - ) + # is generated for each partner 'partner_1' and 'partner_2' + # different language thus different layout + for partner in self.partner_1 + self.partner_2: + _mail = self.assertMailMail( + partner, 'sent', + mail_message=message, + author=self.partner_employee, + email_values={ + 'body_content': '

Hi there

', + 'email_from': self.partner_employee.email_formatted, + 'subject': 'TeDeum', + }, + ) # Low-level checks on outgoing email for the recipient to # check layouting and language. Note that standard layout # is not tested against translations, only the custom one # to ease translations checks. - for partner in self.partner_1 + self.partner_2: + for partner, exp_lang in zip( + self.partner_1 + self.partner_2, + ('en_US', 'es_ES') + ): email = self._find_sent_email( self.partner_employee.email_formatted, [partner.email_formatted] ) self.assertTrue(bool(email), 'Email not found, check recipients') + self.assertEqual(partner.lang, exp_lang, 'Test misconfiguration') exp_layout_content_en = 'English Layout for Lang Chatter Model' exp_layout_content_es = 'Spanish Layout para Spanish Model Description' exp_button_en = 'View Lang Chatter Model' exp_button_es = 'SpanishView Spanish Model Description' - exp_action_en = 'NotificationButtonTitle' - exp_action_es = 'SpanishButtonTitle' if email_layout_xmlid: - if employee_lang == 'es_ES': + if exp_lang == 'es_ES': self.assertIn(exp_layout_content_es, email['body']) self.assertIn(exp_button_es, email['body']) - self.assertIn(exp_action_es, email['body']) else: self.assertIn(exp_layout_content_en, email['body']) self.assertIn(exp_button_en, email['body']) - self.assertIn(exp_action_en, email['body']) else: # check default layouting applies - if employee_lang == 'es_ES': + if exp_lang == 'es_ES': self.assertIn('html lang="es_ES"', email['body']) - elif employee_lang: - self.assertIn('html lang="en_US"', email['body']) else: - # if lang is False -> nothing in header, strange - self.assertNotIn('html lang', email['body']) + self.assertIn('html lang="en_US"', email['body']) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_message_track.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_message_track.py index 5838cf1..1e8a295 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_message_track.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_message_track.py @@ -3,22 +3,129 @@ from unittest.mock import patch -from odoo.addons.test_mail.tests.common import TestMailCommon -from odoo.tests.common import tagged -from odoo.tests import Form +from odoo import fields +from odoo.addons.mail.tests.common import MailCommon +from odoo.addons.mail.tools.discuss import Store +from odoo.addons.test_mail.data.test_mail_data import MAIL_TEMPLATE +from odoo.tests import Form, tagged, users +from odoo.tools import mute_logger @tagged('mail_track') -class TestTracking(TestMailCommon): +class TestTracking(MailCommon): - def setUp(self): - super(TestTracking, self).setUp() + @classmethod + def setUpClass(cls): + super().setUpClass() - record = self.env['mail.test.ticket'].with_user(self.user_employee).with_context(self._test_context).create({ + record = cls.env['mail.test.ticket'].with_user( + cls.user_employee + ).with_context(cls._test_context).create({ 'name': 'Test', }) + cls.record = record.with_context(mail_notrack=False) + + def test_message_track_author(self): + """ Check that the author of the log note matches the user at the time + of writing. """ + with self.mock_mail_gateway(): + self.record._track_set_author(self.partner_admin) + self.record.write({ + 'customer_id': self.partner_employee.id, + }) + self.flush_tracking() + + self.assertEqual(len(self.record.message_ids), 1) + self.assertEqual(len(self.record.message_ids.sudo().tracking_value_ids), 1) + + self.assertEqual(self.record.message_ids.author_id, self.partner_admin) + + @users('employee') + def test_message_track_default_message(self): + """Check that the default tracking log message defined on the model is used + and that setting a log message overrides it. See `_track_get_default_log_message`""" + + record = self.env['mail.test.track'].with_context(self._test_context).create({ + 'name': 'Test', + 'track_enable_default_log': True, + }).with_context(mail_notrack=False) self.flush_tracking() - self.record = record.with_context(mail_notrack=False) + + with self.mock_mail_gateway(): + record.user_id = self.user_admin + self.flush_tracking() + + messages = record.message_ids + self.assertEqual(len(messages), 1) + self.assertEqual(messages.body, '

There was a change on Test for fields "user_id"

', + 'Default message should be used') + + with self.mock_mail_gateway(): + record._track_set_log_message('Hi') + record.user_id = False + self.flush_tracking() + + messages = record.message_ids - messages + self.assertEqual(len(messages), 1) + self.assertEqual(messages.body, '

Hi

', '_track_set_log_message should take priority over default message') + + @users('employee') + def test_message_track_filter_for_display(self): + """Check that tracked fields filtered for display are not present + in the front-end and email formatting methods. See `_track_filter_for_display`""" + field_dname = 'Responsible' + field_name = 'user_id' + field_type = 'many2one' + original_user = self.user_admin + new_user = self.user_employee + + records = self.env['mail.test.track'].create([{ + 'name': 'TestTrack Hide User Field', + 'user_id': original_user.id, + 'track_fields_tofilter': 'user_id', + }, { + 'name': 'TestTrack Show All Fields', + 'user_id': original_user.id, + 'track_fields_tofilter': '', + }]) + self.flush_tracking() + + records.write({'user_id': new_user.id}) + self.flush_tracking() + + for record in records: + self.assertEqual(len(record.message_ids), 2, 'Should be a creation message and a tracking message') + self.assertTracking( + record.message_ids[0], + [('user_id', 'many2one', original_user, new_user)] + ) + # first record: tracking value should be hidden + message_0 = records[0].message_ids[0] + formatted = Store().add(message_0).get_result()["mail.message"][0] + self.assertEqual(formatted['trackingValues'], [], 'Hidden values should not be formatted') + mail_render = records[0]._notify_by_email_prepare_rendering_context(message_0, {}) + self.assertEqual(mail_render['tracking_values'], []) + + # second record: all values displayed + message_1 = records[1].message_ids[0] + formatted = Store().add(message_1).get_result()["mail.message"][0] + self.assertEqual(len(formatted['trackingValues']), 1) + self.assertDictEqual( + formatted['trackingValues'][0], + { + 'id': message_1.sudo().tracking_value_ids.id, + 'fieldInfo': { + 'changedField': field_dname, + 'currencyId': False, + 'floatPrecision': None, + 'fieldType': field_type, + 'isPropertyField': False, + }, + 'newValue': new_user.display_name, + 'oldValue': original_user.display_name, + }) + mail_render = records[1]._notify_by_email_prepare_rendering_context(message_1, {}) + self.assertEqual(mail_render['tracking_values'], [(field_dname, original_user.display_name, new_user.display_name)]) def test_message_track_message_type(self): """Check that the right message type is applied for track templates.""" @@ -35,7 +142,7 @@ class TestTracking(TestMailCommon): def _track_subtype(self, init_values): return self.env.ref('mail.mt_note') - self.patch(self.registry('mail.test.ticket'), '_track_subtype', _track_subtype) + self.patch(self.registry['mail.test.ticket'], '_track_subtype', _track_subtype) def _track_template(self, changes): if 'email_from' in changes: @@ -43,7 +150,7 @@ class TestTracking(TestMailCommon): elif 'container_id' in changes: return {'container_id': (mail_templates[1], {'message_type': 'notification'})} return {} - self.patch(self.registry('mail.test.ticket'), '_track_template', _track_template) + self.patch(self.registry['mail.test.ticket'], '_track_template', _track_template) container = self.env['mail.test.container'].create({'name': 'Container'}) @@ -65,14 +172,23 @@ class TestTracking(TestMailCommon): self.assertEqual(len(self.record.message_ids), 4, 'Should have added one change message and one automated template') self.assertEqual(second_message.message_type, 'notification') - def test_message_track_no_tracking(self): - """ Update a set of non tracked fields -> no message, no tracking """ - self.record.write({ - 'name': 'Tracking or not', - 'count': 32, - }) + def test_message_track_multiple(self): + """ check that multiple updates generate a single tracking message """ + container = self.env['mail.test.container'].with_context(mail_create_nosubscribe=True).create({'name': 'Container'}) + self.record.name = 'Zboub' + self.record.customer_id = self.user_admin.partner_id + self.record.user_id = self.user_admin + self.record.container_id = container self.flush_tracking() - self.assertEqual(self.record.message_ids, self.env['mail.message']) + + # should have a single message with all tracked fields + self.assertEqual(len(self.record.message_ids), 1, 'should have 1 tracking message') + self.assertEqual(self.record.message_ids.author_id, self.partner_employee) + self.assertTracking(self.record.message_ids[0], [ + ('customer_id', 'many2one', False, self.user_admin.partner_id), + ('user_id', 'many2one', False, self.user_admin), + ('container_id', 'many2one', False, container), + ]) def test_message_track_no_subtype(self): """ Update some tracked fields not linked to some subtype -> message with onchange """ @@ -86,6 +202,7 @@ class TestTracking(TestMailCommon): # one new message containing tracking; without subtype linked to tracking, a note is generated self.assertEqual(len(self.record.message_ids), 1) + self.assertEqual(self.record.message_ids.author_id, self.partner_employee) self.assertEqual(self.record.message_ids.subtype_id, self.env.ref('mail.mt_note')) # no specific recipients except those following notes, no email @@ -99,6 +216,23 @@ class TestTracking(TestMailCommon): [('customer_id', 'many2one', False, customer) # onchange tracked field ]) + @users('employee') + def test_message_track_no_tracking(self): + """ Update a set of non tracked fields -> no message, no tracking, or + use dedicated context key """ + record = self.record.with_env(self.env) + record.write({ + 'name': 'Tracking or not', + 'count': 32, + }) + self.flush_tracking() + self.assertFalse(record.message_ids) + + # check context key allowing to skip tracking + record.with_context(mail_notrack=True).write({'email_from': 'new.from@test.example.com'}) + self.flush_tracking() + self.assertFalse(record.message_ids) + def test_message_track_subtype(self): """ Update some tracked fields linked to some subtype -> message with onchange """ self.record.message_subscribe( @@ -115,6 +249,7 @@ class TestTracking(TestMailCommon): self.flush_tracking() # one new message containing tracking; subtype linked to tracking self.assertEqual(len(self.record.message_ids), 1) + self.assertEqual(self.record.message_ids.author_id, self.partner_employee) self.assertEqual(self.record.message_ids.subtype_id, self.env.ref('test_mail.st_mail_test_ticket_container_upd')) # no specific recipients except those following container @@ -127,6 +262,7 @@ class TestTracking(TestMailCommon): [('container_id', 'many2one', False, container) # onchange tracked field ]) + @mute_logger('odoo.addons.mail.models.mail_mail') def test_message_track_template(self): """ Update some tracked fields linked to some template -> message with onchange """ self.record.write({'mail_template': self.env.ref('test_mail.mail_test_ticket_tracking_tpl').id}) @@ -142,6 +278,7 @@ class TestTracking(TestMailCommon): self.assertEqual(len(self.record.message_ids), 2, 'should have 2 new messages: one for tracking, one for template') # one new message containing the template linked to tracking + self.assertEqual(self.record.message_ids[0].author_id, self.partner_employee) self.assertEqual(self.record.message_ids[0].subject, 'Test Template') self.assertEqual(self.record.message_ids[0].body, '

Hello Test2

') @@ -155,6 +292,7 @@ class TestTracking(TestMailCommon): [('customer_id', 'many2one', False, self.user_admin.partner_id) # onchange tracked field ]) + @mute_logger('odoo.addons.mail.models.mail_mail') def test_message_track_template_at_create(self): """ Create a record with tracking template on create, template should be sent.""" @@ -170,12 +308,53 @@ class TestTracking(TestMailCommon): self.assertEqual(len(record.message_ids), 1, 'should have 1 new messages for template') # one new message containing the template linked to tracking + self.assertEqual(record.message_ids[0].author_id, self.partner_employee) self.assertEqual(record.message_ids[0].subject, 'Test Template') self.assertEqual(record.message_ids[0].body, '

Hello Test

') # one email send due to template self.assertSentEmail(self.record.env.user.partner_id, [self.partner_admin], body='

Hello Test

') - def test_create_partner_from_tracking_multicompany(self): + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_thread') + def test_message_track_template_at_create_from_message(self): + """Make sure records created through aliasing show the original message before the template""" + # setup + test_model = self.env['ir.model']._get('mail.test.ticket') + original_sender = self.user_admin.partner_id + custom_values = {'name': 'Test', 'customer_id': original_sender.id, + 'mail_template': self.env.ref('test_mail.mail_test_ticket_tracking_tpl').id} + self.env['mail.alias'].create({ + 'alias_name': 'groups', + 'alias_model_id': test_model.id, + 'alias_contact': 'everyone', + 'alias_defaults': custom_values}) + record = self.format_and_process(MAIL_TEMPLATE, '"Sylvie Lelitre" ', + 'groups@test.mycompany.com', target_field='customer_id', subject=custom_values['customer_id'], + target_model='mail.test.ticket') + + with self.mock_mail_gateway(mail_unlink_sent=False): + self.flush_tracking() + + # Should be trigger message and response template + self.assertEqual(len(record.message_ids), 2) + messages = list(record.message_ids) + messages.sort(key=lambda msg: msg.id) + trigger = messages[0] + template = messages[1] + self.assertIn('Please call me as soon as possible this afternoon!', trigger.body) + self.assertIn(f"Hello {custom_values['name']}", template.body) + self.assertMailMail( + original_sender, + 'sent', + author=self.env.ref('base.partner_root'), + email_values={ + 'body_content': f"

Hello {custom_values['name']}

", + } + ) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_message_track_template_create_partner_multicompany(self): + """ Test partner created due to usage of a mail.template, triggered by + a tracking, in a multi company environment. """ company1 = self.env['res.company'].create({'name': 'company1'}) self.env.user.write({'company_ids': [(4, company1.id, False)]}) self.assertNotEqual(self.env.company, company1) @@ -195,7 +374,7 @@ class TestTracking(TestMailCommon): def patched_message_track_post_template(*args, **kwargs): if args[0]._name == "mail.test.track": - args[0].message_post_with_template(template.id) + args[0].message_post_with_source(template) return True with patch('odoo.addons.mail.models.mail_thread.MailThread._message_track_post_template', patched_message_track_post_template): @@ -210,39 +389,9 @@ class TestTracking(TestMailCommon): self.assertTrue(new_partner) self.assertEqual(new_partner.company_id, company1) - def test_track_invalid_selection(self): - # Test: Check that initial invalid selection values are allowed when tracking - # Create a record with an initially invalid selection value - invalid_value = 'I love writing tests!' - record = self.env['mail.test.track.selection'].create({ - 'name': 'Test Invalid Selection Values', - 'selection_type': 'first', - }) - - self.flush_tracking() - self.env.cr.execute( - """ - UPDATE mail_test_track_selection - SET selection_type = %s - WHERE id = %s - """, - [invalid_value, record.id] - ) - - record.invalidate_recordset() - - self.assertEqual(record.selection_type, invalid_value) - - # Write a valid selection value - record.selection_type = "second" - - self.flush_tracking() - self.assertTracking(record.message_ids, [ - ('selection_type', 'char', invalid_value, 'Second'), - ]) - - def test_track_template(self): - # Test: Check that default_* keys are not taken into account in _message_track_post_template + def test_message_track_template_defaults(self): + """ Check that default_* keys are not taken into account in + _message_track_post_template """ magic_code = 'Up-Up-Down-Down-Left-Right-Left-Right-Square-Triangle' mt_name_changed = self.env['mail.message.subtype'].create({ @@ -263,26 +412,12 @@ class TestTracking(TestMailCommon): 'body_html': '''
WOOP WOOP
''', }) - def _track_subtype(self, init_values): - if 'name' in init_values and init_values['name'] == magic_code: - return 'mail.mt_name_changed' - return False - self.registry('mail.test.container')._patch_method('_track_subtype', _track_subtype) - - def _track_template(self, changes): - res = {} - if 'name' in changes: - res['name'] = (mail_template, {'composition_mode': 'mass_mail'}) - return res - self.registry('mail.test.container')._patch_method('_track_template', _track_template) - - cls = type(self.env['mail.test.container']) - self.assertFalse(hasattr(getattr(cls, 'name'), 'track_visibility')) - getattr(cls, 'name').track_visibility = 'always' - - @self.addCleanup - def cleanup(): - del getattr(cls, 'name').track_visibility + ContainerModel = self.registry['mail.test.container'] + self.assertFalse(hasattr(ContainerModel.name, 'tracking')) + ContainerModel.name.tracking = True + self.addCleanup(delattr, ContainerModel.name, 'tracking') + self.patch(ContainerModel, '_track_subtype', lambda self, init_values: 'mail.mt_name_changed' if 'name' in init_values and init_values['name'] == magic_code else False) + self.patch(ContainerModel, '_track_template', lambda self, changes: {'name': (mail_template, {'composition_mode': 'mass_mail'})} if 'name' in changes else {}) test_mail_record = self.env['mail.test.container'].create({ 'name': 'Zizizatestmailname', @@ -290,129 +425,344 @@ class TestTracking(TestMailCommon): }) test_mail_record.with_context(default_parent_id=2147483647).write({'name': magic_code}) - def test_message_track_multiple(self): - """ check that multiple updates generate a single tracking message """ - container = self.env['mail.test.container'].with_context(mail_create_nosubscribe=True).create({'name': 'Container'}) - self.record.name = 'Zboub' - self.record.customer_id = self.user_admin.partner_id - self.record.user_id = self.user_admin - self.record.container_id = container - self.flush_tracking() - # should have a single message with all tracked fields - self.assertEqual(len(self.record.message_ids), 1, 'should have 1 tracking message') - self.assertTracking(self.record.message_ids[0], [ - ('customer_id', 'many2one', False, self.user_admin.partner_id), - ('user_id', 'many2one', False, self.user_admin), - ('container_id', 'many2one', False, container), +@tagged('mail_track') +class TestTrackingInternals(MailCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.record = cls.env['mail.test.ticket'].with_user(cls.user_employee).create({ + 'name': 'Test', + }) + cls.test_partner = cls.env['res.partner'].create({ + 'country_id': cls.env.ref('base.be').id, + 'email': 'test.partner@test.example.com', + 'name': 'Test Partner', + 'phone': '0456001122', + }) + + cls.properties_linked_records = cls.env['mail.test.ticket'].create([{'name': f'Record {i}'} for i in range(3)]) + cls.properties_parent_1, cls.properties_parent_2 = cls.env['mail.test.track.all.properties.parent'].create([{ + 'definition_properties': [ + {'name': 'property_char', 'string': 'Property Char', 'type': 'char', 'default': 'char value'}, + {'name': 'separator', 'type': 'separator'}, + {'name': 'property_int', 'string': 'Property Int', 'type': 'integer', 'default': 1337}, + {'name': 'property_m2o', 'string': 'Property M2O', 'type': 'many2one', + 'default': (cls.properties_linked_records[0].id, cls.properties_linked_records[0].display_name), 'comodel': 'mail.test.ticket'}, + ], + }, { + 'definition_properties': [ + {'name': 'property_m2m', 'string': 'Property M2M', 'type': 'many2many', + 'default': [(rec.id, rec.display_name) for rec in cls.properties_linked_records], 'comodel': 'mail.test.ticket'}, + {'name': 'property_separator', 'string': 'Separator', 'type': 'separator'}, + {'name': 'property_tags', 'string': 'Property Tags', 'type': 'tags', + 'default': ['aa', 'bb'], 'tags': [('aa', 'AA', 7), ('bb', 'BB', 2), ('cc', 'CC', 3)]}, + {'name': 'property_datetime', 'string': 'Property Datetime', 'type': 'datetime', 'default': '2024-01-02 12:59:01'}, + {'name': 'property_date', 'string': 'Property Date', 'type': 'date', 'default': '2024-01-03'}, + ], + }]) + cls.properties_record_1, cls.properties_record_2 = cls.env['mail.test.track.all'].create([{ + 'properties_parent_id': cls.properties_parent_1.id, + }, { + 'properties_parent_id': cls.properties_parent_2.id, + + }]) + + @users('employee') + def test_mail_track_2many(self): + """ Check result of tracking one2many and many2many fields. Current + usage is to aggregate names into value_char fields. """ + # Create a record with an initially invalid selection value + test_tags = self.env['mail.test.track.all.m2m'].create([ + {'name': 'Tag1',}, + {'name': 'Tag2',}, + {'name': 'Tag3',}, ]) - - def test_tracked_compute(self): - # no tracking at creation - record = self.env['mail.test.track.compute'].create({}) + test_record = self.env['mail.test.track.all'].create({ + 'name': 'Test 2Many fields tracking', + }) self.flush_tracking() - self.assertEqual(len(record.message_ids), 1) - self.assertEqual(len(record.message_ids[0].tracking_value_ids), 0) + + # no tracked field, no tracking at create + last_message = test_record.message_ids[0] + self.assertFalse(last_message.sudo().tracking_value_ids) + + # update m2m + test_record.write({ + 'many2many_field': [(4, test_tags[0].id), (4, test_tags[1].id)], + }) + self.flush_tracking() + last_message = test_record.message_ids[0] + self.assertTracking( + last_message, + [('many2many_field', 'many2many', '', ', '.join(test_tags[:2].mapped('name')))] + ) + + # update m2m + o2m + test_record.write({ + 'many2many_field': [(3, test_tags[0].id), (4, test_tags[2].id)], + 'one2many_field': [ + (0, 0, {'name': 'Child1'}), + (0, 0, {'name': 'Child2'}), + (0, 0, {'name': 'Child3'}), + (0, 0, {'name': False}), + ], + }) + child4_tracking = f'Unnamed Sub-model: pseudo tags for tracking ({test_record.one2many_field[3].id})' + self.flush_tracking() + last_message = test_record.message_ids[0] + self.assertTracking( + last_message, + [ + ('many2many_field', 'many2many', ', '.join(test_tags[:2].mapped('name')), ', '.join((test_tags[1] + test_tags[2]).mapped('name'))), + ('one2many_field', 'one2many', '', f'Child1, Child2, Child3, {child4_tracking}'), + ] + ) + + # remove from o2m + test_record.write({'one2many_field': [(3, test_record.one2many_field[0].id)]}) + self.flush_tracking() + last_message = test_record.message_ids[0] + self.assertTracking( + last_message, + [('one2many_field', 'one2many', f'Child1, Child2, Child3, {child4_tracking}', f'Child2, Child3, {child4_tracking}')] + ) + + @users('employee') + def test_mail_track_all_no2many(self): + test_record = self.env['mail.test.track.all'].create({ + 'company_id': self.env.company.id, + }) + self.flush_tracking() + self.assertEqual(test_record.currency_id, self.env.ref('base.USD')) + messages = test_record.message_ids + today = fields.Date.today() + today_dt = fields.Datetime.to_datetime(today) + now = fields.Datetime.now() + + test_record.write({ + 'boolean_field': True, + 'char_field': 'char_value', + 'date_field': today, + 'datetime_field': now, + 'float_field': 3.22, + 'float_field_with_digits': 3.00001, + 'html_field': '

Html Value

', + 'integer_field': 42, + 'many2one_field_id': self.test_partner.id, + 'monetary_field': 42.42, + 'selection_field': 'first', + 'text_field': 'text_value', + }) + self.flush_tracking() + new_message = test_record.message_ids - messages + self.assertEqual(len(new_message), 1, + 'Should have generated a tracking value') + tracking_value_list = [ + ('boolean_field', 'boolean', 0, 1), + ('char_field', 'char', False, 'char_value'), + ('date_field', 'date', False, today_dt), + ('datetime_field', 'datetime', False, now), + ('float_field', 'float', 0, 3.22), + ('float_field_with_digits', 'float', 0, 3.00001), + ('integer_field', 'integer', 0, 42), + ('many2one_field_id', 'many2one', self.env['res.partner'], self.test_partner), + ('monetary_field', 'monetary', False, (42.42, self.env.ref('base.USD'))), + ('selection_field', 'selection', '', 'FIRST'), + ('text_field', 'text', False, 'text_value'), + ] + self.assertTracking(new_message, tracking_value_list, strict=True) + # check formatting for all field types + formatted_values_all = new_message.sudo().tracking_value_ids._tracking_value_format() + for (field_name, field_type, _, _), formatted_vals in zip(tracking_value_list, formatted_values_all): + currency = self.env.ref('base.USD').id if field_type == 'monetary' else False + precision = None if field_name != 'float_field_with_digits' else (10, 8) + with self.subTest(field_name=field_name): + self.assertEqual(formatted_vals['fieldInfo']['currencyId'], currency) + self.assertEqual(formatted_vals['fieldInfo']['floatPrecision'], precision) + + @users('employee') + def test_mail_track_compute(self): + """ Test tracking of computed fields """ + # no tracking at creation + compute_record = self.env['mail.test.track.compute'].create({}) + self.flush_tracking() + self.assertEqual(len(compute_record.message_ids), 1) + self.assertEqual(len(compute_record.message_ids[0].sudo().tracking_value_ids), 0) # assign partner_id: one tracking message for the modified field and all # the stored and non-stored computed fields on the record - partner = self.env['res.partner'].create({ + partner_su = self.env['res.partner'].sudo().create({ 'name': 'Foo', 'email': 'foo@example.com', 'phone': '1234567890', }) - record.partner_id = partner + compute_record.partner_id = partner_su self.flush_tracking() - self.assertEqual(len(record.message_ids), 2) - self.assertEqual(len(record.message_ids[0].tracking_value_ids), 4) - self.assertTracking(record.message_ids[0], [ - ('partner_id', 'many2one', False, partner), + self.assertEqual(len(compute_record.message_ids), 2) + self.assertEqual(len(compute_record.message_ids[0].sudo().tracking_value_ids), 4) + self.assertEqual(compute_record.message_ids.author_id, self.partner_employee) + self.assertTracking(compute_record.message_ids[0], [ + ('partner_id', 'many2one', False, partner_su), ('partner_name', 'char', False, 'Foo'), ('partner_email', 'char', False, 'foo@example.com'), ('partner_phone', 'char', False, '1234567890'), ]) # modify partner: one tracking message for the only recomputed field - partner.write({'name': 'Fool'}) + partner_su.write({'name': 'Fool'}) self.flush_tracking() - self.assertEqual(len(record.message_ids), 3) - self.assertEqual(len(record.message_ids[0].tracking_value_ids), 1) - self.assertTracking(record.message_ids[0], [ + self.assertEqual(len(compute_record.message_ids), 3) + self.assertEqual(len(compute_record.message_ids[0].sudo().tracking_value_ids), 1) + self.assertTracking(compute_record.message_ids[0], [ ('partner_name', 'char', 'Foo', 'Fool'), ]) # modify partner: one tracking message for both stored computed fields; # the non-stored computed fields have no tracking - partner.write({ + partner_su.write({ 'name': 'Bar', 'email': 'bar@example.com', 'phone': '0987654321', }) # force recomputation of 'partner_phone' to make sure it does not # generate tracking values - self.assertEqual(record.partner_phone, '0987654321') + self.assertEqual(compute_record.partner_phone, '0987654321') self.flush_tracking() - self.assertEqual(len(record.message_ids), 4) - self.assertEqual(len(record.message_ids[0].tracking_value_ids), 2) - self.assertTracking(record.message_ids[0], [ + self.assertEqual(len(compute_record.message_ids), 4) + self.assertEqual(len(compute_record.message_ids[0].sudo().tracking_value_ids), 2) + self.assertTracking(compute_record.message_ids[0], [ ('partner_name', 'char', 'Fool', 'Bar'), ('partner_email', 'char', 'foo@example.com', 'bar@example.com'), ]) -@tagged('mail_track') -class TestTrackingMonetary(TestMailCommon): - - def setUp(self): - super(TestTrackingMonetary, self).setUp() - - self._activate_multi_company() - - record = self.env['mail.test.track.monetary'].with_user(self.user_employee).with_context(self._test_context).create({ + @users('employee') + def test_mail_track_monetary(self): + """ Update a record with a tracked monetary field """ + monetary_record = self.env['mail.test.track.monetary'].with_user(self.user_employee).create({ 'company_id': self.user_employee.company_id.id, }) self.flush_tracking() - self.record = record.with_context(mail_notrack=False) - - def test_message_track_monetary(self): - """ Update a record with a tracked monetary field """ + self.assertEqual(len(monetary_record.message_ids), 1) # Check if the tracking value have the correct currency and values - self.record.write({ + monetary_record.write({ 'revenue': 100, }) self.flush_tracking() - self.assertEqual(len(self.record.message_ids), 1) - - self.assertTracking(self.record.message_ids[0], [ - ('revenue', 'monetary', 0, 100), + self.assertEqual(len(monetary_record.message_ids), 2) + self.assertTracking(monetary_record.message_ids[0], [ + ('revenue', 'monetary', 0, (100, self.env.company.currency_id)), ]) # Check if the tracking value have the correct currency and values after changing the value and the company - self.record.write({ + monetary_record.write({ 'revenue': 200, 'company_id': self.company_2.id, }) self.flush_tracking() - self.assertEqual(len(self.record.message_ids), 2) - - self.assertTracking(self.record.message_ids[0], [ - ('revenue', 'monetary', 100, 200), + self.assertEqual(len(monetary_record.message_ids), 3) + self.assertTracking(monetary_record.message_ids[0], [ + ('revenue', 'monetary', 100, (200, self.company_2.currency_id)), ('company_currency', 'many2one', self.user_employee.company_id.currency_id, self.company_2.currency_id) ]) -@tagged('mail_track') -class TestTrackingInternals(TestMailCommon): - - def setUp(self): - super(TestTrackingInternals, self).setUp() - - record = self.env['mail.test.ticket'].with_user(self.user_employee).with_context(self._test_context).create({ - 'name': 'Test', - }) + def test_mail_track_properties(self): + """Test that the old properties values are logged when the parent changes.""" + record = self.properties_record_2 + # change the parent, it will change the properties values + record.properties_parent_id = self.properties_parent_1 self.flush_tracking() - self.record = record.with_context(mail_notrack=False) + formatted_values = [t._tracking_value_format()[0] for t in record.mapped("message_ids.tracking_value_ids")] + self.assertEqual(len(formatted_values), 5) + self.assertFalse(formatted_values[0]['fieldInfo']['isPropertyField']) + self.assertTrue(all(not f['newValue'] for f in formatted_values[1:])) + self.assertTrue(all(f['fieldInfo']['isPropertyField'] for f in formatted_values[1:])) + self.assertEqual(formatted_values[0]['fieldInfo']['changedField'], 'Properties Parent') + self.assertEqual(formatted_values[1]['fieldInfo']['changedField'], 'Properties: Property Date') + self.assertEqual(formatted_values[1]['oldValue'], '2024-01-03') + self.assertEqual(formatted_values[2]['fieldInfo']['changedField'], 'Properties: Property Datetime') + self.assertEqual(formatted_values[2]['oldValue'], '2024-01-02 12:59:01Z') + self.assertEqual(formatted_values[3]['fieldInfo']['changedField'], 'Properties: Property Tags') + self.assertEqual(formatted_values[3]['oldValue'], 'AA, BB') + self.assertEqual(formatted_values[4]['fieldInfo']['changedField'], 'Properties: Property M2M') + self.assertEqual(formatted_values[4]['oldValue'], 'Record 0, Record 1, Record 2') + + record = self.properties_record_1 + record.properties_parent_id = self.properties_parent_2 + self.flush_tracking() + formatted_values = [t._tracking_value_format()[0] for t in record.mapped("message_ids.tracking_value_ids")] + self.assertEqual(len(formatted_values), 4) + self.assertFalse(formatted_values[0]['fieldInfo']['isPropertyField']) + self.assertTrue(all(not f['newValue'] for f in formatted_values[1:])) + self.assertTrue(all(f['fieldInfo']['isPropertyField'] for f in formatted_values[1:])) + self.assertEqual(formatted_values[0]['fieldInfo']['changedField'], 'Properties Parent') + self.assertEqual(formatted_values[1]['fieldInfo']['changedField'], 'Properties: Property M2O') + self.assertEqual(formatted_values[1]['oldValue'], 'Record 0') + self.assertEqual(formatted_values[2]['fieldInfo']['changedField'], 'Properties: Property Int') + self.assertEqual(formatted_values[2]['oldValue'], 1337) + self.assertEqual(formatted_values[3]['fieldInfo']['changedField'], 'Properties: Property Char') + self.assertEqual(formatted_values[3]['oldValue'], 'char value') + + # changing the parent and then changing again + # to the original one to not create tracking values + with self.mock_mail_gateway(): + record.properties_parent_id = self.properties_parent_1 + record.properties_parent_id = self.properties_parent_2 + self.flush_tracking() + self.assertFalse(self._new_mails) + + # do not create tracking if the value was false + record.properties = { + 'property_m2m': False, + 'property_tags': ['aa'], + 'property_datetime': False, + 'property_date': False, + } + self.flush_tracking() + record.message_ids.unlink() + record.properties_parent_id = self.properties_parent_1 + self.flush_tracking() + self.assertEqual(len(record.mapped("message_ids.tracking_value_ids")), 2, + "Only the parent and the tags property should have been tracked") + self.assertEqual(record._mail_track_get_field_sequence("properties"), 100, + "Properties field should have the same sequence as their parent") + + @users('employee') + def test_mail_track_selection_invalid(self): + """ Check that initial invalid selection values are allowed when tracking """ + # Create a record with an initially invalid selection value + invalid_value = 'I love writing tests!' + record = self.env['mail.test.track.selection'].create({ + 'name': 'Test Invalid Selection Values', + 'selection_type': 'first', + }) + + self.flush_tracking() + self.env.cr.execute( + """ + UPDATE mail_test_track_selection + SET selection_type = %s + WHERE id = %s + """, + [invalid_value, record.id] + ) + record.invalidate_recordset() + self.assertEqual(record.selection_type, invalid_value) + + # Write a valid selection value + record.selection_type = "second" + + self.flush_tracking() + self.assertTracking(record.message_ids, [ + ('selection_type', 'char', invalid_value, 'Second'), + ]) def test_track_groups(self): + """ Test field groups and filtering when using standard helpers """ + # say that 'email_from' is accessible to erp_managers only field = self.record._fields['email_from'] self.addCleanup(setattr, field, 'groups', field.groups) field.groups = 'base.group_erp_manager' @@ -420,34 +770,49 @@ class TestTrackingInternals(TestMailCommon): self.record.sudo().write({'email_from': 'X'}) self.flush_tracking() - msg_emp = self.record.message_ids.message_format() - msg_sudo = self.record.sudo().message_ids.message_format() - tracking_values = self.env['mail.tracking.value'].search([('mail_message_id', '=', self.record.message_ids.id)]) - formattedTrackingValues = [{ - 'changedField': 'Email From', - 'id': tracking_values[0]['id'], - 'newValue': { - 'currencyId': False, - 'fieldType': 'char', - 'value': 'X', - }, - 'oldValue': { - 'currencyId': False, - 'fieldType': 'char', - 'value': False, - }, - }] - self.assertEqual(msg_emp[0].get('trackingValues'), [], "should not have protected tracking values") - self.assertEqual(msg_sudo[0].get('trackingValues'), formattedTrackingValues, "should have protected tracking values") + msg_emp = Store().add(self.record.message_ids).get_result() + msg_admin = Store().add(self.record.with_user(self.user_admin).message_ids).get_result() + msg_sudo = Store().add(self.record.sudo().message_ids).get_result() - msg_emp = self.record._notify_by_email_prepare_rendering_context(self.record.message_ids, {}) - msg_sudo = self.record.sudo()._notify_by_email_prepare_rendering_context(self.record.message_ids, {}) - self.assertFalse(msg_emp.get('tracking_values'), "should not have protected tracking values") - self.assertTrue(msg_sudo.get('tracking_values'), "should have protected tracking values") + tracking_values = self.env['mail.tracking.value'].search([('mail_message_id', '=', self.record.message_ids[0].id)]) + formatted_tracking_values = [{ + 'id': tracking_values[0]['id'], + 'fieldInfo': { + 'changedField': 'Email From', + 'currencyId': False, + 'fieldType': 'char', + 'floatPrecision': None, + 'isPropertyField': False, + }, + 'newValue': 'X', + 'oldValue': False, + }] + self.assertEqual( + msg_emp["mail.message"][0].get("trackingValues"), + [], + "should not have protected tracking values", + ) + self.assertEqual( + msg_admin["mail.message"][0].get("trackingValues"), + formatted_tracking_values, + "should have protected tracking values", + ) + self.assertEqual( + msg_sudo["mail.message"][0].get("trackingValues"), + formatted_tracking_values, + "should have protected tracking values", + ) + + values_emp = self.record._notify_by_email_prepare_rendering_context(self.record.message_ids[0], {}) + values_admin = self.record.with_user(self.user_admin)._notify_by_email_prepare_rendering_context(self.record.message_ids[0], {}) + values_sudo = self.record.sudo()._notify_by_email_prepare_rendering_context(self.record.message_ids[0], {}) + self.assertFalse(values_emp.get('tracking_values'), "should not have protected tracking values") + self.assertTrue(values_admin.get('tracking_values'), "should have protected tracking values") + self.assertTrue(values_sudo.get('tracking_values'), "should have protected tracking values") # test editing the record with user not in the group of the field self.env.invalidate_all() - self.record.clear_caches() + self.env.registry.clear_cache() record_form = Form(self.record.with_user(self.user_employee)) record_form.name = 'TestDoNoCrash' # the employee user must be able to save the fields on which they can write @@ -456,29 +821,320 @@ class TestTrackingInternals(TestMailCommon): record = record_form.save() self.assertEqual(record.name, 'TestDoNoCrash') - def test_track_sequence(self): - """ Update some tracked fields and check that the mail.tracking.value are ordered according to their tracking_sequence""" - self.record.write({ - 'name': 'Zboub', - 'customer_id': self.user_admin.partner_id.id, - 'user_id': self.user_admin.id, - 'container_id': self.env['mail.test.container'].with_context(mail_create_nosubscribe=True).create({'name': 'Container'}).id + @users('employee') + def test_track_invalid(self): + """ Test invalid use cases: unknown field, unsupported type, ... """ + test_record = self.env['mail.test.track.all'].create({ + 'company_id': self.env.company.id, }) self.flush_tracking() - self.assertEqual(len(self.record.message_ids), 1, 'should have 1 tracking message') - tracking_values = self.env['mail.tracking.value'].search([('mail_message_id', '=', self.record.message_ids.id)]) - self.assertEqual(tracking_values[0].tracking_sequence, 1) - self.assertEqual(tracking_values[1].tracking_sequence, 2) - self.assertEqual(tracking_values[2].tracking_sequence, 100) + # raise on non existing field + with self.assertRaises(ValueError): + self.env['mail.tracking.value']._create_tracking_values( + '', 'Test', + 'not_existing_field', {'string': 'Test', 'type': 'char'}, + test_record, + ) - def test_unlinked_field(self): - record_sudo = self.record.sudo() - record_sudo.write({'email_from': 'new_value'}) # create a tracking value + # raise on unsupported field type + with self.assertRaises(NotImplementedError): + self.env['mail.tracking.value']._create_tracking_values( + '', '

Html

', + 'html_field', {'string': 'HTML', 'type': 'html'}, + test_record, + ) + + @users('employee') + def test_track_multi_models(self): + """ Some models track value coming from another model e.g. when having + a sub model (lines) on which some value should be tracked on a parent + model. Test there is no model mismatch. """ + main_track = self.env['mail.test.track.all'].create({ + 'name': 'Multi Models Tracking', + 'char_field': 'char_value', + }) self.flush_tracking() - self.assertEqual(len(record_sudo.message_ids.tracking_value_ids), 1) - ir_model_field = self.env['ir.model.fields'].search([ + self.assertEqual(len(main_track.message_ids), 1) + self.assertFalse(main_track.message_ids.sudo().tracking_value_ids) + + sub_track = self.env['mail.test.track.groups'].create({ + 'name': 'Groups', + 'secret': 'secret', + }) + # some custom code generates tracking values on main_track + main_track.message_post( + body='Custom Log with Tracking', + tracking_value_ids=[ + (0, 0, { + 'field_id': self.env['ir.model.fields']._get(sub_track._name, 'secret').id, + 'new_value_char': 'secret', + 'old_value_char': False, + }), + (0, 0, { + 'field_id': False, + 'new_value_integer': self.env.uid, + 'old_value_integer': False, + }), + (0, 0, { + 'field_id': False, + 'field_info': { + 'desc': 'Old integer', + 'name': 'Removed', + 'sequence': 35, + 'type': 'integer', + }, + 'new_value_integer': 35, + 'old_value_integer': 30, + }), + ], + ) + trackings = main_track.message_ids.sudo().tracking_value_ids + self.assertEqual(len(trackings), 3) + + # check groups, as it depends on model + for tracking, exp_groups in zip(trackings, ['base.group_user', 'base.group_system', 'base.group_system']): + groups = 'base.group_system' + if tracking.field_id: + field = self.env[tracking.field_id.model]._fields[tracking.field_id.name] + groups = field.groups + self.assertEqual(groups, exp_groups) + + # check formatting, as it fetches info on model + formatted = trackings._tracking_value_format() + self.assertEqual( + formatted, + [ + { + 'id': trackings[0].id, + 'fieldInfo': { + 'changedField': 'Secret', + 'currencyId': False, + 'fieldType': 'char', + 'floatPrecision': None, + 'isPropertyField': False, + }, + 'newValue': 'secret', + 'oldValue': False, + }, + { + 'id': trackings[2].id, + 'fieldInfo': { + 'changedField': 'Old integer', + 'currencyId': False, + 'fieldType': 'integer', + 'floatPrecision': None, + 'isPropertyField': False, + }, + 'newValue': 35, + 'oldValue': 30, + }, + { + 'id': trackings[1].id, + 'fieldInfo': { + 'changedField': 'Unknown', + 'currencyId': False, + 'fieldType': 'char', + 'floatPrecision': None, + 'isPropertyField': False, + }, + 'newValue': False, + 'oldValue': False, + }, + ], + ) + + + @users('employee') + def test_track_sequence(self): + """ Update some tracked fields and check that the mail.tracking.value + are ordered according to their tracking_sequence """ + record = self.record.with_env(self.env) + self.assertEqual(len(record.message_ids), 1) + # order: user_id -> 1, customer_id -> 2, container_id -> True -> 100, email_from -> True -> 100 + ordered_fnames = ['user_id', 'customer_id', 'container_id', 'email_from'] + + # Update tracked fields, should generate tracking values correctly ordered + record.write({ + 'container_id': self.env['mail.test.container'].with_context(mail_create_nosubscribe=True).create({'name': 'Container'}).id, + 'customer_id': self.user_admin.partner_id.id, + 'email_from': 'new.from@test.example.com', + 'name': 'Zboub', + 'user_id': self.user_admin.id, + }) + self.flush_tracking() + self.assertEqual(len(record.message_ids), 2, 'should have 1 new tracking message') + tracking_values = self.env['mail.tracking.value'].sudo().search( + [('mail_message_id', '=', record.message_ids[0].id)] + ) + self.assertEqual( + tracking_values.field_id.mapped('name'), + ordered_fnames, + 'Track: order, based on ID DESC, should follow tracking sequence (or name) on field' + ) + + # Manually create trackings, format should be the fallback to reorder them + new_msg = record.message_post( + body='Manual Hack of tracking', + subtype_xmlid='mail.mt_note', + ) + custom_order_fnames = ['container_id', 'customer_id', 'email_from', 'user_id'] + field_ids = [ + self.env['ir.model.fields']._get(record._name, fname).id + for fname in custom_order_fnames + ] + self.env['mail.tracking.value'].sudo().create([ + { + 'field_id': field_id, + 'mail_message_id': new_msg.id, + 'old_value_char': 'unimportant', + 'new_value_char': 'unimportant', + } + for field_id in field_ids + ]) + tracking_values = self.env['mail.tracking.value'].sudo().search( + [('mail_message_id', '=', record.message_ids[0].id)] + ) + self.assertEqual( + tracking_values.field_id.mapped('name'), + list(reversed(custom_order_fnames)), + 'Tracking model: order, based on ID DESC, following reverted insertion' + ) + tracking_formatted = tracking_values._tracking_value_format() + self.assertEqual( + [tracking_values.browse(t['id']).field_id.name for t in tracking_formatted], + ordered_fnames, + 'Track: formatted order is correctly based on field sequence definition' + ) + + @users('employee') + def test_unlinked_model(self): + """ Fields from obsolete models with tracking values can be unlinked without error. """ + record = self.record.with_env(self.env) + record.write({'email_from': 'new_value'}) # create a tracking value + self.flush_tracking() + self.assertTracking( + record.message_ids[0], + [('email_from', 'char', False, 'new_value')], + strict=True, + ) + + fields_to_remove = self.env['ir.model.fields'].sudo().search([ ('model', '=', 'mail.test.ticket'), - ('name', '=', 'email_from')]) - ir_model_field.with_context(_force_unlink=True).unlink() - self.assertEqual(len(record_sudo.message_ids.tracking_value_ids), 0) + ]) + + # Simulate a registry without the model, which is what we have if we + # update a module with the model code removed + model = self.env.registry.models.pop('mail.test.ticket') + try: + fields_to_remove.with_context(_force_unlink=True).unlink() + finally: + # Restore model to prevent registry errors after test + self.env.registry.models['mail.test.ticket'] = model + + @users('employee') + def test_unlinked_field(self): + """ Check that removing a field removes its tracking values. """ + record = self.record.with_env(self.env) + record.write({'email_from': 'new_value'}) # create a tracking value + + record_other = self.env['mail.test.ticket'].create({}) + self.flush_tracking() + record_other.write({'email_from': 'email.from.1@example.com'}) + self.flush_tracking() + record_other.write({ + 'customer_id': self.test_partner.id, + 'email_from': 'email.from.2@example.com', + 'user_id': self.env.user.id, + }) + self.flush_tracking() + + self.assertTracking( + record.message_ids[0], + [('email_from', 'char', False, 'new_value')], + strict=True, + ) + self.assertTracking( + record_other.message_ids[0], + [('customer_id', 'integer', False, self.test_partner.id), + ('email_from', 'char', 'email.from.1@example.com', 'email.from.2@example.com'), + ('user_id', 'integer', False, self.env.user.id)], + strict=True, + ) + self.assertTracking( + record_other.message_ids[1], + [('email_from', 'char', False, 'email.from.1@example.com')], + strict=True, + ) + + # check display / format + trackings_all = (record + record_other).message_ids.sudo().tracking_value_ids + trackings_all_sorted = [ + trackings_all.filtered(lambda t: t.field_id.name == 'user_id'), # tracking=1 + trackings_all.filtered(lambda t: t.field_id.name == 'customer_id'), # tracking=2 + trackings_all.filtered(lambda t: t.field_id.name == 'email_from')[0], # tracking=True -> 100 + trackings_all.filtered(lambda t: t.field_id.name == 'email_from')[1], # tracking=True -> 100 + trackings_all.filtered(lambda t: t.field_id.name == 'email_from')[2], # tracking=True -> 100 + ] + fields_info = [ + ('user_id', 'many2one', 'Responsible'), + ('customer_id', 'many2one', 'Customer'), + ('email_from', 'char', 'Email From'), + ('email_from', 'char', 'Email From'), + ('email_from', 'char', 'Email From'), + ] + values_info = [ + ('', self.env.user.name), + ('', self.test_partner.name), + (False, 'new_value'), + ('email.from.1@example.com', 'email.from.2@example.com'), + (False, 'email.from.1@example.com'), + ] + formatted = trackings_all._tracking_value_format() + self.assertEqual( + formatted, + [ + { + 'id': tracking.id, + 'fieldInfo': { + 'changedField': field_info[2], + 'fieldType': field_info[1], + 'floatPrecision': None, + 'currencyId': False, + 'isPropertyField': False, + }, + 'newValue': values[1], + 'oldValue': values[0], + } + for tracking, field_info, values in zip(trackings_all_sorted, fields_info, values_info) + ] + ) + + # remove fields + fields_toremove = self.env['ir.model.fields'].sudo().search([ + ('model', '=', 'mail.test.ticket'), + ('name', 'in', ('email_from', 'user_id', 'datetime')) # also include a non tracked field + ]) + fields_toremove.with_context(_force_unlink=True).unlink() + self.assertEqual(len(trackings_all.exists()), 5) + + # check display / format, even if field is removed + formatted = trackings_all._tracking_value_format() + self.assertEqual( + formatted, + [ + { + 'id': tracking.id, + 'fieldInfo': { + 'changedField': field_info[2], + 'fieldType': field_info[1], + 'isPropertyField': False, + 'currencyId': False, + 'floatPrecision': None, + }, + 'newValue': values[1], + 'oldValue': values[0], + } + for tracking, field_info, values in zip(trackings_all_sorted, fields_info, values_info) + ] + ) diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_performance.py b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_performance.py index 0fed627..478c9d9 100644 --- a/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_performance.py +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_performance.py @@ -1,13 +1,14 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from contextlib import nullcontext +from markupsafe import Markup from unittest.mock import patch +from odoo import Command, fields from odoo.addons.base.tests.common import TransactionCaseWithUserDemo -from odoo.addons.mail.tests.common import MailCommon -from odoo.tests.common import users, warmup, Form -from odoo.tests import tagged +from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user +from odoo.addons.mail.tools.discuss import Store +from odoo.tests import Form, users, warmup, tagged from odoo.tools import mute_logger, formataddr @@ -16,38 +17,108 @@ class BaseMailPerformance(MailCommon, TransactionCaseWithUserDemo): @classmethod def setUpClass(cls): - super(BaseMailPerformance, cls).setUpClass() + super().setUpClass() - # creating partners is required notably with template usage - cls.user_employee.write({'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)]}) - cls.user_test = cls.env['res.users'].with_context(cls._test_context).create({ - 'name': 'Paulette Testouille', - 'login': 'paul', - 'email': 'user.test.paulette@example.com', - 'notification_type': 'inbox', - 'groups_id': [(6, 0, [cls.env.ref('base.group_user').id])], - }) + # standard users + cls.user_emp_email = mail_new_test_user( + cls.env, + company_id=cls.user_admin.company_id.id, + company_ids=[(4, cls.user_admin.company_id.id)], + email='user.emp.email@test.example.com', + login='user_emp_email', + groups='base.group_user,base.group_partner_manager', + name='Ernestine Email', + notification_type='email', + signature='Ernestine', + ) + cls.user_emp_inbox = mail_new_test_user( + cls.env, + company_id=cls.user_admin.company_id.id, + company_ids=[(4, cls.user_admin.company_id.id)], + email='user.emp.inbox@test.example.com', + login='user_emp_inbox', + groups='base.group_user,base.group_partner_manager', + name='Ignasse Inbox', + notification_type='inbox', + signature='Ignasse', + ) + cls.user_follower_emp_email = mail_new_test_user( + cls.env, + company_id=cls.user_admin.company_id.id, + company_ids=[(4, cls.user_admin.company_id.id)], + email='user.fol.emp.email@test.example.com', + login='user_fol_emp_email', + groups='base.group_user,base.group_partner_manager', + name='Emmanuel Follower Email', + notification_type='email', + signature='Emmanuel', + ) + cls.user_follower_emp_inbox = mail_new_test_user( + cls.env, + company_id=cls.user_admin.company_id.id, + company_ids=[(4, cls.user_admin.company_id.id)], + email='user.fol.emp.inbox@test.example.com', + login='user_fol_emp_inbox', + groups='base.group_user,base.group_partner_manager', + name='Isabelle Follower Inbox', + notification_type='inbox', + signature='Isabelle', + ) - cls.customer = cls.env['res.partner'].with_context(cls._test_context).create({ + # portal test users + cls.user_follower_portal = mail_new_test_user( + cls.env, + company_id=cls.user_admin.company_id.id, + company_ids=[(4, cls.user_admin.company_id.id)], + email='user.fol.portal@test.example.com', + login='user_fol_portal', + groups='base.group_portal', + name='Paul Follower Portal', + signature='Paul', + ) + cls.user_portal = mail_new_test_user( + cls.env, + company_id=cls.user_admin.company_id.id, + company_ids=[(4, cls.user_admin.company_id.id)], + email='user.portal@test.example.com', + login='user_portal', + groups='base.group_portal', + name='Paulette Portal', + signature='Paulette', + ) + + # customers + cls.partner_follower = cls.env['res.partner'].create({ 'country_id': cls.env.ref('base.be').id, - 'email': 'customer.test@example.com', - 'name': 'Test Customer', - 'mobile': '0456123456', + 'email': 'partner_follower@example.com', + 'name': 'partner_follower', + 'phone': '04560011122', }) + cls.customers = cls.env['res.partner'].create([ + { + 'country_id': cls.env.ref('base.be').id, + 'email': f'customer.full.test.{idx}@example.com', + 'name': f'Test Full Customer {idx}', + 'phone': f'045611111{idx}', + } for idx in range(5) + ]) + cls.customer = cls.customers[0] cls.test_attachments_vals = cls._generate_attachments_data(3, 'mail.compose.message', 0) def setUp(self): - super(BaseMailPerformance, self).setUp() + super().setUp() # patch registry to simulate a ready environment self.patch(self.env.registry, 'ready', True) - self.flush_tracking() + # we don't use mock_mail_gateway thus want to mock smtp to test the stack + self._mock_smtplib_connection() + self._mock_push_to_end_point(max_direct_push=10) def _create_test_records(self): test_record_full = self.env['mail.test.ticket'].with_context(self._test_context).create({ 'name': 'TestRecord', 'customer_id': self.customer.id, - 'user_id': self.user_test.id, + 'user_id': self.user_emp_inbox.id, 'email_from': 'nopartner.test@example.com', }) test_template_full = self.env['mail.template'].create({ @@ -76,7 +147,7 @@ class BaseMailPerformance(MailCommon, TransactionCaseWithUserDemo): 'customer_id': test_partners[idx].id, 'email_from': test_partners[idx].email_formatted, 'name': f'Test Ticket {idx}', - 'user_id': self.user_test.id, + 'user_id': self.user_emp_inbox.id, } for idx in range(0, 10)]) test_template_full = self.env['mail.template'].create({ 'name': 'TestTemplate', @@ -91,53 +162,53 @@ class BaseMailPerformance(MailCommon, TransactionCaseWithUserDemo): for attachment in self.test_attachments_vals ], }) - self.flush_tracking() return test_partners, test_records, test_template_full @tagged('mail_performance', 'post_install', '-at_install') class TestBaseMailPerformance(BaseMailPerformance): - def setUp(self): - super(TestBaseMailPerformance, self).setUp() + @classmethod + def setUpClass(cls): + super().setUpClass() - self.res_partner_3 = self.env['res.partner'].create({ + cls.res_partner_3 = cls.env['res.partner'].create({ 'name': 'Gemini Furniture', 'email': 'gemini.furniture39@example.com', }) - self.res_partner_4 = self.env['res.partner'].create({ + cls.res_partner_4 = cls.env['res.partner'].create({ 'name': 'Ready Mat', 'email': 'ready.mat28@example.com', }) - self.res_partner_10 = self.env['res.partner'].create({ + cls.res_partner_10 = cls.env['res.partner'].create({ 'name': 'The Jackson Group', 'email': 'jackson.group82@example.com', }) - self.res_partner_12 = self.env['res.partner'].create({ + cls.res_partner_12 = cls.env['res.partner'].create({ 'name': 'Azure Interior', 'email': 'azure.Interior24@example.com', }) - self.env['mail.performance.thread'].create([ + cls.env['mail.performance.thread'].create([ { 'name': 'Object 0', 'value': 0, - 'partner_id': self.res_partner_3.id, + 'partner_id': cls.res_partner_3.id, }, { 'name': 'Object 1', 'value': 10, - 'partner_id': self.res_partner_3.id, + 'partner_id': cls.res_partner_3.id, }, { 'name': 'Object 2', 'value': 20, - 'partner_id': self.res_partner_4.id, + 'partner_id': cls.res_partner_4.id, }, { 'name': 'Object 3', 'value': 30, - 'partner_id': self.res_partner_10.id, + 'partner_id': cls.res_partner_10.id, }, { 'name': 'Object 4', 'value': 40, - 'partner_id': self.res_partner_12.id, + 'partner_id': cls.res_partner_12.id, } ]) @@ -148,7 +219,7 @@ class TestBaseMailPerformance(BaseMailPerformance): records = self.env['mail.performance.thread'].search([]) self.assertEqual(len(records), 5) - with self.assertQueryCount(admin=2, demo=2): + with self.assertQueryCount(admin=3, demo=3): # without cache for record in records: record.partner_id.country_id.name @@ -210,19 +281,19 @@ class TestBaseMailPerformance(BaseMailPerformance): @warmup def test_create_mail_with_tracking(self): """ Create records inheriting from 'mail.thread' (with field tracking). """ - with self.assertQueryCount(admin=8, demo=8): + with self.assertQueryCount(admin=9, demo=9): self.env['mail.performance.thread'].create({'name': 'X'}) @users('admin', 'employee') @warmup def test_create_mail_simple(self): - with self.assertQueryCount(admin=7, employee=7): + with self.assertQueryCount(admin=8, employee=8): self.env['mail.test.simple'].create({'name': 'Test'}) @users('admin', 'employee') @warmup def test_create_mail_simple_multi(self): - with self.assertQueryCount(admin=7, employee=7): + with self.assertQueryCount(admin=8, employee=8): self.env['mail.test.simple'].create([{'name': 'Test'}] * 5) @users('admin', 'employee') @@ -232,31 +303,32 @@ class TestBaseMailPerformance(BaseMailPerformance): with self.assertQueryCount(admin=1, employee=1): rec.write({ 'name': 'Test2', - 'email_from': 'test@test.com', + 'email_from': 'test@test.mycompany.com', }) @tagged('mail_performance', 'post_install', '-at_install') -class TestMailAPIPerformance(BaseMailPerformance): +class TestBaseAPIPerformance(BaseMailPerformance): - def setUp(self): - super(TestMailAPIPerformance, self).setUp() + @classmethod + def setUpClass(cls): + super().setUpClass() # automatically follow activities, for backward compatibility concerning query count - self.env.ref('mail.mt_activities').write({'default': True}) + cls.env.ref('mail.mt_activities').write({'default': True}) @users('admin', 'employee') @warmup def test_adv_activity(self): model = self.env['mail.test.activity'] - with self.assertQueryCount(admin=7, employee=7): + with self.assertQueryCount(admin=8, employee=8): model.create({'name': 'Test'}) @users('admin', 'employee') @warmup @mute_logger('odoo.models.unlink') - def test_adv_activity_full(self): + def test_activity_full(self): record = self.env['mail.test.activity'].create({'name': 'Test'}) MailActivity = self.env['mail.activity'].with_context({ 'default_res_model': 'mail.test.activity', @@ -272,11 +344,11 @@ class TestMailAPIPerformance(BaseMailPerformance): # voip module read activity_type during create leading to one less query in enterprise on action_feedback _category = activity.activity_type_id.category - with self.assertQueryCount(admin=15, employee=15): + with self.assertQueryCount(admin=9, employee=9): # tm: 7 / 7 activity.action_feedback(feedback='Zizisse Done !') @warmup - def test_adv_activity_mixin_batched(self): + def test_activity_mixin_batched(self): records = self.env['mail.test.activity'].create([{'name': 'Test'}] * 10) MailActivity = self.env['mail.activity'].with_context({ 'default_res_model': 'mail.test.activity', @@ -290,16 +362,16 @@ class TestMailAPIPerformance(BaseMailPerformance): } for record in records]) self.env.invalidate_all() - with self.assertQueryCount(3): + with self.assertQueryCount(2): records.mapped('activity_date_deadline') @users('admin', 'employee') @warmup @mute_logger('odoo.models.unlink') - def test_adv_activity_mixin(self): + def test_activity_mixin(self): record = self.env['mail.test.activity'].create({'name': 'Test'}) - with self.assertQueryCount(admin=6, employee=6): + with self.assertQueryCount(admin=5, employee=5): activity = record.action_start('Test Start') # read activity_type to normalize cache between enterprise and community # voip module read activity_type during create leading to one less query in enterprise on action_close @@ -307,7 +379,7 @@ class TestMailAPIPerformance(BaseMailPerformance): record.write({'name': 'Dupe write'}) - with self.assertQueryCount(admin=17, employee=17): + with self.assertQueryCount(admin=13, employee=13): # tm: 11 / 11 record.action_close('Dupe feedback') self.assertEqual(record.activity_ids, self.env['mail.activity']) @@ -315,7 +387,7 @@ class TestMailAPIPerformance(BaseMailPerformance): @users('admin', 'employee') @warmup @mute_logger('odoo.models.unlink') - def test_adv_activity_mixin_w_attachments(self): + def test_activity_mixin_w_attachments(self): record = self.env['mail.test.activity'].create({'name': 'Test'}) attachments = self.env['ir.attachment'].create([ @@ -325,7 +397,7 @@ class TestMailAPIPerformance(BaseMailPerformance): for values in self.test_attachments_vals ]) - with self.assertQueryCount(admin=6, employee=6): + with self.assertQueryCount(admin=5, employee=5): activity = record.action_start('Test Start') #read activity_type to normalize cache between enterprise and community #voip module read activity_type during create leading to one less query in enterprise on action_close @@ -333,7 +405,7 @@ class TestMailAPIPerformance(BaseMailPerformance): record.write({'name': 'Dupe write'}) - with self.assertQueryCount(admin=21, employee=21): # com+tm 20/20 + with self.assertQueryCount(admin=15, employee=15): # tm 10 / 10 record.action_close('Dupe feedback', attachment_ids=attachments.ids) # notifications @@ -349,17 +421,17 @@ class TestMailAPIPerformance(BaseMailPerformance): def test_mail_composer(self): test_record, _test_template = self._create_test_records() customer_id = self.customer.id - with self.assertQueryCount(admin=2, employee=2): + with self.assertQueryCount(admin=4, employee=4): composer = self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, }).create({ 'body': '

Test Body

', 'partner_ids': [(4, customer_id)], }) - with self.assertQueryCount(admin=41, employee=41): + with self.assertQueryCount(admin=35, employee=35): composer._action_send_mail() @users('admin', 'employee') @@ -369,18 +441,18 @@ class TestMailAPIPerformance(BaseMailPerformance): test_record, _test_template = self._create_test_records() customer = self.env['res.partner'].browse(self.customer.ids) attachments = self.env['ir.attachment'].with_user(self.env.user).create(self.test_attachments_vals) - with self.assertQueryCount(admin=3, employee=3): + with self.assertQueryCount(admin=5, employee=5): composer = self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, }).create({ 'attachment_ids': attachments.ids, 'body': '

Test Body

', 'partner_ids': [(4, customer.id)], }) - with self.assertQueryCount(admin=44, employee=44): + with self.assertQueryCount(admin=38, employee=38): composer._action_send_mail() @users('admin', 'employee') @@ -390,12 +462,12 @@ class TestMailAPIPerformance(BaseMailPerformance): test_record, _test_template = self._create_test_records() customer = self.env['res.partner'].browse(self.customer.ids) attachments = self.env['ir.attachment'].with_user(self.env.user).create(self.test_attachments_vals) - with self.assertQueryCount(admin=12, employee=12): + with self.assertQueryCount(admin=18, employee=18): # tm 15/15 composer_form = Form( self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, }) ) composer_form.body = '

Test Body

' @@ -404,30 +476,30 @@ class TestMailAPIPerformance(BaseMailPerformance): composer_form.attachment_ids.add(attachment) composer = composer_form.save() - with self.assertQueryCount(admin=54, employee=54): # tm+com 47/47 + with self.assertQueryCount(admin=55, employee=55): # tm 53/53 composer._action_send_mail() # notifications message = test_record.message_ids[0] self.assertEqual(message.attachment_ids, attachments) - self.assertEqual(message.notified_partner_ids, customer + self.user_test.partner_id) + self.assertEqual(message.notified_partner_ids, customer + self.user_emp_inbox.partner_id) @users('admin', 'employee') @warmup @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') def test_mail_composer_mass_w_template(self): _partners, test_records, test_template = self._create_test_records_for_batch() + self.flush_tracking() with self.assertQueryCount(admin=3, employee=3): composer = self.env['mail.compose.message'].with_context({ - 'active_ids': test_records.ids, 'default_composition_mode': 'mass_mail', 'default_model': test_records._name, + 'default_res_ids': test_records.ids, 'default_template_id': test_template.id, }).create({}) - composer._onchange_template_id_wrapper() - with self.assertQueryCount(admin=157, employee=160), self.mock_mail_gateway(): + with self.assertQueryCount(admin=67, employee=67), self.mock_mail_gateway(): composer._action_send_mail() self.assertEqual(len(self._new_mails), 10) @@ -438,18 +510,18 @@ class TestMailAPIPerformance(BaseMailPerformance): def test_mail_composer_nodelete(self): test_record, _test_template = self._create_test_records() customer_id = self.customer.id - with self.assertQueryCount(admin=2, employee=2): + with self.assertQueryCount(admin=4, employee=4): composer = self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, 'mail_auto_delete': False, }).create({ 'body': '

Test Body

', 'partner_ids': [(4, customer_id)], }) - with self.assertQueryCount(admin=34, employee=34): + with self.assertQueryCount(admin=35, employee=35): composer._action_send_mail() @users('admin', 'employee') @@ -459,24 +531,26 @@ class TestMailAPIPerformance(BaseMailPerformance): test_record, test_template = self._create_test_records() test_template.write({'attachment_ids': [(5, 0)]}) - with self.assertQueryCount(admin=24, employee=24): # tm 14/14 / com 23/23 + with self.assertQueryCount(admin=27, employee=27): # tm: 20/20 composer = self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, 'default_template_id': test_template.id, }).create({}) - composer._onchange_template_id_wrapper() - with self.assertQueryCount(admin=40, employee=40): + with self.assertQueryCount(admin=34, employee=34): composer._action_send_mail() # notifications message = test_record.message_ids[0] self.assertFalse(message.attachment_ids) + new_partner = self.env['res.partner'].sudo().search([('email', '=', 'nopartner.test@example.com')]) + self.assertTrue(new_partner) + self.assertEqual(message.notified_partner_ids, self.user_emp_inbox.partner_id + self.customer + new_partner) # remove created partner to ensure tests are the same each run - self.env['res.partner'].sudo().search([('email', '=', 'nopartner.test@example.com')]).unlink() + new_partner.unlink() @users('admin', 'employee') @warmup @@ -484,16 +558,15 @@ class TestMailAPIPerformance(BaseMailPerformance): def test_mail_composer_w_template_attachments(self): test_record, test_template = self._create_test_records() - with self.assertQueryCount(admin=25, employee=25): # tm 15/15 / com 24/24 + with self.assertQueryCount(admin=28, employee=28): # tm: 21/21 composer = self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, 'default_template_id': test_template.id, }).create({}) - composer._onchange_template_id_wrapper() - with self.assertQueryCount(admin=51, employee=51): + with self.assertQueryCount(admin=42, employee=42): composer._action_send_mail() # notifications @@ -514,25 +587,25 @@ class TestMailAPIPerformance(BaseMailPerformance): test_template.write({'attachment_ids': [(5, 0)]}) customer = self.env['res.partner'].browse(self.customer.ids) - with self.assertQueryCount(admin=36, employee=36): # tm 26/26 / com 35/35 + with self.assertQueryCount(admin=38, employee=38): # tm 29/29 composer_form = Form( self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, 'default_template_id': test_template.id, }) ) composer = composer_form.save() - with self.assertQueryCount(admin=50, employee=50): + with self.assertQueryCount(admin=49, employee=49): composer._action_send_mail() # notifications new_partner = self.env['res.partner'].sudo().search([('email', '=', 'nopartner.test@example.com')]) message = test_record.message_ids[0] self.assertFalse(message.attachment_ids) - self.assertEqual(message.notified_partner_ids, customer + self.user_test.partner_id + new_partner) + self.assertEqual(message.notified_partner_ids, customer + self.user_emp_inbox.partner_id + new_partner) # remove created partner to ensure tests are the same each run new_partner.unlink() @@ -544,18 +617,18 @@ class TestMailAPIPerformance(BaseMailPerformance): test_record, test_template = self._create_test_records() customer = self.env['res.partner'].browse(self.customer.ids) - with self.assertQueryCount(admin=37, employee=37): # tm 27/27 / com 36/36 + with self.assertQueryCount(admin=40, employee=40): # tm 31/31 composer_form = Form( self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, 'default_template_id': test_template.id, }) ) composer = composer_form.save() - with self.assertQueryCount(admin=71, employee=71): + with self.assertQueryCount(admin=66, employee=66): composer._action_send_mail() # notifications @@ -565,7 +638,7 @@ class TestMailAPIPerformance(BaseMailPerformance): set(message.attachment_ids.mapped('name')), set(['AttFileName_00.txt', 'AttFileName_01.txt', 'AttFileName_02.txt']) ) - self.assertEqual(message.notified_partner_ids, customer + self.user_test.partner_id + new_partner) + self.assertEqual(message.notified_partner_ids, customer + self.user_emp_inbox.partner_id + new_partner) # remove created partner to ensure tests are the same each run new_partner.unlink() @@ -574,20 +647,26 @@ class TestMailAPIPerformance(BaseMailPerformance): @users('admin', 'employee') @warmup def test_message_assignation_email(self): - self.user_test.write({'notification_type': 'email'}) + # Changing the `notification_type` of a user adds or removes him a group + # which clear the caches. + # The @warmup decorator would then becomes useless, + # as the first thing done by this method would be to clear the cache, making the warmup pointless. + # So, instead of changing the user notification type within this method, + # use another user already pre-defined with the email notification type, + # so the ormcache is preserved. record = self.env['mail.test.track'].create({'name': 'Test'}) - with self.assertQueryCount(admin=42, employee=42): + with self.assertQueryCount(admin=41, employee=40): record.write({ - 'user_id': self.user_test.id, + 'user_id': self.user_emp_email.id, }) @users('admin', 'employee') @warmup def test_message_assignation_inbox(self): record = self.env['mail.test.track'].create({'name': 'Test'}) - with self.assertQueryCount(admin=20, employee=20): + with self.assertQueryCount(admin=21, employee=20): record.write({ - 'user_id': self.user_test.id, + 'user_id': self.user_emp_inbox.id, }) @users('admin', 'employee') @@ -597,7 +676,7 @@ class TestMailAPIPerformance(BaseMailPerformance): with self.assertQueryCount(admin=1, employee=1): record._message_log( - body='

Test _message_log

', + body=Markup('

Test _message_log

'), message_type='comment') @users('admin', 'employee') @@ -611,7 +690,7 @@ class TestMailAPIPerformance(BaseMailPerformance): with self.assertQueryCount(admin=1, employee=1): records._message_log_batch( bodies=dict( - (record.id, '

Test _message_log

') + (record.id, Markup('

Test _message_log

')) for record in records ), message_type='comment') @@ -624,10 +703,10 @@ class TestMailAPIPerformance(BaseMailPerformance): for idx in range(10) ]) - with self.assertQueryCount(admin=11, employee=11): + with self.assertQueryCount(admin=3, employee=2): records._message_log_with_view( 'test_mail.mail_template_simple_test', - values={'partner': self.customer.with_env(self.env)} + render_values={'partner': self.customer.with_env(self.env)} ) @users('admin', 'employee') @@ -635,9 +714,9 @@ class TestMailAPIPerformance(BaseMailPerformance): def test_message_log_with_post(self): record = self.env['mail.test.simple'].create({'name': 'Test'}) - with self.assertQueryCount(admin=7, employee=7): + with self.assertQueryCount(admin=5, employee=5): record.message_post( - body='

Test message_post as log

', + body=Markup('

Test message_post as log

'), subtype_xmlid='mail.mt_note', message_type='comment') @@ -648,7 +727,7 @@ class TestMailAPIPerformance(BaseMailPerformance): with self.assertQueryCount(admin=7, employee=7): record.message_post( - body='

Test Post Performances basic

', + body=Markup('

Test Post Performances basic

'), partner_ids=[], message_type='comment', subtype_xmlid='mail.mt_comment') @@ -659,9 +738,9 @@ class TestMailAPIPerformance(BaseMailPerformance): def test_message_post_one_email_notification(self): record = self.env['mail.test.simple'].create({'name': 'Test'}) - with self.assertQueryCount(admin=34, employee=34): + with self.assertQueryCount(admin=30, employee=30): record.message_post( - body='

Test Post Performances with an email ping

', + body=Markup('

Test Post Performances with an email ping

'), partner_ids=self.customer.ids, message_type='comment', subtype_xmlid='mail.mt_comment') @@ -671,10 +750,10 @@ class TestMailAPIPerformance(BaseMailPerformance): def test_message_post_one_inbox_notification(self): record = self.env['mail.test.simple'].create({'name': 'Test'}) - with self.assertQueryCount(admin=18, employee=18): + with self.assertQueryCount(admin=17, employee=17): record.message_post( - body='

Test Post Performances with an inbox ping

', - partner_ids=self.user_test.partner_id.ids, + body=Markup('

Test Post Performances with an inbox ping

'), + partner_ids=self.user_emp_inbox.partner_id.ids, message_type='comment', subtype_xmlid='mail.mt_comment') @@ -685,10 +764,10 @@ class TestMailAPIPerformance(BaseMailPerformance): record = self.env['mail.test.simple'].create({'name': 'Test'}) with self.assertQueryCount(admin=6, employee=6): - record.message_subscribe(partner_ids=self.user_test.partner_id.ids) + record.message_subscribe(partner_ids=self.user_emp_inbox.partner_id.ids) with self.assertQueryCount(admin=3, employee=3): - record.message_subscribe(partner_ids=self.user_test.partner_id.ids) + record.message_subscribe(partner_ids=self.user_emp_inbox.partner_id.ids) @mute_logger('odoo.models.unlink') @users('admin', 'employee') @@ -698,10 +777,10 @@ class TestMailAPIPerformance(BaseMailPerformance): subtype_ids = (self.env.ref('test_mail.st_mail_test_simple_external') | self.env.ref('mail.mt_comment')).ids with self.assertQueryCount(admin=5, employee=5): - record.message_subscribe(partner_ids=self.user_test.partner_id.ids, subtype_ids=subtype_ids) + record.message_subscribe(partner_ids=self.user_emp_inbox.partner_id.ids, subtype_ids=subtype_ids) with self.assertQueryCount(admin=2, employee=2): - record.message_subscribe(partner_ids=self.user_test.partner_id.ids, subtype_ids=subtype_ids) + record.message_subscribe(partner_ids=self.user_emp_inbox.partner_id.ids, subtype_ids=subtype_ids) @mute_logger('odoo.models.unlink') @users('admin', 'employee') @@ -735,55 +814,70 @@ class TestMailAPIPerformance(BaseMailPerformance): with self.assertQueryCount(admin=1, employee=1): test_records = self.env['mail.test.container'].browse(test_records_sudo.ids) reply_to = test_records._notify_get_reply_to( - default=self.env.user.email_formatted + default=self.env.user.email_formatted, ) for record in test_records: self.assertEqual( reply_to[record.id], - formataddr( - ("%s %s" % (self.env.user.company_id.name, record.name), - "%s@%s" % (record.alias_name, self.alias_domain) - ) - ) + formataddr((self.env.user.name, f"{record.alias_name}@{self.alias_domain}")) ) @tagged('mail_performance', 'post_install', '-at_install') -class TestMailComplexPerformance(BaseMailPerformance): +class TestMailAPIPerformance(BaseMailPerformance): - def setUp(self): - super(TestMailComplexPerformance, self).setUp() - self.user_portal = self.env['res.users'].with_context(self._test_context).create({ + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.user_portal = cls.env['res.users'].with_context(cls._test_context).create({ 'name': 'Olivia Portal', 'login': 'port', 'email': 'p.p@example.com', 'signature': '--\nOlivia', 'notification_type': 'email', - 'groups_id': [(6, 0, [self.env.ref('base.group_portal').id])], + 'group_ids': [(6, 0, [cls.env.ref('base.group_portal').id])], }) - self.container = self.env['mail.test.container'].with_context(mail_create_nosubscribe=True).create({ - 'name': 'Test Container', - 'customer_id': self.customer.id, + cls.container = cls.env['mail.test.container'].with_context(mail_create_nosubscribe=True).create({ 'alias_name': 'test-alias', + 'customer_id': cls.customers[0].id, + 'name': 'Test Container', }) - Partners = self.env['res.partner'].with_context(self._test_context) - self.partners = self.env['res.partner'] - for x in range(0, 10): - self.partners |= Partners.create({'name': 'Test %s' % x, 'email': 'test%s@example.com' % x}) - self.container.message_subscribe(self.partners.ids, subtype_ids=[ - self.env.ref('mail.mt_comment').id, - self.env.ref('test_mail.st_mail_test_container_child_full').id + cls.partners = cls.env['res.partner'].with_context(cls._test_context).create([ + { + 'name': f'Test {idx}', + 'email': f'test{idx}@example.com', + } + for idx in range(10) + ]) + cls.container.message_subscribe(cls.partners.ids, subtype_ids=[ + cls.env.ref('mail.mt_comment').id, + cls.env.ref('test_mail.st_mail_test_container_child_full').id ]) - # `test_complex_mail_mail_send` - self.env.flush_all() + cls.test_records_recipients = cls.env['mail.performance.thread.recipients'].create([ + { + 'email_from': 'only.email.1@test.example.com', + }, { + 'email_from': 'only.email.2@test.example.com', + }, { + 'email_from': 'both.1@test.example.com', + 'partner_id': cls.partners[0].id, + }, { + 'email_from': 'trice.1@test.example.com', + 'partner_id': cls.partners[1].id, + 'user_id': cls.user_admin.id, + }, { + 'partner_id': cls.partners[2].id, + }, + ]) @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @users('admin', 'employee') @warmup - def test_complex_mail_mail_send(self): + def test_mail_mail_send(self): message = self.env['mail.message'].sudo().create({ 'author_id': self.env.user.partner_id.id, 'body': '

Test

', @@ -810,7 +904,7 @@ class TestMailComplexPerformance(BaseMailPerformance): @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @users('admin', 'employee') @warmup - def test_complex_mail_mail_send_batch_complete(self): + def test_mail_mail_send_batch_complete(self): """ A more complete use case: 10 mails, attachments, servers, ... And 2 failing emails. """ message = self.env['mail.message'].sudo().create({ @@ -843,7 +937,8 @@ class TestMailComplexPerformance(BaseMailPerformance): unlinked_mails |= set(records.ids) unlinked_mails = set() - with self.assertQueryCount(admin=43, employee=43), \ + with self.assertQueryCount(admin=30, employee=30), \ + self.mock_mail_gateway(), \ patch.object(type(self.env['mail.mail']), 'unlink', _patched_unlink): self.env['mail.mail'].sudo().browse(mails.ids).send() @@ -855,17 +950,78 @@ class TestMailComplexPerformance(BaseMailPerformance): self.assertEqual(mails[-1].state, 'exception') self.assertIn(mails[-1].id, unlinked_mails, 'Mail: mails with invalid recipient are also to be unlinked') + @users('employee') + @warmup + def test_message_get_default_recipients(self): + record = self.test_records_recipients[0].with_env(self.env) + with self.assertQueryCount(employee=7): + defaults = record._message_get_default_recipients() + self.assertDictEqual(defaults, {record.id: { + 'email_cc': '', 'email_to': 'only.email.1@test.example.com', 'partner_ids': [], + }}) + + @users('employee') + @warmup + def test_message_get_default_recipients_batch(self): + records = self.test_records_recipients.with_env(self.env) + with self.assertQueryCount(employee=9): + defaults = records._message_get_default_recipients() + self.assertDictEqual(defaults, { + records[0].id: { + 'email_cc': '', + 'email_to': 'only.email.1@test.example.com', + 'partner_ids': []}, + records[1].id: { + 'email_cc': '', + 'email_to': 'only.email.2@test.example.com', + 'partner_ids': []}, + records[2].id: { + 'email_cc': '', + 'email_to': '', + 'partner_ids': self.partners[0].ids}, + records[3].id: { + 'email_cc': '', + 'email_to': '', + 'partner_ids': self.partners[1].ids}, + records[4].id: { + 'email_cc': '', + 'email_to': '', + 'partner_ids': self.partners[2].ids}, + }) + + @users('employee') + @warmup + def test_message_get_suggested_recipients(self): + record = self.test_records_recipients[0].with_env(self.env) + with self.assertQueryCount(employee=23): # tm: 16 + recipients = record._message_get_suggested_recipients(no_create=False) + new_partner = self.env['res.partner'].search([('email_normalized', '=', 'only.email.1@test.example.com')]) + self.assertEqual(len(new_partner), 1) + self.assertDictEqual(recipients[0], { + 'email': 'only.email.1@test.example.com', + 'name': 'only.email.1@test.example.com', + 'partner_id': new_partner.id, + 'create_values': {}, + }) + + @users('employee') + @warmup + def test_message_get_suggested_recipients_batch(self): + records = self.test_records_recipients.with_env(self.env) + with self.assertQueryCount(employee=32): # tm: 25 + _recipients = records._message_get_suggested_recipients_batch(no_create=False) + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @users('admin', 'employee') @warmup - def test_complex_message_post(self): + def test_message_post_followers(self): self.container.message_subscribe(self.user_portal.partner_id.ids) record = self.container.with_user(self.env.user) # about 20 (19?) queries per additional customer group - with self.assertQueryCount(admin=58, employee=57): + with self.assertQueryCount(admin=55, employee=56): record.message_post( - body='

Test Post Performances

', + body=Markup('

Test Post Performances

'), message_type='comment', subtype_xmlid='mail.mt_comment') @@ -875,14 +1031,18 @@ class TestMailComplexPerformance(BaseMailPerformance): @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @users('admin', 'employee') @warmup - def test_complex_message_post_template(self): + def test_message_post_template(self): self.container.message_subscribe(self.user_portal.partner_id.ids) record = self.container.with_user(self.env.user) - template_id = self.env.ref('test_mail.mail_test_container_tpl').id + template = self.env.ref('test_mail.mail_test_container_tpl') # about 20 (19 ?) queries per additional customer group - with self.assertQueryCount(admin=66, employee=65): - record.message_post_with_template(template_id, message_type='comment', composition_mode='comment') + with self.assertQueryCount(admin=69, employee=70): + record.message_post_with_source( + template, + message_type='comment', + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), + ) self.assertEqual(record.message_ids[0].body, '

Adding stuff on %s

' % record.name) self.assertEqual(record.message_ids[0].notified_partner_ids, self.partners | self.user_portal.partner_id | self.customer) @@ -890,22 +1050,23 @@ class TestMailComplexPerformance(BaseMailPerformance): @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @users('admin', 'employee') @warmup - def test_complex_message_post_view(self): + def test_message_post_view(self): _partners, test_records, test_template = self._create_test_records_for_batch() + self.flush_tracking() with self.assertQueryCount(admin=3, employee=3): - composer = self.env['mail.compose.message'].with_context({ - 'active_ids': test_records.ids, + _composer = self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'mass_mail', 'default_model': test_records._name, + 'default_res_ids': test_records.ids, 'default_template_id': test_template.id, }).create({}) - composer._onchange_template_id_wrapper() - with self.assertQueryCount(admin=141, employee=141): - messages_as_sudo = test_records.message_post_with_view( + with self.assertQueryCount(admin=92, employee=92): + messages_as_sudo = test_records.message_post_with_source( 'test_mail.mail_template_simple_test', - values={'partner': self.user_test.partner_id}, + render_values={'partner': self.user_emp_inbox.partner_id}, + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') ) self.assertEqual(len(messages_as_sudo), 10) @@ -913,7 +1074,7 @@ class TestMailComplexPerformance(BaseMailPerformance): @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @users('admin', 'employee') @warmup - def test_complex_message_subscribe(self): + def test_message_subscribe(self): pids = self.partners.ids subtypes = self.env.ref('mail.mt_comment') | self.env.ref('test_mail.st_mail_test_ticket_container_upd') subtype_ids = subtypes.ids @@ -954,10 +1115,33 @@ class TestMailComplexPerformance(BaseMailPerformance): self.assertEqual(rec1.message_partner_ids, self.env.user.partner_id | self.user_portal.partner_id | self.partners) + @users('employee') + @warmup + def test_partner_find_from_emails(self): + """ Test '_partner_find_from_emails', notably to check batch optimization """ + records = self.test_records_recipients.with_user(self.env.user) + with self.assertQueryCount(employee=27): # tm: 20 + partners = records._partner_find_from_emails( + {record: [record.email_from, record.partner_id.email, record.user_id.email] for record in records}, + avoid_alias=True, + no_create=False, + ) + new_p1 = self.env['res.partner'].search([('email_normalized', '=', 'only.email.1@test.example.com')]) + new_p2 = self.env['res.partner'].search([('email_normalized', '=', 'only.email.2@test.example.com')]) + new_p3 = self.env['res.partner'].search([('email_normalized', '=', 'both.1@test.example.com')]) + new_p4 = self.env['res.partner'].search([('email_normalized', '=', 'trice.1@test.example.com')]) + self.assertDictEqual(partners, { + records[0].id: new_p1, + records[1].id: new_p2, + records[2].id: new_p3 + self.partners[0], + records[3].id: new_p4 + self.partners[1] + self.partner_admin, + records[4].id: self.partners[2], + }) + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @users('admin', 'employee') @warmup - def test_complex_tracking_assignation(self): + def test_tracking_assignation(self): """ Assignation performance test on already-created record """ rec = self.env['mail.test.ticket'].create({ 'name': 'Test', @@ -968,7 +1152,7 @@ class TestMailComplexPerformance(BaseMailPerformance): rec1 = rec.with_context(active_test=False) # to see inactive records self.assertEqual(rec1.message_partner_ids, self.partners | self.env.user.partner_id) - with self.assertQueryCount(admin=44, employee=44): + with self.assertQueryCount(admin=41, employee=42): rec.write({'user_id': self.user_portal.id}) self.assertEqual(rec1.message_partner_ids, self.partners | self.env.user.partner_id | self.user_portal.partner_id) # write tracking message @@ -982,13 +1166,13 @@ class TestMailComplexPerformance(BaseMailPerformance): @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @users('admin', 'employee') @warmup - def test_complex_tracking_subscription_create(self): + def test_tracking_subscription_create(self): """ Creation performance test involving auto subscription, assignation, tracking with subtype and template send. """ container_id = self.container.id customer_id = self.customer.id user_id = self.user_portal.id - with self.assertQueryCount(admin=96, employee=96): + with self.assertQueryCount(admin=94, employee=94): rec = self.env['mail.test.ticket'].create({ 'name': 'Test', 'container_id': container_id, @@ -1006,7 +1190,7 @@ class TestMailComplexPerformance(BaseMailPerformance): @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @users('admin', 'employee') @warmup - def test_complex_tracking_subscription_subtype(self): + def test_tracking_subscription_subtype(self): """ Write performance test involving auto subscription, tracking with subtype """ rec = self.env['mail.test.ticket'].create({ 'name': 'Test', @@ -1018,7 +1202,7 @@ class TestMailComplexPerformance(BaseMailPerformance): self.assertEqual(rec1.message_partner_ids, self.user_portal.partner_id | self.env.user.partner_id) self.assertEqual(len(rec1.message_ids), 1) - with self.assertQueryCount(admin=59, employee=59): + with self.assertQueryCount(admin=57, employee=57): rec.write({ 'name': 'Test2', 'container_id': self.container.id, @@ -1036,7 +1220,7 @@ class TestMailComplexPerformance(BaseMailPerformance): @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @users('admin', 'employee') @warmup - def test_complex_tracking_subscription_write(self): + def test_tracking_subscription_write(self): """ Write performance test involving auto subscription, tracking with subtype and template send """ container_id = self.container.id customer_id = self.customer.id @@ -1055,7 +1239,7 @@ class TestMailComplexPerformance(BaseMailPerformance): rec1 = rec.with_context(active_test=False) # to see inactive records self.assertEqual(rec1.message_partner_ids, self.user_portal.partner_id | self.env.user.partner_id) - with self.assertQueryCount(admin=66, employee=66): + with self.assertQueryCount(admin=62, employee=63): rec.write({ 'name': 'Test2', 'container_id': container_id, @@ -1074,7 +1258,7 @@ class TestMailComplexPerformance(BaseMailPerformance): @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @users('admin', 'employee') @warmup - def test_complex_tracking_template(self): + def test_tracking_template(self): """ Write performance test involving assignation, tracking with template """ customer_id = self.customer.id self.assertTrue(self.env.registry.ready, "We need to simulate that registery is ready") @@ -1088,7 +1272,7 @@ class TestMailComplexPerformance(BaseMailPerformance): rec1 = rec.with_context(active_test=False) # to see inactive records self.assertEqual(rec1.message_partner_ids, self.partners | self.env.user.partner_id | self.user_portal.partner_id) - with self.assertQueryCount(admin=31, employee=31): + with self.assertQueryCount(admin=32, employee=33): rec.write({ 'name': 'Test2', 'customer_id': customer_id, @@ -1106,123 +1290,275 @@ class TestMailComplexPerformance(BaseMailPerformance): self.assertEqual(rec1.message_ids[2].notified_partner_ids, self.partners | self.user_portal.partner_id) self.assertEqual(len(rec1.message_ids), 3) + +@tagged('mail_performance', 'WIP', 'post_install', '-at_install') +class TestMailAccessPerformance(BaseMailPerformance): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.records_access = cls.env['mail.test.access'].create([ + {'name': 'Access.1', 'access': 'public'}, + {'name': 'Access.2', 'access': 'logged'}, + {'name': 'Access.3', 'access': 'followers'}, + ]) + cls.records_access_custo = cls.env['mail.test.access.custo'].create([ + {'name': 'Custo.1'}, + {'name': 'Custo.2', 'is_readonly': True}, # read access, should be able to read messages / activities + {'name': 'Custo.3', 'is_locked': True}, # not able to read messages / activities + ]) + cls.records_mc = cls.env['mail.test.multi.company'].create([ + {'name': 'MC.1'}, + {'name': 'MC.2'}, + {'name': 'MC.3'}, + ]) + + # messages to check access + cls.messages = cls.env['mail.message'] + for records_model in [cls.records_access, cls.records_access_custo, cls.records_mc]: + for record in records_model: + cls.messages += record.with_user(cls.user_admin).message_post( + body=f'Posting on {record.name}', + message_type='comment', + subtype_xmlid='mail.mt_comment', + ) + # message employee cannot read due to specific rules (aka locked record) + cls.messages_emp_nope = cls.messages[5] + + # activities to check access + cls.activities = cls.env['mail.activity'] + for records_model in [cls.records_access, cls.records_access_custo, cls.records_mc]: + cls.activities += cls.env['mail.activity'].create([ + { + 'res_id': record.id, + 'res_model_id': cls.env['ir.model']._get_id(record._name), + 'summary': f'TestActivity {idx} on {record._name},{record.id} for {user.name}', + 'user_id': user.id, + } + for record in records_model + for user in (cls.user_admin + cls.user_employee) + for idx in range(2) + ]) + records_model.message_unsubscribe(partner_ids=(cls.user_admin + cls.user_employee).partner_id.ids) + # activities employee cannot read due to specific rules (other user on non open custo) + cls.activities_emp_nope = cls.activities.filtered( + lambda a: a.res_model == 'mail.test.access.custo' and a.res_id == cls.records_access_custo[2].id and a.user_id == cls.user_admin + ) + + @users('employee') + @warmup + def test_activity_read(self): + # queries + # fetch activities: 1 + # filter records: 3 (1 / model) + # '_fetch_query': 1 + profile = self.profile() if self.warm else nullcontext() + with self.assertQueryCount(employee=5), profile: + content = (self.activities - self.activities_emp_nope).with_env(self.env).read(['summary']) + self.assertEqual(len(content), len(self.activities - self.activities_emp_nope)) + + @users('employee') + @warmup + def test_activity_search(self): + # queries + # select mail.activity: 1 + # filter records: 3 (1 / model) + profile = self.profile() if self.warm else nullcontext() + with self.assertQueryCount(employee=4), profile: + found = self.activities.with_env(self.env).search([('summary', 'ilike', 'TestActivity')]) + self.assertEqual(found, self.activities - self.activities_emp_nope) + + @users('employee') + @warmup + def test_message_read(self): + # queries + # fetch messages: 1 + # filter records: 3 (1 / model) + # 'read': 1 + profile = self.profile() if self.warm else nullcontext() + with self.assertQueryCount(employee=5), profile: + content = (self.messages - self.messages_emp_nope).with_env(self.env).read(['body']) + self.assertEqual(len(content), len(self.messages - self.messages_emp_nope)) + + @users('employee') + @warmup + def test_message_search(self): + # queries + # select mail.message: 1 + # filter records: 3 (1 / model) + profile = self.profile() if self.warm else nullcontext() + with self.assertQueryCount(employee=4), profile: + found = self.messages.with_env(self.env).search([('body', 'ilike', 'Posting on ')]) + self.assertEqual(found, self.messages - self.messages_emp_nope) + + +@tagged('mail_performance', 'mail_store', 'post_install', '-at_install') +class TestMessageToStorePerformance(BaseMailPerformance): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.containers = cls.env['mail.test.container'].with_context(mail_create_nosubscribe=True).create([ + { + 'alias_name': 'test-alias', + 'customer_id': cls.customers[0].id, + 'name': 'Test Container', + }, { + 'alias_name': 'test-alias-2', + 'customer_id': cls.customers[1].id, + 'name': 'Test Container 2', + }, + ]) + cls.partners = cls.env['res.partner'].with_context(cls._test_context).create([ + { + 'name': f'Test {idx}', + 'email': f'test{idx}@example.com', + } + for idx in range(10) + ]) + cls.containers.message_subscribe(cls.partners.ids, subtype_ids=[ + cls.env.ref('mail.mt_comment').id, + cls.env.ref('test_mail.st_mail_test_container_child_full').id + ]) + cls.container = cls.containers[0] + + name_field = cls.env['ir.model.fields']._get(cls.container._name, 'name') + customer_id_field = cls.env['ir.model.fields']._get(cls.container._name, 'customer_id') + comment_subtype_id = cls.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') + + cls.link_previews = cls.env["mail.link.preview"].create( + [ + {"source_url": "https://www.odoo.com"}, + {"source_url": "https://www.example.com"}, + ] + ) + + cls.messages_all = cls.env['mail.message'].sudo().create([ + { + 'attachment_ids': [ + (0, 0, { + 'datas': 'data', + 'name': f'Test file {att_idx}', + 'res_id': record.id, + 'res_model': record._name, + }) + for att_idx in range(2) + ], + 'author_id': cls.partners[msg_idx].id, + 'body': f'

Test {msg_idx}

', + 'email_from': cls.partners[msg_idx].email_formatted, + "message_link_preview_ids": [ + Command.create({"link_preview_id": cls.link_previews[0].id}), + Command.create({"link_preview_id": cls.link_previews[1].id}), + ], + 'message_type': 'comment', + 'model': record._name, + 'notification_ids': [ + (0, 0, { + 'is_read': False, + 'notification_type': 'inbox', + 'res_partner_id': cls.partners[(record_idx * 5) + (msg_idx * 2)].id, + }), + (0, 0, { + 'is_read': True, + 'notification_type': 'email', + 'notification_status': 'sent', + 'res_partner_id': cls.partners[(record_idx * 5) + (msg_idx * 2) + 1].id, + }), + (0, 0, { + 'is_read': True, + 'notification_type': 'email', + 'notification_status': 'exception', + 'res_partner_id': cls.partners[(record_idx * 5) + (msg_idx * 2) + 2].id, + }), + ], + 'partner_ids': [ + (4, cls.partners[(record_idx * 5) + msg_idx].id), + (4, cls.partners[(record_idx * 5) + msg_idx + 1].id), + ], + 'reaction_ids': [ + (0, 0, { + 'content': '\U0001F4E7', + 'partner_id': cls.partners[(record_idx * 5)].id + }), (0, 0, { + 'content': '\U0001F4E8', + 'partner_id': cls.partners[(record_idx * 5) + 1].id + }), + ], + 'res_id': record.id, + 'starred_partner_ids': [ + (4, cls.partners[(record_idx * 5) + msg_idx].id), + (4, cls.partners[(record_idx * 5) + (msg_idx * 2) + 1].id), + ], + 'subject': f'Test Container {msg_idx}', + 'subtype_id': comment_subtype_id, + 'tracking_value_ids': [ + (0, 0, { + 'field_id': name_field.id, + 'new_value_char': 'new 0', + 'old_value_char': 'old 0', + }), + (0, 0, { + 'field_id': customer_id_field.id, + 'new_value_char': 'new 1', + 'new_value_integer': cls.partners[(record_idx * 5)].id, + 'old_value_char': 'old 1', + 'old_value_integer': cls.partners[(record_idx * 5) + 1].id, + }), + ] + } + for msg_idx in range(2) + for record_idx, record in enumerate(cls.containers) + ]) + + def test_assert_initial_values(self): + self.assertEqual(len(self.messages_all), 2*2) + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @users('employee') @warmup - def test_message_format(self): - """Test performance of `_message_format` and of `message_format` with - multiple messages with multiple attachments, different authors, various - notifications, and different tracking values. + def test_message_to_store_multi(self): + """Test performance of `_to_store` with multiple messages with multiple attachments, + different authors, various notifications, and different tracking values. + Those messages might not make sense functionally but they are crafted to cover as much of the code as possible in regard to number of queries. + + Setup : + * 2 records (self.containers -> 2 mail.test.container record, with + a different customer_id each) + * 2 messages / record + * 2 attachments / message + * 3 notifications / message + * 2 tracking values / message """ - name_field = self.env['ir.model.fields']._get(self.container._name, 'name') - customer_id_field = self.env['ir.model.fields']._get(self.container._name, 'customer_id') + messages_all = self.messages_all.with_env(self.env) - messages = self.env['mail.message'].sudo().create([{ - 'subject': 'Test 0', - 'body': '

Test 0

', - 'author_id': self.partners[0].id, - 'email_from': self.partners[0].email, - 'model': 'mail.test.container', - 'res_id': self.container.id, - 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), - 'attachment_ids': [ - (0, 0, { - 'name': 'test file 0 - %d' % j, - 'datas': 'data', - }) for j in range(2) - ], - 'notification_ids': [ - (0, 0, { - 'res_partner_id': self.partners[3].id, - 'notification_type': 'inbox', - }), - (0, 0, { - 'res_partner_id': self.partners[4].id, - 'notification_type': 'email', - 'notification_status': 'exception', - }), - (0, 0, { - 'res_partner_id': self.partners[6].id, - 'notification_type': 'email', - 'notification_status': 'exception', - }), - ], - 'tracking_value_ids': [ - (0, 0, { - 'field': name_field.id, - 'field_desc': 'Name', - 'old_value_char': 'old 0', - 'new_value_char': 'new 0', - }), - (0, 0, { - 'field': customer_id_field.id, - 'field_desc': 'Customer', - 'old_value_integer': self.partners[7].id, - 'new_value_integer': self.partners[8].id, - }), - ] - }, { - 'subject': 'Test 1', - 'body': '

Test 1

', - 'author_id': self.partners[1].id, - 'email_from': self.partners[1].email, - 'model': 'mail.test.container', - 'res_id': self.container.id, - 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), - 'attachment_ids': [ - (0, 0, { - 'name': 'test file 1 - %d' % j, - 'datas': 'data', - }) for j in range(2) - ], - 'notification_ids': [ - (0, 0, { - 'res_partner_id': self.partners[5].id, - 'notification_type': 'inbox', - }), - (0, 0, { - 'res_partner_id': self.partners[6].id, - 'notification_type': 'email', - 'notification_status': 'exception', - }), - ], - 'tracking_value_ids': [ - (0, 0, { - 'field': name_field.id, - 'field_desc': 'Name', - 'old_value_char': 'old 1', - 'new_value_char': 'new 1', - }), - (0, 0, { - 'field': customer_id_field.id, - 'field_desc': 'Customer', - 'old_value_integer': self.partners[7].id, - 'new_value_integer': self.partners[8].id, - }), - ] - }]) + with self.assertQueryCount(employee=24): # tm 23 + res = Store().add(messages_all).get_result() - with self.assertQueryCount(employee=6): - res = messages.message_format() - self.assertEqual(len(res), 2) - for message in res: - self.assertEqual(len(message['attachment_ids']), 2) - - self.env.flush_all() - self.env.invalidate_all() - - with self.assertQueryCount(employee=19): - res = messages.message_format() - self.assertEqual(len(res), 2) - for message in res: - self.assertEqual(len(message['attachment_ids']), 2) + self.assertEqual(len(res["mail.message"]), 2 * 2) + for message in res["mail.message"]: + self.assertEqual(len(message["attachment_ids"]), 2) @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @users('employee') @warmup - def test_message_format_group_thread_name_by_model(self): + def test_message_to_store_single(self): + message = self.messages_all[0].with_env(self.env) + + with self.assertQueryCount(employee=24): # tm 23 + res = Store().add(message).get_result() + + self.assertEqual(len(res["mail.message"]), 1) + self.assertEqual(len(res["mail.message"][0]["attachment_ids"]), 2) + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('employee') + @warmup + def test_message_to_store_group_thread_name_by_model(self): """Ensures the fetch of multiple thread names is grouped by model.""" records = [] for _i in range(5): @@ -1234,106 +1570,440 @@ class TestMailComplexPerformance(BaseMailPerformance): 'res_id': record.id } for record in records]) - with self.assertQueryCount(employee=5): - res = messages.message_format() - self.assertEqual(len(res), 6) + with self.assertQueryCount(employee=3): + res = Store().add(messages).get_result() + self.assertEqual(len(res["mail.message"]), 6) self.env.flush_all() self.env.invalidate_all() - with self.assertQueryCount(employee=14): - res = messages.message_format() - self.assertEqual(len(res), 6) + with self.assertQueryCount(employee=13): # tm: 12 + res = Store().add(messages).get_result() + self.assertEqual(len(res["mail.message"]), 6) + + @warmup + def test_message_to_store_multi_followers_inbox(self): + """Test query count as well as bus notifcations from sending a message to multiple followers + with inbox.""" + record = self.env["mail.test.simple"].create({"name": "Test"}) + record.message_partner_ids = (self.user_emp_inbox + self.user_follower_emp_inbox).partner_id + follower_1 = record.message_follower_ids.filtered( + lambda f: f.partner_id == self.user_emp_inbox.partner_id + ) + follower_2 = record.message_follower_ids.filtered( + lambda f: f.partner_id == self.user_follower_emp_inbox.partner_id + ) + + def get_bus_params(): + message = self.env["mail.message"].search([], order="id desc", limit=1) + notif_1 = message.notification_ids.filtered( + lambda n: n.res_partner_id == self.user_emp_inbox.partner_id + ) + notif_2 = message.notification_ids.filtered( + lambda n: n.res_partner_id == self.user_follower_emp_inbox.partner_id + ) + return ( + [ + (self.cr.dbname, "res.partner", self.user_emp_inbox.partner_id.id), + (self.cr.dbname, "res.partner", self.user_follower_emp_inbox.partner_id.id), + ], + [ + { + "type": "mail.message/inbox", + "payload": { + "message_id": message.id, + "store_data": { + "mail.followers": [ + { + "id": follower_1.id, + "is_active": True, + "partner_id": self.user_emp_inbox.partner_id.id, + }, + ], + "mail.message": self._filter_messages_fields( + { + "attachment_ids": [], + "author_guest_id": False, + "author_id": self.env.user.partner_id.id, + "body": [ + "markup", + "

Test Post Performances with multiple inbox ping!

", + ], + "create_date": fields.Datetime.to_string( + message.create_date + ), + "date": fields.Datetime.to_string(message.date), + "default_subject": "Test", + "email_from": '"OdooBot" ', + "id": message.id, + "incoming_email_cc": False, + "incoming_email_to": False, + "message_link_preview_ids": [], + "message_type": "comment", + "model": "mail.test.simple", + "needaction": True, + "notification_ids": [notif_1.id, notif_2.id], + "partner_ids": [], + "pinned_at": False, + "rating_id": False, + "reactions": [], + "record_name": "Test", + "res_id": record.id, + "scheduledDatetime": False, + "starred": False, + "subject": False, + "subtype_id": self.env.ref("mail.mt_comment").id, + "thread": {"id": record.id, "model": "mail.test.simple"}, + "trackingValues": [], + "write_date": fields.Datetime.to_string(message.write_date), + }, + ), + "mail.message.subtype": [ + { + "description": False, + "id": self.env.ref("mail.mt_comment").id, + }, + ], + "mail.notification": [ + { + "mail_email_address": False, + "failure_type": False, + "id": notif_1.id, + "mail_message_id": message.id, + "notification_status": "sent", + "notification_type": "inbox", + "res_partner_id": self.user_emp_inbox.partner_id.id, + }, + { + "mail_email_address": False, + "failure_type": False, + "id": notif_2.id, + "mail_message_id": message.id, + "notification_status": "sent", + "notification_type": "inbox", + "res_partner_id": self.user_follower_emp_inbox.partner_id.id, + }, + ], + "mail.thread": self._filter_threads_fields( + { + "display_name": "Test", + "id": record.id, + "model": "mail.test.simple", + "module_icon": "/base/static/description/icon.png", + "selfFollower": follower_1.id, + }, + ), + "res.partner": self._filter_partners_fields( + { + "avatar_128_access_token": self.env.user.partner_id._get_avatar_128_access_token(), + "id": self.env.user.partner_id.id, + "is_company": False, + "main_user_id": self.env.user.id, + "name": "OdooBot", + "write_date": fields.Datetime.to_string( + self.env.user.partner_id.write_date + ), + }, + { + "email": self.user_emp_inbox.partner_id.email, + "id": self.user_emp_inbox.partner_id.id, + "name": "Ignasse Inbox", + }, + { + "email": self.user_follower_emp_inbox.partner_id.email, + "id": self.user_follower_emp_inbox.partner_id.id, + "name": "Isabelle Follower Inbox", + }, + ), + "res.users": self._filter_users_fields( + { + "id": self.env.user.id, + "partner_id": self.env.user.partner_id.id, + "share": False, + }, + ), + }, + }, + }, + { + "type": "mail.message/inbox", + "payload": { + "message_id": message.id, + "store_data": { + "mail.followers": [ + { + "id": follower_2.id, + "is_active": True, + "partner_id": self.user_follower_emp_inbox.partner_id.id, + }, + ], + "mail.message": self._filter_messages_fields( + { + "attachment_ids": [], + "author_guest_id": False, + "author_id": self.env.user.partner_id.id, + "body": [ + "markup", + "

Test Post Performances with multiple inbox ping!

", + ], + "create_date": fields.Datetime.to_string( + message.create_date + ), + "date": fields.Datetime.to_string(message.date), + "default_subject": "Test", + "email_from": '"OdooBot" ', + "id": message.id, + "incoming_email_cc": False, + "incoming_email_to": False, + "message_link_preview_ids": [], + "message_type": "comment", + "model": "mail.test.simple", + "needaction": True, + "notification_ids": [notif_1.id, notif_2.id], + "partner_ids": [], + "pinned_at": False, + "rating_id": False, + "reactions": [], + "record_name": "Test", + "res_id": record.id, + "scheduledDatetime": False, + "starred": False, + "subject": False, + "subtype_id": self.env.ref("mail.mt_comment").id, + "thread": {"id": record.id, "model": "mail.test.simple"}, + "trackingValues": [], + "write_date": fields.Datetime.to_string(message.write_date), + }, + ), + "mail.message.subtype": [ + { + "description": False, + "id": self.env.ref("mail.mt_comment").id, + }, + ], + "mail.notification": [ + { + "mail_email_address": False, + "failure_type": False, + "id": notif_1.id, + "mail_message_id": message.id, + "notification_status": "sent", + "notification_type": "inbox", + "res_partner_id": self.user_emp_inbox.partner_id.id, + }, + { + "mail_email_address": False, + "failure_type": False, + "id": notif_2.id, + "mail_message_id": message.id, + "notification_status": "sent", + "notification_type": "inbox", + "res_partner_id": self.user_follower_emp_inbox.partner_id.id, + }, + ], + "mail.thread": self._filter_threads_fields( + { + "display_name": "Test", + "id": record.id, + "model": "mail.test.simple", + "module_icon": "/base/static/description/icon.png", + "selfFollower": follower_2.id, + }, + ), + "res.partner": self._filter_partners_fields( + { + "avatar_128_access_token": self.env.user.partner_id._get_avatar_128_access_token(), + "id": self.env.user.partner_id.id, + "is_company": False, + "main_user_id": self.env.user.id, + "name": "OdooBot", + "write_date": fields.Datetime.to_string( + self.env.user.partner_id.write_date + ), + }, + { + "email": self.user_emp_inbox.partner_id.email, + "id": self.user_emp_inbox.partner_id.id, + "name": "Ignasse Inbox", + }, + { + "email": self.user_follower_emp_inbox.partner_id.email, + "id": self.user_follower_emp_inbox.partner_id.id, + "name": "Isabelle Follower Inbox", + }, + ), + "res.users": self._filter_users_fields( + { + "id": self.env.user.id, + "partner_id": self.env.user.partner_id.id, + "share": False, + }, + ), + }, + }, + }, + ], + ) + + self.env.invalidate_all() + with self.assertBus(get_params=get_bus_params): + with self.assertQueryCount(18): + record.message_post( + body=Markup("

Test Post Performances with multiple inbox ping!

"), + message_type="comment", + subtype_xmlid="mail.mt_comment", + ) + + +class BaseMailPostPerformance(BaseMailPerformance): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # records + cls.record_container = cls.env['mail.test.container'].create({ + 'name': 'Test record', + 'customer_id': cls.customer.id, + 'alias_name': 'test-alias', + }) + _partners, cls.record_tickets, _test_template = cls._create_test_records_for_batch(cls) + # avoid hanging followers, like assigned users (user_id) + cls.env['mail.followers'].search([ + ('res_model', '=', cls.record_tickets._name), + ('res_id', 'in', cls.record_tickets.ids) + ]).unlink() + cls.record_ticket = cls.record_tickets[0] + + # partners + cls.partner = cls.env['res.partner'].create({ + 'country_id': cls.env.ref('base.be').id, + 'email': 'partner@example.com', + 'name': 'partner', + 'phone': '0456334455', + }) + + # generate devices and vapid keys to test push impact + cls._setup_push_devices_for_partners( + cls.user_admin.partner_id + cls.user_employee.partner_id + + cls.user_follower_emp_email.partner_id + + cls.user_follower_emp_inbox.partner_id + + cls.user_follower_portal.partner_id + + cls.partner_follower + + cls.user_emp_inbox.partner_id + + cls.user_emp_email.partner_id + + cls.partner + + cls.customers + ) + + # be sure not to be annoyed by ocn / mobile + cls.env['ir.config_parameter'].sudo().set_param('mail_mobile.enable_ocn', False) @tagged('mail_performance', 'post_install', '-at_install') -class TestMailHeavyPerformancePost(BaseMailPerformance): +class TestPerformance(BaseMailPostPerformance): - def setUp(self): - super(TestMailHeavyPerformancePost, self).setUp() + @classmethod + def setUpClass(cls): + super().setUpClass() - # record - self.record_container = self.env['mail.test.container'].with_context(mail_create_nosubscribe=True).create({ - 'name': 'Test record', - 'customer_id': self.customer.id, - 'alias_name': 'test-alias', - }) - # followers - self.user_follower_email = self.env['res.users'].with_context(self._test_context).create({ - 'name': 'user_follower_email', - 'login': 'user_follower_email', - 'email': 'user_follower_email@example.com', - 'notification_type': 'email', - 'groups_id': [(6, 0, [self.env.ref('base.group_user').id])], - }) - self.user_follower_inbox = self.env['res.users'].with_context(self._test_context).create({ - 'name': 'user_follower_inbox', - 'login': 'user_follower_inbox', - 'email': 'user_follower_inbox@example.com', - 'notification_type': 'inbox', - 'groups_id': [(6, 0, [self.env.ref('base.group_user').id])], - }) - self.partner_follower = self.env['res.partner'].with_context(self._test_context).create({ - 'name': 'partner_follower', - 'email': 'partner_follower@example.com', - }) - self.record_container.message_subscribe([ - self.partner_follower.id, - self.user_follower_inbox.partner_id.id, - self.user_follower_email.partner_id.id - ]) - - # partner_ids - self.user_inbox = self.env['res.users'].with_context(self._test_context).create({ - 'name': 'user_inbox', - 'login': 'user_inbox', - 'email': 'user_inbox@example.com', - 'notification_type': 'inbox', - 'groups_id': [(6, 0, [self.env.ref('base.group_user').id])], - }) - self.user_email = self.env['res.users'].with_context(self._test_context).create({ - 'name': 'user_email', - 'login': 'user_email', - 'email': 'user_email@example.com', - 'notification_type': 'email', - 'groups_id': [(6, 0, [self.env.ref('base.group_user').id])], - }) - self.partner = self.env['res.partner'].with_context(self._test_context).create({ - 'name': 'partner', - 'email': 'partner@example.com', - }) + cls.tracking_values_ids = [ + (0, 0, { + 'field_id': cls.env['ir.model.fields']._get(cls.record_ticket._name, 'email_from').id, + 'new_value_char': 'new_value', + 'old_value_char': 'old_value', + }), + (0, 0, { + 'field_id': cls.env['ir.model.fields']._get(cls.record_ticket._name, 'customer_id').id, + 'new_value_char': 'New Fake', + 'new_value_integer': 2, + 'old_value_char': 'Old Fake', + 'old_value_integer': 1, + }), + ] @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @users('employee') @warmup - def test_complete_message_post(self): - # aims to cover as much features of message_post as possible - recipients = self.user_inbox.partner_id + self.user_email.partner_id + self.partner - record_container = self.record_container.with_user(self.env.user) + def test_message_post(self): + """ Aims to cover as much features of message_post as possible """ + followers = self.partner_follower + self.user_follower_emp_inbox.partner_id + self.user_follower_emp_email.partner_id + recipients = self.user_emp_inbox.partner_id + self.user_emp_email.partner_id + self.partner + ticket = self.record_ticket.with_user(self.env.user) + ticket.message_subscribe(followers.ids) attachments_vals = [ # not linear on number of attachments_vals - ('attach tuple 1', "attachement tupple content 1"), - ('attach tuple 2', "attachement tupple content 2", {'cid': 'cid1'}), - ('attach tuple 3', "attachement tupple content 3", {'cid': 'cid2'}), + ('attach tuple 1', "attachment tuple content 1"), + ('attach tuple 2', "attachment tuple content 2", {'cid': 'cid1'}), + ('attach tuple 3', "attachment tuple content 3", {'cid': 'cid2'}), ] attachments = self.env['ir.attachment'].with_user(self.env.user).create(self.test_attachments_vals) - # enable_logging = self.cr._enable_logging() if self.warm else nullcontext() - # with self.assertQueryCount(employee=68), enable_logging: - with self.assertQueryCount(employee=68): - record_container.with_context({}).message_post( - body='

Test body

', - subject='Test Subject', - message_type='notification', - subtype_xmlid=None, - partner_ids=recipients.ids, - parent_id=False, + self.push_to_end_point_mocked.reset_mock() # reset as executed twice + self.flush_tracking() + + with self.assertQueryCount(employee=83): # tm: 83 + ticket.message_post( attachments=attachments_vals, attachment_ids=attachments.ids, + body=Markup('

Test body

'), email_add_signature=True, - model_description=False, - mail_auto_delete=True + mail_auto_delete=True, + message_type='comment', + parent_id=False, + partner_ids=recipients.ids, + subject='Test Subject', + subtype_xmlid='mail.mt_comment', + tracking_value_ids=self.tracking_values_ids, ) - new_message = record_container.message_ids[0] - self.assertEqual(attachments.mapped('res_model'), [record_container._name for i in range(3)]) - self.assertEqual(attachments.mapped('res_id'), [record_container.id for i in range(3)]) + new_message = ticket.message_ids[0] + self.assertEqual(attachments.mapped('res_model'), [ticket._name for i in range(3)]) + self.assertEqual(attachments.mapped('res_id'), [ticket.id for i in range(3)]) self.assertTrue(new_message.body.startswith('

Test body Test body

'), + email_add_signature=True, + mail_auto_delete=True, + message_type='comment', + parent_id=False, + partner_ids=recipients.ids, + subject='Test Subject', + subtype_xmlid='mail.mt_comment', + tracking_value_ids=self.tracking_values_ids, + ) + for ticket, attachments in zip(tickets, attachments_all, strict=True): + new_message = ticket.message_ids[0] + self.assertEqual(attachments.mapped('res_model'), [ticket._name for i in range(3)]) + self.assertEqual(attachments.mapped('res_id'), [ticket.id for i in range(3)]) + self.assertTrue(new_message.body.startswith('

Test body =16.0.0", - "odoo-bringout-oca-ocb-mail_bot>=16.0.0", - "odoo-bringout-oca-ocb-portal>=16.0.0", - "odoo-bringout-oca-ocb-rating>=16.0.0", - "odoo-bringout-oca-ocb-mass_mailing>=16.0.0", - "odoo-bringout-oca-ocb-mass_mailing_sms>=16.0.0", - "odoo-bringout-oca-ocb-phone_validation>=16.0.0", - "odoo-bringout-oca-ocb-sms>=16.0.0", - "odoo-bringout-oca-ocb-test_mail>=16.0.0", - "odoo-bringout-oca-ocb-test_mail_sms>=16.0.0", - "odoo-bringout-oca-ocb-test_mass_mailing>=16.0.0", + "odoo-bringout-oca-ocb-mail>=19.0.0", + "odoo-bringout-oca-ocb-mail_bot>=19.0.0", + "odoo-bringout-oca-ocb-portal>=19.0.0", + "odoo-bringout-oca-ocb-rating>=19.0.0", + "odoo-bringout-oca-ocb-mass_mailing>=19.0.0", + "odoo-bringout-oca-ocb-mass_mailing_sms>=19.0.0", + "odoo-bringout-oca-ocb-phone_validation>=19.0.0", + "odoo-bringout-oca-ocb-sms>=19.0.0", + "odoo-bringout-oca-ocb-test_mail>=19.0.0", + "odoo-bringout-oca-ocb-test_mail_sms>=19.0.0", + "odoo-bringout-oca-ocb-test_mass_mailing>=19.0.0", "requests>=2.25.1" ] readme = "README.md" @@ -26,7 +28,7 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Office/Business", ] diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/__init__.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/__init__.py index dfccc6b..91c5580 100644 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/__init__.py +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/__init__.py @@ -1,5 +1,2 @@ -# -*- coding: utf-8 -*- - from . import controllers from . import models -from . import tests diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/__manifest__.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/__manifest__.py index cd4c054..95480f9 100644 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/__manifest__.py +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/__manifest__.py @@ -28,15 +28,18 @@ real applications. """, 'data/mail_message_subtype_data.xml', 'security/ir.model.access.csv', 'security/ir_rule_data.xml', + 'views/test_portal_template.xml', ], 'assets': { - 'web.qunit_suite_tests': [ - 'test_mail_full/static/tests/qunit_suite_tests/*.js', + 'web.assets_unit_tests': [ + 'test_mail_full/static/tests/**/*', + ('remove', 'test_mail_full/static/tests/tours/**/*'), ], - 'web.tests_assets': [ - 'test_mail_full/static/tests/helpers/*.js', + 'web.assets_tests': [ + 'test_mail_full/static/tests/tours/**/*', ], }, 'installable': True, + 'author': 'Odoo S.A.', 'license': 'LGPL-3', } diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/controllers/__init__.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/controllers/__init__.py index 8c3feb6..903b755 100644 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/controllers/__init__.py +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/controllers/__init__.py @@ -1 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + from . import portal diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/controllers/portal.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/controllers/portal.py index b0c18d9..ff9dce7 100644 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/controllers/portal.py +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/controllers/portal.py @@ -9,6 +9,34 @@ class PortalTest(http.Controller): def test_portal_record_view(self, res_id, access_token=None, **kwargs): return request.make_response(f'Record view of test_portal {res_id} ({access_token}, {kwargs})') + @http.route("/my/test_portal_records/", type="http", auth="public", website=True) + def test_portal_record_page(self, res_id, **kwargs): + record = request.env["mail.test.portal"]._get_thread_with_access(res_id, **kwargs) + values = { + "object": record, + "token": kwargs.get("token"), + "hash": kwargs.get("hash", None), + "pid": kwargs.get("pid", None), + } + return request.render("test_mail_full.test_portal_template", values) + + @http.route("/my/test_portal_rating_records/", type="http", auth="public", website=True) + def test_portal_rating_record_page(self, res_id, **kwargs): + access_params = { + "hash": kwargs.get("hash"), + "pid": kwargs.get("pid"), + "token": kwargs.get("token"), + } + record = request.env["mail.test.rating"]._get_thread_with_access(res_id, **access_params) + return request.render( + "test_mail_full.test_portal_template", + { + **access_params, + "object": record, + "display_rating": kwargs.get("display_rating"), + }, + ) + @http.route('/test_portal/public_type/', type='http', auth='public', methods=['GET']) def test_public_record_view(self, res_id): return request.make_response(f'Testing public controller for {res_id}') diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/i18n/bs.po b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/i18n/bs.po deleted file mode 100644 index 6643bd3..0000000 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/i18n/bs.po +++ /dev/null @@ -1,224 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * test_mail_full -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server saas~12.5\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-09-09 10:49+0000\n" -"PO-Revision-Date: 2019-09-09 10:49+0000\n" -"Last-Translator: \n" -"Language-Team: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: \n" -"Plural-Forms: \n" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_needaction -msgid "Action Needed" -msgstr "Potrebna akcija" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_attachment_count -msgid "Attachment Count" -msgstr "Broj priloga" - -#. module: test_mail_full -#: model:ir.model,name:test_mail_full.model_mail_test_sms -msgid "Chatter Model for SMS Gateway" -msgstr "Chatter model za SMS pristupnik" - -#. module: test_mail_full -#: model:ir.model,name:test_mail_full.model_mail_test_sms_partner -msgid "Chatter Model for SMS Gateway (Partner only)" -msgstr "Chatter model za SMS pristupnik (samo partner)" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__create_uid -msgid "Created by" -msgstr "Kreirao" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__create_date -msgid "Created on" -msgstr "Kreirano" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__customer_id -msgid "Customer" -msgstr "Kupac" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__display_name -msgid "Display Name" -msgstr "Prikazani naziv" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__email_from -msgid "Email From" -msgstr "Email od" - -#. module: test_mail_full -#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__phone_sanitized -msgid "" -"Field used to store sanitized phone number. Helps speeding up searches and " -"comparisons." -msgstr "" -"Polje koje se koristi za pohranu sanitiziranog telefonskog broja. Pomaže " -"ubrzati pretraživanja i usporedbe." - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_follower_ids -msgid "Followers" -msgstr "Pratioci" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_channel_ids -msgid "Followers (Channels)" -msgstr "Pratioci (Kanali)" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_partner_ids -msgid "Followers (Partners)" -msgstr "Pratioci (Partneri)" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__id -msgid "ID" -msgstr "ID" - -#. module: test_mail_full -#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__message_needaction -msgid "If checked, new messages require your attention." -msgstr "Ako je zakačeno, nove poruke će zahtjevati vašu pažnju" - -#. module: test_mail_full -#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__message_has_error -msgid "If checked, some messages have a delivery error." -msgstr "Ako je označeno neke poruke mogu imati grešku u dostavi." - -#. module: test_mail_full -#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__phone_blacklisted -msgid "" -"If the email address is on the blacklist, the contact won't receive mass " -"mailing anymore, from any list" -msgstr "" -"Ako je adresa e-pošte na crnoj listi, kontakt više neće primati masovnu " -"poštu, ni s jedne liste" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_is_follower -msgid "Is Follower" -msgstr "Pratilac" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms____last_update -msgid "Last Modified on" -msgstr "Zadnje mijenjano" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__write_uid -msgid "Last Updated by" -msgstr "Zadnji ažurirao" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__write_date -msgid "Last Updated on" -msgstr "Zadnje ažurirano" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_main_attachment_id -msgid "Main Attachment" -msgstr "Glavna zakačka" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_has_error -msgid "Message Delivery error" -msgstr "Greška pri isporuci poruke" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_ids -msgid "Messages" -msgstr "Poruke" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__mobile_nbr -msgid "Mobile Nbr" -msgstr "Broj mobilnog" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__name -msgid "Name" -msgstr "Naziv:" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_needaction_counter -msgid "Number of Actions" -msgstr "Broj akcija" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_has_error_counter -msgid "Number of errors" -msgstr "Broj grešaka" - -#. module: test_mail_full -#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__message_needaction_counter -msgid "Number of messages requiring action" -msgstr "Broj poruka koje zahtijevaju aktivnost" - -#. module: test_mail_full -#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__message_has_error_counter -msgid "Number of messages with delivery error" -msgstr "Broj poruka sa greškama pri isporuci" - -#. module: test_mail_full -#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__message_unread_counter -msgid "Number of unread messages" -msgstr "Broj nepročitanih poruka" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__phone_blacklisted -msgid "Phone Blacklisted" -msgstr "Telefon je stavljen na crnu listu" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__phone_nbr -msgid "Phone Nbr" -msgstr "Broj telefona" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_has_sms_error -msgid "SMS Delivery error" -msgstr "Greška u slanju SMSa" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__phone_sanitized -msgid "Sanitized Number" -msgstr "Sanirani broj" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__subject -msgid "Subject" -msgstr "Tema" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_unread -msgid "Unread Messages" -msgstr "Nepročitane poruke" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_unread_counter -msgid "Unread Messages Counter" -msgstr "Brojač nepročitanih poruka" - -#. module: test_mail_full -#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__website_message_ids -msgid "Website Messages" -msgstr "Poruke sa website-a" - -#. module: test_mail_full -#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__website_message_ids -msgid "Website communication history" -msgstr "Povijest komunikacije Web stranice" diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/models/test_mail_models_mail.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/models/test_mail_models_mail.py index a33736a..a9fc87e 100644 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/models/test_mail_models_mail.py +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/models/test_mail_models_mail.py @@ -5,16 +5,16 @@ from odoo import api, fields, models class MailTestPortal(models.Model): - """ A model intheriting from mail.thread with some fields used for portal - sharing, like a partner, ...""" - _description = 'Chatter Model for Portal' + """ A model inheriting from mail.thread and portal.mixin with some fields + used for portal sharing, like a partner, ...""" _name = 'mail.test.portal' + _description = 'Chatter Model for Portal' _inherit = [ - 'mail.thread', 'portal.mixin', + 'mail.thread', ] - name = fields.Char() + name = fields.Char('Name') partner_id = fields.Many2one('res.partner', 'Customer') user_id = fields.Many2one(comodel_name='res.users', string="Salesperson") @@ -26,8 +26,8 @@ class MailTestPortal(models.Model): class MailTestPortalNoPartner(models.Model): """ A model inheriting from portal, but without any partner field """ - _description = 'Chatter Model for Portal (no partner field)' _name = 'mail.test.portal.no.partner' + _description = 'Chatter Model for Portal (no partner field)' _inherit = [ 'mail.thread', 'portal.mixin', @@ -66,26 +66,24 @@ class MailTestPortalPublicAccessAction(models.Model): class MailTestRating(models.Model): - """ A model inheriting from mail.thread with some fields used for SMS + """ A model inheriting from rating.mixin (which inherits from mail.thread) with some fields used for SMS gateway, like a partner, a specific mobile phone, ... """ - _description = 'Rating Model (ticket-like)' _name = 'mail.test.rating' + _description = 'Rating Model (ticket-like)' _inherit = [ - 'mail.thread', - 'mail.activity.mixin', 'rating.mixin', + 'mail.activity.mixin', 'portal.mixin', ] _mailing_enabled = True - _order = 'name asc, id asc' + _order = 'id asc' - name = fields.Char() - subject = fields.Char() + name = fields.Char('Name') + subject = fields.Char('Subject') company_id = fields.Many2one('res.company', 'Company') customer_id = fields.Many2one('res.partner', 'Customer') - email_from = fields.Char(compute='_compute_email_from', precompute=True, readonly=False, store=True) - mobile_nbr = fields.Char(compute='_compute_mobile_nbr', precompute=True, readonly=False, store=True) - phone_nbr = fields.Char(compute='_compute_phone_nbr', precompute=True, readonly=False, store=True) + email_from = fields.Char('From', compute='_compute_email_from', precompute=True, readonly=False, store=True) + phone_nbr = fields.Char('Phone Number', compute='_compute_phone_nbr', precompute=True, readonly=False, store=True) user_id = fields.Many2one('res.users', 'Responsible', tracking=1) @api.depends('customer_id') @@ -96,14 +94,6 @@ class MailTestRating(models.Model): elif not rating.email_from: rating.email_from = False - @api.depends('customer_id') - def _compute_mobile_nbr(self): - for rating in self: - if rating.customer_id.mobile: - rating.mobile_nbr = rating.customer_id.mobile - elif not rating.mobile_nbr: - rating.mobile_nbr = False - @api.depends('customer_id') def _compute_phone_nbr(self): for rating in self: @@ -112,11 +102,50 @@ class MailTestRating(models.Model): elif not rating.phone_nbr: rating.phone_nbr = False - def _mail_get_partner_fields(self): + def _mail_get_partner_fields(self, introspect_fields=False): return ['customer_id'] + def _phone_get_number_fields(self): + return ['phone_nbr'] + def _rating_apply_get_default_subtype_id(self): return self.env['ir.model.data']._xmlid_to_res_id("test_mail_full.mt_mail_test_rating_rating_done") def _rating_get_partner(self): return self.customer_id + + @api.model + def _allow_publish_rating_stats(self): + return True + + +class MailTestRatingThread(models.Model): + """A model inheriting from mail.thread with minimal fields for testing + rating submission without the rating mixin but with the same test code: + + - partner_id: value returned by the base _rating_get_partner method + - user_id: value returned by the base _rating_get_operator method + """ + _name = 'mail.test.rating.thread' + _description = 'Model for testing rating without the rating mixin' + _inherit = ['mail.thread'] + _order = 'name asc, id asc' + + name = fields.Char('Name') + customer_id = fields.Many2one('res.partner', 'Customer') + user_id = fields.Many2one('res.users', 'Responsible', tracking=1) + + def _mail_get_partner_fields(self, introspect_fields=False): + return ['customer_id'] + + def _rating_get_partner(self): + return self.customer_id or super()._rating_get_partner() + + +class MailTestRatingThreadRead(models.Model): + """Same as MailTestRatingThread but post accessible on read by portal users.""" + _name = 'mail.test.rating.thread.read' + _description = "Read-post rating model" + _inherit = ["mail.test.rating.thread"] + _order = "name asc, id asc" + _mail_post_access = "read" diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/security/ir.model.access.csv b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/security/ir.model.access.csv index 429f70a..5f2eeca 100644 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/security/ir.model.access.csv +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/security/ir.model.access.csv @@ -1,10 +1,13 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_mail_test_portal_all,mail.test.portal.all,model_mail_test_portal,,0,0,0,0 access_mail_test_portal_user,mail.test.portal.user,model_mail_test_portal,base.group_user,1,1,1,1 -access_mail_test_portal_no_partner_all,mail.test.portal.no.partner.all,model_mail_test_portal_no_partner,,1,0,0,0 +access_mail_test_portal_no_partner_portal,mail.test.portal.no.partner.all,model_mail_test_portal_no_partner,base.group_portal,1,0,0,0 access_mail_test_portal_no_partner_user,mail.test.portal.no.partner.user,model_mail_test_portal_no_partner,base.group_user,1,1,1,1 access_mail_test_portal_public_access_action_portal,mail.test.portal.public.access.action.portal,model_mail_test_portal_public_access_action,base.group_portal,1,0,0,0 access_mail_test_portal_public_access_action_user,mail.test.portal.public.access.action.user,model_mail_test_portal_public_access_action,base.group_user,1,1,1,1 -access_mail_test_rating_all,mail.test.rating.all,model_mail_test_rating,,0,0,0,0 access_mail_test_rating_portal,mail.test.rating.portal,model_mail_test_rating,base.group_portal,1,0,0,0 access_mail_test_rating_user,mail.test.rating.user,model_mail_test_rating,base.group_user,1,1,1,1 +access_mail_test_rating_thread_all,mail.test.rating.thread.all,model_mail_test_rating_thread,,0,0,0,0 +access_mail_test_rating_thread_portal,mail.test.rating.thread.portal,model_mail_test_rating_thread,base.group_portal,1,0,0,0 +access_mail_test_rating_thread_user,mail.test.rating.thread.user,model_mail_test_rating_thread,base.group_user,1,1,1,1 +access_mail_test_rating_thread_read_portal,mail.test.rating.thread.read.portal,model_mail_test_rating_thread_read,base.group_portal,1,0,0,0 +access_mail_test_rating_thread_read_user,mail.test.rating.thread.read.user,model_mail_test_rating_thread_read,base.group_user,1,1,1,1 diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/helpers/model_definitions_setup.js b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/helpers/model_definitions_setup.js deleted file mode 100644 index dca0887..0000000 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/helpers/model_definitions_setup.js +++ /dev/null @@ -1,5 +0,0 @@ -/** @odoo-module **/ - -import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers'; - -addModelNamesToFetch(['mail.test.rating']); diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/messaging_menu_patch.test.js b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/messaging_menu_patch.test.js new file mode 100644 index 0000000..4cc3ab8 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/messaging_menu_patch.test.js @@ -0,0 +1,61 @@ +import { click, contains, start, startServer } from "@mail/../tests/mail_test_helpers"; +import { test } from "@odoo/hoot"; +import { defineTestMailFullModels } from "@test_mail_full/../tests/test_mail_full_test_helpers"; +import { serverState } from "@web/../tests/web_test_helpers"; + +defineTestMailFullModels(); + +test("rating value displayed on the preview", async () => { + const pyEnv = await startServer(); + const partnerId = pyEnv["res.partner"].create({}); + const channelId = pyEnv["discuss.channel"].create({}); + const messageId = pyEnv["mail.message"].create({ + author_id: partnerId, + body: "non-empty", + model: "discuss.channel", + res_id: channelId, + }); + pyEnv["rating.rating"].create({ + consumed: true, + message_id: messageId, + partner_id: partnerId, + rating_image_url: "/rating/static/src/img/rating_5.png", + rating_text: "top", + }); + await start(); + await click(".o_menu_systray i[aria-label='Messages']"); + await contains(".o-mail-NotificationItem-text", { text: "Rating:" }); + await contains(".o-rating-preview-image[alt='top']"); + await contains(".o-rating-preview-image[data-src='/rating/static/src/img/rating_5.png']"); +}); + +test("rating value displayed on the needaction preview", async () => { + const pyEnv = await startServer(); + const partnerId = pyEnv["res.partner"].create({}); + const ratingId = pyEnv["mail.test.rating"].create({ name: "Test rating" }); + const messageId = pyEnv["mail.message"].create({ + model: "mail.test.rating", + needaction: true, + res_id: ratingId, + }); + pyEnv["mail.notification"].create({ + mail_message_id: messageId, + notification_status: "sent", + notification_type: "inbox", + res_partner_id: serverState.partnerId, + }); + pyEnv["rating.rating"].create([ + { + consumed: true, + message_id: messageId, + partner_id: partnerId, + rating_image_url: "/rating/static/src/img/rating_5.png", + rating_text: "top", + }, + ]); + await start(); + await click(".o_menu_systray i[aria-label='Messages']"); + await contains(".o-mail-NotificationItem-text", { text: "Rating:" }); + await contains(".o-rating-preview-image[alt='top']"); + await contains(".o-rating-preview-image[data-src='/rating/static/src/img/rating_5.png']"); +}); diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/mock_server/models/mail_test_rating.js b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/mock_server/models/mail_test_rating.js new file mode 100644 index 0000000..7ad6378 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/mock_server/models/mail_test_rating.js @@ -0,0 +1,5 @@ +import { models } from "@web/../tests/web_test_helpers"; + +export class MailTestRating extends models.ServerModel { + _name = "mail.test.rating"; +} diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/qunit_suite_tests/channel_preview_view_tests.js b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/qunit_suite_tests/channel_preview_view_tests.js deleted file mode 100644 index a3d0433..0000000 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/qunit_suite_tests/channel_preview_view_tests.js +++ /dev/null @@ -1,55 +0,0 @@ -/** @odoo-module **/ - -import { afterNextRender, start, startServer } from '@mail/../tests/helpers/test_utils'; - -QUnit.module('test_mail_full', {}, function () { -QUnit.module('channel_preview_view_tests.js'); - -QUnit.test('rating value displayed on the thread preview', async function (assert) { - assert.expect(4); - - const pyEnv = await startServer(); - const resPartnerId1 = pyEnv['res.partner'].create({}); - const mailChannelId1 = pyEnv['mail.channel'].create({}); - const mailMessageId1 = pyEnv['mail.message'].create([ - { author_id: resPartnerId1, model: 'mail.channel', res_id: mailChannelId1 }, - ]); - pyEnv['rating.rating'].create({ - consumed: true, - message_id: mailMessageId1, - partner_id: resPartnerId1, - rating_image_url: "/rating/static/src/img/rating_5.png", - rating_text: "top", - }); - const { afterEvent, messaging } = await start(); - await afterNextRender(() => afterEvent({ - eventName: 'o-thread-cache-loaded-messages', - func: () => document.querySelector('.o_MessagingMenu_toggler').click(), - message: "should wait until inbox loaded initial needaction messages", - predicate: ({ threadCache }) => { - return threadCache.thread === messaging.inbox.thread; - }, - })); - assert.strictEqual( - document.querySelector('.o_ChannelPreviewView_ratingText').textContent, - "Rating:", - "should display the correct content (Rating:)" - ); - assert.containsOnce( - document.body, - '.o_ChannelPreviewView_ratingImage', - "should have a rating image in the body" - ); - assert.strictEqual( - $('.o_ChannelPreviewView_ratingImage').attr('data-src'), - "/rating/static/src/img/rating_5.png", - "should contain the correct rating image" - ); - assert.strictEqual( - $('.o_ChannelPreviewView_ratingImage').attr('data-alt'), - "top", - "should contain the correct rating text" - ); -}); - -}); diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/qunit_suite_tests/thread_needaction_preview_tests.js b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/qunit_suite_tests/thread_needaction_preview_tests.js deleted file mode 100644 index d551191..0000000 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/qunit_suite_tests/thread_needaction_preview_tests.js +++ /dev/null @@ -1,64 +0,0 @@ -/** @odoo-module **/ - -import { afterNextRender, start, startServer } from '@mail/../tests/helpers/test_utils'; - -QUnit.module('test_mail_full', {}, function () { -QUnit.module('thread_needaction_preview_tests.js'); - -QUnit.test('rating value displayed on the thread needaction preview', async function (assert) { - assert.expect(4); - - const pyEnv = await startServer(); - const resPartnerId1 = pyEnv['res.partner'].create({}); - const mailTestRating1 = pyEnv['mail.test.rating'].create({}); - const mailMessageId1 = pyEnv['mail.message'].create({ - model: 'mail.test.rating', - needaction: true, - needaction_partner_ids: [pyEnv.currentPartnerId], - res_id: mailTestRating1, - }); - pyEnv['mail.notification'].create({ - mail_message_id: mailMessageId1, - notification_status: 'sent', - notification_type: 'inbox', - res_partner_id: pyEnv.currentPartnerId, - }); - pyEnv['rating.rating'].create([{ - consumed: true, - message_id: mailMessageId1, - partner_id: resPartnerId1, - rating_image_url: "/rating/static/src/img/rating_5.png", - rating_text: "top", - }]); - const { afterEvent, messaging } = await start(); - await afterNextRender(() => afterEvent({ - eventName: 'o-thread-cache-loaded-messages', - func: () => document.querySelector('.o_MessagingMenu_toggler').click(), - message: "should wait until inbox loaded initial needaction messages", - predicate: ({ threadCache }) => { - return threadCache.thread === messaging.inbox.thread; - }, - })); - assert.strictEqual( - document.querySelector('.o_ThreadNeedactionPreview_ratingText').textContent, - "Rating:", - "should display the correct content (Rating:)" - ); - assert.containsOnce( - document.body, - '.o_ThreadNeedactionPreview_ratingImage', - "should have a rating image in the body" - ); - assert.strictEqual( - $('.o_ThreadNeedactionPreview_ratingImage').attr('data-src'), - "/rating/static/src/img/rating_5.png", - "should contain the correct rating image" - ); - assert.strictEqual( - $('.o_ThreadNeedactionPreview_ratingImage').attr('data-alt'), - "top", - "should contain the correct rating text" - ); -}); - -}); diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/test_mail_full_test_helpers.js b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/test_mail_full_test_helpers.js new file mode 100644 index 0000000..f403f2f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/test_mail_full_test_helpers.js @@ -0,0 +1,9 @@ +import { ratingModels } from "@rating/../tests/rating_test_helpers"; +import { MailTestRating } from "@test_mail_full/../tests/mock_server/models/mail_test_rating"; +import { defineModels } from "@web/../tests/web_test_helpers"; + +export const testMailFullModels = { ...ratingModels, MailTestRating }; + +export function defineTestMailFullModels() { + defineModels(testMailFullModels); +} diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/load_more_tour.js b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/load_more_tour.js new file mode 100644 index 0000000..a67afd4 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/load_more_tour.js @@ -0,0 +1,19 @@ +import { registry } from "@web/core/registry"; +import { contains } from "@web/../tests/utils"; + +registry.category("web_tour.tours").add("load_more_tour", { + steps: () => [ + { + trigger: "#chatterRoot:shadow .o-mail-Thread .o-mail-Message", + run: async function () { + await contains(".o-mail-Thread .o-mail-Message", { + count: 30, + target: document.querySelector("#chatterRoot").shadowRoot, + }); + }, + }, + { + trigger: "#chatterRoot:shadow .o-mail-Thread button:contains(Load More):not(:visible)", + }, + ], +}); diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/message_actions_tour.js b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/message_actions_tour.js new file mode 100644 index 0000000..03e4e5c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/message_actions_tour.js @@ -0,0 +1,87 @@ +import { registry } from "@web/core/registry"; +import { contains } from "@web/../tests/utils"; + +registry.category("web_tour.tours").add("star_message_tour", { + steps: () => [ + { + trigger: + "#chatterRoot:shadow .o-mail-Message:not([data-starred]):contains(Test Message)", + run: "hover && click #chatterRoot:shadow [title='Add Star']", + }, + { + trigger: "#chatterRoot:shadow .o-mail-Message[data-starred]:contains(Test Message)", + }, + ], +}); + +registry.category("web_tour.tours").add("message_actions_tour", { + steps: () => [ + { + trigger: "#chatterRoot:shadow .o-mail-Thread .o-mail-Message", + run: async function () { + await contains(".o-mail-Thread .o-mail-Message", { + count: 1, + target: document.querySelector("#chatterRoot").shadowRoot, + }); + }, + }, + { + trigger: "#chatterRoot:shadow .o-mail-Composer-input", + run: "edit New message", + }, + { + trigger: "#chatterRoot:shadow .o-mail-Composer button:contains(Send):enabled", + run: "click", + }, + { + trigger: "#chatterRoot:shadow .o-mail-Thread .o-mail-Message", + run: async function () { + await contains(".o-mail-Thread .o-mail-Message", { + count: 2, + target: document.querySelector("#chatterRoot").shadowRoot, + }); + }, + }, + { + trigger: "#chatterRoot:shadow .o-mail-Message[data-persistent]:contains(New message)", + run: "hover && click #chatterRoot:shadow button[title='Add a Reaction']", + }, + { + trigger: "#chatterRoot:shadow .o-mail-QuickReactionMenu-emoji span:contains(❤️)", + run: "click", + }, + { + trigger: + "#chatterRoot:shadow .o-mail-Message:contains(New message) .o-mail-MessageReaction:contains(❤️)", + }, + { + trigger: "#chatterRoot:shadow .o-mail-Message:contains(New message)", + run: "hover && click #chatterRoot:shadow button[title='Edit']", + }, + { + trigger: "#chatterRoot:shadow .o-mail-Message .o-mail-Composer-input", + run: "edit Message content changed", + }, + { + trigger: "#chatterRoot:shadow .o-mail-Message button:contains(save)", + run: "click", + }, + { + trigger: "#chatterRoot:shadow .o-mail-Message:contains(Message content changed)", + run: "hover && click #chatterRoot:shadow button[title='Delete']", + }, + { + trigger: "#chatterRoot:shadow button:contains(Delete)", + run: "click", + }, + { + trigger: "#chatterRoot:shadow .o-mail-Thread .o-mail-Message", + run: async function () { + await contains(".o-mail-Thread .o-mail-Message", { + count: 1, + target: document.querySelector("#chatterRoot").shadowRoot, + }); + }, + }, + ], +}); diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_composer_actions_tour.js b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_composer_actions_tour.js new file mode 100644 index 0000000..0a0b743 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_composer_actions_tour.js @@ -0,0 +1,34 @@ +import { registry } from "@web/core/registry"; + +const cannedResponseButtonSelector = "button[title='Insert a Canned response']"; + +registry.category("web_tour.tours").add("portal_composer_actions_tour_internal_user", { + steps: () => [ + { + trigger: `#chatterRoot:shadow .o-mail-Composer ${cannedResponseButtonSelector}`, + run: "click", + }, + { + trigger: "#chatterRoot:shadow .o-mail-Composer-input", + run() { + if (this.anchor.value !== "::") { + console.error( + "Clicking on the canned response button should insert the '::' into the composer." + ); + } + }, + }, + { + trigger: + "#chatterRoot:shadow .o-mail-Composer-suggestion:contains(Hello, how may I help you?)", + }, + ], +}); + +registry.category("web_tour.tours").add("portal_composer_actions_tour_portal_user", { + steps: () => [ + { + trigger: `#chatterRoot:shadow .o-mail-Composer:not(:has(${cannedResponseButtonSelector}))`, + }, + ], +}); diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_copy_link_tour.js b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_copy_link_tour.js new file mode 100644 index 0000000..71849df --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_copy_link_tour.js @@ -0,0 +1,22 @@ +import { messageActionsRegistry } from "@mail/core/common/message_actions"; +import { registry } from "@web/core/registry"; +import { patch } from "@web/core/utils/patch"; + +registry.category("web_tour.tours").add("portal_copy_link_tour", { + steps: () => [ + { + trigger: "#chatterRoot:shadow .o-mail-Message", + run: () => { + const copyLinkAction = messageActionsRegistry.get("copy-link"); + patch(copyLinkAction, { sequence: 1 }); // make sure the action is visible without expanding + } + }, + { + trigger: "#chatterRoot:shadow .o-mail-Message:contains(Test Message)", + run: "hover && click", + }, + { + trigger: "#chatterRoot:shadow .o-mail-Message-actions [title='Copy Link']", + }, + ], +}); diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_no_copy_link_tour.js b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_no_copy_link_tour.js new file mode 100644 index 0000000..c256dea --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_no_copy_link_tour.js @@ -0,0 +1,28 @@ +import { messageActionsRegistry } from "@mail/core/common/message_actions"; +import { registry } from "@web/core/registry"; +import { patch } from "@web/core/utils/patch"; + +registry.category("web_tour.tours").add("portal_no_copy_link_tour", { + steps: () => [ + { + trigger: "#chatterRoot:shadow .o-mail-Message", + run: () => { + const copyLinkAction = messageActionsRegistry.get("copy-link"); + patch(copyLinkAction, { sequence: 1 }); // make sure the action is visible without expanding + } + }, + { + trigger: "#chatterRoot:shadow .o-mail-Message:contains(Test Message)", + run: "hover && click", + }, + { + trigger: "#chatterRoot:shadow .o-mail-Message-actions", + run: async () => { + const copyLinkButton = document.querySelector('#chatterRoot').shadowRoot.querySelector("[title='Copy Link']"); + if (copyLinkButton) { + throw new Error("Users without read access should not be able to copy the link to a message"); + } + }, + }, + ], +}); diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_rating_tour.js.js b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_rating_tour.js.js new file mode 100644 index 0000000..6f03934 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/tours/portal_rating_tour.js.js @@ -0,0 +1,46 @@ +import { registry } from "@web/core/registry"; + +const ratingCardSelector = ".o_website_rating_card_container"; + +registry.category("web_tour.tours").add("portal_rating_tour", { + steps: () => [ + { + // Ensure that the rating data has been fetched before making a negative assertion for rating cards. + trigger: "#chatterRoot:shadow .o-mail-Message-body:text(Message without rating)", + }, + { + trigger: `#chatterRoot:shadow .o-mail-Chatter-top:not(:has(${ratingCardSelector}))`, + }, + { + trigger: "#chatterRoot:shadow .o-mail-Composer-input", + run: "edit Excellent service!", + }, + { + trigger: "#chatterRoot:shadow .o-mail-Composer-send:enabled", + run: "click", + }, + { + trigger: `#chatterRoot:shadow .o-mail-Chatter-top ${ratingCardSelector} .o_website_rating_table_row[data-star='4']:has(:text(100%))`, + }, + ], +}); + +registry.category("web_tour.tours").add("portal_display_rating_tour", { + steps: () => [ + { + trigger: `#chatterRoot:shadow .o-mail-Chatter-top ${ratingCardSelector}`, + }, + ], +}); + +registry.category("web_tour.tours").add("portal_not_display_rating_tour", { + steps: () => [ + { + // Ensure that the rating data has been fetched before making a negative assertion for rating cards. + trigger: "#chatterRoot:shadow .o-mail-Message-body:text(Message with rating)", + }, + { + trigger: `#chatterRoot:shadow .o-mail-Chatter-top:not(:has(${ratingCardSelector}))`, + }, + ], +}); diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/__init__.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/__init__.py index 283b523..cce4ee0 100644 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/__init__.py +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/__init__.py @@ -1,10 +1,15 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import test_odoobot +from . import test_controller_attachment +from . import test_controller_reaction +from . import test_controller_update +from . import test_controller_thread +from . import test_ir_mail_server +from . import test_mail_bot from . import test_mail_performance from . import test_mail_thread_internals from . import test_mass_mailing from . import test_portal from . import test_rating from . import test_res_users +from . import test_ui diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_attachment.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_attachment.py new file mode 100644 index 0000000..55534ea --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_attachment.py @@ -0,0 +1,39 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import odoo +from odoo.addons.mail.tests.common_controllers import MailControllerAttachmentCommon + + +@odoo.tests.tagged("-at_install", "post_install", "mail_controller") +class TestPortalAttachmentController(MailControllerAttachmentCommon): + + def test_attachment_upload_portal(self): + """Test access to upload an attachment on portal""" + record = self.env["mail.test.portal.no.partner"].create({"name": "Test"}) + token, bad_token, sign, bad_sign, _ = self._get_sign_token_params(record) + self._execute_subtests_upload( + record, + ( + (self.user_public, False), + (self.user_public, True, token), + (self.user_public, True, sign), + (self.guest, False), + (self.guest, True, token), + (self.guest, True, sign), + (self.user_portal, False), + (self.user_portal, False, bad_token), + (self.user_portal, False, bad_sign), + (self.user_portal, True, token), + (self.user_portal, True, sign), + (self.user_employee, True), + (self.user_employee, True, bad_token), + (self.user_employee, True, bad_sign), + (self.user_employee, True, token), + (self.user_employee, True, sign), + (self.user_admin, True), + (self.user_admin, True, bad_token), + (self.user_admin, True, bad_sign), + (self.user_admin, True, token), + (self.user_admin, True, sign), + ), + ) diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_reaction.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_reaction.py new file mode 100644 index 0000000..c34ae2e --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_reaction.py @@ -0,0 +1,80 @@ +from odoo.addons.mail.tests.common_controllers import MailControllerReactionCommon +from odoo.tests import tagged + + +@tagged("-at_install", "post_install", "mail_controller") +class TestPortalMessageReactionController(MailControllerReactionCommon): + + def test_message_reaction_nomsg(self): + """Test access of message reaction for a non-existing message.""" + self._execute_subtests( + self.fake_message, + ((user, False) for user in [self.user_public, self.guest, self.user_portal, self.user_employee]), + ) + + def test_message_reaction_portal_no_partner(self): + """Test access of message reaction for portal without partner.""" + record = self.env["mail.test.portal.no.partner"].create({"name": "Test"}) + token, bad_token, sign, bad_sign, partner = self._get_sign_token_params(record) + message = record.message_post(body="portal no partner") + self._execute_subtests( + message, + ( + (self.user_public, False), + (self.user_public, False, bad_token), + (self.user_public, False, bad_sign), + # False because no portal partner, no guest + (self.user_public, False, token), + (self.user_public, True, sign, {"partner": partner}), + (self.guest, False), + (self.guest, False, bad_token), + (self.guest, False, bad_sign), + (self.guest, True, token), + (self.guest, True, sign, {"partner": partner}), + (self.user_portal, False), + (self.user_portal, False, bad_token), + (self.user_portal, False, bad_sign), + (self.user_portal, True, token), + (self.user_portal, True, sign), + (self.user_employee, True), + (self.user_employee, True, bad_token), + (self.user_employee, True, bad_sign), + (self.user_employee, True, token), + (self.user_employee, True, sign), + ), + ) + + def test_message_reaction_portal_assigned_partner(self): + """Test access of message reaction for portal with partner.""" + rec_partner = self.env["res.partner"].create({"name": "Record Partner"}) + record = self.env["mail.test.portal"].create({"name": "Test", "partner_id": rec_partner.id}) + message = record.message_post(body="portal with partner") + token, bad_token, sign, bad_sign, partner = self._get_sign_token_params(record) + self._execute_subtests( + message, + ( + (self.user_public, False), + (self.user_public, False, bad_token), + (self.user_public, False, bad_sign), + (self.user_public, True, token, {"partner": rec_partner}), + (self.user_public, True, sign, {"partner": partner}), + # sign has priority over token when both are provided + (self.user_public, True, token | sign, {"partner": partner}), + (self.guest, False), + (self.guest, False, bad_token), + (self.guest, False, bad_sign), + (self.guest, True, token, {"partner": rec_partner}), + (self.guest, True, sign, {"partner": partner}), + (self.guest, True, token | sign, {"partner": partner}), + (self.user_portal, False), + (self.user_portal, False, bad_token), + (self.user_portal, False, bad_sign), + (self.user_portal, True, token), + (self.user_portal, True, sign), + (self.user_employee, True), + (self.user_employee, True, bad_token), + (self.user_employee, True, bad_sign), + (self.user_employee, True, token), + (self.user_employee, True, sign), + ), + ) diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_thread.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_thread.py new file mode 100644 index 0000000..0cd8966 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_thread.py @@ -0,0 +1,171 @@ +from odoo.addons.mail.tests.common_controllers import MailControllerThreadCommon, MessagePostSubTestData +from odoo.tests import tagged + + +@tagged("-at_install", "post_install", "mail_controller") +class TestPortalThreadController(MailControllerThreadCommon): + + def test_message_post_portal_no_partner(self): + """Test access of message post for portal without partner.""" + record = self.env["mail.test.portal.no.partner"].create({"name": "Test"}) + token, bad_token, sign, bad_sign, partner = self._get_sign_token_params(record) + + def test_access(user, allowed, route_kw=None, exp_author=None): + return MessagePostSubTestData(user, allowed, route_kw=route_kw, exp_author=exp_author) + + self._execute_message_post_subtests( + record, + ( + test_access(self.user_public, False), + test_access(self.user_public, False, route_kw=bad_token), + test_access(self.user_public, False, route_kw=bad_sign), + test_access(self.user_public, True, route_kw=token), + test_access(self.user_public, True, route_kw=sign, exp_author=partner), + test_access(self.guest, False), + test_access(self.guest, False, route_kw=bad_token), + test_access(self.guest, False, route_kw=bad_sign), + test_access(self.guest, True, route_kw=token), + test_access(self.guest, True, route_kw=sign, exp_author=partner), + test_access(self.user_portal, False), + test_access(self.user_portal, False, route_kw=bad_token), + test_access(self.user_portal, False, route_kw=bad_sign), + test_access(self.user_portal, True, route_kw=token), + test_access(self.user_portal, True, route_kw=sign), + test_access(self.user_employee, True), + test_access(self.user_employee, True, route_kw=bad_token), + test_access(self.user_employee, True, route_kw=bad_sign), + test_access(self.user_employee, True, route_kw=token), + test_access(self.user_employee, True, route_kw=sign), + ), + ) + + def test_message_post_portal_with_partner(self): + """Test access of message post for portal with partner.""" + rec_partner = self.env["res.partner"].create({"name": "Record Partner"}) + record = self.env["mail.test.portal"].create({"name": "Test", "partner_id": rec_partner.id}) + token, bad_token, sign, bad_sign, partner = self._get_sign_token_params(record) + + def test_access(user, allowed, route_kw=None, exp_author=None): + return MessagePostSubTestData(user, allowed, route_kw=route_kw, exp_author=exp_author) + + self._execute_message_post_subtests( + record, + ( + test_access(self.user_public, False), + test_access(self.user_public, False, route_kw=bad_token), + test_access(self.user_public, False, route_kw=bad_sign), + test_access(self.user_public, True, route_kw=token, exp_author=rec_partner), + test_access(self.user_public, True, route_kw=sign, exp_author=partner), + # sign has priority over token when both are provided + test_access(self.user_public, True, route_kw=token | sign, exp_author=partner), + test_access(self.guest, False), + test_access(self.guest, False, route_kw=bad_token), + test_access(self.guest, False, route_kw=bad_sign), + test_access(self.guest, True, route_kw=token, exp_author=rec_partner), + test_access(self.guest, True, route_kw=sign, exp_author=partner), + test_access(self.guest, True, route_kw=token | sign, exp_author=partner), + test_access(self.user_portal, False), + test_access(self.user_portal, False, route_kw=bad_token), + test_access(self.user_portal, False, route_kw=bad_sign), + test_access(self.user_portal, True, route_kw=token), + test_access(self.user_portal, True, route_kw=sign), + test_access(self.user_employee, True), + test_access(self.user_employee, True, route_kw=bad_token), + test_access(self.user_employee, True, route_kw=bad_sign), + test_access(self.user_employee, True, route_kw=token), + test_access(self.user_employee, True, route_kw=sign), + ), + ) + + def test_message_post_partner_ids_mention_token(self): + """Test partner_ids of message_post for portal record without partner. + All users are allowed to mention with specific message_mention token.""" + record = self.env["mail.test.portal.no.partner"].create({"name": "Test"}) + token, bad_token, sign, bad_sign, partner = self._get_sign_token_params(record) + all_partners = ( + self.user_portal + self.user_employee + self.user_admin + ).partner_id + record.message_subscribe(partner_ids=self.user_employee.partner_id.ids) + + def test_partners(user, allowed, exp_partners, route_kw=None, exp_author=None): + return MessagePostSubTestData( + user, + allowed, + partners=all_partners, + route_kw=route_kw, + exp_author=exp_author, + exp_partners=exp_partners, + add_mention_token=True, + ) + + self._execute_message_post_subtests( + record, + ( + test_partners(self.user_public, False, all_partners), + test_partners(self.user_public, False, all_partners, route_kw=bad_token), + test_partners(self.user_public, False, all_partners, route_kw=bad_sign), + test_partners(self.user_public, True, all_partners, route_kw=token), + test_partners(self.user_public, True, all_partners, route_kw=sign, exp_author=partner), + test_partners(self.guest, False, all_partners), + test_partners(self.guest, False, all_partners, route_kw=bad_token), + test_partners(self.guest, False, all_partners, route_kw=bad_sign), + test_partners(self.guest, True, all_partners, route_kw=token), + test_partners(self.guest, True, all_partners, route_kw=sign, exp_author=partner), + test_partners(self.user_portal, False, all_partners), + test_partners(self.user_portal, False, all_partners, route_kw=bad_token), + test_partners(self.user_portal, False, all_partners, route_kw=bad_sign), + test_partners(self.user_portal, True, all_partners, route_kw=token), + test_partners(self.user_portal, True, all_partners, route_kw=sign), + test_partners(self.user_employee, True, all_partners), + test_partners(self.user_employee, True, all_partners, route_kw=bad_token), + test_partners(self.user_employee, True, all_partners, route_kw=bad_sign), + test_partners(self.user_employee, True, all_partners, route_kw=token), + test_partners(self.user_employee, True, all_partners, route_kw=sign), + ), + ) + + def test_message_post_partner_ids_portal(self): + """Test partner_ids of message_post for portal record without partner. + Only internal users are allowed to mention without specific message_mention token.""" + record = self.env["mail.test.portal.no.partner"].create({"name": "Test"}) + token, bad_token, sign, bad_sign, partner = self._get_sign_token_params(record) + all_partners = ( + self.user_portal + self.user_employee + self.user_admin + ).partner_id + record.message_subscribe(partner_ids=self.user_employee.partner_id.ids) + + def test_partners(user, allowed, exp_partners, route_kw=None, exp_author=None): + return MessagePostSubTestData( + user, + allowed, + partners=all_partners, + route_kw=route_kw, + exp_author=exp_author, + exp_partners=exp_partners, + ) + + self._execute_message_post_subtests( + record, + ( + test_partners(self.user_public, False, self.env["res.partner"]), + test_partners(self.user_public, False, self.env["res.partner"], route_kw=bad_token), + test_partners(self.user_public, False, self.env["res.partner"], route_kw=bad_sign), + test_partners(self.user_public, True, self.env["res.partner"], route_kw=token), + test_partners(self.user_public, True, self.env["res.partner"], route_kw=sign, exp_author=partner), + test_partners(self.guest, False, self.env["res.partner"]), + test_partners(self.guest, False, self.env["res.partner"], route_kw=bad_token), + test_partners(self.guest, False, self.env["res.partner"], route_kw=bad_sign), + test_partners(self.guest, True, self.env["res.partner"], route_kw=token), + test_partners(self.guest, True, self.env["res.partner"], route_kw=sign, exp_author=partner), + test_partners(self.user_portal, False, self.env["res.partner"]), + test_partners(self.user_portal, False, self.env["res.partner"], route_kw=bad_token), + test_partners(self.user_portal, False, self.env["res.partner"], route_kw=bad_sign), + test_partners(self.user_portal, True, self.env["res.partner"], route_kw=token), + test_partners(self.user_portal, True, self.env["res.partner"], route_kw=sign), + test_partners(self.user_employee, True, all_partners), + test_partners(self.user_employee, True, all_partners, route_kw=bad_token), + test_partners(self.user_employee, True, all_partners, route_kw=bad_sign), + test_partners(self.user_employee, True, all_partners, route_kw=token), + test_partners(self.user_employee, True, all_partners, route_kw=sign), + ), + ) diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_update.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_update.py new file mode 100644 index 0000000..e02cd00 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_controller_update.py @@ -0,0 +1,48 @@ +from odoo.addons.mail.tests.common_controllers import MailControllerUpdateCommon +from odoo.tests import tagged + + +@tagged("-at_install", "post_install", "mail_controller") +class TestPortalMessageUpdateController(MailControllerUpdateCommon): + + def test_message_update_no_message(self): + """Test update a non-existing message.""" + self._execute_subtests( + self.fake_message, + ((user, False) for user in [self.guest, self.user_admin, self.user_employee, self.user_portal, self.user_public]), + ) + + def test_message_update_portal(self): + """Test only admin and author can modify content of a message, works if + author is a portal user. """ + record = self.env["mail.test.portal.no.partner"].create({"name": "Test"}) + token, bad_token, sign, bad_sign, _ = self._get_sign_token_params(record) + message = record.message_post( + body=self.message_body, + author_id=self.user_portal.partner_id.id, + message_type="comment", + ) + self._execute_subtests( + message, + ( + (self.user_public, False), + (self.user_public, False, token), + (self.user_public, False, sign), + (self.guest, False), + (self.guest, False, token), + (self.guest, False, sign), + (self.user_portal, False), + (self.user_portal, False, bad_token), + (self.user_portal, False, bad_sign), + (self.user_portal, True, token), + (self.user_portal, True, sign), + (self.user_employee, False), + (self.user_employee, False, token), + (self.user_employee, False, sign), + (self.user_admin, True), + (self.user_admin, True, bad_token), + (self.user_admin, True, bad_sign), + (self.user_admin, True, token), + (self.user_admin, True, sign), + ), + ) diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_ir_mail_server.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_ir_mail_server.py new file mode 100644 index 0000000..2754533 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_ir_mail_server.py @@ -0,0 +1,122 @@ +from contextlib import contextmanager +from unittest.mock import patch + +from odoo.addons.mail.tests.common import MailCommon +from odoo.tests import tagged, users + +from odoo.addons.base.models.ir_mail_server import IrMail_Server +from odoo.exceptions import UserError, ValidationError + + +@tagged('mail_server') +class TestIrMailServerPersonal(MailCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env['ir.config_parameter'].sudo().set_param('mail.disable_personal_mail_servers', False) + cls.user_admin.email = 'admin@test.lan' + cls.user_employee.email = 'employee@test.lan' + cls.user_employee.group_ids += cls.env.ref('mass_mailing.group_mass_mailing_user') + cls.test_partner = cls.env['res.partner'].create({ + 'name': 'test partner', 'email': 'test.partner@test.lan' + }) + cls.mail_server_user.write({ + 'from_filter': cls.user_employee.email, + 'owner_user_id': cls.user_employee, + 'smtp_user': cls.user_employee.email, + }) + cls.user_employee.invalidate_recordset(['outgoing_mail_server_id']) + + @contextmanager + def mock_mail_connect(self): + original_connect = IrMail_Server._connect__ + self.connected_server_ids = [] + + def patched_connect(mail_server, *args, **kwargs): + self.connected_server_ids.append(kwargs.get('mail_server_id')) + original_connect(mail_server, *args, **kwargs) + + with patch.object(IrMail_Server, '_connect__', autospec=True, wraps=IrMail_Server, side_effect=patched_connect): + yield + + @users('admin', 'employee') + def test_personal_mail_server_allowed_post(self): + """Check that only the owner of the mail server can create mails that will be sent from it.""" + test_record = self.test_partner.with_user(self.env.user) + with self.mock_mail_connect(): + test_record.message_post( + body='hello', + author_id=self.user_employee.partner_id.id, email_from=self.user_employee.email, + partner_ids=test_record.ids, + ) + + self.assertEqual(len(self.connected_server_ids), 1) + if self.env.user == self.mail_server_user.owner_user_id: + self.assertEqual(self.connected_server_ids[0], self.mail_server_user.id) + else: + self.assertNotEqual(self.connected_server_ids[0], self.mail_server_user.id) + + # check disallowed exceptions + if self.env.user != self.mail_server_user.owner_user_id: + # check raise on invalid server at create + with self.assertRaises(ValidationError): + test_record.message_post( + body='hello', + author_id=self.user_employee.partner_id.id, email_from=self.user_employee.email, + mail_server_id=self.mail_server_user.id, + partner_ids=test_record.ids, + ) + + # check raise on invalid server at send (should not happen in normal flow) + mail = self.env['mail.mail'].sudo().create({ + 'body_html': 'hello', + 'email_from': self.user_employee.email, + 'author_id': self.user_employee.partner_id.id, + 'partner_ids': test_record.ids, + }) + with self.mock_mail_gateway(), self.assertRaisesRegex(UserError, "Unauthorized server for some of the sending mails."): + mail._send(self, mail_server=self.mail_server_user) + + def test_personal_mail_server_find_mail_server(self): + """Check that _find_mail_server only finds 'public' servers unless otherwise allowed.""" + IrMailServer = self.env['ir.mail_server'] + all_servers = IrMailServer.search([]) + test_cases = [ + (None, False), + (all_servers, True), + ] + for mail_servers, should_find_personal in test_cases: + with self.subTest(mail_servers=mail_servers): + found_server, found_email_from = IrMailServer._find_mail_server(self.user_employee.email, mail_servers=mail_servers) + if should_find_personal: + self.assertEqual( + (found_server, found_email_from), (self.mail_server_user, self.user_employee.email), + 'Passing in a server that is owned should allow finding it.' + ) + else: + self.assertNotEqual( + found_server, self.mail_server_user, + 'Finding a server for an email_from without specifying a list of servers should not find owned servers.' + ) + + @users('employee') + def test_immutable_create_uid(self): + """Make sure create_uid is not writable, as it's a security assumption for these tests.""" + message = self.test_partner.with_user(self.env.user).message_post( + body='hello', + author_id=self.user_employee.partner_id.id, email_from=self.user_employee.email, + partner_ids=self.test_partner.ids, + ) + + self.assertEqual(message.create_uid, self.user_employee) + message.create_uid = self.user_admin + self.assertEqual(message.create_uid, self.user_employee) + + def test_personal_mail_server_mail_for_existing_message(self): + """Crons should be able to send a mail from a personal server for an existing message.""" + message = self.test_partner.with_user(self.user_employee).message_post(body='hello') + message.partner_ids += self.test_partner + with self.mock_mail_connect(): + self.test_partner.with_user(self.env.ref('base.user_root'))._notify_thread(message) + self.assertEqual(self.connected_server_ids, [self.mail_server_user.id], "Should have used message creator's server.") diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_odoobot.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mail_bot.py similarity index 89% rename from odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_odoobot.py rename to odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mail_bot.py index 450d358..a6e0d3f 100644 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_odoobot.py +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mail_bot.py @@ -1,19 +1,17 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - from unittest.mock import patch -from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients +from odoo.addons.mail.tests.common import MailCommon +from odoo.addons.test_mail.tests.common import TestRecipients from odoo.tests import tagged from odoo.tools import mute_logger @tagged("odoobot") -class TestOdoobot(TestMailCommon, TestRecipients): +class TestOdoobot(MailCommon, TestRecipients): @classmethod def setUpClass(cls): - super(TestOdoobot, cls).setUpClass() + super().setUpClass() cls.test_record = cls.env['mail.test.simple'].with_context(cls._test_context).create({'name': 'Test', 'email_from': 'ignasse@example.com'}) cls.odoobot = cls.env.ref("base.partner_root") @@ -24,14 +22,14 @@ class TestOdoobot(TestMailCommon, TestRecipients): 'partner_ids': [], 'subtype_xmlid': 'mail.mt_comment' } - cls.odoobot_ping_body = '@OdooBot' % (cls.odoobot.id, cls.odoobot.id) + cls.odoobot_ping_body = f'@OdooBot' cls.test_record_employe = cls.test_record.with_user(cls.user_employee) @mute_logger('odoo.addons.mail.models.mail_mail') def test_fetch_listener(self): channel = self.user_employee.with_user(self.user_employee)._init_odoobot() odoobot = self.env.ref("base.partner_root") - odoobot_in_fetch_listeners = self.env['mail.channel.member'].search([('channel_id', '=', channel.id), ('partner_id', '=', odoobot.id)]) + odoobot_in_fetch_listeners = self.env['discuss.channel.member'].search([('channel_id', '=', channel.id), ('partner_id', '=', odoobot.id)]) self.assertEqual(len(odoobot_in_fetch_listeners), 1, 'odoobot should appear only once in channel_fetch_listeners') @mute_logger('odoo.addons.mail.models.mail_mail') diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mail_performance.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mail_performance.py index 0d37eb0..f5f62c5 100644 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mail_performance.py +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mail_performance.py @@ -1,76 +1,68 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import datetime, timedelta +from markupsafe import Markup -from odoo.addons.mail.tests.common import mail_new_test_user -from odoo.addons.test_mail.tests.test_performance import BaseMailPerformance +from odoo import Command +from odoo.addons.test_mail.tests.test_performance import BaseMailPostPerformance from odoo.tests.common import users, warmup from odoo.tests import tagged from odoo.tools import mute_logger @tagged('mail_performance', 'post_install', '-at_install') -class TestMailPerformance(BaseMailPerformance): +class FullBaseMailPerformance(BaseMailPostPerformance): @classmethod def setUpClass(cls): - super(TestMailPerformance, cls).setUpClass() + super().setUpClass() - # users / followers - cls.user_emp_email = mail_new_test_user( - cls.env, - company_id=cls.user_admin.company_id.id, - company_ids=[(4, cls.user_admin.company_id.id)], - email='user.emp.email@test.example.com', - login='user_emp_email', - groups='base.group_user,base.group_partner_manager', - name='Emmanuel Email', - notification_type='email', - signature='--\nEmmanuel', - ) - cls.user_portal = mail_new_test_user( - cls.env, - company_id=cls.user_admin.company_id.id, - company_ids=[(4, cls.user_admin.company_id.id)], - email='user.portal@test.example.com', - login='user_portal', - groups='base.group_portal', - name='Paul Portal', - ) - cls.customers = cls.env['res.partner'].create([ - {'country_id': cls.env.ref('base.be').id, - 'email': 'customer.full.test.1@example.com', - 'name': 'Test Full Customer 1', - 'mobile': '0456112233', - 'phone': '0456112233', + # records + cls.record_containers = cls.env['mail.test.container.mc'].create([ + { + 'alias_name': 'test-alias-0', + 'customer_id': cls.customers[0].id, + 'name': 'Test Container 1', }, - {'country_id': cls.env.ref('base.be').id, - 'email': 'customer.full.test.2@example.com', - 'name': 'Test Full Customer 2', - 'mobile': '0456223344', - 'phone': '0456112233', + { + 'alias_name': 'test-alias-1', + 'customer_id': cls.customers[1].id, + 'name': 'Test Container 2', }, ]) - - # record - cls.record_container = cls.env['mail.test.container.mc'].create({ - 'alias_name': 'test-alias', - 'customer_id': cls.customer.id, - 'name': 'Test Container', - }) - cls.record_ticket = cls.env['mail.test.ticket.mc'].create({ + cls.record_ticket_mc = cls.env['mail.test.ticket.mc'].create({ 'email_from': 'email.from@test.example.com', - 'container_id': cls.record_container.id, + 'container_id': cls.record_containers[0].id, 'customer_id': False, 'name': 'Test Ticket', - 'user_id': cls.user_emp_email.id, + 'user_id': cls.user_follower_emp_email.id, }) - cls.record_ticket.message_subscribe(cls.customers.ids + cls.user_admin.partner_id.ids + cls.user_portal.partner_id.ids) + cls.record_ticket_mc.message_subscribe( + cls.customers.ids + cls.user_admin.partner_id.ids + cls.user_follower_portal.partner_id.ids + ) - def test_initial_values(self): + cls.tracking_values_ids = [ + (0, 0, { + 'field_id': cls.env['ir.model.fields']._get(cls.record_ticket._name, 'email_from').id, + 'new_value_char': 'new_value', + 'old_value_char': 'old_value', + }), + (0, 0, { + 'field_id': cls.env['ir.model.fields']._get(cls.record_ticket._name, 'customer_id').id, + 'new_value_char': 'New Fake', + 'new_value_integer': 2, + 'old_value_char': 'Old Fake', + 'old_value_integer': 1, + }), + ] + + +@tagged('mail_performance', 'post_install', '-at_install') +class TestMailPerformance(FullBaseMailPerformance): + + def test_assert_initial_values(self): """ Simply ensure some values through all tests """ - record_ticket = self.env['mail.test.ticket.mc'].browse(self.record_ticket.ids) + record_ticket = self.env['mail.test.ticket.mc'].browse(self.record_ticket_mc.ids) self.assertEqual(record_ticket.message_partner_ids, - self.user_emp_email.partner_id + self.user_admin.partner_id + self.customers + self.user_portal.partner_id) + self.user_follower_emp_email.partner_id + self.user_admin.partner_id + self.customers + self.user_follower_portal.partner_id) self.assertEqual(len(record_ticket.message_ids), 1) @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @@ -78,19 +70,384 @@ class TestMailPerformance(BaseMailPerformance): @warmup def test_message_post_w_followers(self): """ Aims to cover as much features of message_post as possible """ - record_ticket = self.env['mail.test.ticket.mc'].browse(self.record_ticket.ids) + record_ticket = self.env['mail.test.ticket.mc'].browse(self.record_ticket_mc.ids) attachments = self.env['ir.attachment'].create(self.test_attachments_vals) + self.push_to_end_point_mocked.reset_mock() # reset as executed twice + self.flush_tracking() - with self.assertQueryCount(employee=91): # tmf: 60 + with self.assertQueryCount(employee=108): # test_mail_full: 106 new_message = record_ticket.message_post( attachment_ids=attachments.ids, - body='

Test Content

', + body=Markup('

Test Content

'), + email_add_signature=True, + mail_auto_delete=True, message_type='comment', subject='Test Subject', subtype_xmlid='mail.mt_comment', + tracking_value_ids=self.tracking_values_ids, ) self.assertEqual( new_message.notified_partner_ids, - self.user_emp_email.partner_id + self.user_admin.partner_id + self.customers + self.user_portal.partner_id + self.user_follower_emp_email.partner_id + self.user_admin.partner_id + self.customers + self.user_follower_portal.partner_id ) + self.assertEqual(self.push_to_end_point_mocked.call_count, 8, "Not sure why 8") + + +@tagged('mail_performance', 'post_install', '-at_install') +class TestPortalFormatPerformance(FullBaseMailPerformance): + """Test performance of `portal_message_format` with multiple messages + with multiple attachments, with ratings. + + Those messages might not make sense functionally but they are crafted to + cover as much of the code as possible in regard to number of queries. + + Setup : + * 5 records (self.containers -> 5 mail.test.rating records, with + a different customer_id each) + * 2 messages / record + * 2 attachments / message + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.test_users = cls.user_employee + cls.user_emp_inbox + cls.user_emp_email + cls.user_follower_emp_email + cls.user_follower_portal + + # rating-enabled test records + with cls.mock_push_to_end_point(cls): + cls.record_ratings = cls.env['mail.test.rating'].create([ + { + 'customer_id': cls.customers[idx].id, + 'name': f'TestRating_{idx}', + 'user_id': cls.test_users[idx].id, + + } + for idx in range(5) + ]) + + # messages and ratings + user_id_field = cls.env['ir.model.fields']._get(cls.record_ratings._name, 'user_id') + comment_subtype_id = cls.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') + cls.link_previews = cls.env["mail.link.preview"].create( + [ + {"source_url": "https://www.odoo.com"}, + {"source_url": "https://www.example.com"}, + ] + ) + cls.messages_all = cls.env['mail.message'].sudo().create([ + { + 'attachment_ids': [ + (0, 0, { + 'datas': 'data', + 'name': f'Test file {att_idx}', + 'res_id': record.id, + 'res_model': record._name, + }) + for att_idx in range(2) + ], + 'author_id': record.customer_id.id, + 'body': f'

Test {msg_idx}

', + 'date': datetime(2023, 5, 15, 10, 30, 5), + 'email_from': record.customer_id.email_formatted, + "message_link_preview_ids": [ + Command.create({"link_preview_id": cls.link_previews[0].id}), + Command.create({"link_preview_id": cls.link_previews[1].id}), + ], + 'notification_ids': [ + (0, 0, { + 'is_read': False, + 'notification_type': 'inbox', + 'res_partner_id': cls.customers[(msg_idx * 2)].id, + }), + (0, 0, { + 'is_read': True, + 'notification_type': 'email', + 'notification_status': 'sent', + 'res_partner_id': cls.customers[(msg_idx * 2) + 1].id, + }), + ], + 'message_type': 'comment', + 'model': record._name, + 'partner_ids': [ + (4, cls.customers[(msg_idx * 2)].id), + (4, cls.customers[record_idx].id), + ], + 'reaction_ids': [ + (0, 0, { + 'content': 'https://www.odoo.com', + 'partner_id': cls.customers[(msg_idx * 2) + 1].id + }), (0, 0, { + 'content': 'https://www.example.com', + 'partner_id': cls.customers[record_idx].id + }), + ], + 'res_id': record.id, + 'subject': f'Test Rating {msg_idx}', + 'subtype_id': comment_subtype_id, + 'starred_partner_ids': [ + (4, cls.customers[(msg_idx * 2)].id), + (4, cls.customers[(msg_idx * 2) + 1].id), + ], + 'tracking_value_ids': [ + (0, 0, { + 'field_id': user_id_field.id, + 'new_value_char': 'new 1', + 'new_value_integer': record.user_id.id, + 'old_value_char': 'old 1', + 'old_value_integer': cls.user_admin.id, + }), + ] + } + for msg_idx in range(2) + for record_idx, record in enumerate(cls.record_ratings) + ]) + + cls.messages_records = [cls.env[message.model].browse(message.res_id) for message in cls.messages_all] + # ratings values related to rating-enabled records + cls.ratings_all = cls.env['rating.rating'].sudo().create([ + { + 'consumed': True, + 'message_id': message.id, + 'partner_id': record.customer_id.id, + 'publisher_comment': 'Comment', + 'publisher_id': cls.user_admin.partner_id.id, + 'publisher_datetime': datetime(2023, 5, 15, 10, 30, 5) - timedelta(days=2), + 'rated_partner_id': record.user_id.partner_id.id, + 'rating': 4, + 'res_id': message.res_id, + 'res_model_id': cls.env['ir.model']._get_id(message.model), + } + for rating_idx in range(2) + for message, record in zip(cls.messages_all, cls.messages_records) + ]) + + def test_assert_initial_values(self): + self.assertEqual(len(self.messages_all), 5 * 2) + self.assertEqual(len(self.ratings_all), len(self.messages_all) * 2) + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('employee') + @warmup + def test_portal_message_format_norating(self): + messages_all = self.messages_all.with_user(self.env.user) + + with self.assertQueryCount(employee=14): + # res = messages_all.portal_message_format(options=None) + res = messages_all.portal_message_format(options={'rating_include': False}) + + comment_subtype = self.env.ref('mail.mt_comment') + self.assertEqual(len(res), len(messages_all)) + for format_res, message, record in zip(res, messages_all, self.messages_records): + self.assertEqual(len(format_res['attachment_ids']), 2) + self.maxDiff = None + self.assertEqual( + format_res['attachment_ids'], + [ + { + 'checksum': message.attachment_ids[0].checksum, + 'filename': 'Test file 1', + 'id': message.attachment_ids[0].id, + 'mimetype': 'text/plain', + 'name': 'Test file 1', + 'raw_access_token': message.attachment_ids[0]._get_raw_access_token(), + 'res_id': record.id, + 'res_model': record._name, + }, { + 'checksum': message.attachment_ids[1].checksum, + 'filename': 'Test file 0', + 'id': message.attachment_ids[1].id, + 'mimetype': 'text/plain', + 'name': 'Test file 0', + 'raw_access_token': message.attachment_ids[1]._get_raw_access_token(), + 'res_id': record.id, + 'res_model': record._name, + } + ] + ) + self.assertEqual(format_res["author_id"]["id"], record.customer_id.id) + self.assertEqual(format_res["author_id"]["name"], record.customer_id.display_name) + self.assertEqual(format_res['author_avatar_url'], f'/web/image/mail.message/{message.id}/author_avatar/50x50') + self.assertEqual(format_res['date'], datetime(2023, 5, 15, 10, 30, 5)) + self.assertEqual(' '.join(format_res['published_date_str'].split()), '05/15/2023 10:30:05 AM') + self.assertEqual(format_res['id'], message.id) + self.assertFalse(format_res['is_internal']) + self.assertFalse(format_res['is_message_subtype_note']) + self.assertEqual(format_res['subtype_id'], (comment_subtype.id, comment_subtype.name)) + # should not be in, not asked + self.assertNotIn('rating_id', format_res) + self.assertNotIn('rating_stats', format_res) + self.assertNotIn('rating_value', format_res) + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('employee') + @warmup + def test_portal_message_format_rating(self): + messages_all = self.messages_all.with_user(self.env.user) + + with self.assertQueryCount(employee=28): # sometimes +1 + res = messages_all.portal_message_format(options={'rating_include': True}) + + self.assertEqual(len(res), len(messages_all)) + for format_res, _message, _record in zip(res, messages_all, self.messages_records): + self.assertEqual(format_res['rating_id']['publisher_avatar'], f'/web/image/res.partner/{self.partner_admin.id}/avatar_128/50x50') + self.assertEqual(format_res['rating_id']['publisher_comment'], 'Comment') + self.assertEqual(format_res['rating_id']['publisher_id'], self.partner_admin.id) + self.assertEqual(" ".join(format_res['rating_id']['publisher_datetime'].split()), '05/13/2023 10:30:05 AM') + self.assertEqual(format_res['rating_id']['publisher_name'], self.partner_admin.display_name) + self.assertDictEqual( + format_res['rating_stats'], + {'avg': 4.0, 'total': 4, 'percent': {1: 0.0, 2: 0.0, 3: 0.0, 4: 100.0, 5: 0.0}} + ) + self.assertEqual(format_res['rating_value'], 4) + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('employee') + @warmup + def test_portal_message_format_monorecord(self): + message = self.messages_all[0].with_user(self.env.user) + + with self.assertQueryCount(employee=19): # randomness: 18+1 + res = message.portal_message_format(options={'rating_include': True}) + + self.assertEqual(len(res), 1) + + @mute_logger("odoo.tests", "odoo.addons.mail.models.mail_mail", "odoo.models.unlink") + @users("employee") + @warmup + def test_portal_attachment_as_author(self): + message = self.env["mail.message"].create( + { + "attachment_ids": [Command.create({"name": "test attachment"})], + "author_id": self.user_employee.partner_id.id, + } + ) + res = message.portal_message_format() + self.assertEqual( + res[0]["attachment_ids"][0]["ownership_token"], + message.attachment_ids[0]._get_ownership_token(), + ) + + +@tagged('rating', 'mail_performance', 'post_install', '-at_install') +class TestRatingPerformance(FullBaseMailPerformance): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.RECORD_COUNT = 20 + + cls.partners = cls.env['res.partner'].sudo().create([ + {'name': 'Jean-Luc %s' % (idx), 'email': 'jean-luc-%s@opoo.com' % (idx)} + for idx in range(cls.RECORD_COUNT)]) + + # create records with 2 ratings to check batch statistics on them + responsibles = [cls.user_admin, cls.user_employee, cls.env['res.users']] + with cls.mock_push_to_end_point(cls): + cls.record_ratings = cls.env['mail.test.rating'].create([{ + 'customer_id': cls.partners[idx].id, + 'name': f'Test Rating {idx}', + 'user_id': responsibles[idx % 3].id, + } for idx in range(cls.RECORD_COUNT)]) + rates = [enum % 5 for enum, _rec in enumerate(cls.record_ratings)] + # create rating from 1 -> 5 for each record + for rate, record in zip(rates, cls.record_ratings, strict=True): + record.rating_apply(rate + 1, token=record._rating_get_access_token()) + # create rating with 4 or 5 (half records) + for record in cls.record_ratings[:10]: + record.rating_apply(4, token=record._rating_get_access_token()) + for record in cls.record_ratings[10:]: + record.rating_apply(5, token=record._rating_get_access_token()) + + def apply_ratings(self, rate): + for record in self.record_ratings: + access_token = record._rating_get_access_token() + record.rating_apply(rate, token=access_token) + self.flush_tracking() + + def create_ratings(self, model): + self.record_ratings = self.env[model].create([{ + 'customer_id': self.partners[idx].id, + 'name': 'Test Rating', + 'user_id': self.user_admin.id, + } for idx in range(self.RECORD_COUNT)]) + self.flush_tracking() + + @users('employee') + @warmup + def test_rating_api_rating_get_operator(self): + user_names = [] + with self.assertQueryCount(employee=4): # tmf: 4 + ratings = self.record_ratings.with_env(self.env) + for rating in ratings: + user_names.append(rating._rating_get_operator().name) + expected_names = ['Mitchell Admin', 'Ernest Employee', False] * 6 + ['Mitchell Admin', 'Ernest Employee'] + for partner_name, expected_name in zip(user_names, expected_names, strict=True): + self.assertEqual(partner_name, expected_name) + + @users('employee') + @warmup + def test_rating_api_rating_get_partner(self): + partner_names = [] + with self.assertQueryCount(employee=3): # tmf: 3 + ratings = self.record_ratings.with_env(self.env) + for rating in ratings: + partner_names.append(rating._rating_get_partner().name) + for partner_name, expected in zip(partner_names, self.partners, strict=True): + self.assertEqual(partner_name, expected.name) + + @users('employee') + @warmup + def test_rating_get_grades_perfs(self): + with self.assertQueryCount(employee=1): + ratings = self.record_ratings.with_env(self.env) + grades = ratings.rating_get_grades() + self.assertDictEqual(grades, {'great': 28, 'okay': 4, 'bad': 8}) + + @users('employee') + @warmup + def test_rating_get_stats_perfs(self): + with self.assertQueryCount(employee=1): + ratings = self.record_ratings.with_env(self.env) + stats = ratings.rating_get_stats() + self.assertDictEqual(stats, {'avg': 3.75, 'total': 40, 'percent': {1: 10.0, 2: 10.0, 3: 10.0, 4: 35.0, 5: 35.0}}) + + @users('employee') + @warmup + def test_rating_last_value_perfs(self): + with self.assertQueryCount(employee=274): # tmf: 274 + self.create_ratings('mail.test.rating.thread') + + with self.assertQueryCount(employee=283): # tmf: 283 + self.apply_ratings(1) + + with self.assertQueryCount(employee=242): # tmf: 242 + self.apply_ratings(5) + + @users('employee') + @warmup + def test_rating_last_value_perfs_with_rating_mixin(self): + with self.assertQueryCount(employee=317): # tmf: 317 + self.create_ratings('mail.test.rating') + + with self.assertQueryCount(employee=325): # tmf: 325 + self.apply_ratings(1) + + with self.assertQueryCount(employee=304): # tmf: 304 + self.apply_ratings(5) + + with self.assertQueryCount(employee=1): + self.record_ratings._compute_rating_last_value() + vals = (val == 5 for val in self.record_ratings.mapped('rating_last_value')) + self.assertTrue(all(vals), "The last rating is kept.") + + @users('employee') + @warmup + def test_rating_stat_fields(self): + expected_texts = ['ok', 'ok', 'ok', 'top', 'top'] * 2 + ['ok', 'ok', 'top', 'top', 'top'] * 2 + expected_satis = [50.0, 50.0, 50.0, 100.0, 100.0] * 4 + with self.assertQueryCount(employee=2): + ratings = self.record_ratings.with_env(self.env) + for rating, text, satisfaction in zip(ratings, expected_texts, expected_satis, strict=True): + self.assertEqual(rating.rating_avg_text, text) + self.assertEqual(rating.rating_percentage_satisfaction, satisfaction) diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mail_thread_internals.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mail_thread_internals.py index d921b8d..faa2f27 100644 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mail_thread_internals.py +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mail_thread_internals.py @@ -48,7 +48,7 @@ class TestMailThreadInternals(TestMailThreadInternalsCommon): with self.subTest(test_record=test_record): is_portal = test_record._name != 'mail.test.simple' has_customer = test_record._name != 'mail.test.portal.no.partner' - partner_fnames = test_record._mail_get_partner_fields() + partner_fnames = test_record._mail_get_partner_fields(introspect_fields=False) if is_portal: self.assertFalse( @@ -56,7 +56,9 @@ class TestMailThreadInternals(TestMailThreadInternalsCommon): 'By default access tokens are False with portal' ) - groups = test_record._notify_get_recipients_groups() + groups = test_record._notify_get_recipients_groups( + self.env['mail.message'], False, + ) portal_customer_group = next( (group for group in groups if group[0] == 'portal_customer'), False diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mass_mailing.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mass_mailing.py index d4f580a..78ce1e0 100644 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mass_mailing.py +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mass_mailing.py @@ -23,22 +23,23 @@ class TestMassMailing(TestMailFullCommon): # optout records 1 and 2 (recipients[1] | recipients[2]).write({'opt_out': True}) - recipients[1].email_from = f'"Format Me" <{recipients[1].email_from}>' + recipients[1].email_from = f'"Format Me" <{recipients[1].email_normalized}>' # blacklist records 3 and 4 self.env['mail.blacklist'].create({'email': recipients[3].email_normalized}) self.env['mail.blacklist'].create({'email': recipients[4].email_normalized}) - recipients[3].email_from = f'"Format Me" <{recipients[3].email_from}>' + recipients[3].email_from = f'"Format Me" <{recipients[3].email_normalized}>' # have a duplicate email for 9 - recipients[9].email_from = f'"Format Me" <{recipients[9].email_from}>' + recipients[9].email_from = f'"Format Me" <{recipients[9].email_normalized}>' recipient_dup_1 = recipients[9].copy() - recipient_dup_1.email_from = f'"Format Me" <{recipient_dup_1.email_from}>' + recipient_dup_1.email_from = f'"Format Me" <{recipient_dup_1.email_normalized}>' # have another duplicate for 9, but with multi emails already done recipient_dup_2 = recipients[9].copy() - recipient_dup_2.email_from += f'; "TestDupe" <{recipients[8].email_from}>' + recipient_dup_2.email_from += f'; "TestDupe" <{recipients[8].email_normalized}>' # have another duplicate for 9, but with multi emails, one is different recipient_dup_3 = recipients[9].copy() # this one will passthrough (best-effort) recipient_dup_3.email_from += '; "TestMulti" ' recipient_dup_4 = recipient_dup_2.copy() # this one will be discarded (youpi) + # have a void mail recipient_void_1 = self.env['mailing.test.optout'].create({'name': 'TestRecord_void_1'}) # have a falsy mail @@ -57,71 +58,79 @@ class TestMassMailing(TestMailFullCommon): mailing.action_send_mail() for recipient in recipients_all: - recipient_info = { - 'email': recipient.email_normalized, - 'content': f'Hello {recipient.name}', - 'mail_values': { - 'subject': f'Subject {recipient.name}', - }, - } - - # opt-out: cancel (cancel mail) - if recipient in recipients[1] | recipients[2]: - recipient_info['trace_status'] = "cancel" - recipient_info['failure_type'] = "mail_optout" - # blacklisted: cancel (cancel mail) - elif recipient in recipients[3] | recipients[4]: - recipient_info['trace_status'] = "cancel" - recipient_info['failure_type'] = "mail_bl" - # duplicates: cancel (cancel mail) - elif recipient in (recipient_dup_1, recipient_dup_2, recipient_dup_4): - recipient_info['trace_status'] = "cancel" - recipient_info['failure_type'] = "mail_dup" - # void: error (failed mail) - elif recipient == recipient_void_1: - recipient_info['trace_status'] = 'cancel' - recipient_info['failure_type'] = "mail_email_missing" - # falsy: error (failed mail) - elif recipient == recipient_falsy_1: - recipient_info['trace_status'] = "cancel" - recipient_info['failure_type'] = "mail_email_invalid" - recipient_info['email'] = recipient.email_from # normalized is False but email should be falsymail - else: - # multi email -> outgoing email contains all emails + with self.subTest(recipient_from=recipient.email_from): + recipient_info = { + 'content': f'Hello {recipient.name}', + 'email': recipient.email_normalized or '', + 'email_to_mail': recipient.email_from or '', + 'email_to_recipients': [[recipient.email_from]], + 'mail_values': { + 'subject': f'Subject {recipient.name}', + }, + } + # ; transformed into comma + if recipient == recipient_dup_2: + recipient_info['email_to_mail'] = '"Format Me" ,"TestDupe" ' if recipient == recipient_dup_3: - email = self._find_sent_email(self.user_marketing.email_formatted, ['test.record.09@test.example.com', 'test.multi@test.example.com']) + recipient_info['email_to_mail'] = '"Format Me" ,"TestMulti" ' + # multi email -> outgoing email contains all emails + recipient_info['email_to_recipients'] = [['"Format Me" ', '"TestMulti" ']] + if recipient == recipient_dup_4: + recipient_info['email_to_mail'] = '"Format Me" ,"TestDupe" ' + + # opt-out: cancel (cancel mail) + if recipient in recipients[1] | recipients[2]: + recipient_info['trace_status'] = "cancel" + recipient_info['failure_type'] = "mail_optout" + # blacklisted: cancel (cancel mail) + elif recipient in recipients[3] | recipients[4]: + recipient_info['trace_status'] = "cancel" + recipient_info['failure_type'] = "mail_bl" + # duplicates: cancel (cancel mail) + elif recipient in (recipient_dup_1, recipient_dup_2, recipient_dup_4): + recipient_info['trace_status'] = "cancel" + recipient_info['failure_type'] = "mail_dup" + # void: cancel (cancel mail) + elif recipient == recipient_void_1: + recipient_info['trace_status'] = 'cancel' + recipient_info['failure_type'] = "mail_email_missing" + # falsy: cancel (cancel mail) + elif recipient == recipient_falsy_1: + recipient_info['trace_status'] = "cancel" + recipient_info['failure_type'] = "mail_email_invalid" + recipient_info['email'] = recipient.email_from # normalized is False but email should be falsymail else: - email = self._find_sent_email(self.user_marketing.email_formatted, [recipient.email_normalized]) - # preview correctly integrated rendered qweb - self.assertIn( - 'Hi %s :)' % recipient.name, - email['body']) - # rendered unsubscribe - self.assertIn( - '%s/mailing/%s/confirm_unsubscribe' % (mailing.get_base_url(), mailing.id), - email['body']) - unsubscribe_href = self._get_href_from_anchor_id(email['body'], "url6") - unsubscribe_url = werkzeug.urls.url_parse(unsubscribe_href) - unsubscribe_params = unsubscribe_url.decode_query().to_dict(flat=True) - self.assertEqual(int(unsubscribe_params['res_id']), recipient.id) - self.assertEqual(unsubscribe_params['email'], recipient.email_normalized) - self.assertEqual( - mailing._unsubscribe_token(unsubscribe_params['res_id'], (unsubscribe_params['email'])), - unsubscribe_params['token'] - ) - # rendered view - self.assertIn( - '%s/mailing/%s/view' % (mailing.get_base_url(), mailing.id), - email['body']) - view_href = self._get_href_from_anchor_id(email['body'], "url6") - view_url = werkzeug.urls.url_parse(view_href) - view_params = view_url.decode_query().to_dict(flat=True) - self.assertEqual(int(view_params['res_id']), recipient.id) - self.assertEqual(view_params['email'], recipient.email_normalized) - self.assertEqual( - mailing._unsubscribe_token(view_params['res_id'], (view_params['email'])), - view_params['token'] - ) + email = self._find_sent_email(self.user_marketing.email_formatted, recipient_info['email_to_recipients'][0]) + # preview correctly integrated rendered qweb + self.assertIn( + 'Hi %s :)' % recipient.name, + email['body']) + # rendered unsubscribe + self.assertIn( + '%s/mailing/%s/confirm_unsubscribe' % (mailing.get_base_url(), mailing.id), + email['body']) + unsubscribe_href = self._get_href_from_anchor_id(email['body'], "url6") + unsubscribe_url = werkzeug.urls.url_parse(unsubscribe_href) + unsubscribe_params = unsubscribe_url.decode_query().to_dict(flat=True) + self.assertEqual(int(unsubscribe_params['document_id']), recipient.id) + self.assertEqual(unsubscribe_params['email'], recipient.email_normalized) + self.assertEqual( + mailing._generate_mailing_recipient_token(unsubscribe_params['document_id'], (unsubscribe_params['email'])), + unsubscribe_params['hash_token'] + ) + # rendered view + self.assertIn( + '%s/mailing/%s/view' % (mailing.get_base_url(), mailing.id), + email['body']) + view_href = self._get_href_from_anchor_id(email['body'], "url6") + view_url = werkzeug.urls.url_parse(view_href) + view_params = view_url.decode_query().to_dict(flat=True) + self.assertEqual(int(view_params['document_id']), recipient.id) + self.assertEqual(view_params['email'], recipient.email_normalized) + self.assertEqual( + mailing._generate_mailing_recipient_token(view_params['document_id'], (view_params['email'])), + view_params['hash_token'] + ) self.assertMailTraces( [recipient_info], mailing, recipient, diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_portal.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_portal.py index 70876f9..a8105fc 100644 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_portal.py +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_portal.py @@ -2,28 +2,27 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from werkzeug.urls import url_parse, url_decode, url_encode -import json -from odoo import http +from odoo.addons.auth_signup.models.res_partner import ResPartner +from odoo.addons.mail.tests.common import MailCommon from odoo.addons.test_mail_full.tests.common import TestMailFullCommon from odoo.addons.test_mail_sms.tests.common import TestSMSRecipients from odoo.exceptions import AccessError from odoo.tests import tagged, users from odoo.tests.common import HttpCase -from odoo.tools import mute_logger +from odoo.tools import html_escape, mute_logger @tagged('portal') -class TestPortal(HttpCase, TestMailFullCommon, TestSMSRecipients): +class TestPortal(TestMailFullCommon, TestSMSRecipients): def setUp(self): - super(TestPortal, self).setUp() + super().setUp() self.record_portal = self.env['mail.test.portal'].create({ 'partner_id': self.partner_1.id, 'name': 'Test Portal Record', }) - self.record_portal._portal_ensure_token() @@ -36,12 +35,22 @@ class TestPortalControllers(TestPortal): 'model': self.record_portal._name, 'res_id': self.record_portal.id, }) - response = self.url_open(f'/mail/avatar/mail.message/{mail_record.id}/author_avatar/50x50?access_token={self.record_portal.access_token}') + token = self.record_portal.access_token + formatted_record = mail_record.portal_message_format(options={"token": token})[0] + self.assertEqual( + formatted_record.get("author_avatar_url"), + f"/mail/avatar/mail.message/{mail_record.id}/author_avatar/50x50?access_token={token}", + ) + response = self.url_open( + f"/mail/avatar/mail.message/{mail_record.id}/author_avatar/50x50?access_token={token}" + ) self.assertEqual(response.status_code, 200) - self.assertEqual(response.headers.get('Content-Type'), 'image/png') - self.assertRegex(response.headers.get('Content-Disposition', ''), r'mail_message-\d+-author_avatar\.png') + self.assertEqual(response.headers.get('Content-Type'), 'image/svg+xml; charset=utf-8') + self.assertRegex(response.headers.get('Content-Disposition', ''), r'mail_message-\d+-author_avatar\.svg') - placeholder_response = self.url_open(f'/mail/avatar/mail.message/{mail_record.id}/author_avatar/50x50?access_token={self.record_portal.access_token + "a"}') # false token + placeholder_response = self.url_open( + f'/mail/avatar/mail.message/{mail_record.id}/author_avatar/50x50?access_token={token + "a"}' + ) # false token self.assertEqual(placeholder_response.status_code, 200) self.assertEqual(placeholder_response.headers.get('Content-Type'), 'image/png') self.assertRegex(placeholder_response.headers.get('Content-Disposition', ''), r'placeholder\.png') @@ -53,107 +62,71 @@ class TestPortalControllers(TestPortal): def test_portal_avatar_with_hash_pid(self): self.authenticate(None, None) - post_url = f"{self.record_portal.get_base_url()}/mail/chatter_post" - res = self.opener.post( + post_url = f"{self.record_portal.get_base_url()}/mail/message/post" + pid = self.partner_2.id + _hash = self.record_portal._sign_token(pid) + res = self.url_open( url=post_url, json={ 'params': { - 'csrf_token': http.Request.csrf_token(self), - 'message': 'Test', - 'res_model': self.record_portal._name, - 'res_id': self.record_portal.id, - 'hash': self.record_portal._sign_token(self.partner_2.id), - 'pid': self.partner_2.id, + 'thread_model': self.record_portal._name, + 'thread_id': self.record_portal.id, + 'post_data': {'body': "Test"}, + 'hash': _hash, + 'pid': pid, }, }, ) res.raise_for_status() self.assertNotIn("error", res.json()) message = self.record_portal.message_ids[0] + formatted_message = message.portal_message_format(options={"hash": _hash, "pid": pid})[0] + self.assertEqual( + formatted_message.get("author_avatar_url"), + f"/mail/avatar/mail.message/{message.id}/author_avatar/50x50?_hash={_hash}&pid={pid}", + ) response = self.url_open( - f'/mail/avatar/mail.message/{message.id}/author_avatar/50x50?_hash={self.record_portal._sign_token(self.partner_2.id)}&pid={self.partner_2.id}') + f"/mail/avatar/mail.message/{message.id}/author_avatar/50x50?_hash={_hash}&pid={pid}" + ) self.assertEqual(response.status_code, 200) - self.assertEqual(response.headers.get('Content-Type'), 'image/png') - self.assertRegex(response.headers.get('Content-Disposition', ''), r'mail_message-\d+-author_avatar\.png') + self.assertEqual(response.headers.get('Content-Type'), 'image/svg+xml; charset=utf-8') + self.assertRegex(response.headers.get('Content-Disposition', ''), r'mail_message-\d+-author_avatar\.svg') placeholder_response = self.url_open( - f'/mail/avatar/mail.message/{message.id}/author_avatar/50x50?_hash={self.record_portal._sign_token(self.partner_2.id) + "a"}&pid={self.partner_2.id}') # false hash + f'/mail/avatar/mail.message/{message.id}/author_avatar/50x50?_hash={_hash + "a"}&pid={pid}' + ) # false hash self.assertEqual(placeholder_response.status_code, 200) self.assertEqual(placeholder_response.headers.get('Content-Type'), 'image/png') self.assertRegex(placeholder_response.headers.get('Content-Disposition', ''), r'placeholder\.png') - def test_portal_message_fetch(self): - """Test retrieving chatter messages through the portal controller""" - self.authenticate(None, None) - message_fetch_url = '/mail/chatter_fetch' - payload = json.dumps({ - 'jsonrpc': '2.0', - 'method': 'call', - 'id': 0, - 'params': { - 'res_model': 'mail.test.portal', - 'res_id': self.record_portal.id, - 'token': self.record_portal.access_token, - }, - }) - - def get_chatter_message_count(): - res = self.url_open( - url=message_fetch_url, - data=payload, - headers={'Content-Type': 'application/json'} - ) - return res.json().get('result', {}).get('message_count', 0) - - self.assertEqual(get_chatter_message_count(), 0) - - for _ in range(8): - self.record_portal.message_post( - body='Test', - author_id=self.partner_1.id, - message_type='comment', - subtype_id=self.env.ref('mail.mt_comment').id, - ) - - self.assertEqual(get_chatter_message_count(), 8) - - # Empty the body of a few messages - for i in (2, 5, 6): - self.record_portal.message_ids[i].body = "" - - # Empty messages should be ignored - self.assertEqual(get_chatter_message_count(), 5) - def test_portal_share_comment(self): """ Test posting through portal controller allowing to use a hash to post wihtout access rights. """ self.authenticate(None, None) - post_url = f"{self.record_portal.get_base_url()}/mail/chatter_post" + post_url = f"{self.record_portal.get_base_url()}/mail/message/post" # test as not logged - self.opener.post( + self.url_open( url=post_url, json={ 'params': { - 'csrf_token': http.Request.csrf_token(self), - 'hash': self.record_portal._sign_token(self.partner_2.id), - 'message': 'Test', - 'pid': self.partner_2.id, - 'redirect': '/', - 'res_model': self.record_portal._name, - 'res_id': self.record_portal.id, + 'thread_model': self.record_portal._name, + 'thread_id': self.record_portal.id, + 'post_data': {'body': "Test"}, 'token': self.record_portal.access_token, + 'hash': self.record_portal._sign_token(self.partner_2.id), + 'pid': self.partner_2.id, }, }, ) - message = self.record_portal.message_ids[0] + # Only messages from the current user not OdooBot + messages = self.record_portal.message_ids.filtered(lambda msg: msg.author_id == self.partner_2) - self.assertIn('Test', message.body) - self.assertEqual(message.author_id, self.partner_2) + self.assertIn('Test', messages[0].body) @tagged('-at_install', 'post_install', 'portal', 'mail_controller') -class TestPortalFlow(TestMailFullCommon, HttpCase): +class TestPortalFlow(MailCommon, HttpCase): """ Test shared links, mail/view links and redirection (backend, customer portal or frontend for specific addons). """ @@ -164,7 +137,6 @@ class TestPortalFlow(TestMailFullCommon, HttpCase): 'country_id': cls.env.ref('base.fr').id, 'email': 'mdelvaux34@example.com', 'lang': 'en_US', - 'mobile': '+33639982325', 'name': 'Mathias Delvaux', 'phone': '+33353011823', }) @@ -198,6 +170,16 @@ class TestPortalFlow(TestMailFullCommon, HttpCase): }) cls._create_portal_user() + # The test relies on `record_access_url` to check the validity of mails being sent, + # however, when auth_signup is installed, a new token is generated each time the url + # is being requested. + # By removing the time-based hashing from this function we can ensure the stability of + # the url during the tests. + def patched_generate_signup_token(self, *_, **__): + self.ensure_one() + return str([self.id, self._get_login_date(), self.signup_type]) + cls.classPatch(ResPartner, '_generate_signup_token', patched_generate_signup_token) + # prepare access URLs on self to ease tests # ------------------------------------------------------------ base_url = cls.record_portal.get_base_url() @@ -220,7 +202,9 @@ class TestPortalFlow(TestMailFullCommon, HttpCase): cls.record_url_no_model = f'{cls.record_portal.get_base_url()}/mail/view?model=this.should.not.exists&res_id=1' # find portal + auth data url - for group_name, group_func, group_data in cls.record_portal.sudo()._notify_get_recipients_groups(False): + for group_name, group_func, group_data in cls.record_portal.sudo()._notify_get_recipients_groups( + cls.env['mail.message'], False + ): if group_name == 'portal_customer' and group_func(cls.customer): cls.record_portal_url_auth = group_data['button_access']['url'] break @@ -244,25 +228,25 @@ class TestPortalFlow(TestMailFullCommon, HttpCase): cls.portal_web_url = f'{base_url}/my/test_portal/{cls.record_portal.id}' cls.portal_web_url_with_token = f'{base_url}/my/test_portal/{cls.record_portal.id}?{url_encode({"access_token": cls.record_portal.access_token, "pid": cls.customer.id, "hash": cls.record_portal_hash}, sort=True)}' cls.public_act_url_share = f'{base_url}/test_portal/public_type/{cls.record_public_act_url.id}' - cls.internal_backend_local_url = f'/web#{url_encode({"model": cls.record_internal._name, "id": cls.record_internal.id, "active_id": cls.record_internal.id, "cids": cls.company_admin.id}, sort=True)}' - cls.portal_backend_local_url = f'/web#{url_encode({"model": cls.record_portal._name, "id": cls.record_portal.id, "active_id": cls.record_portal.id, "cids": cls.company_admin.id}, sort=True)}' - cls.read_backend_local_url = f'/web#{url_encode({"model": cls.record_read._name, "id": cls.record_read.id, "active_id": cls.record_read.id, "cids": cls.company_admin.id}, sort=True)}' - cls.public_act_url_backend_local_url = f'/web#{url_encode({"model": cls.record_public_act_url._name, "id": cls.record_public_act_url.id, "active_id": cls.record_public_act_url.id, "cids": cls.company_admin.id}, sort=True)}' - cls.discuss_local_url = '/web#action=mail.action_discuss' + cls.internal_backend_local_url = f'/odoo/{cls.record_internal._name}/{cls.record_internal.id}' + cls.portal_backend_local_url = f'/odoo/{cls.record_portal._name}/{cls.record_portal.id}' + cls.read_backend_local_url = f'/odoo/{cls.record_read._name}/{cls.record_read.id}' + cls.public_act_url_backend_local_url = f'/odoo/{cls.record_public_act_url._name}/{cls.record_public_act_url.id}' + cls.discuss_local_url = '/odoo/action-mail.action_discuss' def test_assert_initial_data(self): - """ Test some initial values. Test that record_access_url is a valid URL + """ Test some initial values. Test that record_portal_url_auth is a valid URL to view the record_portal and that record_access_url_wrong_token only differs - from record_access_url by a different access_token. """ - self.record_internal.with_user(self.user_employee).check_access_rule('read') - self.record_portal.with_user(self.user_employee).check_access_rule('read') - self.record_read.with_user(self.user_employee).check_access_rule('read') + from record_portal_url_auth by a different access_token. """ + self.record_internal.with_user(self.user_employee).check_access('read') + self.record_portal.with_user(self.user_employee).check_access('read') + self.record_read.with_user(self.user_employee).check_access('read') with self.assertRaises(AccessError): - self.record_internal.with_user(self.user_portal).check_access_rights('read') + self.record_internal.with_user(self.user_portal).check_access('read') with self.assertRaises(AccessError): - self.record_portal.with_user(self.user_portal).check_access_rights('read') - self.record_read.with_user(self.user_portal).check_access_rights('read') + self.record_portal.with_user(self.user_portal).check_access('read') + self.record_read.with_user(self.user_portal).check_access('read') self.assertNotEqual(self.record_portal_url_auth, self.record_portal_url_auth_wrong_token) url_params = [] @@ -331,7 +315,7 @@ class TestPortalFlow(TestMailFullCommon, HttpCase): # std url, read record -> redirect to my with parameters being record portal action parameters (???) ( 'Access record (no customer portal)', self.record_read_url_base, - f'{self.test_base_url}/my#{url_encode({"model": self.record_read._name, "id": self.record_read.id, "active_id": self.record_read.id, "cids": self.company_admin.id}, sort=True)}', + f'{self.test_base_url}/my?{url_encode({"subpath": f"{self.record_read._name}/{self.record_read.id}"})}', ), # std url, no access to record -> redirect to my ( @@ -445,6 +429,63 @@ class TestPortalFlow(TestMailFullCommon, HttpCase): 'Failed with %s - %s' % (model, res_id) ) + def assert_URL(self, url, expected_path, expected_fragment_params=None, expected_query=None): + """Asserts that the URL has the expected path and if set, the expected fragment parameters and query.""" + parsed_url = url_parse(url) + fragment_params = url_decode(parsed_url.fragment) + self.assertEqual(parsed_url.path, expected_path) + if expected_fragment_params: + for key, expected_value in expected_fragment_params.items(): + self.assertEqual(fragment_params.get(key), expected_value, + f'Expected: "{key}={expected_value}" (for path: {expected_path})') + if expected_query: + self.assertEqual(expected_query, parsed_url.query, + f'Expected: query="{expected_query}" (for path: {expected_path})') + + @users('employee') + def test_send_message_to_customer(self): + """Same as test_send_message_to_customer_using_template but without a template.""" + composer = self.env['mail.compose.message'].with_context( + self._get_mail_composer_web_context( + self.record_portal, + default_email_layout_xmlid='mail.mail_notification_layout_with_responsible_signature', + ) + ).create({ + 'body': '

Hello Mathias Delvaux, your quotation is ready for review.

', + 'partner_ids': self.customer.ids, + 'subject': 'Your Quotation "a white table"', + }) + + with self.mock_mail_gateway(mail_unlink_sent=True): + composer._action_send_mail() + + self.assertEqual(len(self._mails), 1) + self.assertIn(f'"{html_escape(self.record_portal_url_auth)}"', self._mails[0].get('body')) + # Check that the template is not used (not the same subject) + self.assertEqual('Your Quotation "a white table"', self._mails[0].get('subject')) + self.assertIn('Hello Mathias Delvaux', self._mails[0].get('body')) + + @users('employee') + def test_send_message_to_customer_using_template(self): + """Send a mail to a customer without an account and check that it contains a link to view the record. + + Other tests below check that that same link has the correct behavior. + This test follows the common use case by using a template while the next send the mail without a template.""" + composer = self.env['mail.compose.message'].with_context( + self._get_mail_composer_web_context( + self.record_portal, + default_email_layout_xmlid='mail.mail_notification_layout_with_responsible_signature', + default_template_id=self.mail_template.id, + ) + ).create({}) + + with self.mock_mail_gateway(mail_unlink_sent=True): + composer._action_send_mail() + + self.assertEqual(len(self._mails), 1) + self.assertIn(f'"{html_escape(self.record_portal_url_auth)}"', self._mails[0].get('body')) + self.assertEqual(f'Your quotation "{self.record_portal.name}"', self._mails[0].get('subject')) # Check that the template is used + @tagged('portal') class TestPortalMixin(TestPortal): diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_rating.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_rating.py index 057dc73..329d417 100644 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_rating.py +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_rating.py @@ -8,11 +8,12 @@ from odoo import http from odoo.addons.test_mail_full.tests.common import TestMailFullCommon from odoo.addons.test_mail_sms.tests.common import TestSMSRecipients from odoo.tests import tagged -from odoo.tests.common import HttpCase, users, warmup +from odoo.tests.common import users, warmup from odoo.tools import mute_logger class TestRatingCommon(TestMailFullCommon, TestSMSRecipients): + @classmethod def setUpClass(cls): super(TestRatingCommon, cls).setUpClass() @@ -22,88 +23,104 @@ class TestRatingCommon(TestMailFullCommon, TestSMSRecipients): 'name': 'Test Rating', 'user_id': cls.user_admin.id, }) + cls.record_rating_thread = cls.env['mail.test.rating.thread'].create({ + 'customer_id': cls.partner_1.id, + 'name': 'Test rating without rating mixin', + 'user_id': cls.user_admin.id, + }) @tagged('rating') class TestRatingFlow(TestRatingCommon): def test_initial_values(self): - record_rating = self.record_rating.with_env(self.env) - self.assertFalse(record_rating.rating_ids) - self.assertEqual(record_rating.message_partner_ids, self.partner_admin) - self.assertEqual(len(record_rating.message_ids), 1) + for record_rating in [self.record_rating, self.record_rating_thread]: + record_rating = record_rating.with_env(self.env) + self.assertFalse(record_rating.rating_ids) + self.assertEqual(record_rating.message_partner_ids, self.partner_admin) + self.assertEqual(len(record_rating.message_ids), 1) @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') def test_rating_prepare(self): - record_rating = self.record_rating.with_env(self.env) + for record_rating, desc in ((self.record_rating, 'With rating mixin'), + (self.record_rating_thread, 'Without rating mixin')): + with self.subTest(desc): + record_rating = record_rating.with_env(self.env) - # prepare rating token - access_token = record_rating._rating_get_access_token() + # prepare rating token + access_token = record_rating._rating_get_access_token() - # check rating creation - rating = record_rating.rating_ids - self.assertEqual(rating.access_token, access_token) - self.assertFalse(rating.consumed) - self.assertFalse(rating.is_internal) - self.assertEqual(rating.partner_id, self.partner_1) - self.assertEqual(rating.rated_partner_id, self.user_admin.partner_id) - self.assertFalse(rating.rating) + # check rating creation + rating = record_rating.rating_ids + self.assertEqual(rating.access_token, access_token) + self.assertFalse(rating.consumed) + self.assertFalse(rating.is_internal) + self.assertEqual(rating.partner_id, self.partner_1) + self.assertEqual(rating.rated_partner_id, self.user_admin.partner_id) + self.assertFalse(rating.rating) @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') def test_rating_rating_apply(self): - record_rating = self.record_rating.with_env(self.env) - record_messages = record_rating.message_ids + for record_rating, expected_subtype, is_rating_mixin_test in ( + (self.record_rating_thread, self.env.ref('mail.mt_comment'), False), + (self.record_rating, self.env.ref('test_mail_full.mt_mail_test_rating_rating_done'), True), + ): + with self.subTest('With rating mixin' if is_rating_mixin_test else 'Without rating mixin'): + record_rating = record_rating.with_env(self.env) + record_messages = record_rating.message_ids - # prepare rating token - access_token = record_rating._rating_get_access_token() + # prepare rating token + access_token = record_rating._rating_get_access_token() - # simulate an email click: notification should be delayed - with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): - record_rating.rating_apply(5, token=access_token, feedback='Top Feedback', notify_delay_send=True) - message = record_rating.message_ids[0] - rating = record_rating.rating_ids + # simulate an email click: notification should be delayed + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + record_rating.rating_apply(5, token=access_token, feedback='Top Feedback', notify_delay_send=True) + message = record_rating.message_ids[0] + rating = record_rating.rating_ids - # check posted message - self.assertEqual(record_rating.message_ids, record_messages + message) - self.assertIn('Top Feedback', message.body) - self.assertIn('/rating/static/src/img/rating_5.png', message.body) - self.assertEqual(message.author_id, self.partner_1) - self.assertEqual(message.rating_ids, rating) - self.assertFalse(message.notified_partner_ids) - self.assertEqual(message.subtype_id, self.env.ref('test_mail_full.mt_mail_test_rating_rating_done')) + # check posted message + self.assertEqual(record_rating.message_ids, record_messages + message) + self.assertIn('Top Feedback', message.body) + self.assertIn('/rating/static/src/img/rating_5.png', message.body) + self.assertEqual(message.author_id, self.partner_1) + self.assertEqual(message.rating_ids, rating) + self.assertFalse(message.notified_partner_ids) + self.assertEqual(message.subtype_id, expected_subtype) - # check rating update - self.assertTrue(rating.consumed) - self.assertEqual(rating.feedback, 'Top Feedback') - self.assertEqual(rating.message_id, message) - self.assertEqual(rating.rating, 5) - self.assertEqual(record_rating.rating_last_value, 5) + # check rating update + self.assertTrue(rating.consumed) + self.assertEqual(rating.feedback, 'Top Feedback') + self.assertEqual(rating.message_id, message) + self.assertEqual(rating.rating, 5) + if is_rating_mixin_test: + self.assertEqual(record_rating.rating_last_value, 5) - # give a feedback: send notifications (notify_delay_send set to False) - with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): - record_rating.rating_apply(1, token=access_token, feedback='Bad Feedback') + # give a feedback: send notifications (notify_delay_send set to False) + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + record_rating.rating_apply(1, token=access_token, feedback='Bad Feedback') - # check posted message: message is updated - update_message = record_rating.message_ids[0] - self.assertEqual(update_message, message, 'Should update first message') - self.assertEqual(record_rating.message_ids, record_messages + update_message) - self.assertIn('Bad Feedback', update_message.body) - self.assertIn('/rating/static/src/img/rating_1.png', update_message.body) - self.assertEqual(update_message.author_id, self.partner_1) - self.assertEqual(update_message.rating_ids, rating) - self.assertEqual(update_message.notified_partner_ids, self.partner_admin) - self.assertEqual(update_message.subtype_id, self.env.ref("test_mail_full.mt_mail_test_rating_rating_done")) + # check posted message: message is updated + update_message = record_rating.message_ids[0] + self.assertEqual(update_message, message, 'Should update first message') + self.assertEqual(record_rating.message_ids, record_messages + update_message) + self.assertIn('Bad Feedback', update_message.body) + self.assertIn('/rating/static/src/img/rating_1.png', update_message.body) + self.assertEqual(update_message.author_id, self.partner_1) + self.assertEqual(update_message.rating_ids, rating) + self.assertEqual(update_message.notified_partner_ids, self.partner_admin) + self.assertEqual(update_message.subtype_id, expected_subtype) - # check rating update - new_rating = record_rating.rating_ids - self.assertEqual(new_rating, rating, 'Should update first rating') - self.assertTrue(new_rating.consumed) - self.assertEqual(new_rating.feedback, 'Bad Feedback') - self.assertEqual(new_rating.message_id, update_message) - self.assertEqual(new_rating.rating, 1) - self.assertEqual(record_rating.rating_last_value, 1) + # check rating update + new_rating = record_rating.rating_ids + self.assertEqual(new_rating, rating, 'Should update first rating') + self.assertTrue(new_rating.consumed) + self.assertEqual(new_rating.feedback, 'Bad Feedback') + self.assertEqual(new_rating.message_id, update_message) + self.assertEqual(new_rating.rating, 1) + if is_rating_mixin_test: + self.assertEqual(record_rating.rating_last_value, 1) @tagged('rating') @@ -131,107 +148,141 @@ class TestRatingMixin(TestRatingCommon): self.assertEqual(record_rating.rating_avg, 3, "The average should be equal to 3") -@tagged('rating', 'mail_performance', 'post_install', '-at_install') -class TestRatingPerformance(TestRatingCommon): - - @users('employee') - @warmup - def test_rating_last_value_perfs(self): - RECORD_COUNT = 100 - partners = self.env['res.partner'].sudo().create([ - {'name': 'Jean-Luc %s' % (idx), 'email': 'jean-luc-%s@opoo.com' % (idx)} for idx in range(RECORD_COUNT)]) - - with self.assertQueryCount(employee=1516): # tmf 1516 / com 5510 - record_ratings = self.env['mail.test.rating'].create([{ - 'customer_id': partners[idx].id, - 'name': 'Test Rating', - 'user_id': self.user_admin.id, - } for idx in range(RECORD_COUNT)]) - self.flush_tracking() - - with self.assertQueryCount(employee=2004): # tmf 2004 - for record in record_ratings: - access_token = record._rating_get_access_token() - record.rating_apply(1, token=access_token) - self.flush_tracking() - - with self.assertQueryCount(employee=2003): # tmf 2003 - for record in record_ratings: - access_token = record._rating_get_access_token() - record.rating_apply(5, token=access_token) - self.flush_tracking() - - with self.assertQueryCount(employee=1): - record_ratings._compute_rating_last_value() - vals = [val == 5 for val in record_ratings.mapped('rating_last_value')] - self.assertTrue(all(vals), "The last rating is kept.") - - -@tagged('rating') -class TestRatingRoutes(HttpCase, TestRatingCommon): +@tagged("rating", "rating_portal") +class TestRatingRoutes(TestRatingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._create_portal_user() def test_open_rating_route(self): - """ - 16.0 + expected behavior - 1) Clicking on the smiley image triggers the /rate// - route should not update the rating of the record but simply redirect - to the feedback form - 2) Customer interacts with webpage and submits FORM. Triggers /rate//submit_feedback - route. Should update the rating of the record with the data in the POST request - """ - self.authenticate(None, None) # set up session for public user - access_token = self.record_rating._rating_get_access_token() + for record_rating, is_rating_mixin_test in ((self.record_rating_thread, False), + (self.record_rating, True)): + with self.subTest('With rating mixin' if is_rating_mixin_test else 'Without rating mixin'): + """ + 16.0 + expected behavior + 1) Clicking on the smiley image triggers the /rate// + route should not update the rating of the record but simply redirect + to the feedback form + 2) Customer interacts with webpage and submits FORM. Triggers /rate//submit_feedback + route. Should update the rating of the record with the data in the POST request + """ + self.authenticate(None, None) # set up session for public user + access_token = record_rating._rating_get_access_token() - # First round of clicking the URL and then submitting FORM data - response_click_one = self.url_open(f"/rate/{access_token}/5") - response_click_one.raise_for_status() + # First round of clicking the URL and then submitting FORM data + response_click_one = self.url_open(f"/rate/{access_token}/5") + response_click_one.raise_for_status() - # there should be a form to post to validate the feedback and avoid one-click anyway - forms = lxml.html.fromstring(response_click_one.content).xpath('//form') - self.assertEqual(forms[0].get('method'), 'post') - self.assertEqual(forms[0].get('action', ''), f'/rate/{access_token}/submit_feedback') + # there should be a form to post to validate the feedback and avoid one-click anyway + forms = lxml.html.fromstring(response_click_one.content).xpath('//form') + matching_rate_form = next((form for form in forms if form.get("action", "").startswith("/rate")), None) + self.assertEqual(matching_rate_form.get('method'), 'post') + self.assertEqual(matching_rate_form.get('action', ''), f'/rate/{access_token}/submit_feedback') - # rating should not change, i.e. default values - rating = self.record_rating.rating_ids - self.assertFalse(rating.consumed) - self.assertEqual(rating.rating, 0) - self.assertFalse(rating.feedback) - self.assertEqual(self.record_rating.rating_last_value, 0) + # rating should not change, i.e. default values + rating = record_rating.rating_ids + self.assertFalse(rating.consumed) + self.assertEqual(rating.rating, 0) + self.assertFalse(rating.feedback) + if is_rating_mixin_test: + self.assertEqual(record_rating.rating_last_value, 0) - response_submit_one = self.url_open( - f"/rate/{access_token}/submit_feedback", - data={ - "rate": 5, - "csrf_token": http.Request.csrf_token(self), - "feedback": "good", + response_submit_one = self.url_open( + f"/rate/{access_token}/submit_feedback", + data={ + "rate": 5, + "csrf_token": http.Request.csrf_token(self), + "feedback": "good", + } + ) + response_submit_one.raise_for_status() + + rating_post_submit_one = record_rating.rating_ids + self.assertTrue(rating_post_submit_one.consumed) + self.assertEqual(rating_post_submit_one.rating, 5) + self.assertEqual(rating_post_submit_one.feedback, "good") + if is_rating_mixin_test: + self.assertEqual(record_rating.rating_last_value, 5) + + # Second round of clicking the URL and then submitting FORM data + response_click_two = self.url_open(f"/rate/{access_token}/1") + response_click_two.raise_for_status() + if is_rating_mixin_test: + self.assertEqual(record_rating.rating_last_value, 5) # should not be updated to 1 + + # check returned form + forms = lxml.html.fromstring(response_click_two.content).xpath('//form') + matching_rate_form = next((form for form in forms if form.get("action", "").startswith("/rate")), None) + self.assertEqual(matching_rate_form.get('method'), 'post') + self.assertEqual(matching_rate_form.get('action', ''), f'/rate/{access_token}/submit_feedback') + + response_submit_two = self.url_open( + f"/rate/{access_token}/submit_feedback", + data={ + "rate": 1, + "csrf_token": http.Request.csrf_token(self), + "feedback": "bad job" + } + ) + response_submit_two.raise_for_status() + + rating_post_submit_second = record_rating.rating_ids + self.assertTrue(rating_post_submit_second.consumed) + self.assertEqual(rating_post_submit_second.rating, 1) + self.assertEqual(rating_post_submit_second.feedback, "bad job") + if is_rating_mixin_test: + self.assertEqual(record_rating.rating_last_value, 1) + + def test_portal_user_can_post_message_with_rating(self): + """Test portal user can post a message with a rating on a thread with + _mail_post_access as read. In this case, sudo() is not necessary for + message_post itself, but it is necessary for adding the rating. This + tests covers the rating part is properly allowed.""" + record_rating = self.env["mail.test.rating.thread.read"].create( + { + "customer_id": self.partner_1.id, + "name": "Test read access post + rating", + "user_id": self.user_admin.id, } ) - response_submit_one.raise_for_status() - - rating_post_submit_one = self.record_rating.rating_ids - self.assertTrue(rating_post_submit_one.consumed) - self.assertEqual(rating_post_submit_one.rating, 5) - self.assertEqual(rating_post_submit_one.feedback, "good") - self.assertEqual(self.record_rating.rating_last_value, 5) - - # Second round of clicking the URL and then submitting FORM data - response_click_two = self.url_open(f"/rate/{access_token}/1") - response_click_two.raise_for_status() - self.assertEqual(self.record_rating.rating_last_value, 5) # should not be updated to 1 - - # check returned form - forms = lxml.html.fromstring(response_click_two.content).xpath('//form') - self.assertEqual(forms[0].get('method'), 'post') - self.assertEqual(forms[0].get('action', ''), f'/rate/{access_token}/submit_feedback') - - response_submit_two = self.url_open(f"/rate/{access_token}/submit_feedback", - data={"rate": 1, - "csrf_token": http.Request.csrf_token(self), - "feedback": "bad job"}) - response_submit_two.raise_for_status() - - rating_post_submit_second = self.record_rating.rating_ids - self.assertTrue(rating_post_submit_second.consumed) - self.assertEqual(rating_post_submit_second.rating, 1) - self.assertEqual(rating_post_submit_second.feedback, "bad job") - self.assertEqual(self.record_rating.rating_last_value, 1) + # from model + message = record_rating.with_user(self.user_portal).message_post( + body="Not bad", + message_type="comment", + rating_value=3, + subtype_xmlid="mail.mt_comment", + ) + rating = message.sudo().rating_id + self.assertEqual(rating.rating, 3, "rating was properly set") + # stealing attempt from another user + message2 = record_rating.message_post( + body="Attempt to steal rating with another user", + message_type="comment", + rating_id=rating.id, + subtype_xmlid="mail.mt_comment", + ) + self.assertEqual(message.sudo().rating_id, rating, "rating was not removed from m1") + self.assertFalse(message2.rating_id, "rating was not added to m2") + # from controller + self.authenticate("portal_test", "portal_test") + res = self.make_jsonrpc_request( + "/mail/message/post", + { + "post_data": { + "body": "Good service", + "message_type": "comment", + "rating_value": 5, + "subtype_xmlid": "mail.mt_comment", + }, + "thread_id": record_rating.id, + "thread_model": "mail.test.rating.thread.read", + }, + ) + message = next( + m for m in res["store_data"]["mail.message"] if m["id"] == res["message_id"] + ) + rating = next( + r for r in res["store_data"]["rating.rating"] if r["id"] == message["rating_id"] + ) + self.assertEqual(rating["rating"], 5) diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_res_users.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_res_users.py index c871fd9..85c8865 100644 --- a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_res_users.py +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_res_users.py @@ -13,7 +13,6 @@ class TestResUsers(TestMailFullCommon): cls.portal_user = mail_new_test_user( cls.env, login='portal_user', - mobile='+32 494 12 34 56', phone='+32 494 12 34 89', password='password', name='Portal User', @@ -24,7 +23,6 @@ class TestResUsers(TestMailFullCommon): cls.portal_user_2 = mail_new_test_user( cls.env, login='portal_user_2', - mobile='+32 494 12 34 22', phone='invalid phone', password='password', name='Portal User 2', @@ -32,6 +30,16 @@ class TestResUsers(TestMailFullCommon): groups='base.group_portal', ) + cls.portal_user_3 = mail_new_test_user( + cls.env, + login='portal_user_3', + phone='+32 494 12 34 22', + password='password', + name='Portal User 3', + email='portal_3@test.example.com', + groups='base.group_portal', + ) + # Remove existing blacklisted email / phone (they will be sanitized, so we avoid to sanitize them here) cls.env['mail.blacklist'].search([]).unlink() cls.env['phone.blacklist'].search([]).unlink() @@ -40,22 +48,24 @@ class TestResUsers(TestMailFullCommon): """Test that the email and the phone are blacklisted when a portal user deactivate his own account. """ - (self.portal_user | self.portal_user_2)._deactivate_portal_user(request_blacklist=True) + (self.portal_user | self.portal_user_2 | self.portal_user_3)._deactivate_portal_user(request_blacklist=True) self.assertFalse(self.portal_user.active, 'Should have archived the user') self.assertFalse(self.portal_user.partner_id.active, 'Should have archived the partner') self.assertFalse(self.portal_user_2.active, 'Should have archived the user') self.assertFalse(self.portal_user_2.partner_id.active, 'Should have archived the partner') + self.assertFalse(self.portal_user_3.active, 'Should have archived the user') + self.assertFalse(self.portal_user_3.partner_id.active, 'Should have archived the partner') blacklist = self.env['mail.blacklist'].search([ - ('email', 'in', ('portal@test.example.com', 'portal_2@test.example.com')), + ('email', 'in', ('portal@test.example.com', 'portal_2@test.example.com', 'portal_3@test.example.com')), ]) - self.assertEqual(len(blacklist), 2, 'Should have blacklisted the users email') + self.assertEqual(len(blacklist), 3, 'Should have blacklisted the users email') blacklists = self.env['phone.blacklist'].search([ - ('number', 'in', ('+32494123489', '+32494123456', '+32494123422')), + ('number', 'in', ('+32494123489', '+32494123422')), ]) - self.assertEqual(len(blacklists), 3, 'Should have blacklisted the user phone and mobile') + self.assertEqual(len(blacklists), 2, 'Should have blacklisted the user phone') blacklist = self.env['phone.blacklist'].search([('number', '=', 'invalid phone')]) self.assertFalse(blacklist, 'Should have skipped invalid phone') diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_ui.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_ui.py new file mode 100644 index 0000000..ce2be92 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_ui.py @@ -0,0 +1,111 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from urllib.parse import urlencode + +from odoo import tests +from odoo.addons.test_mail_full.tests.test_portal import TestPortal + + +@tests.common.tagged("post_install", "-at_install") +class TestUIPortal(TestPortal): + + def setUp(self): + super().setUp() + self.env["mail.message"].create( + { + "author_id": self.user_employee.partner_id.id, + "body": "Test Message", + "model": self.record_portal._name, + "res_id": self.record_portal.id, + "subtype_id": self.ref("mail.mt_comment"), + } + ) + + def test_star_message(self): + self.start_tour( + f"/my/test_portal_records/{self.record_portal.id}", + "star_message_tour", + login=self.user_employee.login, + ) + + def test_no_copy_link_for_non_readable_portal_record(self): + # mail.test.portal has read access only for base.group_user + self.start_tour( + f"/my/test_portal_records/{self.record_portal.id}?{urlencode({'token': self.record_portal.access_token})}", + "portal_no_copy_link_tour", + login=None, + ) + + def test_copy_link_for_readable_portal_record(self): + # mail.test.portal has read access only for base.group_user + self.start_tour( + f"/my/test_portal_records/{self.record_portal.id}?{urlencode({'token': self.record_portal.access_token})}", + "portal_copy_link_tour", + login=self.user_employee.login, + ) + + def test_load_more(self): + self.env["mail.message"].create( + [ + { + "author_id": self.user_employee.partner_id.id, + "body": f"Test Message {i + 1}", + "model": self.record_portal._name, + "res_id": self.record_portal.id, + "subtype_id": self.ref("mail.mt_comment"), + } + for i in range(30) + ] + ) + self.start_tour( + f"/my/test_portal_records/{self.record_portal.id}", + "load_more_tour", + login=self.user_employee.login, + ) + + def test_message_actions_without_login(self): + self.start_tour( + f"/my/test_portal_records/{self.record_portal.id}?token={self.record_portal._portal_ensure_token()}", + "message_actions_tour", + ) + + def test_rating_record_portal(self): + record_rating = self.env["mail.test.rating"].create({"name": "Test rating record"}) + # To check if there is no message with rating, there is no rating cards feature. + record_rating.message_post( + body="Message without rating", + message_type="comment", + subtype_xmlid="mail.mt_comment", + ) + self.start_tour( + f"/my/test_portal_rating_records/{record_rating.id}?display_rating=True&token={record_rating._portal_ensure_token()}", + "portal_rating_tour" + ) + + def test_display_rating_portal(self): + record_rating = self.env["mail.test.rating"].create({"name": "Test rating record"}) + record_rating.message_post( + body="Message with rating", + message_type="comment", + rating_value="5", + subtype_xmlid="mail.mt_comment", + ) + self.start_tour( + f"/my/test_portal_rating_records/{record_rating.id}?display_rating=True&token={record_rating._portal_ensure_token()}", + "portal_display_rating_tour", + ) + self.start_tour( + f"/my/test_portal_rating_records/{record_rating.id}?display_rating=False&token={record_rating._portal_ensure_token()}", + "portal_not_display_rating_tour", + ) + + def test_composer_actions_portal(self): + self.start_tour( + f"/my/test_portal_records/{self.record_portal.id}", + "portal_composer_actions_tour_internal_user", + login=self.user_employee.login, + ) + self.start_tour( + f"/my/test_portal_records/{self.record_portal.id}?token={self.record_portal._portal_ensure_token()}", + "portal_composer_actions_tour_portal_user", + ) diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/views/test_portal_template.xml b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/views/test_portal_template.xml new file mode 100644 index 0000000..e264ceb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/views/test_portal_template.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/odoo-bringout-oca-ocb-test_resource/README.md b/odoo-bringout-oca-ocb-test_resource/README.md index 422eef8..b579ef2 100644 --- a/odoo-bringout-oca-ocb-test_resource/README.md +++ b/odoo-bringout-oca-ocb-test_resource/README.md @@ -10,37 +10,14 @@ pip install odoo-bringout-oca-ocb-test_resource ## Dependencies -This addon depends on: - resource -## Manifest Information - -- **Name**: Test - Resource -- **Version**: 1.1 -- **Category**: Hidden -- **License**: LGPL-3 -- **Installable**: False - ## Source -Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `test_resource`. +- Repository: https://github.com/OCA/OCB +- Branch: 19.0 +- Path: addons/test_resource ## License -This package maintains the original LGPL-3 license from the upstream Odoo project. - -## Documentation - -- Overview: doc/OVERVIEW.md -- Architecture: doc/ARCHITECTURE.md -- Models: doc/MODELS.md -- Controllers: doc/CONTROLLERS.md -- Wizards: doc/WIZARDS.md -- Reports: doc/REPORTS.md -- Security: doc/SECURITY.md -- Install: doc/INSTALL.md -- Usage: doc/USAGE.md -- Configuration: doc/CONFIGURATION.md -- Dependencies: doc/DEPENDENCIES.md -- Troubleshooting: doc/TROUBLESHOOTING.md -- FAQ: doc/FAQ.md +This package preserves the original LGPL-3 license. diff --git a/odoo-bringout-oca-ocb-test_resource/pyproject.toml b/odoo-bringout-oca-ocb-test_resource/pyproject.toml index 06ce37f..eee231f 100644 --- a/odoo-bringout-oca-ocb-test_resource/pyproject.toml +++ b/odoo-bringout-oca-ocb-test_resource/pyproject.toml @@ -1,12 +1,14 @@ [project] name = "odoo-bringout-oca-ocb-test_resource" version = "16.0.0" -description = "Test - Resource - Odoo addon" +description = "Test - Resource - + Odoo addon + " authors = [ { name = "Ernad Husremovic", email = "hernad@bring.out.ba" } ] dependencies = [ - "odoo-bringout-oca-ocb-resource>=16.0.0", + "odoo-bringout-oca-ocb-resource>=19.0.0", "requests>=2.25.1" ] readme = "README.md" @@ -16,7 +18,7 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Office/Business", ] diff --git a/odoo-bringout-oca-ocb-test_resource/test_resource/__manifest__.py b/odoo-bringout-oca-ocb-test_resource/test_resource/__manifest__.py index ce59dd0..3bc2c0d 100644 --- a/odoo-bringout-oca-ocb-test_resource/test_resource/__manifest__.py +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/__manifest__.py @@ -9,5 +9,6 @@ 'data': [ 'security/ir.model.access.csv', ], + 'author': 'Odoo S.A.', 'license': 'LGPL-3', } diff --git a/odoo-bringout-oca-ocb-test_resource/test_resource/models/test_resource.py b/odoo-bringout-oca-ocb-test_resource/test_resource/models/test_resource.py index d137951..96baf0f 100644 --- a/odoo-bringout-oca-ocb-test_resource/test_resource/models/test_resource.py +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/models/test_resource.py @@ -5,8 +5,8 @@ from odoo import fields, models class ResourceTest(models.Model): - _description = 'Test Resource Model' _name = 'resource.test' + _description = 'Test Resource Model' _inherit = ['resource.mixin'] name = fields.Char() diff --git a/odoo-bringout-oca-ocb-test_resource/test_resource/tests/__init__.py b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/__init__.py index 0a816e9..bc4c338 100644 --- a/odoo-bringout-oca-ocb-test_resource/test_resource/tests/__init__.py +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/__init__.py @@ -1,5 +1,10 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import test_resource -from . import test_performance +from . import ( + test_calendar, + test_mixin, + test_performance, + test_resource, + test_resource_errors, + test_timezones, +) diff --git a/odoo-bringout-oca-ocb-test_resource/test_resource/tests/common.py b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/common.py index 8a10032..e6f446b 100644 --- a/odoo-bringout-oca-ocb-test_resource/test_resource/tests/common.py +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/common.py @@ -1,104 +1,174 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import datetime +from pytz import timezone, utc + +from odoo import fields from odoo.tests.common import TransactionCase class TestResourceCommon(TransactionCase): + @classmethod + def datetime_tz(cls, year, month, day, hour=0, minute=0, second=0, microsecond=0, tzinfo=None): + """ Return a `datetime` object with a given timezone (if given). """ + dt = datetime(year, month, day, hour, minute, second, microsecond) + return timezone(tzinfo).localize(dt) if tzinfo else dt + + @classmethod + def datetime_str(cls, year, month, day, hour=0, minute=0, second=0, microsecond=0, tzinfo=None): + """ Return a fields.Datetime value with the given timezone. """ + dt = datetime(year, month, day, hour, minute, second, microsecond) + if tzinfo: + dt = timezone(tzinfo).localize(dt).astimezone(utc) + return fields.Datetime.to_string(dt) @classmethod def _define_calendar(cls, name, attendances, tz): - return cls.env['resource.calendar'].create({ - 'name': name, - 'tz': tz, - 'attendance_ids': [ - (0, 0, { - 'name': '%s_%d' % (name, index), - 'hour_from': att[0], - 'hour_to': att[1], - 'dayofweek': str(att[2]), - }) - for index, att in enumerate(attendances) - ], - }) + return cls.env["resource.calendar"].create( + { + "name": name, + "tz": tz, + "attendance_ids": [ + ( + 0, + 0, + { + "name": "%s_%d" % (name, index), + "hour_from": att[0], + "hour_to": att[1], + "dayofweek": str(att[2]), + "duration_days": att[3], + }, + ) + for index, att in enumerate(attendances) + ], + }, + ) @classmethod def _define_calendar_2_weeks(cls, name, attendances, tz): - return cls.env['resource.calendar'].create({ - 'name': name, - 'tz': tz, - 'two_weeks_calendar': True, - 'attendance_ids': [ - (0, 0, { - 'name': '%s_%d' % (name, index), - 'hour_from': att[0], - 'hour_to': att[1], - 'dayofweek': str(att[2]), - 'week_type': att[3], - 'display_type': att[4], - 'sequence': att[5], - }) - for index, att in enumerate(attendances) - ], - }) + return cls.env["resource.calendar"].create( + { + "name": name, + "tz": tz, + "two_weeks_calendar": True, + "attendance_ids": [ + ( + 0, + 0, + { + "name": "%s_%d" % (name, index), + "hour_from": att[0], + "hour_to": att[1], + "dayofweek": str(att[2]), + "week_type": att[3], + }, + ) + for index, att in enumerate(attendances) + ], + }, + ) @classmethod def setUpClass(cls): - super(TestResourceCommon, cls).setUpClass() + super().setUpClass() cls.env.company.resource_calendar_id.tz = "Europe/Brussels" # UTC+1 winter, UTC+2 summer - cls.calendar_jean = cls._define_calendar('40 Hours', [(8, 16, i) for i in range(5)], 'Europe/Brussels') + cls.calendar_jean = cls._define_calendar( + "40 Hours", [(8, 16, i, 1) for i in range(5)], "Europe/Brussels", + ) # UTC+6 - cls.calendar_patel = cls._define_calendar('38 Hours', sum([((9, 12, i), (13, 17, i)) for i in range(5)], ()), 'Etc/GMT-6') + cls.calendar_patel = cls._define_calendar( + "38 Hours", + sum((((9, 12, i, 3 / 7), (13, 17, i, 4 / 7)) for i in range(5)), ()), + "Etc/GMT-6", + ) # UTC-8 winter, UTC-7 summer - cls.calendar_john = cls._define_calendar('8+12 Hours', [(8, 16, 1), (8, 13, 4), (16, 23, 4)], 'America/Los_Angeles') + cls.calendar_john = cls._define_calendar( + "8+12 Hours", + [(8, 16, 1, 1), (8, 13, 4, 5 / 12), (16, 23, 4, 7 / 12)], + "America/Los_Angeles", + ) # UTC+1 winter, UTC+2 summer - cls.calendar_jules = cls._define_calendar_2_weeks('Week 1: 30 Hours - Week 2: 16 Hours', [ - (0, 0, 0, '0', 'line_section', 0), (8, 16, 0, '0', False, 1), (9, 17, 1, '0', False, 2), - (0, 0, 0, '1', 'line_section', 10), (8, 16, 0, '1', False, 11), (7, 15, 2, '1', False, 12), - (8, 16, 3, '1', False, 13), (10, 16, 4, '1', False, 14)], 'Europe/Brussels') + cls.calendar_jules = cls._define_calendar_2_weeks( + "Week 1: 30 Hours - Week 2: 16 Hours", + [ + (8, 16, 0, "0"), + (9, 17, 1, "0"), + (8, 16, 0, "1"), + (7, 15, 2, "1"), + (8, 16, 3, "1"), + (10, 16, 4, "1"), + ], + "Europe/Brussels", + ) - cls.calendar_paul = cls._define_calendar('Morning and evening shifts', sum([((2, 7, i), (10, 16, i)) for i in range(5)], ()), 'America/Noronha') + cls.calendar_paul = cls._define_calendar( + "Morning and evening shifts", + sum((((2, 7, i, 0.5), (10, 16, i, 0.5)) for i in range(5)), ()), + "America/Noronha", + ) + + cls.calendar_bob = cls._define_calendar( + "Calendar with adjacent attendances", + sum((((8, 12, i, 0.5), (12, 16, i, 0.5)) for i in range(5)), ()), + "Europe/Brussels", + ) # Employee is linked to a resource.resource via resource.mixin - cls.jean = cls.env['resource.test'].create({ - 'name': 'Jean', - 'resource_calendar_id': cls.calendar_jean.id, - }) - cls.patel = cls.env['resource.test'].create({ - 'name': 'Patel', - 'resource_calendar_id': cls.calendar_patel.id, - }) - cls.john = cls.env['resource.test'].create({ - 'name': 'John', - 'resource_calendar_id': cls.calendar_john.id, - }) - cls.jules = cls.env['resource.test'].create({ - 'name': 'Jules', - 'resource_calendar_id': cls.calendar_jules.id, - }) + cls.jean = cls.env["resource.test"].create( + { + "name": "Jean", + "resource_calendar_id": cls.calendar_jean.id, + }, + ) + cls.patel = cls.env["resource.test"].create( + { + "name": "Patel", + "resource_calendar_id": cls.calendar_patel.id, + }, + ) + cls.john = cls.env["resource.test"].create( + { + "name": "John", + "resource_calendar_id": cls.calendar_john.id, + }, + ) + cls.jules = cls.env["resource.test"].create( + { + "name": "Jules", + "resource_calendar_id": cls.calendar_jules.id, + }, + ) - cls.paul = cls.env['resource.test'].create({ - 'name': 'Paul', - 'resource_calendar_id': cls.calendar_paul.id, - }) + cls.paul = cls.env["resource.test"].create( + { + "name": "Paul", + "resource_calendar_id": cls.calendar_paul.id, + }, + ) + + cls.bob = cls.env["resource.test"].create( + { + "name": "Bob", + "resource_calendar_id": cls.calendar_bob.id, + }, + ) cls.two_weeks_resource = cls._define_calendar_2_weeks( - 'Two weeks resource', + "Two weeks resource", [ - (0, 0, 0, '0', 'line_section', 0), - (8, 16, 0, '0', False, 1), - (8, 16, 1, '0', False, 2), - (8, 16, 2, '0', False, 3), - (8, 16, 3, '0', False, 4), - (8, 16, 4, '0', False, 5), - (0, 0, 0, '1', 'line_section', 10), - (8, 16, 0, '1', False, 11), - (8, 16, 1, '1', False, 12), - (8, 16, 2, '1', False, 13), - (8, 16, 3, '1', False, 14), - (8, 16, 4, '1', False, 15) + (8, 16, 0, "0"), + (8, 16, 1, "0"), + (8, 16, 2, "0"), + (8, 16, 3, "0"), + (8, 16, 4, "0"), + (8, 16, 0, "1"), + (8, 16, 1, "1"), + (8, 16, 2, "1"), + (8, 16, 3, "1"), + (8, 16, 4, "1"), ], - 'Europe/Brussels' + "Europe/Brussels", ) diff --git a/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_calendar.py b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_calendar.py new file mode 100644 index 0000000..220c31e --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_calendar.py @@ -0,0 +1,485 @@ +from datetime import date, datetime + +from pytz import timezone + +from odoo import fields + +from odoo.addons.test_resource.tests.common import TestResourceCommon + + +class TestCalendar(TestResourceCommon): + def setUp(self): + super().setUp() + + def test_get_work_hours_count(self): + self.env['resource.calendar.leaves'].create({ + 'name': 'Global Time Off', + 'resource_id': False, + 'calendar_id': self.calendar_jean.id, + 'date_from': self.datetime_str(2018, 4, 3, 0, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 3, 23, 59, 59, tzinfo=self.jean.tz), + }) + + self.env['resource.calendar.leaves'].create({ + 'name': 'leave for Jean', + 'calendar_id': self.calendar_jean.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': self.datetime_str(2018, 4, 5, 0, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 5, 23, 59, 59, tzinfo=self.jean.tz), + }) + + hours = self.calendar_jean.get_work_hours_count( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.jean.tz), + ) + self.assertEqual(hours, 32) + + hours = self.calendar_jean.get_work_hours_count( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.jean.tz), + compute_leaves=False, + ) + self.assertEqual(hours, 40) + + # leave of size 0 + self.env['resource.calendar.leaves'].create({ + 'name': 'zero_length', + 'calendar_id': self.calendar_patel.id, + 'resource_id': False, + 'date_from': self.datetime_str(2018, 4, 3, 0, 0, 0, tzinfo=self.patel.tz), + 'date_to': self.datetime_str(2018, 4, 3, 0, 0, 0, tzinfo=self.patel.tz), + }) + + hours = self.calendar_patel.get_work_hours_count( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), + self.datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.patel.tz), + ) + self.assertEqual(hours, 35) + + # leave of medium size + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'zero_length', + 'calendar_id': self.calendar_patel.id, + 'resource_id': False, + 'date_from': self.datetime_str(2018, 4, 3, 9, 0, 0, tzinfo=self.patel.tz), + 'date_to': self.datetime_str(2018, 4, 3, 12, 0, 0, tzinfo=self.patel.tz), + }) + + hours = self.calendar_patel.get_work_hours_count( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), + self.datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.patel.tz), + ) + self.assertEqual(hours, 32) + + leave.unlink() + + # leave of very small size + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'zero_length', + 'calendar_id': self.calendar_patel.id, + 'resource_id': False, + 'date_from': self.datetime_str(2018, 4, 3, 0, 0, 0, tzinfo=self.patel.tz), + 'date_to': self.datetime_str(2018, 4, 3, 0, 0, 10, tzinfo=self.patel.tz), + }) + + hours = self.calendar_patel.get_work_hours_count( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), + self.datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.patel.tz), + ) + self.assertEqual(hours, 35) + + leave.unlink() + + # no timezone given should be converted to UTC + # Should equal to a leave between 2018/04/03 10:00:00 and 2018/04/04 10:00:00 + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'no timezone', + 'calendar_id': self.calendar_patel.id, + 'resource_id': False, + 'date_from': self.datetime_str(2018, 4, 3, 4, 0, 0), + 'date_to': self.datetime_str(2018, 4, 4, 4, 0, 0), + }) + + hours = self.calendar_patel.get_work_hours_count( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), + self.datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.patel.tz), + ) + self.assertEqual(hours, 28) + + hours = self.calendar_patel.get_work_hours_count( + self.datetime_tz(2018, 4, 2, 23, 59, 59, tzinfo=self.patel.tz), + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), + ) + self.assertEqual(hours, 0) + + leave.unlink() + + # 2 weeks calendar week 1 + hours = self.calendar_jules.get_work_hours_count( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jules.tz), + self.datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.jules.tz), + ) + self.assertEqual(hours, 30) + + # 2 weeks calendar week 1 + hours = self.calendar_jules.get_work_hours_count( + self.datetime_tz(2018, 4, 16, 0, 0, 0, tzinfo=self.jules.tz), + self.datetime_tz(2018, 4, 20, 23, 59, 59, tzinfo=self.jules.tz), + ) + self.assertEqual(hours, 30) + + # 2 weeks calendar week 2 + hours = self.calendar_jules.get_work_hours_count( + self.datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.jules.tz), + self.datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.jules.tz), + ) + self.assertEqual(hours, 16) + + # 2 weeks calendar week 2, leave during a day where he doesn't work this week + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'Time Off Jules week 2', + 'calendar_id': self.calendar_jules.id, + 'resource_id': False, + 'date_from': self.datetime_str(2018, 4, 11, 4, 0, 0, tzinfo=self.jules.tz), + 'date_to': self.datetime_str(2018, 4, 13, 4, 0, 0, tzinfo=self.jules.tz), + }) + + hours = self.calendar_jules.get_work_hours_count( + self.datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.jules.tz), + self.datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.jules.tz), + ) + self.assertEqual(hours, 16) + + leave.unlink() + + # 2 weeks calendar week 2, leave during a day where he works this week + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'Time Off Jules week 2', + 'calendar_id': self.calendar_jules.id, + 'resource_id': False, + 'date_from': self.datetime_str(2018, 4, 9, 0, 0, 0, tzinfo=self.jules.tz), + 'date_to': self.datetime_str(2018, 4, 9, 23, 59, 0, tzinfo=self.jules.tz), + }) + + hours = self.calendar_jules.get_work_hours_count( + self.datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.jules.tz), + self.datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.jules.tz), + ) + self.assertEqual(hours, 8) + + leave.unlink() + + # leave without calendar, should count for anyone in the company + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'small leave', + 'resource_id': False, + 'date_from': self.datetime_str(2018, 4, 3, 9, 0, 0, tzinfo=self.patel.tz), + 'date_to': self.datetime_str(2018, 4, 3, 12, 0, 0, tzinfo=self.patel.tz), + }) + + hours = self.calendar_patel.get_work_hours_count( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), + self.datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.patel.tz), + ) + self.assertEqual(hours, 32) + + def test_calendar_working_hours_count(self): + calendar = self.env['resource.calendar'].create({ + 'name': 'Standard 35 hours/week', + 'company_id': self.env.company.id, + 'tz': 'UTC', + 'attendance_ids': [(5, 0, 0), + (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + ], + }) + res = calendar.get_work_hours_count( + fields.Datetime.from_string('2017-05-03 14:03:00'), # Wednesday (8:00-12:00, 13:00-16:00) + fields.Datetime.from_string('2017-05-04 11:03:00'), # Thursday (8:00-12:00, 13:00-16:00) + compute_leaves=False) + self.assertEqual(res, 5.0) + + def test_calendar_working_hours_24(self): + self.att_4 = self.env['resource.calendar.attendance'].create({ + 'name': 'Att4', + 'calendar_id': self.calendar_jean.id, + 'dayofweek': '2', + 'hour_from': 0, + 'hour_to': 24, + }) + res = self.calendar_jean.get_work_hours_count( + self.datetime_tz(2018, 6, 19, 23, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 6, 21, 1, 0, 0, tzinfo=self.jean.tz), + compute_leaves=True) + self.assertAlmostEqual(res, 24.0) + + def test_plan_hours(self): + self.env['resource.calendar.leaves'].create({ + 'name': 'global', + 'calendar_id': self.calendar_jean.id, + 'resource_id': False, + 'date_from': self.datetime_str(2018, 4, 11, 0, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 11, 23, 59, 59, tzinfo=self.jean.tz), + }) + + time = self.calendar_jean.plan_hours(2, self.datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) + self.assertEqual(time, self.datetime_tz(2018, 4, 10, 10, 0, 0, tzinfo=self.jean.tz)) + + time = self.calendar_jean.plan_hours(20, self.datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) + self.assertEqual(time, self.datetime_tz(2018, 4, 12, 12, 0, 0, tzinfo=self.jean.tz)) + + time = self.calendar_jean.plan_hours(5, self.datetime_tz(2018, 4, 10, 15, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, self.datetime_tz(2018, 4, 12, 12, 0, 0, tzinfo=self.jean.tz)) + + # negative planning + time = self.calendar_jean.plan_hours(-10, self.datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, self.datetime_tz(2018, 4, 6, 14, 0, 0, tzinfo=self.jean.tz)) + + # zero planning with holidays + time = self.calendar_jean.plan_hours(0, self.datetime_tz(2018, 4, 11, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, self.datetime_tz(2018, 4, 12, 8, 0, 0, tzinfo=self.jean.tz)) + time = self.calendar_jean.plan_hours(0, self.datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) + self.assertEqual(time, self.datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.jean.tz)) + + # very small planning + time = self.calendar_jean.plan_hours(0.0002, self.datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, self.datetime_tz(2018, 4, 10, 8, 0, 0, 720000, tzinfo=self.jean.tz)) + + # huge planning + time = self.calendar_jean.plan_hours(3000, self.datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) + self.assertEqual(time, self.datetime_tz(2019, 9, 16, 16, 0, 0, tzinfo=self.jean.tz)) + + def test_plan_days(self): + self.env['resource.calendar.leaves'].create({ + 'name': 'global', + 'calendar_id': self.calendar_jean.id, + 'resource_id': False, + 'date_from': self.datetime_str(2018, 4, 11, 0, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 11, 23, 59, 59, tzinfo=self.jean.tz), + }) + + time = self.calendar_jean.plan_days(1, self.datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) + self.assertEqual(time, self.datetime_tz(2018, 4, 10, 16, 0, 0, tzinfo=self.jean.tz)) + + time = self.calendar_jean.plan_days(3, self.datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) + self.assertEqual(time, self.datetime_tz(2018, 4, 12, 16, 0, 0, tzinfo=self.jean.tz)) + + time = self.calendar_jean.plan_days(4, self.datetime_tz(2018, 4, 10, 16, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, self.datetime_tz(2018, 4, 17, 16, 0, 0, tzinfo=self.jean.tz)) + + # negative planning + time = self.calendar_jean.plan_days(-10, self.datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, self.datetime_tz(2018, 3, 27, 8, 0, 0, tzinfo=self.jean.tz)) + + # zero planning + time = self.calendar_jean.plan_days(0, self.datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, self.datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz)) + + # very small planning returns False in this case + # TODO: decide if this behaviour is alright + time = self.calendar_jean.plan_days(0.0002, self.datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, False) + + # huge planning + # TODO: Same as above + # NOTE: Maybe allow to set a max limit to the method + time = self.calendar_jean.plan_days(3000, self.datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) + self.assertEqual(time, False) + + def test_closest_time(self): + # Calendar: + # Tuesdays 8-16 + # Fridays 8-13 and 16-23 + dt = self.datetime_tz(2020, 4, 2, 7, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt) + self.assertFalse(calendar_dt, "It should not return any value for unattended days") + + dt = self.datetime_tz(2020, 4, 3, 7, 0, 0, tzinfo=self.john.tz) + range_start = self.datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz) + range_end = self.datetime_tz(2020, 4, 3, 19, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt, search_range=(range_start, range_end)) + self.assertFalse(calendar_dt, "It should not return any value if dt outside of range") + + dt = self.datetime_tz(2020, 4, 3, 7, 0, 0, tzinfo=self.john.tz) # before + start = self.datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt) + self.assertEqual(calendar_dt, start, "It should return the start of the day") + + dt = self.datetime_tz(2020, 4, 3, 10, 0, 0, tzinfo=self.john.tz) # after + start = self.datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt) + self.assertEqual(calendar_dt, start, "It should return the start of the closest attendance") + + dt = self.datetime_tz(2020, 4, 3, 7, 0, 0, tzinfo=self.john.tz) # before + end = self.datetime_tz(2020, 4, 3, 13, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt, match_end=True) + self.assertEqual(calendar_dt, end, "It should return the end of the closest attendance") + + dt = self.datetime_tz(2020, 4, 3, 14, 0, 0, tzinfo=self.john.tz) # after + end = self.datetime_tz(2020, 4, 3, 13, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt, match_end=True) + self.assertEqual(calendar_dt, end, "It should return the end of the closest attendance") + + dt = self.datetime_tz(2020, 4, 3, 0, 0, 0, tzinfo=self.john.tz) + start = self.datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt) + self.assertEqual(calendar_dt, start, "It should return the start of the closest attendance") + + dt = self.datetime_tz(2020, 4, 3, 23, 59, 59, tzinfo=self.john.tz) + end = self.datetime_tz(2020, 4, 3, 23, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt, match_end=True) + self.assertEqual(calendar_dt, end, "It should return the end of the closest attendance") + + def test_attendance_interval_edge_tz(self): + # When genereting the attendance intervals in an edge timezone, the last interval shouldn't + # be truncated if the timezone is correctly set + self.env.user.tz = "America/Los_Angeles" + self.calendar_jean.tz = "America/Los_Angeles" + attendances = self.calendar_jean._attendance_intervals_batch( + datetime.combine(date(2023, 1, 1), datetime.min.time(), tzinfo=timezone("UTC")), + datetime.combine(date(2023, 1, 31), datetime.max.time(), tzinfo=timezone("UTC"))) + last_attendance = list(attendances[False])[-1] + self.assertEqual(last_attendance[0].replace(tzinfo=None), datetime(2023, 1, 31, 8)) + self.assertEqual(last_attendance[1].replace(tzinfo=None), datetime(2023, 1, 31, 15, 59, 59, 999999)) + + attendances = self.calendar_jean._attendance_intervals_batch( + datetime.combine(date(2023, 1, 1), datetime.min.time(), tzinfo=timezone("America/Los_Angeles")), + datetime.combine(date(2023, 1, 31), datetime.max.time(), tzinfo=timezone("America/Los_Angeles"))) + last_attendance = list(attendances[False])[-1] + self.assertEqual(last_attendance[0].replace(tzinfo=None), datetime(2023, 1, 31, 8)) + self.assertEqual(last_attendance[1].replace(tzinfo=None), datetime(2023, 1, 31, 16)) + + def test_resource_calendar_update(self): + """ Ensure leave calendar gets set correctly when updating resource calendar. """ + holiday = self.env['resource.calendar.leaves'].create({ + 'name': "May Day", + 'calendar_id': self.calendar_jean.id, + 'date_from': self.datetime_str(2024, 5, 1, 0, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2024, 5, 1, 23, 59, 59, tzinfo=self.jean.tz), + }) + + # Jean takes a leave + leave = self.env['resource.calendar.leaves'].create({ + 'name': "Jean is AFK", + 'calendar_id': self.calendar_jean.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': self.datetime_str(2024, 5, 10, 8, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2024, 5, 10, 16, 0, 0, tzinfo=self.jean.tz), + }) + + # Jean changes working schedule to Jules' + self.jean.resource_calendar_id = self.calendar_jules + self.assertEqual(leave.calendar_id, self.calendar_jules, "Leave calendar should update") + self.assertEqual(holiday.calendar_id, self.calendar_jean, "Global leave shouldn't change") + + def test_compute_work_time_rate_with_one_week_calendar(self): + """Test Case: check if the computation of the work time rate in the resource.calendar is correct.""" + # Define a mid time + resource_calendar = self.env['resource.calendar'].create({ + 'name': 'Calendar Mid-Time', + 'tz': "Europe/Brussels", + 'two_weeks_calendar': False, + 'full_time_required_hours': 40, + 'attendance_ids': [ + (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + ], + }) + self.assertAlmostEqual(resource_calendar.work_time_rate, 50, 2) + + # Define a 4/5 + resource_calendar.write({ + 'name': 'Calendar (4 / 5)', + 'attendance_ids': [ + (0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), + (0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), + ], + }) + self.assertAlmostEqual(resource_calendar.work_time_rate, 80, 2) + + # Define a 9/10 + resource_calendar.write({ + 'name': 'Calendar (9 / 10)', + 'attendance_ids': [(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'})], + }) + self.assertAlmostEqual(resource_calendar.work_time_rate, 90, 2) + + # Define a Full-Time + resource_calendar.write({ + 'name': 'Calendar Full-Time', + 'attendance_ids': [(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'})], + }) + self.assertAlmostEqual(resource_calendar.work_time_rate, 100, 2) + + def test_compute_work_time_rate_with_two_weeks_calendar(self): + """Test Case: check if the computation of the work time rate in the resource.calendar is correct.""" + def create_attendance_ids(attendance_list): + return [(0, 0, {'week_type': str(i), **attendance}) for i in range(0, 2) for attendance in attendance_list] + + attendance_list = [ + {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}, + {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}, + {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}, + {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}, + {'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}, + {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}, + {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}, + ] + + # Define a mid time + resource_calendar = self.env['resource.calendar'].create({ + 'name': 'Calendar Mid-Time', + 'tz': "Europe/Brussels", + 'two_weeks_calendar': True, + 'full_time_required_hours': 40, + 'attendance_ids': create_attendance_ids(attendance_list), + }) + self.assertAlmostEqual(resource_calendar.work_time_rate, 50, 2) + + attendance_list = [ + {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}, + {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}, + {'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}, + {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}, + ] + + # Define a 4/5 + resource_calendar.write({ + 'name': 'Calendar (4 / 5)', + 'attendance_ids': create_attendance_ids(attendance_list), + }) + self.assertAlmostEqual(resource_calendar.work_time_rate, 80, 2) + + # Define a 9/10 + resource_calendar.write({ + 'name': 'Calendar (9 / 10)', + 'attendance_ids': create_attendance_ids([{'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}]), + }) + self.assertAlmostEqual(resource_calendar.work_time_rate, 90, 2) + + # Define a Full-Time + resource_calendar.write({ + 'name': 'Calendar Full-Time', + 'attendance_ids': create_attendance_ids([{'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}]), + }) + self.assertAlmostEqual(resource_calendar.work_time_rate, 100, 2) diff --git a/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_mixin.py b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_mixin.py new file mode 100644 index 0000000..cd692a9 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_mixin.py @@ -0,0 +1,517 @@ +from datetime import date, datetime + +from odoo.addons.test_resource.tests.common import TestResourceCommon + + +class TestResMixin(TestResourceCommon): + + def test_adjust_calendar(self): + # Calendar: + # Tuesdays 8-16 + # Fridays 8-13 and 16-23 + result = self.john._adjust_to_calendar( + self.datetime_tz(2020, 4, 3, 9, 0, 0, tzinfo=self.john.tz), + self.datetime_tz(2020, 4, 3, 14, 0, 0, tzinfo=self.john.tz), + ) + self.assertEqual(result[self.john], ( + self.datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz), + self.datetime_tz(2020, 4, 3, 13, 0, 0, tzinfo=self.john.tz), + )) + + result = self.john._adjust_to_calendar( + self.datetime_tz(2020, 4, 3, 13, 1, 0, tzinfo=self.john.tz), + self.datetime_tz(2020, 4, 3, 14, 0, 0, tzinfo=self.john.tz), + ) + self.assertEqual(result[self.john], ( + self.datetime_tz(2020, 4, 3, 16, 0, 0, tzinfo=self.john.tz), + self.datetime_tz(2020, 4, 3, 23, 0, 0, tzinfo=self.john.tz), + )) + + result = self.john._adjust_to_calendar( + self.datetime_tz(2020, 4, 4, 9, 0, 0, tzinfo=self.john.tz), # both a day without attendance + self.datetime_tz(2020, 4, 4, 14, 0, 0, tzinfo=self.john.tz), + ) + self.assertEqual(result[self.john], (None, None)) + + result = self.john._adjust_to_calendar( + self.datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz), + self.datetime_tz(2020, 4, 4, 14, 0, 0, tzinfo=self.john.tz), # day without attendance + ) + self.assertEqual(result[self.john], ( + self.datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz), + self.datetime_tz(2020, 4, 3, 23, 0, 0, tzinfo=self.john.tz), + )) + + result = self.john._adjust_to_calendar( + self.datetime_tz(2020, 4, 2, 8, 0, 0, tzinfo=self.john.tz), # day without attendance + self.datetime_tz(2020, 4, 3, 14, 0, 0, tzinfo=self.john.tz), + ) + self.assertEqual(result[self.john], ( + self.datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz), + self.datetime_tz(2020, 4, 3, 13, 0, 0, tzinfo=self.john.tz), + )) + + result = self.john._adjust_to_calendar( + self.datetime_tz(2020, 3, 31, 0, 0, 0, tzinfo=self.john.tz), # search between Tuesday and Thursday + self.datetime_tz(2020, 4, 2, 23, 59, 59, tzinfo=self.john.tz), + ) + self.assertEqual(result[self.john], ( + self.datetime_tz(2020, 3, 31, 8, 0, tzinfo=self.john.tz), + self.datetime_tz(2020, 3, 31, 16, 0, tzinfo=self.john.tz), + )) + + result = self.john._adjust_to_calendar( + self.datetime_tz(2020, 3, 31, 0, 0, 0, tzinfo=self.john.tz), # search between Tuesday and Friday + self.datetime_tz(2020, 4, 3, 23, 59, 59, tzinfo=self.john.tz), + ) + result = self.john._adjust_to_calendar( + self.datetime_tz(2020, 3, 31, 8, 0, 0, tzinfo=self.john.tz), + self.datetime_tz(2020, 4, 3, 23, 0, 0, tzinfo=self.john.tz), + ) + # It should find the start and end within the search range + result = self.paul._adjust_to_calendar( + self.datetime_tz(2020, 4, 2, 2, 0, 0, tzinfo='UTC'), + self.datetime_tz(2020, 4, 3, 1, 59, 59, tzinfo='UTC'), + ) + + self.assertEqual(result[self.paul], ( + self.datetime_tz(2020, 4, 2, 4, 0, tzinfo='UTC'), + self.datetime_tz(2020, 4, 2, 18, 0, tzinfo='UTC'), + ), "It should have found the start and end of the shift on the same day on April 2nd, 2020") + + def test_adjust_calendar_timezone_before(self): + # Calendar: + # Every day 8-16 + self.jean.tz = 'Asia/Tokyo' + self.calendar_jean.tz = 'Europe/Brussels' + + result = self.jean._adjust_to_calendar( + self.datetime_tz(2020, 4, 1, 0, 0, 0, tzinfo='Asia/Tokyo'), + self.datetime_tz(2020, 4, 1, 23, 59, 59, tzinfo='Asia/Tokyo'), + ) + self.assertEqual(result[self.jean], ( + self.datetime_tz(2020, 4, 1, 8, 0, 0, tzinfo='Asia/Tokyo'), + self.datetime_tz(2020, 4, 1, 16, 0, 0, tzinfo='Asia/Tokyo'), + ), "It should have found a starting time the 1st") + + def test_adjust_calendar_timezone_after(self): + # Calendar: + # Tuesdays 8-16 + # Fridays 8-13 and 16-23 + tz = 'Europe/Brussels' + self.john.tz = tz + result = self.john._adjust_to_calendar( + datetime(2020, 4, 2, 23, 0, 0), # The previous day in UTC, but the 3rd in Europe/Brussels + datetime(2020, 4, 3, 20, 0, 0), + ) + self.assertEqual(result[self.john], ( + datetime(2020, 4, 3, 6, 0, 0), + datetime(2020, 4, 3, 21, 0, 0), + ), "It should have found a starting time the 3rd") + + def test_work_days_data(self): + # Looking at Jean's calendar + + # Viewing it as Jean + data = self.jean._get_work_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 16, 0, 0, tzinfo=self.jean.tz), + )[self.jean.id] + self.assertEqual(data, {'days': 5, 'hours': 40}) + + # Viewing it as Bob + data = self.bob._get_work_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.bob.tz), + self.datetime_tz(2018, 4, 6, 16, 0, 0, tzinfo=self.bob.tz), + )[self.bob.id] + self.assertEqual(data, {'days': 5, 'hours': 40}) + + # Viewing it as Patel + # Views from 2018/04/01 20:00:00 to 2018/04/06 12:00:00 + data = self.jean._get_work_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), + self.datetime_tz(2018, 4, 6, 16, 0, 0, tzinfo=self.patel.tz), + )[self.jean.id] + self.assertEqual(data, {'days': 4.5, 'hours': 36}) # We see only 36 hours + + # Viewing it as John + # Views from 2018/04/02 09:00:00 to 2018/04/07 02:00:00 + data = self.jean._get_work_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.john.tz), + self.datetime_tz(2018, 4, 6, 16, 0, 0, tzinfo=self.john.tz), + )[self.jean.id] + # still showing as 5 days because of rounding, but we see only 39 hours + self.assertEqual(data, {'days': 4.875, 'hours': 39}) + + # Looking at John's calendar + + # Viewing it as Jean + # Views from 2018/04/01 15:00:00 to 2018/04/06 14:00:00 + data = self.john._get_work_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + )[self.john.id] + self.assertEqual(data, {'days': 1.417, 'hours': 13}) + + # Viewing it as Patel + # Views from 2018/04/01 11:00:00 to 2018/04/06 10:00:00 + data = self.john._get_work_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.patel.tz), + )[self.john.id] + self.assertEqual(data, {'days': 1.167, 'hours': 10}) + + # Viewing it as John + data = self.john._get_work_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.john.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.john.tz), + )[self.john.id] + self.assertEqual(data, {'days': 2, 'hours': 20}) + + # using Jean as a timezone reference + data = self.john._get_work_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.john.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.john.tz), + calendar=self.calendar_jean, + )[self.john.id] + self.assertEqual(data, {'days': 5, 'hours': 40}) + + # half days + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'half', + 'calendar_id': self.calendar_jean.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 2, 14, 0, 0, tzinfo=self.jean.tz), + }) + + data = self.jean._get_work_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + )[self.jean.id] + self.assertEqual(data, {'days': 4.5, 'hours': 36}) + + # using John as a timezone reference, leaves are outside attendances + data = self.john._get_work_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.john.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.john.tz), + calendar=self.calendar_jean, + )[self.john.id] + self.assertEqual(data, {'days': 5, 'hours': 40}) + + leave.unlink() + + # leave size 0 + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'zero', + 'calendar_id': self.calendar_jean.id, + 'resource_id': False, + 'date_from': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + }) + + data = self.jean._get_work_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + )[self.jean.id] + self.assertEqual(data, {'days': 5, 'hours': 40}) + + leave.unlink() + + # leave very small size + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'small', + 'calendar_id': self.calendar_jean.id, + 'resource_id': False, + 'date_from': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 2, 10, 0, 1, tzinfo=self.jean.tz), + }) + + data = self.jean._get_work_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + )[self.jean.id] + self.assertEqual(data['days'], 5) + self.assertAlmostEqual(data['hours'], 40, 2) + + def test_leaves_days_data(self): + # Jean takes a leave + self.env['resource.calendar.leaves'].create({ + 'name': 'Jean is visiting India', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': self.datetime_str(2018, 4, 10, 8, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 10, 16, 0, 0, tzinfo=self.jean.tz), + }) + + # John takes a leave for Jean + self.env['resource.calendar.leaves'].create({ + 'name': 'Jean is comming in USA', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': self.datetime_str(2018, 4, 12, 8, 0, 0, tzinfo=self.john.tz), + 'date_to': self.datetime_str(2018, 4, 12, 16, 0, 0, tzinfo=self.john.tz), + }) + + # Jean asks to see how much leave he has taken + data = self.jean._get_leave_days_data_batch( + self.datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.jean.tz), + )[self.jean.id] + # Sees only 1 day and 8 hours because, as john is in UTC-7 the second leave is not in + # the attendances of Jean + self.assertEqual(data, {'days': 1, 'hours': 8}) + + # Patel Asks to see when Jean has taken some leaves + # Patel should see the same + data = self.jean._get_leave_days_data_batch( + self.datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.patel.tz), + self.datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.patel.tz), + )[self.jean.id] + self.assertEqual(data, {'days': 1, 'hours': 8}) + + # use Patel as a resource, jean's leaves are not visible + datas = self.patel._get_leave_days_data_batch( + self.datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.patel.tz), + self.datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.patel.tz), + calendar=self.calendar_jean, + )[self.patel.id] + self.assertEqual(datas['days'], 0) + self.assertEqual(datas['hours'], 0) + + # Jean takes a leave for John + # Gives 3 hours (3/8 of a day) + self.env['resource.calendar.leaves'].create({ + 'name': 'John is sick', + 'calendar_id': self.john.resource_calendar_id.id, + 'resource_id': self.john.resource_id.id, + 'date_from': self.datetime_str(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 10, 20, 0, 0, tzinfo=self.jean.tz), + }) + + # John takes a leave + # Gives all day (12 hours) + self.env['resource.calendar.leaves'].create({ + 'name': 'John goes to holywood', + 'calendar_id': self.john.resource_calendar_id.id, + 'resource_id': self.john.resource_id.id, + 'date_from': self.datetime_str(2018, 4, 13, 7, 0, 0, tzinfo=self.john.tz), + 'date_to': self.datetime_str(2018, 4, 13, 18, 0, 0, tzinfo=self.john.tz), + }) + + # John asks how much leaves he has + # He sees that he has only 15 hours of leave in his attendances + data = self.john._get_leave_days_data_batch( + self.datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.john.tz), + self.datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.john.tz), + )[self.john.id] + self.assertEqual(data, {'days': 0.958, 'hours': 10}) + + # half days + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'half', + 'calendar_id': self.calendar_jean.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 2, 14, 0, 0, tzinfo=self.jean.tz), + }) + + data = self.jean._get_leave_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + )[self.jean.id] + self.assertEqual(data, {'days': 0.5, 'hours': 4}) + + leave.unlink() + + # leave size 0 + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'zero', + 'calendar_id': self.calendar_jean.id, + 'resource_id': False, + 'date_from': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + }) + + data = self.jean._get_leave_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + )[self.jean.id] + self.assertEqual(data, {'days': 0, 'hours': 0}) + + leave.unlink() + + # leave very small size + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'small', + 'calendar_id': self.calendar_jean.id, + 'resource_id': False, + 'date_from': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 2, 10, 0, 1, tzinfo=self.jean.tz), + }) + + data = self.jean._get_leave_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + )[self.jean.id] + self.assertEqual(data['days'], 0) + self.assertAlmostEqual(data['hours'], 0, 2) + + leave.unlink() + + def test_list_leaves(self): + jean_leave = self.env['resource.calendar.leaves'].create({ + 'name': "Jean's son is sick", + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': False, + 'date_from': self.datetime_str(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 10, 23, 59, 59, tzinfo=self.jean.tz), + }) + + leaves = self.jean.list_leaves( + self.datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.jean.tz), + ) + self.assertEqual(leaves, [(date(2018, 4, 10), 8, jean_leave)]) + + # half days + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'half', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 2, 14, 0, 0, tzinfo=self.jean.tz), + }) + + leaves = self.jean.list_leaves( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + ) + self.assertEqual(leaves, [(date(2018, 4, 2), 4, leave)]) + + leave.unlink() + + # very small size + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'small', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 2, 10, 0, 1, tzinfo=self.jean.tz), + }) + + leaves = self.jean.list_leaves( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + ) + self.assertEqual(len(leaves), 1) + self.assertEqual(leaves[0][0], date(2018, 4, 2)) + self.assertAlmostEqual(leaves[0][1], 0, 2) + self.assertEqual(leaves[0][2].id, leave.id) + + leave.unlink() + + # size 0 + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'zero', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + }) + + leaves = self.jean.list_leaves( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + ) + self.assertEqual(leaves, []) + + leave.unlink() + + def test_list_work_time_per_day(self): + working_time = self.john._list_work_time_per_day( + self.datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.john.tz), + self.datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.john.tz), + )[self.john.id] + self.assertEqual(working_time, [ + (date(2018, 4, 10), 8), + (date(2018, 4, 13), 12), + ]) + + # change john's resource's timezone + self.john.resource_id.tz = 'Europe/Brussels' + self.assertEqual(self.john.tz, 'Europe/Brussels') + self.assertEqual(self.calendar_john.tz, 'America/Los_Angeles') + working_time = self.john._list_work_time_per_day( + self.datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.john.tz), + self.datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.john.tz), + )[self.john.id] + self.assertEqual(working_time, [ + (date(2018, 4, 10), 8), + (date(2018, 4, 13), 12), + ]) + + # half days + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'small', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 2, 14, 0, 0, tzinfo=self.jean.tz), + }) + + working_time = self.jean._list_work_time_per_day( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + )[self.jean.id] + self.assertEqual(working_time, [ + (date(2018, 4, 2), 4), + (date(2018, 4, 3), 8), + (date(2018, 4, 4), 8), + (date(2018, 4, 5), 8), + (date(2018, 4, 6), 8), + ]) + + leave.unlink() + + # very small size + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'small', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 2, 10, 0, 1, tzinfo=self.jean.tz), + }) + + working_time = self.jean._list_work_time_per_day( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + )[self.jean.id] + self.assertEqual(len(working_time), 5) + self.assertEqual(working_time[0][0], date(2018, 4, 2)) + self.assertAlmostEqual(working_time[0][1], 8, 2) + + leave.unlink() + + # size 0 + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'zero', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + }) + + working_time = self.jean._list_work_time_per_day( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + self.datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + )[self.jean.id] + self.assertEqual(working_time, [ + (date(2018, 4, 2), 8), + (date(2018, 4, 3), 8), + (date(2018, 4, 4), 8), + (date(2018, 4, 5), 8), + (date(2018, 4, 6), 8), + ]) + + leave.unlink() diff --git a/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_performance.py b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_performance.py index bb0aedc..097fdd2 100644 --- a/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_performance.py +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_performance.py @@ -32,7 +32,7 @@ class TestResourcePerformance(TransactionCase): start = pytz.utc.localize(datetime.now() + relativedelta(month=1, day=1)) stop = pytz.utc.localize(datetime.now() + relativedelta(month=12, day=31)) start_time = time.time() - calendar._attendance_intervals_batch(start, stop, resources=resources) + calendar._attendance_intervals_batch(start, stop, resources=resources.resource_id) _logger.info('Attendance Intervals Batch (100): --- %s seconds ---', time.time() - start_time) # Before #INFO master test_performance: Attendance Intervals Batch (100): --- 2.0667169094085693 seconds --- diff --git a/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_resource.py b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_resource.py index a3fd84c..2d4ed69 100644 --- a/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_resource.py +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_resource.py @@ -1,1281 +1,15 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. -from datetime import date, datetime -from freezegun import freeze_time -from pytz import timezone, utc +from datetime import datetime + +from pytz import utc + +from odoo.tools.date_utils import sum_intervals +from odoo.tools.intervals import Intervals -from odoo import fields -from odoo.exceptions import ValidationError -from odoo.addons.resource.models.resource import Intervals, sum_intervals from odoo.addons.test_resource.tests.common import TestResourceCommon -from odoo.tests.common import TransactionCase -def datetime_tz(year, month, day, hour=0, minute=0, second=0, microsecond=0, tzinfo=None): - """ Return a `datetime` object with a given timezone (if given). """ - dt = datetime(year, month, day, hour, minute, second, microsecond) - return timezone(tzinfo).localize(dt) if tzinfo else dt - - -def datetime_str(year, month, day, hour=0, minute=0, second=0, microsecond=0, tzinfo=None): - """ Return a fields.Datetime value with the given timezone. """ - dt = datetime(year, month, day, hour, minute, second, microsecond) - if tzinfo: - dt = timezone(tzinfo).localize(dt).astimezone(utc) - return fields.Datetime.to_string(dt) - - -class TestIntervals(TransactionCase): - - def ints(self, pairs): - recs = self.env['base'] - return [(a, b, recs) for a, b in pairs] - - def test_union(self): - def check(a, b): - a, b = self.ints(a), self.ints(b) - self.assertEqual(list(Intervals(a)), b) - - check([(1, 2), (3, 4)], [(1, 2), (3, 4)]) - check([(1, 2), (2, 4)], [(1, 4)]) - check([(1, 3), (2, 4)], [(1, 4)]) - check([(1, 4), (2, 3)], [(1, 4)]) - check([(3, 4), (1, 2)], [(1, 2), (3, 4)]) - check([(2, 4), (1, 2)], [(1, 4)]) - check([(2, 4), (1, 3)], [(1, 4)]) - check([(2, 3), (1, 4)], [(1, 4)]) - - def test_intersection(self): - def check(a, b, c): - a, b, c = self.ints(a), self.ints(b), self.ints(c) - self.assertEqual(list(Intervals(a) & Intervals(b)), c) - - check([(10, 20)], [(5, 8)], []) - check([(10, 20)], [(5, 10)], []) - check([(10, 20)], [(5, 15)], [(10, 15)]) - check([(10, 20)], [(5, 20)], [(10, 20)]) - check([(10, 20)], [(5, 25)], [(10, 20)]) - check([(10, 20)], [(10, 15)], [(10, 15)]) - check([(10, 20)], [(10, 20)], [(10, 20)]) - check([(10, 20)], [(10, 25)], [(10, 20)]) - check([(10, 20)], [(15, 18)], [(15, 18)]) - check([(10, 20)], [(15, 20)], [(15, 20)]) - check([(10, 20)], [(15, 25)], [(15, 20)]) - check([(10, 20)], [(20, 25)], []) - check( - [(0, 5), (10, 15), (20, 25), (30, 35)], - [(6, 7), (9, 12), (13, 17), (22, 23), (24, 40)], - [(10, 12), (13, 15), (22, 23), (24, 25), (30, 35)], - ) - - def test_difference(self): - def check(a, b, c): - a, b, c = self.ints(a), self.ints(b), self.ints(c) - self.assertEqual(list(Intervals(a) - Intervals(b)), c) - - check([(10, 20)], [(5, 8)], [(10, 20)]) - check([(10, 20)], [(5, 10)], [(10, 20)]) - check([(10, 20)], [(5, 15)], [(15, 20)]) - check([(10, 20)], [(5, 20)], []) - check([(10, 20)], [(5, 25)], []) - check([(10, 20)], [(10, 15)], [(15, 20)]) - check([(10, 20)], [(10, 20)], []) - check([(10, 20)], [(10, 25)], []) - check([(10, 20)], [(15, 18)], [(10, 15), (18, 20)]) - check([(10, 20)], [(15, 20)], [(10, 15)]) - check([(10, 20)], [(15, 25)], [(10, 15)]) - check([(10, 20)], [(20, 25)], [(10, 20)]) - check( - [(0, 5), (10, 15), (20, 25), (30, 35)], - [(6, 7), (9, 12), (13, 17), (22, 23), (24, 40)], - [(0, 5), (12, 13), (20, 22), (23, 24)], - ) - - -class TestErrors(TestResourceCommon): - def setUp(self): - super(TestErrors, self).setUp() - - def test_create_negative_leave(self): - # from > to - with self.assertRaises(ValidationError): - self.env['resource.calendar.leaves'].create({ - 'name': 'error cannot return in the past', - 'resource_id': False, - 'calendar_id': self.calendar_jean.id, - 'date_from': datetime_str(2018, 4, 3, 20, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 3, 0, 0, 0, tzinfo=self.jean.tz), - }) - - with self.assertRaises(ValidationError): - self.env['resource.calendar.leaves'].create({ - 'name': 'error caused by timezones', - 'resource_id': False, - 'calendar_id': self.calendar_jean.id, - 'date_from': datetime_str(2018, 4, 3, 10, 0, 0, tzinfo='UTC'), - 'date_to': datetime_str(2018, 4, 3, 12, 0, 0, tzinfo='Etc/GMT-6') - }) - - -class TestCalendar(TestResourceCommon): - def setUp(self): - super(TestCalendar, self).setUp() - - def test_get_work_hours_count(self): - self.env['resource.calendar.leaves'].create({ - 'name': 'Global Leave', - 'resource_id': False, - 'calendar_id': self.calendar_jean.id, - 'date_from': datetime_str(2018, 4, 3, 0, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 3, 23, 59, 59, tzinfo=self.jean.tz), - }) - - self.env['resource.calendar.leaves'].create({ - 'name': 'leave for Jean', - 'calendar_id': self.calendar_jean.id, - 'resource_id': self.jean.resource_id.id, - 'date_from': datetime_str(2018, 4, 5, 0, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 5, 23, 59, 59, tzinfo=self.jean.tz), - }) - - hours = self.calendar_jean.get_work_hours_count( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.jean.tz), - ) - self.assertEqual(hours, 32) - - hours = self.calendar_jean.get_work_hours_count( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.jean.tz), - compute_leaves=False, - ) - self.assertEqual(hours, 40) - - # leave of size 0 - self.env['resource.calendar.leaves'].create({ - 'name': 'zero_length', - 'calendar_id': self.calendar_patel.id, - 'resource_id': False, - 'date_from': datetime_str(2018, 4, 3, 0, 0, 0, tzinfo=self.patel.tz), - 'date_to': datetime_str(2018, 4, 3, 0, 0, 0, tzinfo=self.patel.tz), - }) - - hours = self.calendar_patel.get_work_hours_count( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), - datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.patel.tz), - ) - self.assertEqual(hours, 35) - - # leave of medium size - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'zero_length', - 'calendar_id': self.calendar_patel.id, - 'resource_id': False, - 'date_from': datetime_str(2018, 4, 3, 9, 0, 0, tzinfo=self.patel.tz), - 'date_to': datetime_str(2018, 4, 3, 12, 0, 0, tzinfo=self.patel.tz), - }) - - hours = self.calendar_patel.get_work_hours_count( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), - datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.patel.tz), - ) - self.assertEqual(hours, 32) - - leave.unlink() - - # leave of very small size - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'zero_length', - 'calendar_id': self.calendar_patel.id, - 'resource_id': False, - 'date_from': datetime_str(2018, 4, 3, 0, 0, 0, tzinfo=self.patel.tz), - 'date_to': datetime_str(2018, 4, 3, 0, 0, 10, tzinfo=self.patel.tz), - }) - - hours = self.calendar_patel.get_work_hours_count( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), - datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.patel.tz), - ) - self.assertEqual(hours, 35) - - leave.unlink() - - # no timezone given should be converted to UTC - # Should equal to a leave between 2018/04/03 10:00:00 and 2018/04/04 10:00:00 - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'no timezone', - 'calendar_id': self.calendar_patel.id, - 'resource_id': False, - 'date_from': datetime_str(2018, 4, 3, 4, 0, 0), - 'date_to': datetime_str(2018, 4, 4, 4, 0, 0), - }) - - hours = self.calendar_patel.get_work_hours_count( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), - datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.patel.tz), - ) - self.assertEqual(hours, 28) - - hours = self.calendar_patel.get_work_hours_count( - datetime_tz(2018, 4, 2, 23, 59, 59, tzinfo=self.patel.tz), - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), - ) - self.assertEqual(hours, 0) - - leave.unlink() - - # 2 weeks calendar week 1 - hours = self.calendar_jules.get_work_hours_count( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jules.tz), - datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.jules.tz), - ) - self.assertEqual(hours, 30) - - # 2 weeks calendar week 1 - hours = self.calendar_jules.get_work_hours_count( - datetime_tz(2018, 4, 16, 0, 0, 0, tzinfo=self.jules.tz), - datetime_tz(2018, 4, 20, 23, 59, 59, tzinfo=self.jules.tz), - ) - self.assertEqual(hours, 30) - - # 2 weeks calendar week 2 - hours = self.calendar_jules.get_work_hours_count( - datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.jules.tz), - datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.jules.tz), - ) - self.assertEqual(hours, 16) - - # 2 weeks calendar week 2, leave during a day where he doesn't work this week - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'Leave Jules week 2', - 'calendar_id': self.calendar_jules.id, - 'resource_id': False, - 'date_from': datetime_str(2018, 4, 11, 4, 0, 0, tzinfo=self.jules.tz), - 'date_to': datetime_str(2018, 4, 13, 4, 0, 0, tzinfo=self.jules.tz), - }) - - hours = self.calendar_jules.get_work_hours_count( - datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.jules.tz), - datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.jules.tz), - ) - self.assertEqual(hours, 16) - - leave.unlink() - - # 2 weeks calendar week 2, leave during a day where he works this week - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'Leave Jules week 2', - 'calendar_id': self.calendar_jules.id, - 'resource_id': False, - 'date_from': datetime_str(2018, 4, 9, 0, 0, 0, tzinfo=self.jules.tz), - 'date_to': datetime_str(2018, 4, 9, 23, 59, 0, tzinfo=self.jules.tz), - }) - - hours = self.calendar_jules.get_work_hours_count( - datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.jules.tz), - datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.jules.tz), - ) - self.assertEqual(hours, 8) - - leave.unlink() - - # leave without calendar, should count for anyone in the company - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'small leave', - 'resource_id': False, - 'date_from': datetime_str(2018, 4, 3, 9, 0, 0, tzinfo=self.patel.tz), - 'date_to': datetime_str(2018, 4, 3, 12, 0, 0, tzinfo=self.patel.tz), - }) - - hours = self.calendar_patel.get_work_hours_count( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), - datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.patel.tz), - ) - self.assertEqual(hours, 32) - - # 2 weeks calendar with date_from and date_to to check work_hours - self.calendar_jules.write({ - "attendance_ids": [ - (5, 0, 0), - (0, 0, { - "name": "Monday (morning)", - "day_period": "morning", - "dayofweek": "0", - "week_type": "0", - "hour_from": 8.0, - "hour_to": 12.0, - "date_from": "2022-01-01", - "date_to": "2022-01-16"}), - (0, 0, { - "name": "Monday (morning)", - "day_period": "morning", - "dayofweek": "0", - "week_type": "0", - "hour_from": 8.0, - "hour_to": 12.0, - "date_from": "2022-01-17"}), - (0, 0, { - "name": "Monday (afternoon)", - "day_period": "afternoon", - "dayofweek": "0", - "week_type": "0", - "hour_from": 16.0, - "hour_to": 20.0, - "date_from": "2022-01-17"}), - (0, 0, { - "name": "Monday (morning)", - "day_period": "morning", - "dayofweek": "0", - "week_type": "1", - "hour_from": 8.0, - "hour_to": 12.0, - "date_from": "2022-01-01", - "date_to": "2022-01-16"}), - (0, 0, { - "name": "Monday (afternoon)", - "day_period": "afternoon", - "dayofweek": "0", - "week_type": "1", - "hour_from": 16.0, - "hour_to": 20.0, - "date_from": "2022-01-01", - "date_to": "2022-01-16"}), - (0, 0, { - "name": "Monday (morning)", - "day_period": "morning", - "dayofweek": "0", - "week_type": "1", - "hour_from": 8.0, - "hour_to": 12.0, - "date_from": "2022-01-17"}), - (0, 0, { - "name": "Monday (afternoon)", - "day_period": "afternoon", - "dayofweek": "0", - "week_type": "1", - "hour_from": 16.0, - "hour_to": 20.0, - "date_from": "2022-01-17"})]}) - hours = self.calendar_jules.get_work_hours_count( - datetime_tz(2022, 1, 10, 0, 0, 0, tzinfo=self.jules.tz), - datetime_tz(2022, 1, 10, 23, 59, 59, tzinfo=self.jules.tz), - ) - self.assertEqual(hours, 4) - hours = self.calendar_jules.get_work_hours_count( - datetime_tz(2022, 1, 17, 0, 0, 0, tzinfo=self.jules.tz), - datetime_tz(2022, 1, 17, 23, 59, 59, tzinfo=self.jules.tz), - ) - self.assertEqual(hours, 8) - - def test_calendar_working_hours_count(self): - calendar = self.env.ref('resource.resource_calendar_std_35h') - calendar.tz = 'UTC' - res = calendar.get_work_hours_count( - fields.Datetime.from_string('2017-05-03 14:03:00'), # Wednesday (8:00-12:00, 13:00-16:00) - fields.Datetime.from_string('2017-05-04 11:03:00'), # Thursday (8:00-12:00, 13:00-16:00) - compute_leaves=False) - self.assertEqual(res, 5.0) - - def test_calendar_working_hours_24(self): - self.att_4 = self.env['resource.calendar.attendance'].create({ - 'name': 'Att4', - 'calendar_id': self.calendar_jean.id, - 'dayofweek': '2', - 'hour_from': 0, - 'hour_to': 24 - }) - res = self.calendar_jean.get_work_hours_count( - datetime_tz(2018, 6, 19, 23, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 6, 21, 1, 0, 0, tzinfo=self.jean.tz), - compute_leaves=True) - self.assertAlmostEqual(res, 24.0) - - def test_plan_hours(self): - self.env['resource.calendar.leaves'].create({ - 'name': 'global', - 'calendar_id': self.calendar_jean.id, - 'resource_id': False, - 'date_from': datetime_str(2018, 4, 11, 0, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 11, 23, 59, 59, tzinfo=self.jean.tz), - }) - - time = self.calendar_jean.plan_hours(2, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) - self.assertEqual(time, datetime_tz(2018, 4, 10, 10, 0, 0, tzinfo=self.jean.tz)) - - time = self.calendar_jean.plan_hours(20, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) - self.assertEqual(time, datetime_tz(2018, 4, 12, 12, 0, 0, tzinfo=self.jean.tz)) - - time = self.calendar_jean.plan_hours(5, datetime_tz(2018, 4, 10, 15, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) - self.assertEqual(time, datetime_tz(2018, 4, 12, 12, 0, 0, tzinfo=self.jean.tz)) - - # negative planning - time = self.calendar_jean.plan_hours(-10, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) - self.assertEqual(time, datetime_tz(2018, 4, 6, 14, 0, 0, tzinfo=self.jean.tz)) - - # zero planning with holidays - time = self.calendar_jean.plan_hours(0, datetime_tz(2018, 4, 11, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) - self.assertEqual(time, datetime_tz(2018, 4, 12, 8, 0, 0, tzinfo=self.jean.tz)) - time = self.calendar_jean.plan_hours(0, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) - self.assertEqual(time, datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.jean.tz)) - - # very small planning - time = self.calendar_jean.plan_hours(0.0002, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) - self.assertEqual(time, datetime_tz(2018, 4, 10, 8, 0, 0, 720000, tzinfo=self.jean.tz)) - - # huge planning - time = self.calendar_jean.plan_hours(3000, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) - self.assertEqual(time, datetime_tz(2019, 9, 16, 16, 0, 0, tzinfo=self.jean.tz)) - - def test_plan_days(self): - self.env['resource.calendar.leaves'].create({ - 'name': 'global', - 'calendar_id': self.calendar_jean.id, - 'resource_id': False, - 'date_from': datetime_str(2018, 4, 11, 0, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 11, 23, 59, 59, tzinfo=self.jean.tz), - }) - - time = self.calendar_jean.plan_days(1, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) - self.assertEqual(time, datetime_tz(2018, 4, 10, 16, 0, 0, tzinfo=self.jean.tz)) - - time = self.calendar_jean.plan_days(3, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) - self.assertEqual(time, datetime_tz(2018, 4, 12, 16, 0, 0, tzinfo=self.jean.tz)) - - time = self.calendar_jean.plan_days(4, datetime_tz(2018, 4, 10, 16, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) - self.assertEqual(time, datetime_tz(2018, 4, 17, 16, 0, 0, tzinfo=self.jean.tz)) - - # negative planning - time = self.calendar_jean.plan_days(-10, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) - self.assertEqual(time, datetime_tz(2018, 3, 27, 8, 0, 0, tzinfo=self.jean.tz)) - - # zero planning - time = self.calendar_jean.plan_days(0, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) - self.assertEqual(time, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz)) - - # very small planning returns False in this case - # TODO: decide if this behaviour is alright - time = self.calendar_jean.plan_days(0.0002, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) - self.assertEqual(time, False) - - # huge planning - # TODO: Same as above - # NOTE: Maybe allow to set a max limit to the method - time = self.calendar_jean.plan_days(3000, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) - self.assertEqual(time, False) - - def test_closest_time(self): - # Calendar: - # Tuesdays 8-16 - # Fridays 8-13 and 16-23 - dt = datetime_tz(2020, 4, 2, 7, 0, 0, tzinfo=self.john.tz) - calendar_dt = self.calendar_john._get_closest_work_time(dt) - self.assertFalse(calendar_dt, "It should not return any value for unattended days") - - dt = datetime_tz(2020, 4, 3, 7, 0, 0, tzinfo=self.john.tz) - range_start = datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz) - range_end = datetime_tz(2020, 4, 3, 19, 0, 0, tzinfo=self.john.tz) - calendar_dt = self.calendar_john._get_closest_work_time(dt, search_range=(range_start, range_end)) - self.assertFalse(calendar_dt, "It should not return any value if dt outside of range") - - dt = datetime_tz(2020, 4, 3, 7, 0, 0, tzinfo=self.john.tz) # before - start = datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz) - calendar_dt = self.calendar_john._get_closest_work_time(dt) - self.assertEqual(calendar_dt, start, "It should return the start of the day") - - dt = datetime_tz(2020, 4, 3, 10, 0, 0, tzinfo=self.john.tz) # after - start = datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz) - calendar_dt = self.calendar_john._get_closest_work_time(dt) - self.assertEqual(calendar_dt, start, "It should return the start of the closest attendance") - - dt = datetime_tz(2020, 4, 3, 7, 0, 0, tzinfo=self.john.tz) # before - end = datetime_tz(2020, 4, 3, 13, 0, 0, tzinfo=self.john.tz) - calendar_dt = self.calendar_john._get_closest_work_time(dt, match_end=True) - self.assertEqual(calendar_dt, end, "It should return the end of the closest attendance") - - dt = datetime_tz(2020, 4, 3, 14, 0, 0, tzinfo=self.john.tz) # after - end = datetime_tz(2020, 4, 3, 13, 0, 0, tzinfo=self.john.tz) - calendar_dt = self.calendar_john._get_closest_work_time(dt, match_end=True) - self.assertEqual(calendar_dt, end, "It should return the end of the closest attendance") - - dt = datetime_tz(2020, 4, 3, 0, 0, 0, tzinfo=self.john.tz) - start = datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz) - calendar_dt = self.calendar_john._get_closest_work_time(dt) - self.assertEqual(calendar_dt, start, "It should return the start of the closest attendance") - - dt = datetime_tz(2020, 4, 3, 23, 59, 59, tzinfo=self.john.tz) - end = datetime_tz(2020, 4, 3, 23, 0, 0, tzinfo=self.john.tz) - calendar_dt = self.calendar_john._get_closest_work_time(dt, match_end=True) - self.assertEqual(calendar_dt, end, "It should return the end of the closest attendance") - - # with a resource specific attendance - self.env['resource.calendar.attendance'].create({ - 'name': 'Att4', - 'calendar_id': self.calendar_john.id, - 'dayofweek': '4', - 'hour_from': 5, - 'hour_to': 6, - 'resource_id': self.john.resource_id.id, - }) - dt = datetime_tz(2020, 4, 3, 5, 0, 0, tzinfo=self.john.tz) - start = datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz) - calendar_dt = self.calendar_john._get_closest_work_time(dt) - self.assertEqual(calendar_dt, start, "It should not take into account resouce specific attendances") - - dt = datetime_tz(2020, 4, 3, 5, 0, 0, tzinfo=self.john.tz) - start = datetime_tz(2020, 4, 3, 5, 0, 0, tzinfo=self.john.tz) - calendar_dt = self.calendar_john._get_closest_work_time(dt, resource=self.john.resource_id) - self.assertEqual(calendar_dt, start, "It should have taken john's specific attendances") - - dt = datetime_tz(2020, 4, 4, 1, 0, 0, tzinfo='UTC') # The next day in UTC, but still the 3rd in john's timezone (America/Los_Angeles) - start = datetime_tz(2020, 4, 3, 16, 0, 0, tzinfo=self.john.tz) - calendar_dt = self.calendar_john._get_closest_work_time(dt, resource=self.john.resource_id) - self.assertEqual(calendar_dt, start, "It should have found the attendance on the 3rd April") - - def test_resource_calendar_update(self): - """ Ensure leave calendar gets set correctly when updating resource calendar. """ - holiday = self.env['resource.calendar.leaves'].create({ - 'name': "May Day", - 'calendar_id': self.calendar_jean.id, - 'date_from': datetime_str(2024, 5, 1, 0, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2024, 5, 1, 23, 59, 59, tzinfo=self.jean.tz), - }) - - # Jean takes a leave - leave = self.env['resource.calendar.leaves'].create({ - 'name': "Jean is AFK", - 'calendar_id': self.calendar_jean.id, - 'resource_id': self.jean.resource_id.id, - 'date_from': datetime_str(2024, 5, 10, 8, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2024, 5, 10, 16, 0, 0, tzinfo=self.jean.tz), - }) - - # Jean changes working schedule to Jules' - self.jean.resource_calendar_id = self.calendar_jules - self.assertEqual(leave.calendar_id, self.calendar_jules, "leave calendar should be updated") - self.assertEqual(holiday.calendar_id, self.calendar_jean, "global leave shouldn't change") - - -class TestResMixin(TestResourceCommon): - - def test_adjust_calendar(self): - # Calendar: - # Tuesdays 8-16 - # Fridays 8-13 and 16-23 - result = self.john._adjust_to_calendar( - datetime_tz(2020, 4, 3, 9, 0, 0, tzinfo=self.john.tz), - datetime_tz(2020, 4, 3, 14, 0, 0, tzinfo=self.john.tz), - ) - self.assertEqual(result[self.john],( - datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz), - datetime_tz(2020, 4, 3, 13, 0, 0, tzinfo=self.john.tz), - )) - - result = self.john._adjust_to_calendar( - datetime_tz(2020, 4, 3, 13, 1, 0, tzinfo=self.john.tz), - datetime_tz(2020, 4, 3, 14, 0, 0, tzinfo=self.john.tz), - ) - self.assertEqual(result[self.john],( - datetime_tz(2020, 4, 3, 16, 0, 0, tzinfo=self.john.tz), - datetime_tz(2020, 4, 3, 23, 0, 0, tzinfo=self.john.tz), - )) - - result = self.john._adjust_to_calendar( - datetime_tz(2020, 4, 4, 9, 0, 0, tzinfo=self.john.tz), # both a day without attendance - datetime_tz(2020, 4, 4, 14, 0, 0, tzinfo=self.john.tz), - ) - self.assertEqual(result[self.john], (None, None)) - - result = self.john._adjust_to_calendar( - datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz), - datetime_tz(2020, 4, 4, 14, 0, 0, tzinfo=self.john.tz), # day without attendance - ) - self.assertEqual(result[self.john], ( - datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz), - datetime_tz(2020, 4, 3, 23, 0, 0, tzinfo=self.john.tz), - )) - - result = self.john._adjust_to_calendar( - datetime_tz(2020, 4, 2, 8, 0, 0, tzinfo=self.john.tz), # day without attendance - datetime_tz(2020, 4, 3, 14, 0, 0, tzinfo=self.john.tz), - ) - self.assertEqual(result[self.john], ( - datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz), - datetime_tz(2020, 4, 3, 13, 0, 0, tzinfo=self.john.tz), - )) - - result = self.john._adjust_to_calendar( - datetime_tz(2020, 3, 31, 0, 0, 0, tzinfo=self.john.tz), # search between Tuesday and Thursday - datetime_tz(2020, 4, 2, 23, 59, 59, tzinfo=self.john.tz), - ) - self.assertEqual(result[self.john], ( - datetime_tz(2020, 3, 31, 8, 0, tzinfo=self.john.tz), - datetime_tz(2020, 3, 31, 16, 0, tzinfo=self.john.tz), - )) - - result = self.john._adjust_to_calendar( - datetime_tz(2020, 3, 31, 0, 0, 0, tzinfo=self.john.tz), # search between Tuesday and Friday - datetime_tz(2020, 4, 3, 23, 59, 59, tzinfo=self.john.tz), - ) - result = self.john._adjust_to_calendar( - datetime_tz(2020, 3, 31, 8, 0, 0, tzinfo=self.john.tz), - datetime_tz(2020, 4, 3, 23, 0, 0, tzinfo=self.john.tz), - ) - # It should find the start and end within the search range - result = self.paul._adjust_to_calendar( - datetime_tz(2020, 4, 2, 2, 0, 0, tzinfo='UTC'), - datetime_tz(2020, 4, 3, 1, 59, 59, tzinfo='UTC'), - ) - - self.assertEqual(result[self.paul], ( - datetime_tz(2020, 4, 2, 4, 0, tzinfo='UTC'), - datetime_tz(2020, 4, 2, 18, 0, tzinfo='UTC') - ), "It should have found the start and end of the shift on the same day on April 2nd, 2020") - - def test_adjust_calendar_timezone_before(self): - # Calendar: - # Every day 8-16 - self.jean.tz = 'Asia/Tokyo' - self.calendar_jean.tz = 'Europe/Brussels' - - result = self.jean._adjust_to_calendar( - datetime_tz(2020, 4, 1, 0, 0, 0, tzinfo='Asia/Tokyo'), - datetime_tz(2020, 4, 1, 23, 59, 59, tzinfo='Asia/Tokyo'), - ) - self.assertEqual(result[self.jean], ( - datetime_tz(2020, 4, 1, 8, 0, 0, tzinfo='Asia/Tokyo'), - datetime_tz(2020, 4, 1, 16, 0, 0, tzinfo='Asia/Tokyo'), - ), "It should have found a starting time the 1st") - - def test_adjust_calendar_timezone_after(self): - # Calendar: - # Tuesdays 8-16 - # Fridays 8-13 and 16-23 - tz = 'Europe/Brussels' - self.john.tz = tz - result = self.john._adjust_to_calendar( - datetime(2020, 4, 2, 23, 0, 0), # The previous day in UTC, but the 3rd in Europe/Brussels - datetime(2020, 4, 3, 20, 0, 0), - ) - self.assertEqual(result[self.john], ( - datetime(2020, 4, 3, 6, 0, 0), - datetime(2020, 4, 3, 21, 0, 0), - ), "It should have found a starting time the 3rd") - - def test_work_days_data(self): - # Looking at Jean's calendar - - # Viewing it as Jean - data = self.jean._get_work_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 16, 0, 0, tzinfo=self.jean.tz), - )[self.jean.id] - self.assertEqual(data, {'days': 5, 'hours': 40}) - - # Viewing it as Patel - # Views from 2018/04/01 20:00:00 to 2018/04/06 12:00:00 - data = self.jean._get_work_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), - datetime_tz(2018, 4, 6, 16, 0, 0, tzinfo=self.patel.tz), - )[self.jean.id] - self.assertEqual(data, {'days': 4.5, 'hours': 36}) # We see only 36 hours - - # Viewing it as John - # Views from 2018/04/02 09:00:00 to 2018/04/07 02:00:00 - data = self.jean._get_work_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.john.tz), - datetime_tz(2018, 4, 6, 16, 0, 0, tzinfo=self.john.tz), - )[self.jean.id] - # still showing as 5 days because of rounding, but we see only 39 hours - self.assertEqual(data, {'days': 4.875, 'hours': 39}) - - # Looking at John's calendar - - # Viewing it as Jean - # Views from 2018/04/01 15:00:00 to 2018/04/06 14:00:00 - data = self.john._get_work_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), - )[self.john.id] - self.assertEqual(data, {'days': 1.417, 'hours': 13}) - - # Viewing it as Patel - # Views from 2018/04/01 11:00:00 to 2018/04/06 10:00:00 - data = self.john._get_work_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.patel.tz), - )[self.john.id] - self.assertEqual(data, {'days': 1.167, 'hours': 10}) - - # Viewing it as John - data = self.john._get_work_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.john.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.john.tz), - )[self.john.id] - self.assertEqual(data, {'days': 2, 'hours': 20}) - - # using Jean as a timezone reference - data = self.john._get_work_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.john.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.john.tz), - calendar=self.calendar_jean, - )[self.john.id] - self.assertEqual(data, {'days': 5, 'hours': 40}) - - # half days - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'half', - 'calendar_id': self.calendar_jean.id, - 'resource_id': self.jean.resource_id.id, - 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 2, 14, 0, 0, tzinfo=self.jean.tz), - }) - - data = self.jean._get_work_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), - )[self.jean.id] - self.assertEqual(data, {'days': 4.5, 'hours': 36}) - - # using John as a timezone reference, leaves are outside attendances - data = self.john._get_work_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.john.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.john.tz), - calendar=self.calendar_jean, - )[self.john.id] - self.assertEqual(data, {'days': 5, 'hours': 40}) - - leave.unlink() - - # leave size 0 - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'zero', - 'calendar_id': self.calendar_jean.id, - 'resource_id': False, - 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - }) - - data = self.jean._get_work_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), - )[self.jean.id] - self.assertEqual(data, {'days': 5, 'hours': 40}) - - leave.unlink() - - # leave very small size - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'small', - 'calendar_id': self.calendar_jean.id, - 'resource_id': False, - 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 2, 10, 0, 1, tzinfo=self.jean.tz), - }) - - data = self.jean._get_work_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), - )[self.jean.id] - self.assertEqual(data['days'], 5) - self.assertAlmostEqual(data['hours'], 40, 2) - - def test_leaves_days_data(self): - # Jean takes a leave - self.env['resource.calendar.leaves'].create({ - 'name': 'Jean is visiting India', - 'calendar_id': self.jean.resource_calendar_id.id, - 'resource_id': self.jean.resource_id.id, - 'date_from': datetime_str(2018, 4, 10, 8, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 10, 16, 0, 0, tzinfo=self.jean.tz), - }) - - # John takes a leave for Jean - self.env['resource.calendar.leaves'].create({ - 'name': 'Jean is comming in USA', - 'calendar_id': self.jean.resource_calendar_id.id, - 'resource_id': self.jean.resource_id.id, - 'date_from': datetime_str(2018, 4, 12, 8, 0, 0, tzinfo=self.john.tz), - 'date_to': datetime_str(2018, 4, 12, 16, 0, 0, tzinfo=self.john.tz), - }) - - # Jean asks to see how much leave he has taken - data = self.jean._get_leave_days_data_batch( - datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.jean.tz), - )[self.jean.id] - # Sees only 1 day and 8 hours because, as john is in UTC-7 the second leave is not in - # the attendances of Jean - self.assertEqual(data, {'days': 1, 'hours': 8}) - - # Patel Asks to see when Jean has taken some leaves - # Patel should see the same - data = self.jean._get_leave_days_data_batch( - datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.patel.tz), - datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.patel.tz), - )[self.jean.id] - self.assertEqual(data, {'days': 1, 'hours': 8}) - - # use Patel as a resource, jean's leaves are not visible - datas = self.patel._get_leave_days_data_batch( - datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.patel.tz), - datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.patel.tz), - calendar=self.calendar_jean, - )[self.patel.id] - self.assertEqual(datas['days'], 0) - self.assertEqual(datas['hours'], 0) - - # Jean takes a leave for John - # Gives 3 hours (3/8 of a day) - self.env['resource.calendar.leaves'].create({ - 'name': 'John is sick', - 'calendar_id': self.john.resource_calendar_id.id, - 'resource_id': self.john.resource_id.id, - 'date_from': datetime_str(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 10, 20, 0, 0, tzinfo=self.jean.tz), - }) - - # John takes a leave - # Gives all day (12 hours) - self.env['resource.calendar.leaves'].create({ - 'name': 'John goes to holywood', - 'calendar_id': self.john.resource_calendar_id.id, - 'resource_id': self.john.resource_id.id, - 'date_from': datetime_str(2018, 4, 13, 7, 0, 0, tzinfo=self.john.tz), - 'date_to': datetime_str(2018, 4, 13, 18, 0, 0, tzinfo=self.john.tz), - }) - - # John asks how much leaves he has - # He sees that he has only 15 hours of leave in his attendances - data = self.john._get_leave_days_data_batch( - datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.john.tz), - datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.john.tz), - )[self.john.id] - # For some reason float_round fails to limit precision to 3 decimals here - self.assertEqual(data, {'days': 0.9580000000000001, 'hours': 10}) - - # half days - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'half', - 'calendar_id': self.calendar_jean.id, - 'resource_id': self.jean.resource_id.id, - 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 2, 14, 0, 0, tzinfo=self.jean.tz), - }) - - data = self.jean._get_leave_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), - )[self.jean.id] - self.assertEqual(data, {'days': 0.5, 'hours': 4}) - - leave.unlink() - - # leave size 0 - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'zero', - 'calendar_id': self.calendar_jean.id, - 'resource_id': False, - 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - }) - - data = self.jean._get_leave_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), - )[self.jean.id] - self.assertEqual(data, {'days': 0, 'hours': 0}) - - leave.unlink() - - # leave very small size - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'small', - 'calendar_id': self.calendar_jean.id, - 'resource_id': False, - 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 2, 10, 0, 1, tzinfo=self.jean.tz), - }) - - data = self.jean._get_leave_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), - )[self.jean.id] - self.assertEqual(data['days'], 0) - self.assertAlmostEqual(data['hours'], 0, 2) - - leave.unlink() - - def test_list_leaves(self): - jean_leave = self.env['resource.calendar.leaves'].create({ - 'name': "Jean's son is sick", - 'calendar_id': self.jean.resource_calendar_id.id, - 'resource_id': False, - 'date_from': datetime_str(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 10, 23, 59, 59, tzinfo=self.jean.tz), - }) - - leaves = self.jean.list_leaves( - datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.jean.tz), - ) - self.assertEqual(leaves, [(date(2018, 4, 10), 8, jean_leave)]) - - # half days - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'half', - 'calendar_id': self.jean.resource_calendar_id.id, - 'resource_id': self.jean.resource_id.id, - 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 2, 14, 0, 0, tzinfo=self.jean.tz), - }) - - leaves = self.jean.list_leaves( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), - ) - self.assertEqual(leaves, [(date(2018, 4, 2), 4, leave)]) - - leave.unlink() - - # very small size - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'small', - 'calendar_id': self.jean.resource_calendar_id.id, - 'resource_id': self.jean.resource_id.id, - 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 2, 10, 0, 1, tzinfo=self.jean.tz), - }) - - leaves = self.jean.list_leaves( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), - ) - self.assertEqual(len(leaves), 1) - self.assertEqual(leaves[0][0], date(2018, 4, 2)) - self.assertAlmostEqual(leaves[0][1], 0, 2) - self.assertEqual(leaves[0][2].id, leave.id) - - leave.unlink() - - # size 0 - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'zero', - 'calendar_id': self.jean.resource_calendar_id.id, - 'resource_id': self.jean.resource_id.id, - 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - }) - - leaves = self.jean.list_leaves( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), - ) - self.assertEqual(leaves, []) - - leave.unlink() - - def test_list_work_time_per_day(self): - working_time = self.john.list_work_time_per_day( - datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.john.tz), - datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.john.tz), - ) - self.assertEqual(working_time, [ - (date(2018, 4, 10), 8), - (date(2018, 4, 13), 12), - ]) - - # change john's resource's timezone - self.john.resource_id.tz = 'Europe/Brussels' - self.assertEqual(self.john.tz, 'Europe/Brussels') - self.assertEqual(self.calendar_john.tz, 'America/Los_Angeles') - working_time = self.john.list_work_time_per_day( - datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.john.tz), - datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.john.tz), - ) - self.assertEqual(working_time, [ - (date(2018, 4, 10), 8), - (date(2018, 4, 13), 12), - ]) - - # half days - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'small', - 'calendar_id': self.jean.resource_calendar_id.id, - 'resource_id': self.jean.resource_id.id, - 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 2, 14, 0, 0, tzinfo=self.jean.tz), - }) - - working_time = self.jean.list_work_time_per_day( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), - ) - self.assertEqual(working_time, [ - (date(2018, 4, 2), 4), - (date(2018, 4, 3), 8), - (date(2018, 4, 4), 8), - (date(2018, 4, 5), 8), - (date(2018, 4, 6), 8), - ]) - - leave.unlink() - - # very small size - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'small', - 'calendar_id': self.jean.resource_calendar_id.id, - 'resource_id': self.jean.resource_id.id, - 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 2, 10, 0, 1, tzinfo=self.jean.tz), - }) - - working_time = self.jean.list_work_time_per_day( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), - ) - self.assertEqual(len(working_time), 5) - self.assertEqual(working_time[0][0], date(2018, 4, 2)) - self.assertAlmostEqual(working_time[0][1], 8, 2) - - leave.unlink() - - # size 0 - leave = self.env['resource.calendar.leaves'].create({ - 'name': 'zero', - 'calendar_id': self.jean.resource_calendar_id.id, - 'resource_id': self.jean.resource_id.id, - 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - 'date_to': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), - }) - - working_time = self.jean.list_work_time_per_day( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), - datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), - ) - self.assertEqual(working_time, [ - (date(2018, 4, 2), 8), - (date(2018, 4, 3), 8), - (date(2018, 4, 4), 8), - (date(2018, 4, 5), 8), - (date(2018, 4, 6), 8), - ]) - - leave.unlink() - - -class TestTimezones(TestResourceCommon): - def setUp(self): - super(TestTimezones, self).setUp() - - self.tz1 = 'Etc/GMT+6' - self.tz2 = 'Europe/Brussels' - self.tz3 = 'Etc/GMT-10' - self.tz4 = 'Etc/GMT+10' - - def test_work_hours_count(self): - # When no timezone => UTC - count = self.calendar_jean.get_work_hours_count( - datetime_tz(2018, 4, 10, 8, 0, 0), - datetime_tz(2018, 4, 10, 12, 0, 0), - ) - self.assertEqual(count, 4) - - # This timezone is not the same as the calendar's one - count = self.calendar_jean.get_work_hours_count( - datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.tz1), - datetime_tz(2018, 4, 10, 12, 0, 0, tzinfo=self.tz1), - ) - self.assertEqual(count, 0) - - # Using two different timezones - # 10-04-2018 06:00:00 - 10-04-2018 02:00:00 - count = self.calendar_jean.get_work_hours_count( - datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.tz2), - datetime_tz(2018, 4, 10, 12, 0, 0, tzinfo=self.tz3), - ) - self.assertEqual(count, 0) - - # Using two different timezones - # 2018-4-10 06:00:00 - 2018-4-10 22:00:00 - count = self.calendar_jean.get_work_hours_count( - datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.tz2), - datetime_tz(2018, 4, 10, 12, 0, 0, tzinfo=self.tz4), - ) - self.assertEqual(count, 8) - - def test_plan_hours(self): - dt = self.calendar_jean.plan_hours(10, datetime_tz(2018, 4, 10, 8, 0, 0)) - self.assertEqual(dt, datetime_tz(2018, 4, 11, 10, 0, 0)) - - dt = self.calendar_jean.plan_hours(10, datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.tz4)) - self.assertEqual(dt, datetime_tz(2018, 4, 11, 22, 0, 0, tzinfo=self.tz4)) - - def test_plan_days(self): - dt = self.calendar_jean.plan_days(2, datetime_tz(2018, 4, 10, 8, 0, 0)) - self.assertEqual(dt, datetime_tz(2018, 4, 11, 14, 0, 0)) - - # We lose one day because of timezone - dt = self.calendar_jean.plan_days(2, datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.tz4)) - self.assertEqual(dt, datetime_tz(2018, 4, 12, 4, 0, 0, tzinfo=self.tz4)) - - def test_work_data(self): - # 09-04-2018 10:00:00 - 13-04-2018 18:00:00 - data = self.jean._get_work_days_data_batch( - datetime_tz(2018, 4, 9, 8, 0, 0), - datetime_tz(2018, 4, 13, 16, 0, 0), - )[self.jean.id] - self.assertEqual(data, {'days': 4.75, 'hours': 38}) - - # 09-04-2018 00:00:00 - 13-04-2018 08:00:00 - data = self.jean._get_work_days_data_batch( - datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz3), - datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz3), - )[self.jean.id] - self.assertEqual(data, {'days': 4, 'hours': 32}) - - # 09-04-2018 08:00:00 - 14-04-2018 12:00:00 - data = self.jean._get_work_days_data_batch( - datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), - datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz4), - )[self.jean.id] - self.assertEqual(data, {'days': 5, 'hours': 40}) - - # Jules with 2 weeks calendar - # 02-04-2018 00:00:00 - 6-04-2018 23:59:59 - data = self.jules._get_work_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jules.tz), - datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.jules.tz), - )[self.jules.id] - self.assertEqual(data, {'days': 4, 'hours': 30}) - - # Jules with 2 weeks calendar - # 02-04-2018 00:00:00 - 14-04-2018 23:59:59 - data = self.jules._get_work_days_data_batch( - datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jules.tz), - datetime_tz(2018, 4, 14, 23, 59, 59, tzinfo=self.jules.tz), - )[self.jules.id] - self.assertEqual(data, {'days': 6, 'hours': 46}) - - # Jules with 2 weeks calendar - # 12-29-2014 00:00:00 - 27-12-2019 23:59:59 => 261 weeks - # 130 weeks type 1: 131*4 = 524 days and 131*30 = 3930 hours - # 131 weeks type 2: 130*2 = 260 days and 130*16 = 2080 hours - data = self.jules._get_work_days_data_batch( - datetime_tz(2014, 12, 29, 0, 0, 0, tzinfo=self.jules.tz), - datetime_tz(2019, 12, 27, 23, 59, 59, tzinfo=self.jules.tz), - )[self.jules.id] - self.assertEqual(data, {'days': 784, 'hours': 6010}) - - def test_leave_data(self): - self.env['resource.calendar.leaves'].create({ - 'name': '', - 'calendar_id': self.jean.resource_calendar_id.id, - 'resource_id': self.jean.resource_id.id, - 'date_from': datetime_str(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), - 'date_to': datetime_str(2018, 4, 9, 14, 0, 0, tzinfo=self.tz2), - }) - - # 09-04-2018 10:00:00 - 13-04-2018 18:00:00 - data = self.jean._get_leave_days_data_batch( - datetime_tz(2018, 4, 9, 8, 0, 0), - datetime_tz(2018, 4, 13, 16, 0, 0), - )[self.jean.id] - self.assertEqual(data, {'days': 0.5, 'hours': 4}) - - # 09-04-2018 00:00:00 - 13-04-2018 08:00:00 - data = self.jean._get_leave_days_data_batch( - datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz3), - datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz3), - )[self.jean.id] - self.assertEqual(data, {'days': 0.75, 'hours': 6}) - - # 09-04-2018 08:00:00 - 14-04-2018 12:00:00 - data = self.jean._get_leave_days_data_batch( - datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), - datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz4), - )[self.jean.id] - self.assertEqual(data, {'days': 0.75, 'hours': 6}) - - def test_leaves(self): - leave = self.env['resource.calendar.leaves'].create({ - 'name': '', - 'calendar_id': self.jean.resource_calendar_id.id, - 'resource_id': self.jean.resource_id.id, - 'date_from': datetime_str(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), - 'date_to': datetime_str(2018, 4, 9, 14, 0, 0, tzinfo=self.tz2), - }) - - # 09-04-2018 10:00:00 - 13-04-2018 18:00:00 - leaves = self.jean.list_leaves( - datetime_tz(2018, 4, 9, 8, 0, 0), - datetime_tz(2018, 4, 13, 16, 0, 0), - ) - self.assertEqual(leaves, [(date(2018, 4, 9), 4, leave)]) - - # 09-04-2018 00:00:00 - 13-04-2018 08:00:00 - leaves = self.jean.list_leaves( - datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz3), - datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz3), - ) - self.assertEqual(leaves, [(date(2018, 4, 9), 6, leave)]) - - # 09-04-2018 08:00:00 - 14-04-2018 12:00:00 - leaves = self.jean.list_leaves( - datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), - datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz4), - ) - self.assertEqual(leaves, [(date(2018, 4, 9), 6, leave)]) - - def test_works(self): - work = self.jean.list_work_time_per_day( - datetime_tz(2018, 4, 9, 8, 0, 0), - datetime_tz(2018, 4, 13, 16, 0, 0), - ) - self.assertEqual(work, [ - (date(2018, 4, 9), 6), - (date(2018, 4, 10), 8), - (date(2018, 4, 11), 8), - (date(2018, 4, 12), 8), - (date(2018, 4, 13), 8), - ]) - - work = self.jean.list_work_time_per_day( - datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz3), - datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz3), - ) - self.assertEqual(len(work), 4) - self.assertEqual(work, [ - (date(2018, 4, 9), 8), - (date(2018, 4, 10), 8), - (date(2018, 4, 11), 8), - (date(2018, 4, 12), 8), - ]) - - work = self.jean.list_work_time_per_day( - datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), - datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz4), - ) - self.assertEqual(work, [ - (date(2018, 4, 9), 8), - (date(2018, 4, 10), 8), - (date(2018, 4, 11), 8), - (date(2018, 4, 12), 8), - (date(2018, 4, 13), 8), - ]) - - @freeze_time("2022-09-21 15:30:00", tz_offset=-10) - def test_unavailable_intervals(self): - resource = self.env['resource.resource'].create({ - 'name': 'resource', - 'tz': self.tz3, - }) - intervals = resource._get_unavailable_intervals(datetime(2022, 9, 21), datetime(2022, 9, 22)) - self.assertEqual(list(intervals.values())[0], [ - (datetime(2022, 9, 21, 0, 0, tzinfo=utc), datetime(2022, 9, 21, 6, 0, tzinfo=utc)), - (datetime(2022, 9, 21, 10, 0, tzinfo=utc), datetime(2022, 9, 21, 11, 0, tzinfo=utc)), - (datetime(2022, 9, 21, 15, 0, tzinfo=utc), datetime(2022, 9, 22, 0, 0, tzinfo=utc)), - ]) - class TestResource(TestResourceCommon): def test_calendars_validity_within_period(self): @@ -1286,7 +20,7 @@ class TestResource(TestResourceCommon): interval = Intervals([( utc.localize(datetime(2021, 7, 1, 8, 0, 0)), utc.localize(datetime(2021, 7, 30, 17, 0, 0)), - self.env['resource.calendar.attendance'] + self.env['resource.calendar.attendance'], )]) self.assertEqual(1, len(calendars), "The dict returned by calendars validity should only have 1 entry") @@ -1362,3 +96,28 @@ class TestResource(TestResourceCommon): """ self.env.company.resource_calendar_id = self.two_weeks_resource self.env['res.company'].create({'name': 'New Company'}) + + def test_empty_working_hours_for_two_weeks_resource(self): + resource = self._define_calendar_2_weeks( + 'Two weeks resource', + [], + 'Europe/Brussels', + ) + self.env['resource.calendar.attendance'].create({ + 'name': 'test', + 'calendar_id': resource.id, + 'hour_from': 0, + 'hour_to': 0, + }) + resource_hour = resource._get_hours_per_day() + self.assertEqual(resource_hour, 0.0) + + def test_resource_without_calendar(self): + resource = self.env['resource.resource'].create({ + 'name': 'resource', + 'calendar_id': False, + }) + + resource.company_id.resource_calendar_id = False + unavailabilities = resource._get_unavailable_intervals(datetime(2024, 7, 11), datetime(2024, 7, 12)) + self.assertFalse(unavailabilities) diff --git a/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_resource_errors.py b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_resource_errors.py new file mode 100644 index 0000000..f8b41a2 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_resource_errors.py @@ -0,0 +1,28 @@ +from odoo.exceptions import ValidationError + +from odoo.addons.test_resource.tests.common import TestResourceCommon + + +class TestErrors(TestResourceCommon): + def setUp(self): + super().setUp() + + def test_create_negative_leave(self): + # from > to + with self.assertRaises(ValidationError): + self.env['resource.calendar.leaves'].create({ + 'name': 'error cannot return in the past', + 'resource_id': False, + 'calendar_id': self.calendar_jean.id, + 'date_from': self.datetime_str(2018, 4, 3, 20, 0, 0, tzinfo=self.jean.tz), + 'date_to': self.datetime_str(2018, 4, 3, 0, 0, 0, tzinfo=self.jean.tz), + }) + + with self.assertRaises(ValidationError): + self.env['resource.calendar.leaves'].create({ + 'name': 'error caused by timezones', + 'resource_id': False, + 'calendar_id': self.calendar_jean.id, + 'date_from': self.datetime_str(2018, 4, 3, 10, 0, 0, tzinfo='UTC'), + 'date_to': self.datetime_str(2018, 4, 3, 12, 0, 0, tzinfo='Etc/GMT-6'), + }) diff --git a/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_timezones.py b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_timezones.py new file mode 100644 index 0000000..ea92142 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_timezones.py @@ -0,0 +1,256 @@ +from datetime import date, datetime + +from freezegun import freeze_time +from pytz import utc + +from odoo.addons.test_resource.tests.common import TestResourceCommon + + +class TestTimezones(TestResourceCommon): + def setUp(self): + super().setUp() + + self.tz1 = 'Etc/GMT+6' + self.tz2 = 'Europe/Brussels' + self.tz3 = 'Etc/GMT-10' + self.tz4 = 'Etc/GMT+10' + + def test_work_hours_count(self): + # When no timezone => UTC + count = self.calendar_jean.get_work_hours_count( + self.datetime_tz(2018, 4, 10, 8, 0, 0), + self.datetime_tz(2018, 4, 10, 12, 0, 0), + ) + self.assertEqual(count, 4) + + # This timezone is not the same as the calendar's one + count = self.calendar_jean.get_work_hours_count( + self.datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.tz1), + self.datetime_tz(2018, 4, 10, 12, 0, 0, tzinfo=self.tz1), + ) + self.assertEqual(count, 0) + + # Using two different timezones + # 10-04-2018 06:00:00 - 10-04-2018 02:00:00 + count = self.calendar_jean.get_work_hours_count( + self.datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.tz2), + self.datetime_tz(2018, 4, 10, 12, 0, 0, tzinfo=self.tz3), + ) + self.assertEqual(count, 0) + + # Using two different timezones + # 2018-04-10 06:00:00 - 2018-04-10 22:00:00 + count = self.calendar_jean.get_work_hours_count( + self.datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.tz2), + self.datetime_tz(2018, 4, 10, 12, 0, 0, tzinfo=self.tz4), + ) + self.assertEqual(count, 8) + + def test_plan_hours(self): + dt = self.calendar_jean.plan_hours(10, self.datetime_tz(2018, 4, 10, 8, 0, 0)) + self.assertEqual(dt, self.datetime_tz(2018, 4, 11, 10, 0, 0)) + + dt = self.calendar_jean.plan_hours(10, self.datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.tz4)) + self.assertEqual(dt, self.datetime_tz(2018, 4, 11, 22, 0, 0, tzinfo=self.tz4)) + + def test_plan_days(self): + dt = self.calendar_jean.plan_days(2, self.datetime_tz(2018, 4, 10, 8, 0, 0)) + self.assertEqual(dt, self.datetime_tz(2018, 4, 11, 14, 0, 0)) + + # We lose one day because of timezone + dt = self.calendar_jean.plan_days(2, self.datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.tz4)) + self.assertEqual(dt, self.datetime_tz(2018, 4, 12, 4, 0, 0, tzinfo=self.tz4)) + + def test_work_data(self): + # 09-04-2018 10:00:00 - 13-04-2018 18:00:00 + data = self.jean._get_work_days_data_batch( + self.datetime_tz(2018, 4, 9, 8, 0, 0), + self.datetime_tz(2018, 4, 13, 16, 0, 0), + )[self.jean.id] + self.assertEqual(data, {'days': 4.75, 'hours': 38}) + + # 09-04-2018 00:00:00 - 13-04-2018 08:00:00 + data = self.jean._get_work_days_data_batch( + self.datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz3), + self.datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz3), + )[self.jean.id] + self.assertEqual(data, {'days': 4, 'hours': 32}) + + # 09-04-2018 08:00:00 - 14-04-2018 12:00:00 + data = self.jean._get_work_days_data_batch( + self.datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), + self.datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz4), + )[self.jean.id] + self.assertEqual(data, {'days': 5, 'hours': 40}) + + # Jules with 2 weeks calendar + # 02-04-2018 00:00:00 - 6-04-2018 23:59:59 + data = self.jules._get_work_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jules.tz), + self.datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.jules.tz), + )[self.jules.id] + self.assertEqual(data, {'days': 4, 'hours': 30}) + + # Jules with 2 weeks calendar + # 02-04-2018 00:00:00 - 14-04-2018 23:59:59 + data = self.jules._get_work_days_data_batch( + self.datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jules.tz), + self.datetime_tz(2018, 4, 14, 23, 59, 59, tzinfo=self.jules.tz), + )[self.jules.id] + self.assertEqual(data, {'days': 6, 'hours': 46}) + + # Jules with 2 weeks calendar + # 12-29-2014 00:00:00 - 27-12-2019 23:59:59 => 261 weeks + # 130 weeks type 1: 131*4 = 524 days and 131*30 = 3930 hours + # 131 weeks type 2: 130*2 = 260 days and 130*16 = 2080 hours + data = self.jules._get_work_days_data_batch( + self.datetime_tz(2014, 12, 29, 0, 0, 0, tzinfo=self.jules.tz), + self.datetime_tz(2019, 12, 27, 23, 59, 59, tzinfo=self.jules.tz), + )[self.jules.id] + self.assertEqual(data, {'days': 784, 'hours': 6010}) + + def test_leave_data(self): + self.env['resource.calendar.leaves'].create({ + 'name': '', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': self.datetime_str(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), + 'date_to': self.datetime_str(2018, 4, 9, 14, 0, 0, tzinfo=self.tz2), + }) + + # 09-04-2018 10:00:00 - 13-04-2018 18:00:00 + data = self.jean._get_leave_days_data_batch( + self.datetime_tz(2018, 4, 9, 8, 0, 0), + self.datetime_tz(2018, 4, 13, 16, 0, 0), + )[self.jean.id] + self.assertEqual(data, {'days': 0.5, 'hours': 4}) + + # 09-04-2018 00:00:00 - 13-04-2018 08:00:00 + data = self.jean._get_leave_days_data_batch( + self.datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz3), + self.datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz3), + )[self.jean.id] + self.assertEqual(data, {'days': 0.75, 'hours': 6}) + + # 09-04-2018 08:00:00 - 14-04-2018 12:00:00 + data = self.jean._get_leave_days_data_batch( + self.datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), + self.datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz4), + )[self.jean.id] + self.assertEqual(data, {'days': 0.75, 'hours': 6}) + + def test_leaves(self): + leave = self.env['resource.calendar.leaves'].create({ + 'name': '', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': self.datetime_str(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), + 'date_to': self.datetime_str(2018, 4, 9, 14, 0, 0, tzinfo=self.tz2), + }) + + # 09-04-2018 10:00:00 - 13-04-2018 18:00:00 + leaves = self.jean.list_leaves( + self.datetime_tz(2018, 4, 9, 8, 0, 0), + self.datetime_tz(2018, 4, 13, 16, 0, 0), + ) + self.assertEqual(leaves, [(date(2018, 4, 9), 4, leave)]) + + # 09-04-2018 00:00:00 - 13-04-2018 08:00:00 + leaves = self.jean.list_leaves( + self.datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz3), + self.datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz3), + ) + self.assertEqual(leaves, [(date(2018, 4, 9), 6, leave)]) + + # 09-04-2018 08:00:00 - 14-04-2018 12:00:00 + leaves = self.jean.list_leaves( + self.datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), + self.datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz4), + ) + self.assertEqual(leaves, [(date(2018, 4, 9), 6, leave)]) + + def test_works(self): + work = self.jean._list_work_time_per_day( + self.datetime_tz(2018, 4, 9, 8, 0, 0), + self.datetime_tz(2018, 4, 13, 16, 0, 0), + )[self.jean.id] + self.assertEqual(work, [ + (date(2018, 4, 9), 6), + (date(2018, 4, 10), 8), + (date(2018, 4, 11), 8), + (date(2018, 4, 12), 8), + (date(2018, 4, 13), 8), + ]) + + work = self.jean._list_work_time_per_day( + self.datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz3), + self.datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz3), + )[self.jean.id] + self.assertEqual(len(work), 4) + self.assertEqual(work, [ + (date(2018, 4, 9), 8), + (date(2018, 4, 10), 8), + (date(2018, 4, 11), 8), + (date(2018, 4, 12), 8), + ]) + + work = self.jean._list_work_time_per_day( + self.datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), + self.datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz4), + )[self.jean.id] + self.assertEqual(work, [ + (date(2018, 4, 9), 8), + (date(2018, 4, 10), 8), + (date(2018, 4, 11), 8), + (date(2018, 4, 12), 8), + (date(2018, 4, 13), 8), + ]) + + @freeze_time("2022-09-21 15:30:00", tz_offset=-10) + def test_unavailable_intervals(self): + resource = self.env['resource.resource'].create({ + 'name': 'resource', + 'tz': self.tz3, + }) + intervals = resource._get_unavailable_intervals(datetime(2022, 9, 21), datetime(2022, 9, 22)) + self.assertEqual(next(iter(intervals.values())), [ + (datetime(2022, 9, 21, 0, 0, tzinfo=utc), datetime(2022, 9, 21, 6, 0, tzinfo=utc)), + (datetime(2022, 9, 21, 10, 0, tzinfo=utc), datetime(2022, 9, 21, 11, 0, tzinfo=utc)), + (datetime(2022, 9, 21, 15, 0, tzinfo=utc), datetime(2022, 9, 22, 0, 0, tzinfo=utc)), + ]) + + def test_flexible_resource_leave_interval(self): + """ + Test whole day off for a flexible resource. + The standard 8 - 17 leave should be converted to a whole day leave interval for the flexible resource. + """ + + flexible_calendar = self.env['resource.calendar'].create({ + 'name': 'Flex Calendar', + 'tz': 'UTC', + 'flexible_hours': True, + 'full_time_required_hours': 40, + 'hours_per_day': 8, + 'hours_per_week': 40, + }) + flex_resource = self.env['resource.resource'].create({ + 'name': 'Test FlexResource', + 'calendar_id': flexible_calendar.id, + }) + self.env['resource.calendar.leaves'].create({ + 'name': 'Standard Time Off', + 'calendar_id': flexible_calendar.id, + 'resource_id': flex_resource.id, + 'date_from': '2025-03-07 08:00:00', + 'date_to': '2025-03-07 17:00:00', + }) + + start_dt = datetime(2025, 3, 7, 8, 0, 0, tzinfo=utc) + end_dt = datetime(2025, 3, 7, 16, 00, 00, 00, tzinfo=utc) + + intervals = flexible_calendar._leave_intervals_batch(start_dt, end_dt, [flex_resource]) + intervals_list = list(intervals[flex_resource.id]) + self.assertEqual(len(intervals_list), 1, "There should be one leave interval") + interval = intervals_list[0] + self.assertEqual(interval[0], start_dt, "The start of the interval should be 08:00:00") + self.assertEqual(interval[1], end_dt, "The end of the interval should be 16:00:00") diff --git a/odoo-bringout-oca-ocb-test_website/README.md b/odoo-bringout-oca-ocb-test_website/README.md index 8621ad2..72bf7d9 100644 --- a/odoo-bringout-oca-ocb-test_website/README.md +++ b/odoo-bringout-oca-ocb-test_website/README.md @@ -14,39 +14,16 @@ pip install odoo-bringout-oca-ocb-test_website ## Dependencies -This addon depends on: - web_unsplash - website - theme_default -## Manifest Information - -- **Name**: Website Test -- **Version**: 1.0 -- **Category**: Hidden -- **License**: LGPL-3 -- **Installable**: True - ## Source -Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `test_website`. +- Repository: https://github.com/OCA/OCB +- Branch: 19.0 +- Path: addons/test_website ## License -This package maintains the original LGPL-3 license from the upstream Odoo project. - -## Documentation - -- Overview: doc/OVERVIEW.md -- Architecture: doc/ARCHITECTURE.md -- Models: doc/MODELS.md -- Controllers: doc/CONTROLLERS.md -- Wizards: doc/WIZARDS.md -- Reports: doc/REPORTS.md -- Security: doc/SECURITY.md -- Install: doc/INSTALL.md -- Usage: doc/USAGE.md -- Configuration: doc/CONFIGURATION.md -- Dependencies: doc/DEPENDENCIES.md -- Troubleshooting: doc/TROUBLESHOOTING.md -- FAQ: doc/FAQ.md +This package preserves the original LGPL-3 license. diff --git a/odoo-bringout-oca-ocb-test_website/pyproject.toml b/odoo-bringout-oca-ocb-test_website/pyproject.toml index f3e9c57..865640c 100644 --- a/odoo-bringout-oca-ocb-test_website/pyproject.toml +++ b/odoo-bringout-oca-ocb-test_website/pyproject.toml @@ -1,14 +1,16 @@ [project] name = "odoo-bringout-oca-ocb-test_website" version = "16.0.0" -description = "Website Test - Website Test, mainly for module install/uninstall tests" +description = "Website Test - + Website Test, mainly for module install/uninstall tests + " authors = [ { name = "Ernad Husremovic", email = "hernad@bring.out.ba" } ] dependencies = [ - "odoo-bringout-oca-ocb-web_unsplash>=16.0.0", - "odoo-bringout-oca-ocb-website>=16.0.0", - "odoo-bringout-oca-ocb-theme_default>=16.0.0", + "odoo-bringout-oca-ocb-web_unsplash>=19.0.0", + "odoo-bringout-oca-ocb-website>=19.0.0", + "odoo-bringout-oca-ocb-theme_default>=19.0.0", "requests>=2.25.1" ] readme = "README.md" @@ -18,7 +20,7 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Office/Business", ] diff --git a/odoo-bringout-oca-ocb-test_website/test_website/__manifest__.py b/odoo-bringout-oca-ocb-test_website/test_website/__manifest__.py index e05335a..b50665a 100644 --- a/odoo-bringout-oca-ocb-test_website/test_website/__manifest__.py +++ b/odoo-bringout-oca-ocb-test_website/test_website/__manifest__.py @@ -21,24 +21,30 @@ models which only purpose is to run tests.""", 'data/test_website_demo.xml', ], 'data': [ + 'security/test_website_security.xml', + 'security/ir.model.access.csv', 'views/templates.xml', 'views/test_model_multi_website_views.xml', 'views/test_model_views.xml', 'data/test_website_data.xml', - 'security/test_website_security.xml', - 'security/ir.model.access.csv', ], 'installable': True, 'assets': { + 'test_website.test_bundle': [ + 'http://test.external.link/javascript1.js', + '/web/static/src/libs/fontawesome/css/font-awesome.css', + 'http://test.external.link/style1.css', + '/web/static/src/module_loader.js', + 'http://test.external.link/javascript2.js', + 'http://test.external.link/style2.css', + ], 'web.assets_frontend': [ - 'test_website/static/src/js/test_error.js', + 'test_website/static/src/interactions/**/*', ], 'web.assets_tests': [ 'test_website/static/tests/tours/*', ], - 'web.qunit_suite_tests': [ - 'test_website/static/tests/*.js', - ], }, + 'author': 'Odoo S.A.', 'license': 'LGPL-3', } diff --git a/odoo-bringout-oca-ocb-test_website/test_website/controllers/main.py b/odoo-bringout-oca-ocb-test_website/test_website/controllers/main.py index 6ed151e..064a14a 100644 --- a/odoo-bringout-oca-ocb-test_website/test_website/controllers/main.py +++ b/odoo-bringout-oca-ocb-test_website/test_website/controllers/main.py @@ -16,6 +16,12 @@ class WebsiteTest(Home): def test_view(self, **kwargs): return request.render('test_website.test_view') + @http.route('/test_view_access_error', type='http', auth='public', website=True, sitemap=False) + def test_view_access_error(self, **kwargs): + public = self.env.ref('base.public_user') + record = self.env.ref('test_website.test_model_exposed_record_not_published') + return request.render('test_website.test_view_access_error', {'record': record.with_user(public)}) + @http.route('/ignore_args/converteronly/', type='http', auth="public", website=True, sitemap=False) def test_ignore_args_converter_only(self, a): return request.make_response(json.dumps(dict(a=a, kw=None))) @@ -42,7 +48,7 @@ class WebsiteTest(Home): @http.route('/multi_company_website', type='http', auth="public", website=True, sitemap=False) def test_company_context(self): - return request.make_response(json.dumps(request.context.get('allowed_company_ids'))) + return request.make_response(json.dumps(request.env.context.get('allowed_company_ids'))) @http.route('/test_lang_url/', type='http', auth='public', website=True, sitemap=False) def test_lang_url(self, **kwargs): @@ -50,7 +56,7 @@ class WebsiteTest(Home): # Test Session - @http.route('/test_get_dbname', type='json', auth='public', website=True, sitemap=False) + @http.route('/test_get_dbname', type='jsonrpc', auth='public', website=True, sitemap=False) def test_get_dbname(self, **kwargs): return request.env.cr.dbname @@ -64,7 +70,7 @@ class WebsiteTest(Home): def test_user_error_http(self, **kwargs): raise UserError("This is a user http test") - @http.route('/test_user_error_json', type='json', auth='public', website=True, sitemap=False) + @http.route('/test_user_error_json', type='jsonrpc', auth='public', website=True, sitemap=False) def test_user_error_json(self, **kwargs): raise UserError("This is a user rpc test") @@ -72,11 +78,11 @@ class WebsiteTest(Home): def test_validation_error_http(self, **kwargs): raise ValidationError("This is a validation http test") - @http.route('/test_validation_error_json', type='json', auth='public', website=True, sitemap=False) + @http.route('/test_validation_error_json', type='jsonrpc', auth='public', website=True, sitemap=False) def test_validation_error_json(self, **kwargs): raise ValidationError("This is a validation rpc test") - @http.route('/test_access_error_json', type='json', auth='public', website=True, sitemap=False) + @http.route('/test_access_error_json', type='jsonrpc', auth='public', website=True, sitemap=False) def test_access_error_json(self, **kwargs): raise AccessError("This is an access rpc test") @@ -84,7 +90,7 @@ class WebsiteTest(Home): def test_access_error_http(self, **kwargs): raise AccessError("This is an access http test") - @http.route('/test_missing_error_json', type='json', auth='public', website=True, sitemap=False) + @http.route('/test_missing_error_json', type='jsonrpc', auth='public', website=True, sitemap=False) def test_missing_error_json(self, **kwargs): raise MissingError("This is a missing rpc test") @@ -92,7 +98,7 @@ class WebsiteTest(Home): def test_missing_error_http(self, **kwargs): raise MissingError("This is a missing http test") - @http.route('/test_internal_error_json', type='json', auth='public', website=True, sitemap=False) + @http.route('/test_internal_error_json', type='jsonrpc', auth='public', website=True, sitemap=False) def test_internal_error_json(self, **kwargs): raise werkzeug.exceptions.InternalServerError() @@ -100,7 +106,7 @@ class WebsiteTest(Home): def test_internal_error_http(self, **kwargs): raise werkzeug.exceptions.InternalServerError() - @http.route('/test_access_denied_json', type='json', auth='public', website=True, sitemap=False) + @http.route('/test_access_denied_json', type='jsonrpc', auth='public', website=True, sitemap=False) def test_denied_error_json(self, **kwargs): raise AccessDenied("This is an access denied rpc test") @@ -131,7 +137,7 @@ class WebsiteTest(Home): return 'Basic Controller Content' # Test Redirects - @http.route(['/test_website/country/'], type='http', auth="public", website=True, sitemap=False) + @http.route(['/test_website/country/'], type='http', auth="public", website=True, sitemap=True) def test_model_converter_country(self, country, **kw): return request.render('test_website.test_redirect_view', {'country': country}) @@ -141,6 +147,16 @@ class WebsiteTest(Home): @http.route(['/test_website/model_item/'], type='http', methods=['GET'], auth="public", website=True, sitemap=False) def test_model_item(self, record_id): + record = request.env['test.model'].browse(record_id) + values = { + 'record': record, + 'main_object': record, + 'tag': record.tag_id, + } + return request.render("test_website.model_item", values) + + @http.route(['/test_website/model_item_sudo/'], type='http', methods=['GET'], auth="public", website=True, sitemap=False) + def test_model_item_sudo(self, record_id): values = { 'record': request.env['test.model'].sudo().browse(record_id), } diff --git a/odoo-bringout-oca-ocb-test_website/test_website/data/test_website_data.xml b/odoo-bringout-oca-ocb-test_website/test_website/data/test_website_data.xml index 740f8ba..d12df03 100644 --- a/odoo-bringout-oca-ocb-test_website/test_website/data/test_website_data.xml +++ b/odoo-bringout-oca-ocb-test_website/test_website/data/test_website_data.xml @@ -11,8 +11,23 @@ + + Test Tag + + + Test Tag #2 + + + Test Tag #3 + Test Model + + + + Test Submodel + + Test Multi Model Generic @@ -62,9 +77,9 @@