commit 080accd21ce8501d477258141f9e4c3df20d326c Author: Ernad Husremovic Date: Fri Aug 29 15:20:52 2025 +0200 Initial commit: Test packages diff --git a/README.md b/README.md new file mode 100644 index 0000000..6661025 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Test + +This repository contains OCA OCB packages for test. + +## Packages Included + +- odoo-bringout-oca-ocb-test_base_automation +- odoo-bringout-oca-ocb-test_crm_full +- odoo-bringout-oca-ocb-test_discuss_full +- odoo-bringout-oca-ocb-test_event_full +- odoo-bringout-oca-ocb-test_mail +- odoo-bringout-oca-ocb-test_mail_full +- odoo-bringout-oca-ocb-test_resource +- odoo-bringout-oca-ocb-test_website +- odoo-bringout-oca-ocb-test_website_modules +- odoo-bringout-oca-ocb-test_website_slides_full +- odoo-bringout-oca-ocb-test_xlsx_export diff --git a/odoo-bringout-oca-ocb-test_base_automation/README.md b/odoo-bringout-oca-ocb-test_base_automation/README.md new file mode 100644 index 0000000..8ed964a --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/README.md @@ -0,0 +1,48 @@ +# Test - Base Automation + +This module contains tests related to base automation. Those are +present in a separate module as it contains models used only to perform +tests independently to functional aspects of other models. + +## Installation + +```bash +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`. + +## 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 diff --git a/odoo-bringout-oca-ocb-test_base_automation/doc/ARCHITECTURE.md b/odoo-bringout-oca-ocb-test_base_automation/doc/ARCHITECTURE.md new file mode 100644 index 0000000..f191a21 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/doc/ARCHITECTURE.md @@ -0,0 +1,32 @@ +# Architecture + +```mermaid +flowchart TD + U[Users] -->|HTTP| V[Views and QWeb Templates] + V --> C[Controllers] + V --> W[Wizards – Transient Models] + C --> M[Models and ORM] + W --> M + M --> R[Reports] + DX[Data XML] --> M + S[Security – ACLs and Groups] -. enforces .-> M + + subgraph Test_base_automation Module - test_base_automation + direction LR + M:::layer + W:::layer + C:::layer + V:::layer + R:::layer + S:::layer + DX:::layer + end + + classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px +``` + +Notes +- Views include tree/form/kanban templates and report templates. +- Controllers provide website/portal routes when present. +- Wizards are UI flows implemented with `models.TransientModel`. +- Data XML loads data/demo records; Security defines groups and access. diff --git a/odoo-bringout-oca-ocb-test_base_automation/doc/CONFIGURATION.md b/odoo-bringout-oca-ocb-test_base_automation/doc/CONFIGURATION.md new file mode 100644 index 0000000..6ba9397 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/doc/CONFIGURATION.md @@ -0,0 +1,3 @@ +# Configuration + +Refer to Odoo settings for test_base_automation. Configure related models, access rights, and options as needed. diff --git a/odoo-bringout-oca-ocb-test_base_automation/doc/CONTROLLERS.md b/odoo-bringout-oca-ocb-test_base_automation/doc/CONTROLLERS.md new file mode 100644 index 0000000..f628e77 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/doc/CONTROLLERS.md @@ -0,0 +1,3 @@ +# Controllers + +This module does not define custom HTTP controllers. diff --git a/odoo-bringout-oca-ocb-test_base_automation/doc/DEPENDENCIES.md b/odoo-bringout-oca-ocb-test_base_automation/doc/DEPENDENCIES.md new file mode 100644 index 0000000..37c8720 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/doc/DEPENDENCIES.md @@ -0,0 +1,5 @@ +# Dependencies + +This addon depends on: + +- [base_automation](../../odoo-bringout-oca-ocb-base_automation) diff --git a/odoo-bringout-oca-ocb-test_base_automation/doc/FAQ.md b/odoo-bringout-oca-ocb-test_base_automation/doc/FAQ.md new file mode 100644 index 0000000..84610b8 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/doc/FAQ.md @@ -0,0 +1,4 @@ +# FAQ + +- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged). +- Q: How to enable? A: Start server with --addon test_base_automation or install in UI. diff --git a/odoo-bringout-oca-ocb-test_base_automation/doc/INSTALL.md b/odoo-bringout-oca-ocb-test_base_automation/doc/INSTALL.md new file mode 100644 index 0000000..8d5a9b5 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/doc/INSTALL.md @@ -0,0 +1,7 @@ +# Install + +```bash +pip install odoo-bringout-oca-ocb-test_base_automation" +# or +uv pip install odoo-bringout-oca-ocb-test_base_automation" +``` diff --git a/odoo-bringout-oca-ocb-test_base_automation/doc/MODELS.md b/odoo-bringout-oca-ocb-test_base_automation/doc/MODELS.md new file mode 100644 index 0000000..da73c82 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/doc/MODELS.md @@ -0,0 +1,15 @@ +# Models + +Detected core models and extensions in test_base_automation. + +```mermaid +classDiagram + class base_automation_lead_test + class base_automation_line_test + class base_automation_linked_test + class base_automation_link_test +``` + +Notes +- Classes show model technical names; fields omitted for brevity. +- Items listed under _inherit are extensions of existing models. diff --git a/odoo-bringout-oca-ocb-test_base_automation/doc/OVERVIEW.md b/odoo-bringout-oca-ocb-test_base_automation/doc/OVERVIEW.md new file mode 100644 index 0000000..586a0c5 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/doc/OVERVIEW.md @@ -0,0 +1,6 @@ +# Overview + +Packaged Odoo addon: test_base_automation. Provides features documented in upstream Odoo 16 under this addon. + +- Source: OCA/OCB 16.0, addon test_base_automation +- License: LGPL-3 diff --git a/odoo-bringout-oca-ocb-test_base_automation/doc/REPORTS.md b/odoo-bringout-oca-ocb-test_base_automation/doc/REPORTS.md new file mode 100644 index 0000000..e0ea35f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/doc/REPORTS.md @@ -0,0 +1,3 @@ +# Reports + +This module does not define custom reports. diff --git a/odoo-bringout-oca-ocb-test_base_automation/doc/SECURITY.md b/odoo-bringout-oca-ocb-test_base_automation/doc/SECURITY.md new file mode 100644 index 0000000..9741885 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/doc/SECURITY.md @@ -0,0 +1,34 @@ +# Security + +Access control and security definitions in test_base_automation. + +## Access Control Lists (ACLs) + +Model access permissions defined in: +- **[ir.model.access.csv](../test_base_automation/security/ir.model.access.csv)** + - 6 model access rules + +## Record Rules + +Row-level security rules defined in: + +```mermaid +graph TB + subgraph "Security Layers" + A[Users] --> B[Groups] + B --> C[Access Control Lists] + C --> D[Models] + B --> E[Record Rules] + E --> F[Individual Records] + end +``` + +Security files overview: +- **[ir.model.access.csv](../test_base_automation/security/ir.model.access.csv)** + - Model access permissions (CRUD rights) + +Notes +- Access Control Lists define which groups can access which models +- Record Rules provide row-level security (filter records by user/group) +- Security groups organize users and define permission sets +- All security is enforced at the ORM level by Odoo diff --git a/odoo-bringout-oca-ocb-test_base_automation/doc/TROUBLESHOOTING.md b/odoo-bringout-oca-ocb-test_base_automation/doc/TROUBLESHOOTING.md new file mode 100644 index 0000000..56853cb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/doc/TROUBLESHOOTING.md @@ -0,0 +1,5 @@ +# Troubleshooting + +- Ensure Python and Odoo environment matches repo guidance. +- Check database connectivity and logs if startup fails. +- Validate that dependent addons listed in DEPENDENCIES.md are installed. diff --git a/odoo-bringout-oca-ocb-test_base_automation/doc/USAGE.md b/odoo-bringout-oca-ocb-test_base_automation/doc/USAGE.md new file mode 100644 index 0000000..87dbe11 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/doc/USAGE.md @@ -0,0 +1,7 @@ +# Usage + +Start Odoo including this addon (from repo root): + +```bash +python3 scripts/nix_odoo_web_server.py --db-name mydb --addon test_base_automation +``` diff --git a/odoo-bringout-oca-ocb-test_base_automation/doc/WIZARDS.md b/odoo-bringout-oca-ocb-test_base_automation/doc/WIZARDS.md new file mode 100644 index 0000000..48e790d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/doc/WIZARDS.md @@ -0,0 +1,3 @@ +# Wizards + +This module does not include UI wizards. diff --git a/odoo-bringout-oca-ocb-test_base_automation/pyproject.toml b/odoo-bringout-oca-ocb-test_base_automation/pyproject.toml new file mode 100644 index 0000000..0026791 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/pyproject.toml @@ -0,0 +1,42 @@ +[project] +name = "odoo-bringout-oca-ocb-test_base_automation" +version = "16.0.0" +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", + "requests>=2.25.1" +] +readme = "README.md" +requires-python = ">= 3.11" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "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.12", + "Topic :: Office/Business", +] + +[project.urls] +homepage = "https://github.com/bringout/0" +repository = "https://github.com/bringout/0" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["test_base_automation"] + +[tool.rye] +managed = true +dev-dependencies = [ + "pytest>=8.4.1", +] diff --git a/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/__init__.py b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/__init__.py new file mode 100644 index 0000000..dc5e6b6 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models 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 new file mode 100644 index 0000000..aa06d31 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/__manifest__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Test - Base Automation', + 'version': '1.0', + 'category': 'Hidden', + 'sequence': 9877, + 'summary': 'Base Automation Tests: Ensure Flow Robustness', + 'description': """This module contains tests related to base automation. Those are +present in a separate module as it contains models used only to perform +tests independently to functional aspects of other models.""", + 'depends': ['base_automation'], + 'data': [ + 'security/ir.model.access.csv', + ], + 'installable': True, + 'license': 'LGPL-3', +} diff --git a/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/models/__init__.py b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/models/__init__.py new file mode 100644 index 0000000..5bf7127 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_base_automation \ No newline at end of file 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 new file mode 100644 index 0000000..8a58328 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/models/test_base_automation.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from dateutil import relativedelta +from odoo import fields, models, api + + +class LeadTest(models.Model): + _name = "base.automation.lead.test" + _description = "Automated Rule Test" + + name = fields.Char(string='Subject', required=True) + user_id = fields.Many2one('res.users', string='Responsible') + state = fields.Selection([('draft', 'New'), ('cancel', 'Cancelled'), ('open', 'In Progress'), + ('pending', 'Pending'), ('done', 'Closed')], + string="Status", readonly=True, default='draft') + active = fields.Boolean(default=True) + partner_id = fields.Many2one('res.partner', string='Partner') + date_action_last = fields.Datetime(string='Last Action', readonly=True) + employee = fields.Boolean(compute='_compute_employee_deadline', store=True) + line_ids = fields.One2many('base.automation.line.test', 'lead_id') + + priority = fields.Boolean() + deadline = fields.Boolean(compute='_compute_employee_deadline', store=True) + is_assigned_to_admin = fields.Boolean(string='Assigned to admin user') + + @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: + record.deadline = False + else: + record.deadline = record.create_date + relativedelta.relativedelta(days=3) + + def write(self, vals): + result = super().write(vals) + # force recomputation of field 'deadline' via 'employee': the action + # based on 'deadline' must be triggered + self.mapped('employee') + return result + + +class LineTest(models.Model): + _name = "base.automation.line.test" + _description = "Automated Rule Line Test" + + name = fields.Char() + lead_id = fields.Many2one('base.automation.lead.test', ondelete='cascade') + user_id = fields.Many2one('res.users') + + +class ModelWithAccess(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" + _description = "Automated Rule Linked Test" + + name = fields.Char() + another_field = fields.Char() + + +class Project(models.Model): + _name = _description = 'test_base_automation.project' + + name = fields.Char() + task_ids = fields.One2many('test_base_automation.task', 'project_id') + + +class Task(models.Model): + _name = _description = 'test_base_automation.task' + + name = fields.Char() + parent_id = fields.Many2one('test_base_automation.task') + project_id = fields.Many2one( + 'test_base_automation.project', + compute='_compute_project_id', recursive=True, store=True, readonly=False, + ) + + @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 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 new file mode 100644 index 0000000..24605b6 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/security/ir.model.access.csv @@ -0,0 +1,7 @@ +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_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 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 new file mode 100644 index 0000000..b96c2ec --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_flow 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 new file mode 100644 index 0000000..e62f850 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_base_automation/test_base_automation/tests/test_flow.py @@ -0,0 +1,462 @@ +# # -*- coding: utf-8 -*- +# # Part of Odoo. See LICENSE file for full copyright and licensing details. + +from unittest.mock import patch +import sys + +from odoo.addons.base.tests.common import TransactionCaseWithUserDemo +from odoo.tests import common, tagged +from odoo.exceptions import AccessError + + +@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([ + { + '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)]", + } + ]) + + def tearDown(self): + super().tearDown() + self.env['base.automation']._unregister_hook() + + def create_lead(self, **kwargs): + vals = { + 'name': "Lead Test", + 'user_id': self.user_root.id, + } + vals.update(kwargs) + return self.env['base.automation.lead.test'].create(vals) + + def test_00_check_to_state_open_pre(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. + """ + lead = self.create_lead(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'.") + + def test_01_check_to_state_draft_post(self): + """ + Check that a new record changes its responsible when there is a postcondition + filter which check that the 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'.") + + 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. """ + 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}) + self.assertEqual(lead.state, 'draft') + + 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) + + def test_20_indirect_line(self): + """ + Check that creating a lead with a line executes rules on both records. + """ + 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.") + + 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 + self.assertEqual(lead.name, 'X', "No update until now.") + + lead.state = 'open' + self.assertEqual(lead.name, 'XX', "One update should have happened.") + lead.state = 'done' + self.assertEqual(lead.name, 'XXX', "One update should have happened.") + lead.state = 'done' + self.assertEqual(lead.name, 'XXX', "No update should have happened.") + lead.state = 'cancel' + 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])]}) + + partner2 = self.env['res.partner'].create({'name': 'A new partner'}) + lead.name = 'X' + lead.state = 'open' + self.assertEqual(lead.name, 'X', "No update should have happened.") + lead.partner_id = partner2 + self.assertEqual(lead.name, 'XX', "One update should have happened.") + lead.partner_id = partner2 + self.assertEqual(lead.name, 'XX', "No update should have happened.") + lead.partner_id = partner1 + self.assertEqual(lead.name, 'XXX', "One update should have happened.") + + def test_30_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 + - create a group + - restrict acl to this group and set only admin in it + - create base.automation with a filter + - create a record in the restricted model in admin + - create a record in the non restricted model in demo + """ + Model = self.env['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({ + 'name': "Access to base.automation.linked.test", + "users": [(6, 0, [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') + + # 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]" + }) + Comodel.create([ + {'name': 'a first record', 'another_field': 'something'}, + {'name': 'another record', 'another_field': 'something different'}, + ]) + rec1 = Model.create({'name': 'a record'}) + rec1.write({'name': 'a first record'}) + rec2 = Model.with_user(self.user_demo).create({'name': 'another record'}) + 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]" + }) + 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'}) + + +@common.tagged('post_install', '-at_install') +class TestCompute(common.TransactionCase): + 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. + + However if a search() is performed during the computation of A + ??? 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 + it's an empty list (``'[]'`` as opposed to ``False``). + """ + company1 = self.env['res.partner'].create({ + 'name': "Gorofy", + 'is_company': True, + }) + company2 = self.env['res.partner'].create({ + 'name': "Awiclo", + 'is_company': True + }) + r = self.env['res.partner'].create({ + 'name': 'Bob', + 'is_company': False, + 'parent_id': company1.id + }) + self.assertEqual(r.display_name, 'Gorofy, Bob') + 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, + }) + 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, + }) + r.parent_id = company2 + self.assertEqual(r.display_name, 'Awiclo, Bob') + + 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 one task in project with 10 subtasks; all the subtasks are + # automatically assigned to project, too + task = self.env['test_base_automation.task'].create({'project_id': project.id}) + subtasks = task.create([{'parent_id': task.id} for _ in range(10)]) + 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 + # 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 + # 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(). + # + # The execution of task.unlink() looks like this: + # - mark 'project_id' to compute on subtasks + # - delete task + # - flush() + # - recompute 'project_id' on subtask1 + # - call compute on subtask1 + # - in action, search([('id', 'in', subtask1.ids), ('project_id', '=', pid)]) + # - flush(['id', 'project_id']) + # - recompute 'project_id' on subtask2 + # - call compute on subtask2 + # - in action search([('id', 'in', subtask2.ids), ('project_id', '=', pid)]) + # - flush(['id', 'project_id']) + # - recompute 'project_id' on subtask3 + # - call compute on subtask3 + # - in action, search([('id', 'in', subtask3.ids), ('project_id', '=', pid)]) + # - flush(['id', 'project_id']) + # - recompute 'project_id' on subtask4 + # ... + limit = sys.getrecursionlimit() + try: + sys.setrecursionlimit(100) + task.unlink() + finally: + sys.setrecursionlimit(limit) diff --git a/odoo-bringout-oca-ocb-test_crm_full/README.md b/odoo-bringout-oca-ocb-test_crm_full/README.md new file mode 100644 index 0000000..bf8f679 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/README.md @@ -0,0 +1,57 @@ +# Test Full Crm Flow + + +This module is intended to test the main crm flows of Odoo, both frontend and +backend. It notably includes IAP bridges modules to test their impact. + +## Installation + +```bash +pip install odoo-bringout-oca-ocb-test_crm_full +``` + +## Dependencies + +This addon depends on: +- crm +- crm_iap_enrich +- crm_iap_mine +- crm_sms +- event_crm +- sale_crm +- website_crm +- website_crm_iap_reveal +- 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`. + +## 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 diff --git a/odoo-bringout-oca-ocb-test_crm_full/doc/ARCHITECTURE.md b/odoo-bringout-oca-ocb-test_crm_full/doc/ARCHITECTURE.md new file mode 100644 index 0000000..a0f2157 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/doc/ARCHITECTURE.md @@ -0,0 +1,32 @@ +# Architecture + +```mermaid +flowchart TD + U[Users] -->|HTTP| V[Views and QWeb Templates] + V --> C[Controllers] + V --> W[Wizards – Transient Models] + C --> M[Models and ORM] + W --> M + M --> R[Reports] + DX[Data XML] --> M + S[Security – ACLs and Groups] -. enforces .-> M + + subgraph Test_crm_full Module - test_crm_full + direction LR + M:::layer + W:::layer + C:::layer + V:::layer + R:::layer + S:::layer + DX:::layer + end + + classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px +``` + +Notes +- Views include tree/form/kanban templates and report templates. +- Controllers provide website/portal routes when present. +- Wizards are UI flows implemented with `models.TransientModel`. +- Data XML loads data/demo records; Security defines groups and access. diff --git a/odoo-bringout-oca-ocb-test_crm_full/doc/CONFIGURATION.md b/odoo-bringout-oca-ocb-test_crm_full/doc/CONFIGURATION.md new file mode 100644 index 0000000..5866043 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/doc/CONFIGURATION.md @@ -0,0 +1,3 @@ +# Configuration + +Refer to Odoo settings for test_crm_full. Configure related models, access rights, and options as needed. diff --git a/odoo-bringout-oca-ocb-test_crm_full/doc/CONTROLLERS.md b/odoo-bringout-oca-ocb-test_crm_full/doc/CONTROLLERS.md new file mode 100644 index 0000000..f628e77 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/doc/CONTROLLERS.md @@ -0,0 +1,3 @@ +# Controllers + +This module does not define custom HTTP controllers. diff --git a/odoo-bringout-oca-ocb-test_crm_full/doc/DEPENDENCIES.md b/odoo-bringout-oca-ocb-test_crm_full/doc/DEPENDENCIES.md new file mode 100644 index 0000000..a9b5c95 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/doc/DEPENDENCIES.md @@ -0,0 +1,14 @@ +# Dependencies + +This addon depends on: + +- [crm](../../odoo-bringout-oca-ocb-crm) +- [crm_iap_enrich](../../odoo-bringout-oca-ocb-crm_iap_enrich) +- [crm_iap_mine](../../odoo-bringout-oca-ocb-crm_iap_mine) +- [crm_sms](../../odoo-bringout-oca-ocb-crm_sms) +- [event_crm](../../odoo-bringout-oca-ocb-event_crm) +- [sale_crm](../../odoo-bringout-oca-ocb-sale_crm) +- [website_crm](../../odoo-bringout-oca-ocb-website_crm) +- [website_crm_iap_reveal](../../odoo-bringout-oca-ocb-website_crm_iap_reveal) +- [website_crm_partner_assign](../../odoo-bringout-oca-ocb-website_crm_partner_assign) +- [website_crm_livechat](../../odoo-bringout-oca-ocb-website_crm_livechat) diff --git a/odoo-bringout-oca-ocb-test_crm_full/doc/FAQ.md b/odoo-bringout-oca-ocb-test_crm_full/doc/FAQ.md new file mode 100644 index 0000000..f2964c1 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/doc/FAQ.md @@ -0,0 +1,4 @@ +# FAQ + +- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged). +- Q: How to enable? A: Start server with --addon test_crm_full or install in UI. diff --git a/odoo-bringout-oca-ocb-test_crm_full/doc/INSTALL.md b/odoo-bringout-oca-ocb-test_crm_full/doc/INSTALL.md new file mode 100644 index 0000000..375d40f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/doc/INSTALL.md @@ -0,0 +1,7 @@ +# Install + +```bash +pip install odoo-bringout-oca-ocb-test_crm_full" +# or +uv pip install odoo-bringout-oca-ocb-test_crm_full" +``` diff --git a/odoo-bringout-oca-ocb-test_crm_full/doc/MODELS.md b/odoo-bringout-oca-ocb-test_crm_full/doc/MODELS.md new file mode 100644 index 0000000..3e96309 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/doc/MODELS.md @@ -0,0 +1,11 @@ +# Models + +Detected core models and extensions in test_crm_full. + +```mermaid +classDiagram +``` + +Notes +- Classes show model technical names; fields omitted for brevity. +- Items listed under _inherit are extensions of existing models. diff --git a/odoo-bringout-oca-ocb-test_crm_full/doc/OVERVIEW.md b/odoo-bringout-oca-ocb-test_crm_full/doc/OVERVIEW.md new file mode 100644 index 0000000..4d25cf8 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/doc/OVERVIEW.md @@ -0,0 +1,6 @@ +# Overview + +Packaged Odoo addon: test_crm_full. Provides features documented in upstream Odoo 16 under this addon. + +- Source: OCA/OCB 16.0, addon test_crm_full +- License: LGPL-3 diff --git a/odoo-bringout-oca-ocb-test_crm_full/doc/REPORTS.md b/odoo-bringout-oca-ocb-test_crm_full/doc/REPORTS.md new file mode 100644 index 0000000..e0ea35f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/doc/REPORTS.md @@ -0,0 +1,3 @@ +# Reports + +This module does not define custom reports. diff --git a/odoo-bringout-oca-ocb-test_crm_full/doc/SECURITY.md b/odoo-bringout-oca-ocb-test_crm_full/doc/SECURITY.md new file mode 100644 index 0000000..e07da9d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/doc/SECURITY.md @@ -0,0 +1,8 @@ +# Security + +This module does not define custom security rules or access controls beyond Odoo defaults. + +Default Odoo security applies: +- Base user access through standard groups +- Model access inherited from dependencies +- No custom row-level security rules diff --git a/odoo-bringout-oca-ocb-test_crm_full/doc/TROUBLESHOOTING.md b/odoo-bringout-oca-ocb-test_crm_full/doc/TROUBLESHOOTING.md new file mode 100644 index 0000000..56853cb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/doc/TROUBLESHOOTING.md @@ -0,0 +1,5 @@ +# Troubleshooting + +- Ensure Python and Odoo environment matches repo guidance. +- Check database connectivity and logs if startup fails. +- Validate that dependent addons listed in DEPENDENCIES.md are installed. diff --git a/odoo-bringout-oca-ocb-test_crm_full/doc/USAGE.md b/odoo-bringout-oca-ocb-test_crm_full/doc/USAGE.md new file mode 100644 index 0000000..ce03aa2 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/doc/USAGE.md @@ -0,0 +1,7 @@ +# Usage + +Start Odoo including this addon (from repo root): + +```bash +python3 scripts/nix_odoo_web_server.py --db-name mydb --addon test_crm_full +``` diff --git a/odoo-bringout-oca-ocb-test_crm_full/doc/WIZARDS.md b/odoo-bringout-oca-ocb-test_crm_full/doc/WIZARDS.md new file mode 100644 index 0000000..48e790d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/doc/WIZARDS.md @@ -0,0 +1,3 @@ +# Wizards + +This module does not include UI wizards. diff --git a/odoo-bringout-oca-ocb-test_crm_full/pyproject.toml b/odoo-bringout-oca-ocb-test_crm_full/pyproject.toml new file mode 100644 index 0000000..101fe71 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/pyproject.toml @@ -0,0 +1,51 @@ +[project] +name = "odoo-bringout-oca-ocb-test_crm_full" +version = "16.0.0" +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", + "requests>=2.25.1" +] +readme = "README.md" +requires-python = ">= 3.11" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "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.12", + "Topic :: Office/Business", +] + +[project.urls] +homepage = "https://github.com/bringout/0" +repository = "https://github.com/bringout/0" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["test_crm_full"] + +[tool.rye] +managed = true +dev-dependencies = [ + "pytest>=8.4.1", +] diff --git a/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/__init__.py b/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/__init__.py new file mode 100644 index 0000000..67dee8c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. 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 new file mode 100644 index 0000000..86302d6 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/__manifest__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Test Full Crm Flow', + 'version': '1.0', + 'category': 'Hidden/Tests', + 'description': """ +This module is intended to test the main crm flows of Odoo, both frontend and +backend. It notably includes IAP bridges modules to test their impact. """, + 'depends': [ + 'crm', + 'crm_iap_enrich', + 'crm_iap_mine', + 'crm_sms', + 'event_crm', + 'sale_crm', + 'website_crm', + 'website_crm_iap_reveal', + 'website_crm_partner_assign', + 'website_crm_livechat', + ], + 'license': 'LGPL-3', +} diff --git a/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/tests/__init__.py b/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/tests/__init__.py new file mode 100644 index 0000000..72c0b71 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_performance 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 new file mode 100644 index 0000000..bce1f74 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/tests/common.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime + +from odoo.addons.crm.tests.common import TestCrmCommon +from odoo.addons.crm_iap_mine.tests.common import MockIAPReveal # MockIAPEnrich +from odoo.addons.website.tests.test_website_visitor import MockVisitor + + +class TestCrmFullCommon(TestCrmCommon, MockIAPReveal, MockVisitor): + + @classmethod + def setUpClass(cls): + super(TestCrmFullCommon, cls).setUpClass() + cls._init_mail_gateway() + cls._activate_multi_company() + + # Context data: dates + # ------------------------------------------------------------ + + # Mock dates to have reproducible computed fields based on time + cls.reference_now = datetime(2022, 1, 1, 10, 0, 0) + cls.reference_today = datetime(2022, 1, 1) + + # Customers + # ------------------------------------------------------------ + + country_be = cls.env.ref('base.be') + cls.env['res.lang']._activate_lang('fr_BE') + + cls.partners = cls.env['res.partner'].create([ + {'country_id': country_be.id, + '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, + 'zip': '1400', + } for idx in range(0, 10) + ]) 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 new file mode 100644 index 0000000..571f5e4 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_crm_full/test_crm_full/tests/test_performance.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +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 + + +@tagged('crm_performance', 'post_install', '-at_install', '-standard') +class CrmPerformanceCase(TestCrmFullCommon): + + def setUp(self): + super(CrmPerformanceCase, self).setUp() + # patch registry to simulate a ready environment + self.patch(self.env.registry, 'ready', True) + self._flush_tracking() + + self.user_sales_leads.write({ + 'groups_id': [ + (4, self.env.ref('event.group_event_user').id), + (4, self.env.ref('im_livechat.im_livechat_group_user').id), + ] + }) + + def _flush_tracking(self): + """ Force the creation of tracking values notably, and ensure tests are + reproducible. """ + self.env.flush_all() + self.cr.flush() + + +@tagged('crm_performance', 'post_install', '-at_install', '-standard') +class TestCrmPerformance(CrmPerformanceCase): + + @users('user_sales_leads') + @warmup + def test_lead_create_batch_mixed(self): + """ 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') + + with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=194): # tcf 193 / com 194 + 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, + 'zip': '1400', + } for idx in range(batch_size) + ] + crm_values += [ + {'partner_id': self.partners[idx].id, + 'name': 'Test Lead %02d' % idx, + } for idx in range(batch_size) + ] + _leads = self.env['crm.lead'].create(crm_values) + + @users('user_sales_leads') + @warmup + def test_lead_create_form_address(self): + """ Test a single lead creation using Form """ + 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 + 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' + lead_form.zip = '1400' + + _lead = lead_form.save() + + @users('user_sales_leads') + @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 + self.env.cr._now = self.reference_now # force create_date to check schedulers + with self.debug_mode(): + # {'invisible': ['|', ('type', '=', 'opportunity'), ('is_partner_visible', '=', False)]} + # lead.is_partner_visible = bool(lead.type == 'opportunity' or lead.partner_id or is_debug_mode) + with Form(self.env['crm.lead']) as lead_form: + lead_form.partner_id = self.partners[0] + lead_form.name = 'Test Lead' + + _lead = lead_form.save() + + @users('user_sales_leads') + @warmup + 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') + + with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=43): # tcf only: 41 - com runbot: 42 + 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', + 'zip': '1400', + } + ] + _lead = self.env['crm.lead'].create(crm_values) + + @users('user_sales_leads') + @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 + self.env.cr._now = self.reference_now # force create_date to check schedulers + crm_values = [ + {'partner_id': self.partners[0].id, + 'name': 'Test Lead', + } + ] + _lead = self.env['crm.lead'].create(crm_values) diff --git a/odoo-bringout-oca-ocb-test_discuss_full/README.md b/odoo-bringout-oca-ocb-test_discuss_full/README.md new file mode 100644 index 0000000..032e3a1 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/README.md @@ -0,0 +1,54 @@ +# Test Discuss (full) + +Test of Discuss with all possible overrides installed, including feature and performance tests. + +## Installation + +```bash +pip install odoo-bringout-oca-ocb-test_discuss_full +``` + +## Dependencies + +This addon depends on: +- calendar +- crm +- crm_livechat +- hr_holidays +- im_livechat +- mail +- mail_bot +- note +- website_livechat + +## Manifest Information + +- **Name**: Test Discuss (full) +- **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_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 diff --git a/odoo-bringout-oca-ocb-test_discuss_full/doc/ARCHITECTURE.md b/odoo-bringout-oca-ocb-test_discuss_full/doc/ARCHITECTURE.md new file mode 100644 index 0000000..d0306b0 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/doc/ARCHITECTURE.md @@ -0,0 +1,32 @@ +# Architecture + +```mermaid +flowchart TD + U[Users] -->|HTTP| V[Views and QWeb Templates] + V --> C[Controllers] + V --> W[Wizards – Transient Models] + C --> M[Models and ORM] + W --> M + M --> R[Reports] + DX[Data XML] --> M + S[Security – ACLs and Groups] -. enforces .-> M + + subgraph Test_discuss_full Module - test_discuss_full + direction LR + M:::layer + W:::layer + C:::layer + V:::layer + R:::layer + S:::layer + DX:::layer + end + + classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px +``` + +Notes +- Views include tree/form/kanban templates and report templates. +- Controllers provide website/portal routes when present. +- Wizards are UI flows implemented with `models.TransientModel`. +- Data XML loads data/demo records; Security defines groups and access. diff --git a/odoo-bringout-oca-ocb-test_discuss_full/doc/CONFIGURATION.md b/odoo-bringout-oca-ocb-test_discuss_full/doc/CONFIGURATION.md new file mode 100644 index 0000000..4f6b601 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/doc/CONFIGURATION.md @@ -0,0 +1,3 @@ +# Configuration + +Refer to Odoo settings for test_discuss_full. Configure related models, access rights, and options as needed. diff --git a/odoo-bringout-oca-ocb-test_discuss_full/doc/CONTROLLERS.md b/odoo-bringout-oca-ocb-test_discuss_full/doc/CONTROLLERS.md new file mode 100644 index 0000000..f628e77 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/doc/CONTROLLERS.md @@ -0,0 +1,3 @@ +# Controllers + +This module does not define custom HTTP controllers. diff --git a/odoo-bringout-oca-ocb-test_discuss_full/doc/DEPENDENCIES.md b/odoo-bringout-oca-ocb-test_discuss_full/doc/DEPENDENCIES.md new file mode 100644 index 0000000..2bf9786 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/doc/DEPENDENCIES.md @@ -0,0 +1,13 @@ +# Dependencies + +This addon depends on: + +- [calendar](../../odoo-bringout-oca-ocb-calendar) +- [crm](../../odoo-bringout-oca-ocb-crm) +- [crm_livechat](../../odoo-bringout-oca-ocb-crm_livechat) +- [hr_holidays](../../odoo-bringout-oca-ocb-hr_holidays) +- [im_livechat](../../odoo-bringout-oca-ocb-im_livechat) +- [mail](../../odoo-bringout-oca-ocb-mail) +- [mail_bot](../../odoo-bringout-oca-ocb-mail_bot) +- [note](../../odoo-bringout-oca-ocb-note) +- [website_livechat](../../odoo-bringout-oca-ocb-website_livechat) diff --git a/odoo-bringout-oca-ocb-test_discuss_full/doc/FAQ.md b/odoo-bringout-oca-ocb-test_discuss_full/doc/FAQ.md new file mode 100644 index 0000000..89bc7f4 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/doc/FAQ.md @@ -0,0 +1,4 @@ +# FAQ + +- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged). +- Q: How to enable? A: Start server with --addon test_discuss_full or install in UI. diff --git a/odoo-bringout-oca-ocb-test_discuss_full/doc/INSTALL.md b/odoo-bringout-oca-ocb-test_discuss_full/doc/INSTALL.md new file mode 100644 index 0000000..240da3a --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/doc/INSTALL.md @@ -0,0 +1,7 @@ +# Install + +```bash +pip install odoo-bringout-oca-ocb-test_discuss_full" +# or +uv pip install odoo-bringout-oca-ocb-test_discuss_full" +``` diff --git a/odoo-bringout-oca-ocb-test_discuss_full/doc/MODELS.md b/odoo-bringout-oca-ocb-test_discuss_full/doc/MODELS.md new file mode 100644 index 0000000..c31f3f3 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/doc/MODELS.md @@ -0,0 +1,11 @@ +# Models + +Detected core models and extensions in test_discuss_full. + +```mermaid +classDiagram +``` + +Notes +- Classes show model technical names; fields omitted for brevity. +- Items listed under _inherit are extensions of existing models. diff --git a/odoo-bringout-oca-ocb-test_discuss_full/doc/OVERVIEW.md b/odoo-bringout-oca-ocb-test_discuss_full/doc/OVERVIEW.md new file mode 100644 index 0000000..756dc8e --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/doc/OVERVIEW.md @@ -0,0 +1,6 @@ +# Overview + +Packaged Odoo addon: test_discuss_full. Provides features documented in upstream Odoo 16 under this addon. + +- Source: OCA/OCB 16.0, addon test_discuss_full +- License: LGPL-3 diff --git a/odoo-bringout-oca-ocb-test_discuss_full/doc/REPORTS.md b/odoo-bringout-oca-ocb-test_discuss_full/doc/REPORTS.md new file mode 100644 index 0000000..e0ea35f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/doc/REPORTS.md @@ -0,0 +1,3 @@ +# Reports + +This module does not define custom reports. diff --git a/odoo-bringout-oca-ocb-test_discuss_full/doc/SECURITY.md b/odoo-bringout-oca-ocb-test_discuss_full/doc/SECURITY.md new file mode 100644 index 0000000..e07da9d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/doc/SECURITY.md @@ -0,0 +1,8 @@ +# Security + +This module does not define custom security rules or access controls beyond Odoo defaults. + +Default Odoo security applies: +- Base user access through standard groups +- Model access inherited from dependencies +- No custom row-level security rules diff --git a/odoo-bringout-oca-ocb-test_discuss_full/doc/TROUBLESHOOTING.md b/odoo-bringout-oca-ocb-test_discuss_full/doc/TROUBLESHOOTING.md new file mode 100644 index 0000000..56853cb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/doc/TROUBLESHOOTING.md @@ -0,0 +1,5 @@ +# Troubleshooting + +- Ensure Python and Odoo environment matches repo guidance. +- Check database connectivity and logs if startup fails. +- Validate that dependent addons listed in DEPENDENCIES.md are installed. diff --git a/odoo-bringout-oca-ocb-test_discuss_full/doc/USAGE.md b/odoo-bringout-oca-ocb-test_discuss_full/doc/USAGE.md new file mode 100644 index 0000000..7ce890b --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/doc/USAGE.md @@ -0,0 +1,7 @@ +# Usage + +Start Odoo including this addon (from repo root): + +```bash +python3 scripts/nix_odoo_web_server.py --db-name mydb --addon test_discuss_full +``` diff --git a/odoo-bringout-oca-ocb-test_discuss_full/doc/WIZARDS.md b/odoo-bringout-oca-ocb-test_discuss_full/doc/WIZARDS.md new file mode 100644 index 0000000..48e790d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/doc/WIZARDS.md @@ -0,0 +1,3 @@ +# Wizards + +This module does not include UI wizards. diff --git a/odoo-bringout-oca-ocb-test_discuss_full/pyproject.toml b/odoo-bringout-oca-ocb-test_discuss_full/pyproject.toml new file mode 100644 index 0000000..0467327 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/pyproject.toml @@ -0,0 +1,50 @@ +[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." +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", + "requests>=2.25.1" +] +readme = "README.md" +requires-python = ">= 3.11" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "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.12", + "Topic :: Office/Business", +] + +[project.urls] +homepage = "https://github.com/bringout/0" +repository = "https://github.com/bringout/0" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["test_discuss_full"] + +[tool.rye] +managed = true +dev-dependencies = [ + "pytest>=8.4.1", +] diff --git a/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/__init__.py b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/__init__.py new file mode 100644 index 0000000..67dee8c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. 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 new file mode 100644 index 0000000..7e9acd9 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/__manifest__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# 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', + ], + 'installable': True, + 'license': 'LGPL-3', +} 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 new file mode 100644 index 0000000..72c0b71 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_performance 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 new file mode 100644 index 0000000..bcfc7a2 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_discuss_full/test_discuss_full/tests/test_performance.py @@ -0,0 +1,1007 @@ +# -*- 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 odoo import Command +from odoo.tests.common import users, tagged, TransactionCase, warmup +from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT + + +@tagged('post_install', '-at_install') +class TestDiscussFullPerformance(TransactionCase): + def setUp(self): + super().setUp() + 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.users = self.env['res.users'].create([ + { + 'email': 'e.e@example.com', + 'groups_id': [Command.link(self.group_user.id)], + 'login': 'emp', + 'name': 'Ernest Employee', + 'notification_type': 'inbox', + 'odoobot_state': 'disabled', + 'signature': '--\nErnest', + }, + {'name': 'test1', 'login': 'test1', 'email': 'test1@example.com'}, + {'name': 'test2', 'login': 'test2', 'email': 'test2@example.com'}, + {'name': 'test3', 'login': 'test3'}, + {'name': 'test4', 'login': 'test4'}, + {'name': 'test5', 'login': 'test5'}, + {'name': 'test6', 'login': 'test6'}, + {'name': 'test7', 'login': 'test7'}, + {'name': 'test8', 'login': 'test8'}, + {'name': 'test9', 'login': 'test9'}, + {'name': 'test10', 'login': 'test10'}, + {'name': 'test11', 'login': 'test11'}, + {'name': 'test12', 'login': 'test12'}, + {'name': 'test13', 'login': 'test13'}, + {'name': 'test14', 'login': 'test14'}, + {'name': 'test15', 'login': 'test15'}, + ]) + self.employees = self.env['hr.employee'].create([{ + 'user_id': user.id, + } for user in self.users]) + self.leave_type = self.env['hr.leave.type'].create({ + 'requires_allocation': 'no', + '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), + '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.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.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) + # 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) + # 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']) + # 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']) + # 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") + # 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) + # add star + message.toggle_message_starred() + self.env.company.sudo().name = 'YourCompany' + + 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() + + self.assertEqual(init_messaging, self._get_init_messaging_result()) + + 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. + """ + return { + 'hasLinkPreviewFeature': True, + 'needaction_inbox_counter': 1, + 'starred_counter': 1, + 'channels': [ + { + '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, + }, + { + '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, + }, + { + '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, + }, + { + '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, + }, + { + '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): + """ + Returns the expected query count. + The point of having a separate getter is to allow it to be overriden. + """ + return 81 diff --git a/odoo-bringout-oca-ocb-test_event_full/README.md b/odoo-bringout-oca-ocb-test_event_full/README.md new file mode 100644 index 0000000..4f537b2 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/README.md @@ -0,0 +1,65 @@ +# Test Full Event Flow + + +This module will test the main event flows of Odoo, both frontend and backend. +It installs sale capabilities, front-end flow, eCommerce, questions and +automatic lead generation, full Online support, ... + + +## Installation + +```bash +pip install odoo-bringout-oca-ocb-test_event_full +``` + +## Dependencies + +This addon depends on: +- event +- event_booth +- event_crm +- event_crm_sale +- event_sale +- 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`. + +## 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 diff --git a/odoo-bringout-oca-ocb-test_event_full/doc/ARCHITECTURE.md b/odoo-bringout-oca-ocb-test_event_full/doc/ARCHITECTURE.md new file mode 100644 index 0000000..90d7859 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/doc/ARCHITECTURE.md @@ -0,0 +1,32 @@ +# Architecture + +```mermaid +flowchart TD + U[Users] -->|HTTP| V[Views and QWeb Templates] + V --> C[Controllers] + V --> W[Wizards – Transient Models] + C --> M[Models and ORM] + W --> M + M --> R[Reports] + DX[Data XML] --> M + S[Security – ACLs and Groups] -. enforces .-> M + + subgraph Test_event_full Module - test_event_full + direction LR + M:::layer + W:::layer + C:::layer + V:::layer + R:::layer + S:::layer + DX:::layer + end + + classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px +``` + +Notes +- Views include tree/form/kanban templates and report templates. +- Controllers provide website/portal routes when present. +- Wizards are UI flows implemented with `models.TransientModel`. +- Data XML loads data/demo records; Security defines groups and access. diff --git a/odoo-bringout-oca-ocb-test_event_full/doc/CONFIGURATION.md b/odoo-bringout-oca-ocb-test_event_full/doc/CONFIGURATION.md new file mode 100644 index 0000000..7262647 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/doc/CONFIGURATION.md @@ -0,0 +1,3 @@ +# Configuration + +Refer to Odoo settings for test_event_full. Configure related models, access rights, and options as needed. diff --git a/odoo-bringout-oca-ocb-test_event_full/doc/CONTROLLERS.md b/odoo-bringout-oca-ocb-test_event_full/doc/CONTROLLERS.md new file mode 100644 index 0000000..f628e77 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/doc/CONTROLLERS.md @@ -0,0 +1,3 @@ +# Controllers + +This module does not define custom HTTP controllers. diff --git a/odoo-bringout-oca-ocb-test_event_full/doc/DEPENDENCIES.md b/odoo-bringout-oca-ocb-test_event_full/doc/DEPENDENCIES.md new file mode 100644 index 0000000..a5802ef --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/doc/DEPENDENCIES.md @@ -0,0 +1,20 @@ +# Dependencies + +This addon depends on: + +- [event](../../odoo-bringout-oca-ocb-event) +- [event_booth](../../odoo-bringout-oca-ocb-event_booth) +- [event_crm](../../odoo-bringout-oca-ocb-event_crm) +- [event_crm_sale](../../odoo-bringout-oca-ocb-event_crm_sale) +- [event_sale](../../odoo-bringout-oca-ocb-event_sale) +- [event_sms](../../odoo-bringout-oca-ocb-event_sms) +- [payment_demo](../../odoo-bringout-oca-ocb-payment_demo) +- [website_event_booth_sale_exhibitor](../../odoo-bringout-oca-ocb-website_event_booth_sale_exhibitor) +- [website_event_crm_questions](../../odoo-bringout-oca-ocb-website_event_crm_questions) +- [website_event_exhibitor](../../odoo-bringout-oca-ocb-website_event_exhibitor) +- [website_event_questions](../../odoo-bringout-oca-ocb-website_event_questions) +- [website_event_meet](../../odoo-bringout-oca-ocb-website_event_meet) +- [website_event_sale](../../odoo-bringout-oca-ocb-website_event_sale) +- [website_event_track](../../odoo-bringout-oca-ocb-website_event_track) +- [website_event_track_live](../../odoo-bringout-oca-ocb-website_event_track_live) +- [website_event_track_quiz](../../odoo-bringout-oca-ocb-website_event_track_quiz) diff --git a/odoo-bringout-oca-ocb-test_event_full/doc/FAQ.md b/odoo-bringout-oca-ocb-test_event_full/doc/FAQ.md new file mode 100644 index 0000000..988acb2 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/doc/FAQ.md @@ -0,0 +1,4 @@ +# FAQ + +- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged). +- Q: How to enable? A: Start server with --addon test_event_full or install in UI. diff --git a/odoo-bringout-oca-ocb-test_event_full/doc/INSTALL.md b/odoo-bringout-oca-ocb-test_event_full/doc/INSTALL.md new file mode 100644 index 0000000..8f3c96a --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/doc/INSTALL.md @@ -0,0 +1,7 @@ +# Install + +```bash +pip install odoo-bringout-oca-ocb-test_event_full" +# or +uv pip install odoo-bringout-oca-ocb-test_event_full" +``` diff --git a/odoo-bringout-oca-ocb-test_event_full/doc/MODELS.md b/odoo-bringout-oca-ocb-test_event_full/doc/MODELS.md new file mode 100644 index 0000000..73f0ddb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/doc/MODELS.md @@ -0,0 +1,11 @@ +# Models + +Detected core models and extensions in test_event_full. + +```mermaid +classDiagram +``` + +Notes +- Classes show model technical names; fields omitted for brevity. +- Items listed under _inherit are extensions of existing models. diff --git a/odoo-bringout-oca-ocb-test_event_full/doc/OVERVIEW.md b/odoo-bringout-oca-ocb-test_event_full/doc/OVERVIEW.md new file mode 100644 index 0000000..57e4fc5 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/doc/OVERVIEW.md @@ -0,0 +1,6 @@ +# Overview + +Packaged Odoo addon: test_event_full. Provides features documented in upstream Odoo 16 under this addon. + +- Source: OCA/OCB 16.0, addon test_event_full +- License: LGPL-3 diff --git a/odoo-bringout-oca-ocb-test_event_full/doc/REPORTS.md b/odoo-bringout-oca-ocb-test_event_full/doc/REPORTS.md new file mode 100644 index 0000000..e0ea35f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/doc/REPORTS.md @@ -0,0 +1,3 @@ +# Reports + +This module does not define custom reports. diff --git a/odoo-bringout-oca-ocb-test_event_full/doc/SECURITY.md b/odoo-bringout-oca-ocb-test_event_full/doc/SECURITY.md new file mode 100644 index 0000000..e07da9d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/doc/SECURITY.md @@ -0,0 +1,8 @@ +# Security + +This module does not define custom security rules or access controls beyond Odoo defaults. + +Default Odoo security applies: +- Base user access through standard groups +- Model access inherited from dependencies +- No custom row-level security rules diff --git a/odoo-bringout-oca-ocb-test_event_full/doc/TROUBLESHOOTING.md b/odoo-bringout-oca-ocb-test_event_full/doc/TROUBLESHOOTING.md new file mode 100644 index 0000000..56853cb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/doc/TROUBLESHOOTING.md @@ -0,0 +1,5 @@ +# Troubleshooting + +- Ensure Python and Odoo environment matches repo guidance. +- Check database connectivity and logs if startup fails. +- Validate that dependent addons listed in DEPENDENCIES.md are installed. diff --git a/odoo-bringout-oca-ocb-test_event_full/doc/USAGE.md b/odoo-bringout-oca-ocb-test_event_full/doc/USAGE.md new file mode 100644 index 0000000..876fe81 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/doc/USAGE.md @@ -0,0 +1,7 @@ +# Usage + +Start Odoo including this addon (from repo root): + +```bash +python3 scripts/nix_odoo_web_server.py --db-name mydb --addon test_event_full +``` diff --git a/odoo-bringout-oca-ocb-test_event_full/doc/WIZARDS.md b/odoo-bringout-oca-ocb-test_event_full/doc/WIZARDS.md new file mode 100644 index 0000000..48e790d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/doc/WIZARDS.md @@ -0,0 +1,3 @@ +# Wizards + +This module does not include UI wizards. diff --git a/odoo-bringout-oca-ocb-test_event_full/pyproject.toml b/odoo-bringout-oca-ocb-test_event_full/pyproject.toml new file mode 100644 index 0000000..b12e81e --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/pyproject.toml @@ -0,0 +1,57 @@ +[project] +name = "odoo-bringout-oca-ocb-test_event_full" +version = "16.0.0" +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", + "requests>=2.25.1" +] +readme = "README.md" +requires-python = ">= 3.11" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "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.12", + "Topic :: Office/Business", +] + +[project.urls] +homepage = "https://github.com/bringout/0" +repository = "https://github.com/bringout/0" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["test_event_full"] + +[tool.rye] +managed = true +dev-dependencies = [ + "pytest>=8.4.1", +] diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/__init__.py b/odoo-bringout-oca-ocb-test_event_full/test_event_full/__init__.py new file mode 100644 index 0000000..67dee8c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. 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 new file mode 100644 index 0000000..82d233c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/__manifest__.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Test Full Event Flow', + 'version': '1.0', + 'category': 'Hidden/Tests', + 'description': """ +This module will test the main event flows of Odoo, both frontend and backend. +It installs sale capabilities, front-end flow, eCommerce, questions and +automatic lead generation, full Online support, ... +""", + 'depends': [ + 'event', + 'event_booth', + 'event_crm', + 'event_crm_sale', + 'event_sale', + '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', + ], + 'data': [ + # 'data/event_type_data.xml', # uncomment to reproduce test tour + 'data/ir_actions_report_data.xml', + 'views/event_registration_templates_reports.xml', + ], + 'assets': { + 'web.assets_tests': [ + 'test_event_full/static/**/*', + ], + }, + '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 new file mode 100644 index 0000000..4ebf4a2 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/data/event_type_data.xml @@ -0,0 +1,122 @@ + + + + +

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/data/ir_actions_report_data.xml b/odoo-bringout-oca-ocb-test_event_full/test_event_full/data/ir_actions_report_data.xml new file mode 100644 index 0000000..1e36bc5 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/data/ir_actions_report_data.xml @@ -0,0 +1,13 @@ + + + + Test Report + event.registration + qweb-pdf + test_event_full.event_registration_template_report + test_event_full.event_registration_template_report + 'Badge - %s - %s' % ((object.event_id.name or 'Event').replace('/',''), (object.name or '').replace('/','')) + + report + + 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 new file mode 100644 index 0000000..290d581 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/static/src/js/tours/wevent_performance_tour.js @@ -0,0 +1,85 @@ +odoo.define('test_event_full.tour.performance', function (require) { +"use strict"; + +var tour = require('web_tour.tour'); + +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: "Select 1 unit of 'Ticket2' ticket type", + trigger: '#o_wevent_tickets_collapse .row.o_wevent_ticket_selector[name="Ticket2"] select', + run: 'text 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: "Validate attendees details", + extra_trigger: "input[name='1-name'], input[name='2-name'], input[name='3-name']", + trigger: 'button:contains("Continue")', + 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 () {} +}]; + + +tour.register('wevent_performance_register', { + test: true +}, [].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 new file mode 100644 index 0000000..ee7a1ad --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/static/src/js/tours/wevent_register_tour.js @@ -0,0 +1,174 @@ +odoo.define('test_event_full.tour.register', function (require) { +"use strict"; + +var tour = require('web_tour.tour'); + +/** + * TALKS STEPS + */ + +var discoverTalkSteps = function (talkName, fromList, reminderOn, toggleReminder) { + var steps; + if (fromList) { + steps = [{ + content: 'Go on "' + talkName + '" talk in List', + trigger: 'a:contains("' + talkName + '")', + }]; + } + else { + steps = [{ + content: 'Click on Live Track', + trigger: 'article span:contains("' + talkName + '")', + run: 'click', + }]; + } + 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 + }]); + } + } + 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."); + }, +}, { + 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() {}, +}]; + +/** + * MAIN STEPS + */ + +var initTourSteps = function (eventName) { + return [{ + content: 'Go on "' + eventName + '" page', + trigger: 'a[href*="/event"]:contains("' + eventName + '"):first', + }]; +}; + +var browseTalksSteps = [{ + content: 'Browse Talks', + trigger: 'a:contains("Talks")', +}, { + content: 'Check we are on the talk list page', + trigger: 'h1:contains("Book your talks")', + run: function () {} // check +}]; + +var browseMeetSteps = [{ + content: 'Browse Meet', + trigger: 'a:contains("Community")', +}, { + content: 'Check we are on the community page', + trigger: 'span:contains("Join a room")', + run: function () {} // check +}]; + + +tour.register('wevent_register', { + url: '/event', + test: true +}, [].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'), + 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 new file mode 100644 index 0000000..94b6abc --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import common +from . import test_event_crm +from . import test_event_event +from . import test_event_mail +from . import test_event_security +from . import test_performance +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 new file mode 100644 index 0000000..9107fb5 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/common.py @@ -0,0 +1,462 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, timedelta, time + +from odoo.addons.base.tests.common import HttpCaseWithUserDemo, HttpCaseWithUserPortal +from odoo.addons.event_crm.tests.common import EventCrmCase +from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.sales_team.tests.common import TestSalesCommon +from odoo.addons.website.tests.test_website_visitor import MockVisitor + + +class TestEventFullCommon(EventCrmCase, TestSalesCommon, MockVisitor): + + @classmethod + def setUpClass(cls): + super(TestEventFullCommon, cls).setUpClass() + cls._init_mail_gateway() + + # Context data: dates + # ------------------------------------------------------------ + + # Mock dates to have reproducible computed fields based on time + cls.reference_now = datetime(2021, 12, 1, 10, 0, 0) + cls.reference_today = datetime(2021, 12, 1) + + # Users and contacts + # ------------------------------------------------------------ + + cls.admin_user = cls.env.ref('base.user_admin') + cls.admin_user.write({ + 'country_id': cls.env.ref('base.be').id, + 'login': 'admin', + 'notification_type': 'inbox', + }) + cls.company_admin = cls.admin_user.company_id + # set country in order to format Belgian numbers + cls.company_admin.write({ + 'country_id': cls.env.ref('base.be').id, + }) + cls.event_user = mail_new_test_user( + cls.env, + company_id=cls.company_admin.id, + company_ids=[(4, cls.company_admin.id)], + country_id=cls.env.ref('base.be').id, + groups='base.group_user,base.group_partner_manager,event.group_event_user', + email='e.e@example.com', + login='event_user', + name='Ernest Employee', + notification_type='inbox', + signature='--\nErnest', + ) + + cls.customer = cls.env['res.partner'].create({ + '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 + cls.customer_so = cls.env['sale.order'].with_user(cls.user_sales_salesman).create({ + 'partner_id': cls.event_customer.id, + }) + + # Side records for event main records + # ------------------------------------------------------------ + + cls.ticket_product = cls.env['product.product'].create({ + 'description_sale': 'Ticket Product Description', + 'detailed_type': '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', + 'list_price': 20, + 'name': 'Test Booth Product', + 'standard_price': 60.0, + }) + + cls.tag_categories = cls.env['event.tag.category'].create([ + {'is_published': True, 'name': 'Published Category'}, + {'is_published': False, 'name': 'Unpublished Category'}, + ]) + cls.tags = cls.env['event.tag'].create([ + {'category_id': cls.tag_categories[0].id, 'name': 'PubTag1'}, + {'category_id': cls.tag_categories[0].id, 'color': 0, 'name': 'PubTag2'}, + {'category_id': cls.tag_categories[1].id, 'name': 'UnpubTag1'}, + ]) + + cls.event_booth_categories = cls.env['event.booth.category'].create([ + {'description': '

Standard

', + 'name': 'Standard', + 'product_id': cls.booth_product.id, + }, + {'description': '

Premium

', + 'name': 'Premium', + 'product_id': cls.booth_product.id, + 'price': 90, + } + ]) + + cls.sponsor_types = cls.env['event.sponsor.type'].create([ + {'name': 'GigaTop', + 'sequence': 1, + } + ]) + cls.sponsor_partners = cls.env['res.partner'].create([ + {'country_id': cls.env.ref('base.be').id, + 'email': 'event.sponsor@example.com', + 'name': 'EventSponsor', + 'phone': '04856112233', + } + ]) + + # Event type + # ------------------------------------------------------------ + 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}) + 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, + 'name': 'Standard Booth', + } + ), + (0, 0, {'booth_category_id': cls.event_booth_categories[0].id, + 'name': 'Standard Booth 2', + } + ), + (0, 0, {'booth_category_id': cls.event_booth_categories[1].id, + 'name': 'Premium Booth', + } + ), + (0, 0, {'booth_category_id': cls.event_booth_categories[1].id, + 'name': 'Premium Booth 2', + } + ), + ], + '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'), + } + ), + ], + 'event_type_ticket_ids': [ + (0, 0, {'description': 'Ticket1 Description', + 'name': 'Ticket1', + 'product_id': cls.ticket_product.id, + 'seats_max': 10, + } + ), + (0, 0, {'description': 'Ticket2 Description', + 'name': 'Ticket2', + 'product_id': cls.ticket_product.id, + 'price': 45, + } + ) + ], + 'has_seats_limitation': True, + 'name': 'Test Type', + 'note': '

Template note

', + 'question_ids': [ + (0, 0, {'answer_ids': + [(0, 0, {'name': 'Q1-Answer1'}), + (0, 0, {'name': 'Q1-Answer2'}), + ], + 'question_type': 'simple_choice', + 'once_per_order': False, + 'title': 'Question1', + } + ), + (0, 0, {'answer_ids': + [(0, 0, {'name': 'Q2-Answer1'}), + (0, 0, {'name': 'Q2-Answer2'}), + ], + 'question_type': 'simple_choice', + 'once_per_order': False, + 'title': 'Question2', + } + ), + (0, 0, {'question_type': 'text_box', + 'once_per_order': True, + 'title': 'Question3', + } + ), + ], + 'seats_max': 30, + 'tag_ids': [(4, tag.id) for tag in cls.tags], + 'ticket_instructions': '

Ticket Instructions

', + 'website_menu': True, + }) + + # Stages + cls.stage_def = cls.env['event.stage'].create({ + 'name': 'First Stage', + 'sequence': 0, + }) + + # Event data + # ------------------------------------------------------------ + + cls.event_base_vals = { + 'name': 'Test Event', + 'date_begin': cls.reference_now + timedelta(days=1), + 'date_end': cls.reference_now + timedelta(days=5), + '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, + }) + # 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') + ticket_1.start_sale_datetime = cls.reference_now + timedelta(hours=1) + ticket_2.start_sale_datetime = cls.reference_now + timedelta(hours=2) + + # Website data + # ------------------------------------------------------------ + + cls.website = cls.env['website'].search([ + ('company_id', '=', cls.company_admin.id) + ], limit=1) + + cls.customer_data = [ + {'email': 'customer.email.%02d@test.example.com' % x, + 'name': 'My Customer %02d' % x, + 'mobile': '04569999%02d' % x, + 'partner_id': False, + 'phone': '04560000%02d' % x, + } for x 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, + 'partner_id': cls.env.ref('base.public_partner').id, + 'phone': '04560000%02d' % x, + '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, + }), (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, + }), (0, 0, { + 'question_id': cls.test_event.question_ids[2].id, + 'value_text_box': 'CustomerAnswer%s' % x, + }) + ], + } for x 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) + ]) + + def assertLeadConvertion(self, rule, registrations, partner=None, **expected): + super(TestEventFullCommon, self).assertLeadConvertion(rule, registrations, partner=partner, **expected) + lead = self.env['crm.lead'].sudo().search([ + ('registration_ids', 'in', registrations.ids), + ('event_lead_rule_id', '=', rule.id) + ]) + + for registration in registrations: + if not registration.registration_answer_ids: + continue + for answer in registration.registration_answer_ids: + self.assertIn(answer.question_id.title, lead.description) + if answer.question_type == 'simple_choice': + self.assertIn(answer.value_answer_id.name, lead.description) + else: + self.assertIn(answer.value_text_box, lead.description) # better: check multi line + + +class TestWEventCommon(HttpCaseWithUserDemo, HttpCaseWithUserPortal, MockVisitor): + + def setUp(self): + super(TestWEventCommon, self).setUp() + + # Context data: dates + # ------------------------------------------------------------ + + # Mock dates to have reproducible computed fields based on time + self.reference_now = datetime(2021, 12, 1, 10, 0, 0) + self.reference_today = datetime(2021, 12, 1) + + self.event_product = self.env['product.product'].create({ + 'name': 'Test Event Registration', + 'default_code': 'EVENT_REG', + 'description_sale': 'Mighty Description', + 'list_price': 10, + 'standard_price': 30.0, + 'detailed_type': 'event', + }) + + self.event_tag_category_1 = self.env['event.tag.category'].create({ + 'name': 'Type', + 'sequence': 2, + }) + self.event_tag_category_1_tag_1 = self.env['event.tag'].create({ + 'name': 'Online', + 'sequence': 10, + 'category_id': self.event_tag_category_1.id, + 'color': 8, + }) + self.env['event.event'].search( + [('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, + 'tag_ids': [(4, self.event_tag_category_1_tag_1.id)], + # event if 8-18 in Europe/Brussels (DST) (first day: begins at 7, last day: ends at 17) + 'date_tz': 'Europe/Brussels', + 'date_begin': datetime.combine(self.reference_now, time(5, 0)) - timedelta(days=1), + 'date_end': datetime.combine(self.reference_now, time(15, 0)) + timedelta(days=1), + # ticket informations + 'event_ticket_ids': [ + (0, 0, { + 'name': 'Standard', + 'product_id': self.event_product.id, + 'price': 0, + }), (0, 0, { + 'name': 'VIP', + 'product_id': self.event_product.id, + 'seats_max': 10, + }) + ], + # activate menus + 'is_published': True, + 'website_menu': True, + 'website_track': True, + 'website_track_proposal': True, + 'exhibitor_menu': True, + 'community_menu': True, + }) + + self.event_customer = self.env['res.partner'].create({ + 'name': 'Constantin Customer', + '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', + 'email': 'brandon.freeman55@example.com', + 'phone': '(355)-687-3262', + }) + + # ------------------------------------------------------------ + # QUESTIONS + # ------------------------------------------------------------ + + 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, + 'once_per_order': False, + 'answer_ids': [ + (0, 0, {'name': 'Consumers'}), + (0, 0, {'name': 'Sales'}), + (0, 0, {'name': 'Research'}), + ], + }) + 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, + 'once_per_order': True, + }) + + # ------------------------------------------------------------ + # TRACKS + # ------------------------------------------------------------ + + self.track_0 = self.env['event.track'].create({ + 'name': 'What This Event Is All About', + 'event_id': self.event.id, + 'stage_id': self.env.ref('website_event_track.event_track_stage3').id, + 'date': self.reference_now + timedelta(hours=1), + 'duration': 2, + 'is_published': True, + 'wishlisted_by_default': True, + 'user_id': self.user_admin.id, + 'partner_id': self.event_speaker.id, + }) + self.track_1 = self.env['event.track'].create({ + 'name': 'Live Testimonial', + 'event_id': self.event.id, + 'stage_id': self.env.ref('website_event_track.event_track_stage3').id, + 'date': self.reference_now - timedelta(minutes=30), + 'duration': 0.75, + 'is_published': True, + 'user_id': self.user_admin.id, + 'partner_id': self.event_speaker.id, + }) + self.track_2 = self.env['event.track'].create({ + '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), + 'duration': 0.75, + '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, + }) + + 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 new file mode 100644 index 0000000..e0cecb8 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_crm.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +# 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 + + +class TestEventCrm(TestEventFullCommon): + + @classmethod + def setUpClass(cls): + super(TestEventCrm, cls).setUpClass() + + cls.TICKET1_COUNT, cls.TICKET2_COUNT = 3, 1 + ticket1 = cls.test_event.event_ticket_ids[0] + ticket2 = cls.test_event.event_ticket_ids[1] + + (cls.test_rule_attendee + cls.test_rule_order).write({'event_id': cls.test_event.id}) + + # PREPARE SO DATA + # ------------------------------------------------------------ + + # adding some tickets to SO + cls.customer_so.write({ + 'order_line': [ + (0, 0, { + 'event_id': cls.test_event.id, + 'event_ticket_id': ticket1.id, + 'product_id': ticket1.product_id.id, + 'product_uom_qty': cls.TICKET1_COUNT, + }), (0, 0, { + 'event_id': cls.test_event.id, + 'event_ticket_id': ticket2.id, + 'product_id': ticket2.product_id.id, + 'product_uom_qty': cls.TICKET2_COUNT, + 'price_unit': 50, + }) + ] + }) + + @users('user_sales_salesman') + def test_event_crm_sale_customer(self): + """ Test a SO with a real customer set on it, check partner propagation + as well as group-based lead update. """ + customer_so = self.env['sale.order'].browse(self.customer_so.id) + + # adding some tickets to SO + t1_reg_vals = [ + dict(customer_data, + partner_id=customer_so.partner_id.id, + sale_order_line_id=customer_so.order_line[0].id) + for customer_data in self.website_customer_data[:self.TICKET1_COUNT] + ] + t1_registrations = self.env['event.registration'].create(t1_reg_vals) + + # check effect: registrations, leads + self.assertEqual(self.test_event.registration_ids, t1_registrations) + self.assertEqual(len(self.test_rule_order.lead_ids), 1) + self.assertEqual(self.test_rule_order_done.lead_ids, self.env['crm.lead']) + # check lead converted based on registrations + self.assertLeadConvertion(self.test_rule_order, t1_registrations, partner=customer_so.partner_id) + + # SO is confirmed -> missing registrations should be automatically added + # and added to the lead as part of the same group + customer_so.action_confirm() + self.assertEqual(customer_so.state, 'sale') + self.assertEqual(len(self.test_event.registration_ids), self.TICKET1_COUNT + self.TICKET2_COUNT) + self.assertEqual(len(self.test_rule_order.lead_ids), 1) # no new lead created + self.assertEqual(self.test_rule_order_done.lead_ids, self.env['crm.lead']) # this one still not triggered + + # check existing lead has been updated with new registrations + self.assertLeadConvertion(self.test_rule_order, self.test_event.registration_ids, partner=customer_so.partner_id) + + # Confirm registrations -> trigger the "DONE" rule, one new lead linked to all + # event registrations created in this test as all belong to the same SO + self.test_event.registration_ids.write({'state': 'done'}) + self.assertLeadConvertion(self.test_rule_order_done, self.test_event.registration_ids, partner=customer_so.partner_id) + + @users('user_sales_salesman') + def test_event_crm_sale_mixed_group(self): + """ Test a mixed sale order line creation. This should not happen in a customer + use case but should be supported by the code. """ + public_partner = self.env.ref('base.public_partner') + public_so = self.env['sale.order'].create({ + 'partner_id': public_partner.id, + 'order_line': [ + (0, 0, { + 'event_id': self.test_event.id, + 'event_ticket_id': self.test_event.event_ticket_ids[0].id, + 'product_id': self.test_event.event_ticket_ids[0].product_id.id, + 'product_uom_qty': 2, + }) + ] + }) + customer_so = self.env['sale.order'].browse(self.customer_so.id) + + # make a multi-SO create + mixed_reg_vals = [ + dict(self.website_customer_data[0], + partner_id=customer_so.partner_id.id, + sale_order_line_id=customer_so.order_line[0].id), + dict(self.website_customer_data[1], + partner_id=customer_so.partner_id.id, + sale_order_line_id=customer_so.order_line[0].id), + dict(self.website_customer_data[2], + partner_id=public_so.partner_id.id, + sale_order_line_id=public_so.order_line[0].id), + dict(self.website_customer_data[3], + partner_id=public_so.partner_id.id, + sale_order_line_id=public_so.order_line[0].id), + ] + self.env['event.registration'].create(mixed_reg_vals) + + public_regs = self.test_event.registration_ids.filtered(lambda reg: reg.sale_order_id == public_so) + self.assertEqual(len(public_regs), 2) + customer_regs = self.test_event.registration_ids.filtered(lambda reg: reg.sale_order_id == customer_so) + self.assertEqual(len(customer_regs), 2) + self.assertLeadConvertion(self.test_rule_order, public_regs, partner=None) + self.assertLeadConvertion(self.test_rule_order, customer_regs, partner=customer_so.partner_id) + + @users('user_sales_salesman') + def test_event_crm_sale_public(self): + """ Test a SO with a public partner on it, then updated when SO is confirmed. + This somehow simulates a simplified website_event_sale flow. """ + public_partner = self.env.ref('base.public_partner') + customer_so = self.env['sale.order'].browse(self.customer_so.id) + customer_so.write({ + 'partner_id': public_partner.id, + }) + + # adding some tickets to SO + t1_reg_vals = [ + dict(customer_data, + partner_id=public_partner.id, + sale_order_line_id=customer_so.order_line[0].id) + for customer_data in self.website_customer_data[:self.TICKET1_COUNT] + ] + t1_registrations = self.env['event.registration'].create(t1_reg_vals) + self.assertEqual(self.test_event.registration_ids, t1_registrations) + + # check lead converted based on registrations + self.assertLeadConvertion(self.test_rule_order, t1_registrations, partner=None) + + # SO is confirmed -> missing registrations should be automatically added + # BUT as public user -> no email -> not taken into account by rule + customer_so.action_confirm() + self.assertEqual(customer_so.state, 'sale') + self.assertEqual(len(self.test_event.registration_ids), self.TICKET1_COUNT + self.TICKET2_COUNT) + self.assertLeadConvertion(self.test_rule_order, t1_registrations, partner=None) + + # SO has a customer set -> main contact of lead is updated accordingly + customer_so.write({'partner_id': self.event_customer.id}) + self.assertLeadConvertion(self.test_rule_order, t1_registrations, partner=self.event_customer) + + def test_event_update_lead(self): + """Make sure that we update leads without issues when question's answer is added to an event attendee.""" + self.env['event.lead.rule'].search([]).write({'active': False}) + self.env['event.lead.rule'].create({ + 'name': 'test_event_lead_rule', + 'lead_creation_basis': 'attendee', + 'lead_creation_trigger': 'create', + 'event_registration_filter': [['partner_id', '!=', False]], + 'lead_type': 'lead', + }) + event_registration = self.env['event.registration'].create({ + 'name': 'Event Registration without answers added at first', + 'event_id': self.test_event.id, + 'partner_id': self.event_customer.id, + }) + event_registration.write({ + 'registration_answer_ids': [(0, 0, { + 'question_id': self.test_event.question_ids[1].id, + 'value_answer_id': self.test_event.question_ids[1].answer_ids[0].id, + })] + }) + self.assertIn(self.test_event.question_ids[1].answer_ids[0].name, event_registration.lead_ids[0].description, + "lead description not updated with the answer to the question") 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 new file mode 100644 index 0000000..e6e04ad --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_discount.py @@ -0,0 +1,78 @@ +# 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 new file mode 100644 index 0000000..8bb0fdb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_event.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, timedelta +from freezegun import freeze_time + +from odoo import Command, exceptions +from odoo.addons.test_event_full.tests.common import TestEventFullCommon +from odoo.tests.common import users + + +class TestEventEvent(TestEventFullCommon): + + @users('event_user') + def test_event_create_wtype(self): + """ Test a single event creation with a type defining all sub records. """ + event_type = self.env['event.type'].browse(self.test_event_type.ids) + + event_values = dict( + self.event_base_vals, + event_type_id=event_type.id, + ) + event = self.env['event.event'].create([event_values]) + event.write({ + 'event_ticket_ids': [ + Command.update( + event.event_ticket_ids[0].id, + {'start_sale_datetime': self.reference_now + timedelta(hours=1)}, + ), + Command.update( + event.event_ticket_ids[1].id, + {'start_sale_datetime': self.reference_now + timedelta(hours=2)}, + ) + ], + }) + + # 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.note, '

Template note

') + self.assertTrue(event.register_menu) + self.assertEqual(len(event.question_ids), 3) + self.assertTrue(event.seats_limited) + self.assertEqual(event.seats_max, 30) + self.assertEqual(event.stage_id, self.stage_def) + self.assertEqual(event.tag_ids, self.tags) + self.assertTrue(event.website_menu) + + # check time dependent computation: before event + with freeze_time(self.reference_now): + self.assertFalse(event.is_finished) + self.assertFalse(event.is_ongoing) + self.assertFalse(event.event_registrations_started) + + # check time dependent computation: registrations started + with freeze_time(self.reference_now + timedelta(hours=1)): + event.invalidate_model(['is_finished', 'is_ongoing', 'event_registrations_started']) + self.assertFalse(event.is_finished) + self.assertFalse(event.is_ongoing) + self.assertTrue(event.event_registrations_started) + + # check time dependent computation: during event + with freeze_time(self.reference_now + timedelta(days=1, hours=1)): + event.invalidate_model(['is_finished', 'is_ongoing', 'event_registrations_started']) + self.assertFalse(event.is_finished) + self.assertTrue(event.is_ongoing) + self.assertTrue(event.event_registrations_started) + + @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') + + # check initial data + self.assertTrue(test_event.event_registrations_started) + self.assertEqual(test_event.seats_available, 30) + self.assertEqual(ticket_1.seats_available, 10) + self.assertTrue(ticket_1.sale_available) + self.assertEqual(ticket_2.seats_available, 0) + self.assertFalse(ticket_2.sale_available) + + # make 9 registrations (let 1 on ticket) + with 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, + } + for x in range(0, 9) + ]) + # generated emails from scheduler + self.assertEqual(len(self._new_mails), 9) + # event and ticket seats update + self.assertEqual(len(test_event.registration_ids), 9) + self.assertEqual(test_event.seats_available, 21) + self.assertEqual(ticket_1.seats_available, 1) + self.assertEqual(ticket_2.seats_available, 0) + + # prevent registration due to ticket limit + with 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, + } + for x in range(0, 2) + ]) + + # make 20 registrations (on free ticket) + with 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, + } + for x in range(0, 20) + ]) + # event and ticket seats update + self.assertEqual(len(test_event.registration_ids), 29) + self.assertEqual(test_event.seats_available, 1) + self.assertEqual(ticket_1.seats_available, 1) + self.assertEqual(ticket_2.seats_available, 0) + + # prevent registration due to event limit + with 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, + } + 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 new file mode 100644 index 0000000..d3bd0c4 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_mail.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, timedelta +from freezegun import freeze_time + +from 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 + +class TestTemplateRefModel(TestWEventCommon): + + def test_template_ref_delete_lines(self): + """ When deleting a template, related lines should be deleted too """ + event_type = self.env['event.type'].create({ + 'name': 'Event Type', + 'default_timezone': 'Europe/Brussels', + 'event_type_mail_ids': [ + (0, 0, { + 'interval_unit': 'now', + 'interval_type': 'after_sub', + 'template_ref': 'mail.template,%i' % self.env['ir.model.data']._xmlid_to_res_id('event.event_subscription')}), + (0, 0, { + 'interval_unit': 'now', + 'interval_type': 'after_sub', + 'notification_type': 'sms', + 'template_ref': 'sms.template,%i' % self.env['ir.model.data']._xmlid_to_res_id('event_sms.sms_template_data_event_registration')}), + ], + }) + + template_mail = event_type.event_type_mail_ids[0].template_ref + template_sms = event_type.event_type_mail_ids[1].template_ref + + event = self.env['event.event'].create({ + 'name': 'event mail template removed', + 'event_type_id': event_type.id, + 'date_begin': datetime(2020, 2, 1, 8, 30, 0), + 'date_end': datetime(2020, 2, 4, 18, 45, 0), + 'date_tz': 'Europe/Brussels', + }) + self.assertEqual(len(event_type.event_type_mail_ids), 2) + self.assertEqual(len(event.event_mail_ids), 2) + + template_mail.unlink() + self.assertEqual(len(event_type.event_type_mail_ids.exists()), 1) + self.assertEqual(len(event.event_mail_ids.exists()), 1) + + template_sms.unlink() + 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), + ] + + 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') + + # Add registrations + self.env['event.registration'].create([{ + 'event_id': test_event.id, + 'name': 'RegistrationUnconfirmed', + 'email': 'Registration@Unconfirmed.com', + 'state': 'draft', + }, { + 'event_id': test_event.id, + 'name': 'RegistrationCanceled', + 'email': 'Registration@Canceled.com', + 'state': 'cancel', + }, { + 'event_id': test_event.id, + 'name': 'RegistrationConfirmed', + 'email': 'Registration@Confirmed.com', + 'state': 'open', + }]) + + with self.mock_mail_gateway(), self.mockSMSGateway(): + mail_scheduler.execute() + + self.assertEqual(len(self._new_mails), 2, 'Mails were not created') + self.assertEqual(len(self._new_sms), 2, 'SMS were not created') + + self.assertEqual(test_event.seats_expected, 2, 'Wrong number of expected seats (attendees)') + + 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') 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 new file mode 100644 index 0000000..e6adc58 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_event_security.py @@ -0,0 +1,213 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, timedelta + +from odoo.addons.test_event_full.tests.common import TestEventFullCommon +from odoo.exceptions import AccessError +from odoo.tests import tagged +from odoo.tests.common import users +from odoo.tools import mute_logger + + +@tagged('security') +class TestEventSecurity(TestEventFullCommon): + + @users('user_employee') + @mute_logger('odoo.models.unlink', 'odoo.addons.base.models.ir_model') + def test_event_access_employee(self): + # Event: read ok + event = self.test_event.with_user(self.env.user) + event.read(['name']) + + # Event: read only + with self.assertRaises(AccessError): + self.env['event.event'].create({ + 'name': 'TestEvent', + 'date_begin': datetime.now() + timedelta(days=-1), + 'date_end': datetime.now() + timedelta(days=1), + 'seats_limited': True, + 'seats_max': 10, + }) + with self.assertRaises(AccessError): + event.write({ + 'name': 'TestEvent Modified', + }) + + # Event Type + with self.assertRaises(AccessError): + self.test_event_type.with_user(self.env.user).read(['name']) + with self.assertRaises(AccessError): + self.test_event_type.with_user(self.env.user).write({'name': 'Test Write'}) + + # Event Stage + with self.assertRaises(AccessError): + self.env['event.stage'].create({ + 'name': 'TestStage', + }) + + # Event Registration + with self.assertRaises(AccessError): + self.env['event.registration'].search([]) + + @users('user_eventregistrationdesk') + @mute_logger('odoo.models.unlink', 'odoo.addons.base.models.ir_model') + 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 only + with self.assertRaises(AccessError): + event.name = 'Test' + with self.assertRaises(AccessError): + event.unlink() + + # Event Registration + registration = self.env['event.registration'].create({ + 'event_id': event.id, + }) + self.assertEqual(registration.event_id.name, event.name, 'Registration users should be able to read') + registration.name = 'Test write' + with self.assertRaises(AccessError): + registration.unlink() + + @users('user_eventuser') + @mute_logger('odoo.models.unlink', 'odoo.addons.base.models.ir_model') + 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.write({'name': 'New name'}) + self.env['event.event'].create({ + 'name': 'Event', + 'date_begin': datetime.now() + timedelta(days=-1), + 'date_end': datetime.now() + timedelta(days=1), + }) + + # Event: cannot unlink + with self.assertRaises(AccessError): + event.unlink() + + # Event Type + with self.assertRaises(AccessError): + self.env['event.type'].create({ + 'name': 'ManagerEventType', + 'event_type_mail_ids': [(5, 0), (0, 0, { + 'interval_nbr': 1, 'interval_unit': 'days', 'interval_type': 'before_event', + 'template_ref': 'mail.template,%i' % self.env['ir.model.data']._xmlid_to_res_id('event.event_reminder')})] + }) + + @users('user_eventmanager') + @mute_logger('odoo.models.unlink', 'odoo.addons.base.models.ir_model') + def test_event_access_event_manager(self): + # Event Type + event_type = self.env['event.type'].create({ + 'name': 'ManagerEventType', + 'event_type_mail_ids': [(5, 0), (0, 0, { + 'interval_nbr': 1, 'interval_unit': 'days', 'interval_type': 'before_event', + 'template_ref': 'mail.template,%i' % self.env['ir.model.data']._xmlid_to_res_id('event.event_reminder')})] + }) + event_type.write({'name': 'New Name'}) + + # Event + event = self.env['event.event'].create({ + 'name': 'ManagerEvent', + 'date_begin': datetime.now() + timedelta(days=-1), + 'date_end': datetime.now() + timedelta(days=1), + }) + event.write({'name': 'New Event Name'}) + + # Event Stage + stage = self.env['event.stage'].create({'name': 'test'}) + stage.write({'name': 'ManagerTest'}) + event.write({'stage_id': stage.id}) + + # Event Registration + registration = self.env['event.registration'].create({'event_id': event.id, 'name': 'Myself'}) + registration.write({'name': 'Myself2'}) + registration.unlink() + + event.unlink() + stage.unlink() + event_type.unlink() + + # Settings access rights required to enable some features + self.user_eventmanager.write({'groups_id': [ + (3, self.env.ref('base.group_system').id), + (4, self.env.ref('base.group_erp_manager').id) + ]}) + with self.assertRaises(AccessError): + event_config = self.env['res.config.settings'].with_user(self.user_eventmanager).create({ + }) + event_config.execute() + + def test_implied_groups(self): + """Test that the implied groups are correctly set. + + - Event Manager imply Event User + - Event User imply Registration user + """ + # Event Manager + self.assertTrue( + self.user_eventmanager.has_group('event.group_event_user'), + 'The event manager group must imply the event user group') + self.assertTrue( + self.user_eventmanager.has_group('event.group_event_registration_desk'), + 'The event manager group must imply the registration user group') + + # Event User + self.assertTrue( + self.user_eventuser.has_group('event.group_event_registration_desk'), + 'The event user group must imply the event user group') + self.assertFalse( + self.user_eventuser.has_group('event.group_event_manager'), + 'The event user group must not imply the event user group') + + # Registration User + self.assertFalse( + self.user_eventregistrationdesk.has_group('event.group_event_manager'), + 'The event registration group must not imply the event user manager') + self.assertFalse( + self.user_eventregistrationdesk.has_group('event.group_event_user'), + 'The event registration group must not imply the event user group') + + def test_multi_companies(self): + """Test ACLs with multi company. """ + company_1 = self.env.ref("base.main_company") + company_2 = self.env['res.company'].create({'name': 'Company 2'}) + user_company_1 = self.user_eventuser + + event_company_1, event_company_2 = self.env['event.event'].create([ + { + 'name': 'Event Company 1', + 'date_begin': datetime.now() + timedelta(days=-1), + 'date_end': datetime.now() + timedelta(days=1), + 'company_id': company_1.id, + }, { + 'name': 'Event Company 2', + 'date_begin': datetime.now() + timedelta(days=-1), + 'date_end': datetime.now() + timedelta(days=1), + 'company_id': company_2.id, + } + ]) + + registration_company_1, registration_company_2 = self.env['event.registration'].create([ + { + 'name': 'Registration Company 1', + 'event_id': event_company_1.id, + 'company_id': company_1.id, + }, { + 'name': 'Registration Company 2', + 'event_id': event_company_2.id, + 'company_id': company_2.id, + } + ]) + + result = self.env['event.event'].with_user(user_company_1).search([]) + self.assertIn(event_company_1, result, 'You must be able to read the events in your company') + self.assertNotIn(event_company_2, result, 'You must not be able to read events outside of your company') + + result = self.env['event.registration'].with_user(user_company_1).search([]) + self.assertIn(registration_company_1, result, 'You must be able to read the registrations in your company') + self.assertNotIn(registration_company_2, result, 'You must not be able to read registrations outside of your company') 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 new file mode 100644 index 0000000..6d4bee5 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_performance.py @@ -0,0 +1,483 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import timedelta +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 + + +@tagged('event_performance', 'post_install', '-at_install', '-standard') +class EventPerformanceCase(TestEventFullCommon): + + def setUp(self): + super(EventPerformanceCase, self).setUp() + # patch registry to simulate a ready environment + self.patch(self.env.registry, 'ready', True) + self._flush_tracking() + + def _flush_tracking(self): + """ Force the creation of tracking values notably, and ensure tests are + reproducible. """ + self.env.flush_all() + self.cr.flush() + + +@tagged('event_performance', 'post_install', '-at_install', '-standard') +class TestEventPerformance(EventPerformanceCase): + + @users('event_user') + @warmup + def test_event_create_batch_notype(self): + """ Test multiple event creation (import) """ + batch_size = 20 + + # simple without type involved + with freeze_time(self.reference_now), self.assertQueryCount(event_user=335): # tef 335 + self.env.cr._now = self.reference_now # force create_date to check schedulers + event_values = [ + dict(self.event_base_vals, + website_menu=False) + for x in range(batch_size) + ] + self.env['event.event'].create(event_values) + + @users('event_user') + @warmup + def test_event_create_batch_notype_website(self): + """ Test multiple event creation (import) """ + batch_size = 20 + + # simple without type involved + website + with freeze_time(self.reference_now), self.assertQueryCount(event_user=5368): # tef 4944 / com 4943 + self.env.cr._now = self.reference_now # force create_date to check schedulers + event_values = [ + dict(self.event_base_vals, + website_menu=True + ) + for x in range(batch_size) + ] + self.env['event.event'].create(event_values) + + @users('event_user') + @warmup + def test_event_create_batch_wtype(self): + """ Test multiple event creation (import) """ + batch_size = 20 + 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 + 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, + website_menu=False, + ) + for x in range(batch_size) + ] + self.env['event.event'].create(event_values) + + @users('event_user') + @warmup + def test_event_create_batch_wtype_website(self): + """ Test multiple event creation (import) """ + batch_size = 20 + 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 + 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, + ) + for x in range(batch_size) + ] + self.env['event.event'].create(event_values) + + + @users('event_user') + @warmup + def test_event_create_form_notype(self): + """ Test a single event creation using Form """ + 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 + self.env.cr._now = self.reference_now # force create_date to check schedulers + # Require for `website_menu` to be visible + #
+ with self.debug_mode(): + with Form(self.env['event.event']) as event_form: + event_form.name = 'Test Event' + event_form.date_begin = self.reference_now + timedelta(days=1) + event_form.date_end = self.reference_now + timedelta(days=5) + event_form.website_menu = False + if has_social: + event_form.social_menu = False + _event = event_form.save() + + @users('event_user') + @warmup + def test_event_create_form_notype_website(self): + """ Test a single event creation using Form """ + 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 + self.env.cr._now = self.reference_now # force create_date to check schedulers + # Require for `website_menu` to be visible + #
+ with self.debug_mode(): + with Form(self.env['event.event']) as event_form: + event_form.name = 'Test Event' + event_form.date_begin = self.reference_now + timedelta(days=1) + event_form.date_end = self.reference_now + timedelta(days=5) + event_form.website_menu = True + if has_social: + event_form.social_menu = False + _event = event_form.save() + + @users('event_user') + @warmup + def test_event_create_form_type_website(self): + """ Test a single event creation using Form """ + event_type = self.env['event.type'].browse(self.test_event_type.ids) + 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 + self.env.cr._now = self.reference_now # force create_date to check schedulers + # Require for `website_menu` to be visible + #
+ with self.debug_mode(): + with Form(self.env['event.event']) as event_form: + event_form.name = 'Test Event' + event_form.date_begin = self.reference_now + timedelta(days=1) + event_form.date_end = self.reference_now + timedelta(days=5) + event_form.event_type_id = event_type + if has_social: + event_form.social_menu = False + + @users('event_user') + @warmup + 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 + self.env.cr._now = self.reference_now # force create_date to check schedulers + event_values = dict( + self.event_base_vals, + website_menu=False + ) + self.env['event.event'].create([event_values]) + + @users('event_user') + @warmup + 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 + 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]) + + @users('event_user') + @warmup + def test_event_create_single_wtype(self): + """ Test a single event creation """ + 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 + 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, + website_menu=False + ) + self.env['event.event'].create([event_values]) + + @users('event_user') + @warmup + def test_event_create_single_wtype_website(self): + """ Test a single event creation """ + 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 + 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]) + + +@tagged('event_performance', 'registration_performance', 'post_install', '-at_install', '-standard') +class TestRegistrationPerformance(EventPerformanceCase): + + @users('event_user') + @warmup + def test_registration_create_batch(self): + """ Test multiple registrations creation (batch of 10 without partner + and batch of 10 with partner) + + # TODO: with self.profile(collectors=['sql']) as _profile: + """ + 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 + self.env.cr._now = self.reference_now # force create_date to check schedulers + registration_values = [ + dict(reg_data, + event_id=event.id) + for reg_data in self.customer_data + ] + registration_values += [ + {'event_id': event.id, + 'partner_id': partner.id, + } for partner in self.partners + ] + _registrations = self.env['event.registration'].create(registration_values) + + @users('event_user') + @warmup + def test_registration_create_batch_nolead(self): + """ Test multiple registrations creation (batch of 10 without partner + and batch of 10 with partner) + + # TODO: with self.profile(collectors=['sql']) as _profile: + """ + 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 + self.env.cr._now = self.reference_now # force create_date to check schedulers + registration_values = [ + dict(reg_data, + event_id=event.id) + for reg_data in self.customer_data + ] + registration_values += [ + {'event_id': event.id, + 'partner_id': partner.id, + } for partner in self.partners + ] + _registrations = self.env['event.registration'].with_context(event_lead_rule_skip=True).create(registration_values) + + @users('event_user') + @warmup + def test_registration_create_batch_website(self): + """ Test multiple registrations creation (batch of 10 without partner + and batch of 10 with partner) with some additional informations (register + 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 + self.env.cr._now = self.reference_now # force create_date to check schedulers + registration_values = [ + dict(reg_data, + event_id=event.id) + for reg_data in self.website_customer_data + ] + registration_values += [ + {'event_id': event.id, + 'partner_id': partner.id, + 'registration_answer_ids': self.website_customer_data[0]['registration_answer_ids'], + } for partner in self.partners + ] + _registrations = self.env['event.registration'].create(registration_values) + + @users('event_user') + @warmup + def test_registration_create_form_customer(self): + """ 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 + 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() + + @users('event_user') + @warmup + def test_registration_create_form_partner(self): + """ 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 + 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.partner_id = self.partners[0] + _registration = reg_form.save() + + @users('event_user') + @warmup + def test_registration_create_form_partner_nolead(self): + """ 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 + 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 + reg_form.partner_id = self.partners[0] + _registration = reg_form.save() + + @users('event_user') + @warmup + def test_registration_create_single_customer(self): + """ Test a single registration creation """ + 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 + self.env.cr._now = self.reference_now # force create_date to check schedulers + registration_values = dict( + self.customer_data[0], + event_id=event.id) + _registration = self.env['event.registration'].create([registration_values]) + + @users('event_user') + @warmup + def test_registration_create_single_partner(self): + """ Test a single registration creation """ + 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 + self.env.cr._now = self.reference_now # force create_date to check schedulers + registration_values = { + 'event_id': event.id, + 'partner_id': self.partners[0].id, + } + _registration = self.env['event.registration'].create([registration_values]) + + @users('event_user') + @warmup + def test_registration_create_single_partner_nolead(self): + """ Test a single registration creation """ + 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 + self.env.cr._now = self.reference_now # force create_date to check schedulers + registration_values = { + 'event_id': event.id, + 'partner_id': self.partners[0].id, + } + _registration = self.env['event.registration'].with_context(event_lead_rule_skip=True).create([registration_values]) + + @users('event_user') + @warmup + def test_registration_create_single_website(self): + """ Test a single registration creation """ + 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 + self.env.cr._now = self.reference_now # force create_date to check schedulers + registration_values = dict( + self.website_customer_data[0], + event_id=event.id) + _registration = self.env['event.registration'].create([registration_values]) + + +@tagged('event_performance', 'event_online', 'post_install', '-at_install', '-standard') +class TestOnlineEventPerformance(EventPerformanceCase, UtilPerf): + + @classmethod + def setUpClass(cls): + super(TestOnlineEventPerformance, cls).setUpClass() + # if website_livechat is installed, disable it + if 'channel_id' in cls.env['website']: + cls.env['website'].search([]).channel_id = False + + cash_journal = cls.env['account.journal'].create({ + 'name': 'Cash - Test', + 'type': 'cash', + 'code': 'CASH - Test' + }) + cls.env['payment.provider'].search([('code', '=', 'test')]).write({ + 'journal_id': cash_journal.id, + 'state': 'test' + }) + + # clean even page to make it reproducible + cls.env['event.event'].search([('name', '!=', 'Test Event')]).write({'active': False}) + # create noise for events + cls.noise_events = cls.env['event.event'].create([ + {'name': 'Event %02d' % idx, + 'date_begin': cls.reference_now + timedelta(days=(-2 + int(idx/10))), + 'date_end': cls.reference_now + timedelta(days=5), + 'is_published': True, + } + for idx in range(0, 50) + ]) + + def _test_url_open(self, url): + url += ('?' not in url and '?' or '') + '&debug=disable-t-cache' + return self.url_open(url) + + @warmup + def test_event_page_event_manager(self): + # website customer data + with freeze_time(self.reference_now): + self.authenticate('user_eventmanager', 'user_eventmanager') + with self.assertQueryCount(default=36): # tef 35 + self._test_url_open('/event/%i' % self.test_event.id) + + @warmup + def test_event_page_public(self): + # website customer data + with freeze_time(self.reference_now): + self.authenticate(None, None) + with self.assertQueryCount(default=27): + self._test_url_open('/event/%i' % self.test_event.id) + + @warmup + def test_events_browse_event_manager(self): + # website customer data + with freeze_time(self.reference_now): + self.authenticate('user_eventmanager', 'user_eventmanager') + with self.assertQueryCount(default=39): # tef 38 + self._test_url_open('/event') + + @warmup + def test_events_browse_public(self): + # website customer data + with freeze_time(self.reference_now): + self.authenticate(None, None) + with self.assertQueryCount(default=28): + 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, + # ) + + # # 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_register.py b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_wevent_register.py new file mode 100644 index 0000000..cc38388 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/tests/test_wevent_register.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from freezegun import freeze_time + +from odoo import tests +from odoo.addons.test_event_full.tests.common import TestWEventCommon + + +@tests.common.tagged('event_online', 'post_install', '-at_install') +class TestWEventRegister(TestWEventCommon): + + def test_register(self): + 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 + ) + new_registrations = self.event.registration_ids + visitor = new_registrations.visitor_id + + # check registration content + self.assertEqual(len(new_registrations), 2) + self.assertEqual( + set(new_registrations.mapped("name")), + set(["Raoulette Poiluchette", "Michel Tractopelle"]) + ) + self.assertEqual( + set(new_registrations.mapped("phone")), + set(["0456112233", "0456332211"]) + ) + self.assertEqual( + set(new_registrations.mapped("email")), + set(["raoulette@example.com", "michel@example.com"]) + ) + + # check visitor stored information + 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") diff --git a/odoo-bringout-oca-ocb-test_event_full/test_event_full/views/event_registration_templates_reports.xml b/odoo-bringout-oca-ocb-test_event_full/test_event_full/views/event_registration_templates_reports.xml new file mode 100644 index 0000000..0fede09 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_event_full/test_event_full/views/event_registration_templates_reports.xml @@ -0,0 +1,14 @@ + + + + diff --git a/odoo-bringout-oca-ocb-test_mail/README.md b/odoo-bringout-oca-ocb-test_mail/README.md new file mode 100644 index 0000000..0ad6c7f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/README.md @@ -0,0 +1,49 @@ +# Mail Tests + +This module contains tests related to mail. Those are +present in a separate module as it contains models used only to perform +tests independently to functional aspects of other models. + +## Installation + +```bash +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 + +## Source + +Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `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 diff --git a/odoo-bringout-oca-ocb-test_mail/doc/ARCHITECTURE.md b/odoo-bringout-oca-ocb-test_mail/doc/ARCHITECTURE.md new file mode 100644 index 0000000..20e6be6 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/doc/ARCHITECTURE.md @@ -0,0 +1,32 @@ +# Architecture + +```mermaid +flowchart TD + U[Users] -->|HTTP| V[Views and QWeb Templates] + V --> C[Controllers] + V --> W[Wizards – Transient Models] + C --> M[Models and ORM] + W --> M + M --> R[Reports] + DX[Data XML] --> M + S[Security – ACLs and Groups] -. enforces .-> M + + subgraph Test_mail Module - test_mail + direction LR + M:::layer + W:::layer + C:::layer + V:::layer + R:::layer + S:::layer + DX:::layer + end + + classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px +``` + +Notes +- Views include tree/form/kanban templates and report templates. +- Controllers provide website/portal routes when present. +- Wizards are UI flows implemented with `models.TransientModel`. +- Data XML loads data/demo records; Security defines groups and access. diff --git a/odoo-bringout-oca-ocb-test_mail/doc/CONFIGURATION.md b/odoo-bringout-oca-ocb-test_mail/doc/CONFIGURATION.md new file mode 100644 index 0000000..83785d6 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/doc/CONFIGURATION.md @@ -0,0 +1,3 @@ +# Configuration + +Refer to Odoo settings for test_mail. Configure related models, access rights, and options as needed. diff --git a/odoo-bringout-oca-ocb-test_mail/doc/CONTROLLERS.md b/odoo-bringout-oca-ocb-test_mail/doc/CONTROLLERS.md new file mode 100644 index 0000000..f628e77 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/doc/CONTROLLERS.md @@ -0,0 +1,3 @@ +# Controllers + +This module does not define custom HTTP controllers. diff --git a/odoo-bringout-oca-ocb-test_mail/doc/DEPENDENCIES.md b/odoo-bringout-oca-ocb-test_mail/doc/DEPENDENCIES.md new file mode 100644 index 0000000..0051aa1 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/doc/DEPENDENCIES.md @@ -0,0 +1,6 @@ +# Dependencies + +This addon depends on: + +- [mail](../../odoo-bringout-oca-ocb-mail) +- test_performance diff --git a/odoo-bringout-oca-ocb-test_mail/doc/FAQ.md b/odoo-bringout-oca-ocb-test_mail/doc/FAQ.md new file mode 100644 index 0000000..6084d48 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/doc/FAQ.md @@ -0,0 +1,4 @@ +# FAQ + +- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged). +- Q: How to enable? A: Start server with --addon test_mail or install in UI. diff --git a/odoo-bringout-oca-ocb-test_mail/doc/INSTALL.md b/odoo-bringout-oca-ocb-test_mail/doc/INSTALL.md new file mode 100644 index 0000000..c8a2339 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/doc/INSTALL.md @@ -0,0 +1,7 @@ +# Install + +```bash +pip install odoo-bringout-oca-ocb-test_mail" +# or +uv pip install odoo-bringout-oca-ocb-test_mail" +``` diff --git a/odoo-bringout-oca-ocb-test_mail/doc/MODELS.md b/odoo-bringout-oca-ocb-test_mail/doc/MODELS.md new file mode 100644 index 0000000..5d7a5dd --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/doc/MODELS.md @@ -0,0 +1,39 @@ +# Models + +Detected core models and extensions in test_mail. + +```mermaid +classDiagram + class mail_performance_thread + class mail_performance_tracking + class mail_test_access + class mail_test_access_custo + class mail_test_activity + class mail_test_cc + class mail_test_composer_mixin + class mail_test_composer_source + class mail_test_container + class mail_test_container_mc + class mail_test_field_type + class mail_test_gateway + class mail_test_gateway_groups + class mail_test_lang + class mail_test_multi_company + class mail_test_multi_company_read + class mail_test_multi_company_with_activity + class mail_test_nothread + class mail_test_simple + class mail_test_ticket + class mail_test_ticket_el + class mail_test_ticket_mc + class mail_test_track + class mail_test_track_all + class mail_test_track_compute + class mail_test_track_monetary + class mail_test_track_selection + class mail_thread +``` + +Notes +- Classes show model technical names; fields omitted for brevity. +- Items listed under _inherit are extensions of existing models. diff --git a/odoo-bringout-oca-ocb-test_mail/doc/OVERVIEW.md b/odoo-bringout-oca-ocb-test_mail/doc/OVERVIEW.md new file mode 100644 index 0000000..377eed5 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/doc/OVERVIEW.md @@ -0,0 +1,6 @@ +# Overview + +Packaged Odoo addon: test_mail. Provides features documented in upstream Odoo 16 under this addon. + +- Source: OCA/OCB 16.0, addon test_mail +- License: LGPL-3 diff --git a/odoo-bringout-oca-ocb-test_mail/doc/REPORTS.md b/odoo-bringout-oca-ocb-test_mail/doc/REPORTS.md new file mode 100644 index 0000000..e0ea35f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/doc/REPORTS.md @@ -0,0 +1,3 @@ +# Reports + +This module does not define custom reports. diff --git a/odoo-bringout-oca-ocb-test_mail/doc/SECURITY.md b/odoo-bringout-oca-ocb-test_mail/doc/SECURITY.md new file mode 100644 index 0000000..f783ca2 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/doc/SECURITY.md @@ -0,0 +1,41 @@ +# Security + +Access control and security definitions in test_mail. + +## Access Control Lists (ACLs) + +Model access permissions defined in: +- **[ir.model.access.csv](../test_mail/security/ir.model.access.csv)** + - 50 model access rules + +## Record Rules + +Row-level security rules defined in: + +## Security Groups & Configuration + +Security groups and permissions defined in: +- **[test_mail_security.xml](../test_mail/security/test_mail_security.xml)** + +```mermaid +graph TB + subgraph "Security Layers" + A[Users] --> B[Groups] + B --> C[Access Control Lists] + C --> D[Models] + B --> E[Record Rules] + E --> F[Individual Records] + end +``` + +Security files overview: +- **[ir.model.access.csv](../test_mail/security/ir.model.access.csv)** + - Model access permissions (CRUD rights) +- **[test_mail_security.xml](../test_mail/security/test_mail_security.xml)** + - Security groups, categories, and XML-based rules + +Notes +- Access Control Lists define which groups can access which models +- Record Rules provide row-level security (filter records by user/group) +- Security groups organize users and define permission sets +- All security is enforced at the ORM level by Odoo diff --git a/odoo-bringout-oca-ocb-test_mail/doc/TROUBLESHOOTING.md b/odoo-bringout-oca-ocb-test_mail/doc/TROUBLESHOOTING.md new file mode 100644 index 0000000..56853cb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/doc/TROUBLESHOOTING.md @@ -0,0 +1,5 @@ +# Troubleshooting + +- Ensure Python and Odoo environment matches repo guidance. +- Check database connectivity and logs if startup fails. +- Validate that dependent addons listed in DEPENDENCIES.md are installed. diff --git a/odoo-bringout-oca-ocb-test_mail/doc/USAGE.md b/odoo-bringout-oca-ocb-test_mail/doc/USAGE.md new file mode 100644 index 0000000..2fa3ce1 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/doc/USAGE.md @@ -0,0 +1,7 @@ +# Usage + +Start Odoo including this addon (from repo root): + +```bash +python3 scripts/nix_odoo_web_server.py --db-name mydb --addon test_mail +``` diff --git a/odoo-bringout-oca-ocb-test_mail/doc/WIZARDS.md b/odoo-bringout-oca-ocb-test_mail/doc/WIZARDS.md new file mode 100644 index 0000000..48e790d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/doc/WIZARDS.md @@ -0,0 +1,3 @@ +# Wizards + +This module does not include UI wizards. diff --git a/odoo-bringout-oca-ocb-test_mail/pyproject.toml b/odoo-bringout-oca-ocb-test_mail/pyproject.toml new file mode 100644 index 0000000..902e522 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "odoo-bringout-oca-ocb-test_mail" +version = "16.0.0" +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", + "requests>=2.25.1" +] +readme = "README.md" +requires-python = ">= 3.11" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "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.12", + "Topic :: Office/Business", +] + +[project.urls] +homepage = "https://github.com/bringout/0" +repository = "https://github.com/bringout/0" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["test_mail"] + +[tool.rye] +managed = true +dev-dependencies = [ + "pytest>=8.4.1", +] diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/__init__.py b/odoo-bringout-oca-ocb-test_mail/test_mail/__init__.py new file mode 100644 index 0000000..c36df4a --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import data +from . import models diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/__manifest__.py b/odoo-bringout-oca-ocb-test_mail/test_mail/__manifest__.py new file mode 100644 index 0000000..2a8f154 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/__manifest__.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- + +{ + 'name': 'Mail Tests', + 'version': '1.0', + 'category': 'Hidden', + 'sequence': 9876, + 'summary': 'Mail Tests: performances and tests specific to mail', + 'description': """This module contains tests related to mail. Those are +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', + ], + 'data': [ + 'security/ir.model.access.csv', + 'security/test_mail_security.xml', + 'data/data.xml', + 'data/mail_template_data.xml', + 'data/subtype_data.xml', + ], + 'assets': { + 'web.qunit_suite_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/*', + ], + }, + 'installable': True, + 'license': 'LGPL-3', +} diff --git a/odoo-bringout-oca-ocb-test_mail/test_mail/data/__init__.py b/odoo-bringout-oca-ocb-test_mail/test_mail/data/__init__.py new file mode 100644 index 0000000..3c2bb4e --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/data/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import test_mail_data 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 new file mode 100644 index 0000000..1e4c55f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/data/data.xml @@ -0,0 +1,40 @@ + + + + + Do Stuff + Really?! Wow! A superpowers drug you can just rub onto your skin? + default + mail.test.activity + + + Meet People + You'd think it would be something you'd have to freebase. Noooooo! + default + mail.test.activity + + + Call People + Then throw her in the laundry room, which will hereafter be referred to as "the brig". + default + mail.test.activity + + + Step 2 + Take the second step. + default + mail.test.activity + 10 + current_date + days + + + Step 1 + Take the first step. + default + mail.test.activity + trigger + + + + 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 new file mode 100644 index 0000000..944a1b7 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/data/mail_template_data.xml @@ -0,0 +1,40 @@ + + + + Mail Test Full: Tracking Template + Test Template + {{ object.customer_id.id }} +

Hello

+ + +
+ + + Mail Test: Template + Post on {{ object.name }} + {{ object.customer_id.id }} +

Adding stuff on

+ + +
+ + + + + + + +
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 new file mode 100644 index 0000000..2880100 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/data/subtype_data.xml @@ -0,0 +1,55 @@ + + + + + + External subtype + External subtype + mail.test.simple + + + + + + + Container Changed Subtype + Container Changed + mail.test.ticket + + + + + + + Container Default Subtype + mail.test.container + + + + + + Container Child Full Subtype + mail.test.container + + container_id + + + + + + + Container MC Changed Subtype + Container Changed + mail.test.ticket.mc + + + + + Ticket MC Internal + Ticket MC Internal + mail.test.ticket.mc + + + + + 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 new file mode 100644 index 0000000..0c5d787 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/data/test_mail_data.py @@ -0,0 +1,1426 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +MAIL_TEMPLATE = """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 + +Please call me as soon as possible this afternoon! + +-- +Sylvie +------=_Part_4200734_24778174.1344608186754 +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + + + =20 + + =20 + =20 + +

Please call me as soon as possible this afternoon!

+ +

--
+ Sylvie +

+ + +------=_Part_4200734_24778174.1344608186754-- +""" + +MAIL_TEMPLATE_EXTRA_HTML = """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 + +Please call me as soon as possible this afternoon! + +-- +Sylvie +------=_Part_4200734_24778174.1344608186754 +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + + + =20 + + =20 + =20 + +

Please call me as soon as possible this afternoon!

+ {extra_html} + +

--
+ Sylvie +

+ + +------=_Part_4200734_24778174.1344608186754-- +""" + + +MAIL_TEMPLATE_PLAINTEXT = """Return-Path: +To: {to} +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: text/plain +Date: Fri, 10 Aug 2012 14:16:26 +0000 +Message-ID: {msg_id} +{extra} + +Please call me as soon as possible this afternoon! + +-- +Sylvie +""" + +MAIL_TEMPLATE_HTML = """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: text/html; charset=utf-8 +Content-Transfer-Encoding: quoted-printable +Date: Fri, 10 Aug 2012 14:16:26 +0000 +Message-ID: {msg_id} +{extra} + + + + =20 + + =20 + =20 + +

Please call me as soon as possible this afternoon!

+ +

--
+ Sylvie +

+ + +""" + +MAIL_MULTIPART_MIXED = """Return-Path: +X-Original-To: raoul@grosbedon.fr +Delivered-To: raoul@grosbedon.fr +Received: by mail1.grosbedon.com (Postfix, from userid 10002) + id E8166BFACA; Fri, 23 Aug 2013 13:18:01 +0200 (CEST) +X-Spam-Checker-Version: SpamAssassin 3.3.1 (2010-03-16) on mail1.grosbedon.com +X-Spam-Level: +X-Spam-Status: No, score=-2.6 required=5.0 tests=BAYES_00,FREEMAIL_FROM, + HTML_MESSAGE,RCVD_IN_DNSWL_LOW autolearn=unavailable version=3.3.1 +Received: from mail-ie0-f173.google.com (mail-ie0-f173.google.com [209.85.223.173]) + by mail1.grosbedon.com (Postfix) with ESMTPS id 9BBD7BFAAA + for ; Fri, 23 Aug 2013 13:17:55 +0200 (CEST) +Received: by mail-ie0-f173.google.com with SMTP id qd12so575130ieb.4 + for ; Fri, 23 Aug 2013 04:17:54 -0700 (PDT) +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=gmail.com; s=20120113; + h=mime-version:date:message-id:subject:from:to:content-type; + bh=dMNHV52EC7GAa7+9a9tqwT9joy9z+1950J/3A6/M/hU=; + b=DGuv0VjegdSrEe36ADC8XZ9Inrb3Iu+3/52Bm+caltddXFH9yewTr0JkCRQaJgMwG9 + qXTQgP8qu/VFEbCh6scu5ZgU1hknzlNCYr3LT+Ih7dAZVUEHUJdwjzUU1LFV95G2RaCd + /Lwff6CibuUvrA+0CBO7IRKW0Sn5j0mukYu8dbaKsm6ou6HqS8Nuj85fcXJfHSHp6Y9u + dmE8jBh3fHCHF/nAvU+8aBNSIzl1FGfiBYb2jCoapIuVFitKR4q5cuoodpkH9XqqtOdH + DG+YjEyi8L7uvdOfN16eMr7hfUkQei1yQgvGu9/5kXoHg9+Gx6VsZIycn4zoaXTV3Nhn + nu4g== +MIME-Version: 1.0 +X-Received: by 10.50.124.65 with SMTP id mg1mr1144467igb.43.1377256674216; + Fri, 23 Aug 2013 04:17:54 -0700 (PDT) +Received: by 10.43.99.71 with HTTP; Fri, 23 Aug 2013 04:17:54 -0700 (PDT) +Date: Fri, 23 Aug 2013 13:17:54 +0200 +Message-ID: +Subject: Test mail multipart/mixed +From: =?ISO-8859-1?Q?Raoul Grosbedon=E9e?= +To: Followers of ASUSTeK-Joseph-Walters +Content-Type: multipart/mixed; boundary=089e01536c4ed4d17204e49b8e96 + +--089e01536c4ed4d17204e49b8e96 +Content-Type: multipart/alternative; boundary=089e01536c4ed4d16d04e49b8e94 + +--089e01536c4ed4d16d04e49b8e94 +Content-Type: text/plain; charset=ISO-8859-1 + +Should create a multipart/mixed: from gmail, *bold*, with attachment. + +-- +Marcel Boitempoils. + +--089e01536c4ed4d16d04e49b8e94 +Content-Type: text/html; charset=ISO-8859-1 + +

Should create a multipart/mixed: from gmail, bold, with attachment.

--
Marcel Boitempoils.
+ +--089e01536c4ed4d16d04e49b8e94-- +--089e01536c4ed4d17204e49b8e96 +Content-Type: text/plain; charset=US-ASCII; name="test.txt" +Content-Disposition: attachment; filename="test.txt" +Content-Transfer-Encoding: base64 +X-Attachment-Id: f_hkpb27k00 + +dGVzdAo= +--089e01536c4ed4d17204e49b8e96--""" + +MAIL_MULTIPART_MIXED_TWO = r"""X-Original-To: raoul@grosbedon.fr +Delivered-To: raoul@grosbedon.fr +Received: by mail1.grosbedon.com (Postfix, from userid 10002) + id E8166BFACA; Fri, 23 Aug 2013 13:18:01 +0200 (CEST) +From: "Bruce Wayne" +Content-Type: multipart/alternative; + boundary="Apple-Mail=_9331E12B-8BD2-4EC7-B53E-01F3FBEC9227" +Message-Id: <6BB1FAB2-2104-438E-9447-07AE2C8C4A92@sexample.com> +Mime-Version: 1.0 (Mac OS X Mail 7.3 \(1878.6\)) + +--Apple-Mail=_9331E12B-8BD2-4EC7-B53E-01F3FBEC9227 +Content-Transfer-Encoding: 7bit +Content-Type: text/plain; + charset=us-ascii + +First and second part + +--Apple-Mail=_9331E12B-8BD2-4EC7-B53E-01F3FBEC9227 +Content-Type: multipart/mixed; + boundary="Apple-Mail=_CA6C687E-6AA0-411E-B0FE-F0ABB4CFED1F" + +--Apple-Mail=_CA6C687E-6AA0-411E-B0FE-F0ABB4CFED1F +Content-Transfer-Encoding: 7bit +Content-Type: text/html; + charset=us-ascii + +First part + +--Apple-Mail=_CA6C687E-6AA0-411E-B0FE-F0ABB4CFED1F +Content-Disposition: inline; + filename=thetruth.pdf +Content-Type: application/pdf; + name="thetruth.pdf" +Content-Transfer-Encoding: base64 + +SSBhbSB0aGUgQmF0TWFuCg== + +--Apple-Mail=_CA6C687E-6AA0-411E-B0FE-F0ABB4CFED1F +Content-Transfer-Encoding: 7bit +Content-Type: text/html; + charset=us-ascii + +Second part +--Apple-Mail=_CA6C687E-6AA0-411E-B0FE-F0ABB4CFED1F-- + +--Apple-Mail=_9331E12B-8BD2-4EC7-B53E-01F3FBEC9227-- +""" + +MAIL_FILE_ENCODING = """MIME-Version: 1.0 +Date: Sun, 26 Mar 2023 05:23:22 +0200 +Message-ID: {msg_id} +Subject: {subject} +From: "Sylvie Lelitre" +To: groups@test.com +Content-Type: multipart/mixed; boundary="000000000000b951de05f7c47a9e" + +--000000000000b951de05f7c47a9e +Content-Type: multipart/alternative; boundary="000000000000b951da05f7c47a9c" + +--000000000000b951da05f7c47a9c +Content-Type: text/plain; charset="UTF-8" + +Test Body + +--000000000000b951da05f7c47a9c +Content-Type: text/html; charset="UTF-8" + +
Test Body
+ +--000000000000b951da05f7c47a9c-- +--000000000000b951de05f7c47a9e +Content-Type: text/plain; name="test.txt"{charset} +Content-Disposition: attachment; filename="test.txt" +Content-Transfer-Encoding: base64 +X-Attachment-Id: f_lfosfm0l0 +Content-ID: + +{content} + +--000000000000b951de05f7c47a9e-- +""" + +MAIL_MULTIPART_BINARY_OCTET_STREAM = """X-Original-To: raoul@grosbedon.fr +Delivered-To: raoul@grosbedon.fr +Received: by mail1.grosbedon.com (Postfix, from userid 10002) + id E8166BFACA; Fri, 10 Nov 2021 06:04:01 +0200 (CEST) +From: "Bruce Wayne" +Content-Type: multipart/alternative; + boundary="Apple-Mail=_9331E12B-8BD2-4EC7-B53E-01F3FBEC9227" +Message-Id: <6BB1FAB2-2104-438E-9447-07AE2C8C4A92@sexample.com> +Mime-Version: 1.0 (Mac OS X Mail 7.3 \\(1878.6\\)) + +--Apple-Mail=_9331E12B-8BD2-4EC7-B53E-01F3FBEC9227 +Content-Transfer-Encoding: 7bit +Content-Type: text/plain; + charset=us-ascii + +The attached file contains b"Hello world\\n" + +--Apple-Mail=_9331E12B-8BD2-4EC7-B53E-01F3FBEC9227 +Content-Disposition: attachment; + filename="hello_world.dat" +Content-Type: binary/octet-stream; + name="hello_world.dat" +Content-Transfer-Encoding: base64 + +SGVsbG8gd29ybGQK +--Apple-Mail=_9331E12B-8BD2-4EC7-B53E-01F3FBEC9227-- +""" + +MAIL_MULTIPART_INVALID_ENCODING = """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="00000000000005d9da05fa394cc0" +Date: Fri, 10 Aug 2012 14:16:26 +0000 +Message-ID: {msg_id} +{extra} + +--00000000000005d9da05fa394cc0 +Content-Type: multipart/alternative; boundary="00000000000005d9d905fa394cbe" + +--00000000000005d9d905fa394cbe +Content-Type: text/plain; charset="UTF-8" + +Dear customer, + +Please find attached the Peppol Bis 3 attachment of your invoice (with an +encoding error in the address) + +Cheers, + +--00000000000005d9d905fa394cbe +Content-Type: text/html; charset="UTF-8" + +
Dear customer,

Please find attached the Peppol Bis 3 attachment of your invoice (with an encoding error in the address)

Cheers,
+ +--00000000000005d9d905fa394cbe-- + +--00000000000005d9da05fa394cc0 +Content-Type: text/xml; charset="US-ASCII"; + name="bis3_with_error_encoding_address.xml" +Content-Disposition: attachment; + filename="bis3_with_error_encoding_address.xml" +Content-Transfer-Encoding: base64 +Content-ID: +X-Attachment-Id: f_lgxgdqx40 + +PEludm9pY2UgeG1sbnM6Y2JjPSJ1cm46b2FzaXM6bmFtZXM6c3BlY2lmaWNhdGlvbjp1Ymw6c2No +ZW1hOnhzZDpDb21tb25CYXNpY0NvbXBvbmVudHMtMiIgeG1sbnM9InVybjpvYXNpczpuYW1lczpz +cGVjaWZpY2F0aW9uOnVibDpzY2hlbWE6eHNkOkludm9pY2UtMiI+DQo8Y2JjOlN0cmVldE5hbWU+ +Q2hhdXNz77+977+9ZSBkZSBCcnV4ZWxsZXM8L2NiYzpTdHJlZXROYW1lPg0KPC9JbnZvaWNlPg0K +--00000000000005d9da05fa394cc0-- +""" + +MAIL_MULTIPART_OMITTED_CHARSET_XML = """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="00000000000005d9da05fa394cc0" +Date: Fri, 10 Aug 2012 14:16:26 +0000 +Message-ID: {msg_id} +{extra} + +--00000000000005d9da05fa394cc0 +Content-Type: multipart/alternative; boundary="00000000000005d9d905fa394cbe" + +--00000000000005d9d905fa394cbe +Content-Type: text/plain; charset="UTF-8" + +Dear customer, + +Please find attached the UBL attachment of your invoice + +Cheers, + +--00000000000005d9d905fa394cbe +Content-Type: text/html; charset="UTF-8" + +
Dear customer,

Please find attached the UBL attachment of your invoice

Cheers,
+ +--00000000000005d9d905fa394cbe-- + +--00000000000005d9da05fa394cc0 +Content-Disposition: attachment; filename="bis3.xml" +Content-Transfer-Encoding: base64 +Content-Type: text/xml; name="bis3.xml" +Content-ID: +X-Attachment-Id: f_lgxgdqx40 + +PEludm9pY2U+Q2hhdXNzw6llIGRlIEJydXhlbGxlczwvSW52b2ljZT4= +--00000000000005d9da05fa394cc0-- +""" + +MAIL_MULTIPART_OMITTED_CHARSET_CSV = """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="00000000000005d9da05fa394cc0" +Date: Fri, 10 Aug 2012 14:16:26 +0000 +Message-ID: {msg_id} +{extra} + +--00000000000005d9da05fa394cc0 +Content-Type: multipart/alternative; boundary="00000000000005d9d905fa394cbe" + +--00000000000005d9d905fa394cbe +Content-Type: text/plain; charset="UTF-8" + +Dear customer, + +Please find attached the UBL attachment of your invoice + +Cheers, + +--00000000000005d9d905fa394cbe +Content-Type: text/html; charset="UTF-8" + +
Dear customer,

Please find attached the UBL attachment of your invoice

Cheers,
+ +--00000000000005d9d905fa394cbe-- + +--00000000000005d9da05fa394cc0 +Content-Disposition: attachment; filename="bis3.csv" +Content-Transfer-Encoding: quoted-printable +Content-Type: text/csv; name="bis3.csv" + +=EF=BB=BFAuftraggeber;LieferadresseStra=C3=9Fe;= +--00000000000005d9da05fa394cc0-- +""" + +MAIL_MULTIPART_OMITTED_CHARSET_TXT = """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="00000000000005d9da05fa394cc0" +Date: Fri, 10 Aug 2012 14:16:26 +0000 +Message-ID: {msg_id} +{extra} + +--00000000000005d9da05fa394cc0 +Content-Type: multipart/alternative; boundary="00000000000005d9d905fa394cbe" + +--00000000000005d9d905fa394cbe +Content-Type: text/plain; charset="UTF-8" + +Dear customer, + +Please find attached the UBL attachment of your invoice + +Cheers, + +--00000000000005d9d905fa394cbe +Content-Type: text/html; charset="UTF-8" + +
Dear customer,

Please find attached the UBL attachment of your invoice

Cheers,
+ +--00000000000005d9d905fa394cbe-- + +--00000000000005d9da05fa394cc0 +Content-Disposition: attachment; filename="bis3.txt" +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; name="bis3.txt" + +=C3=84pfel und Birnen sind Fr=C3=BCchte, die im Herbst geerntet werden. = +In der N=C3=A4he des Flusses steht ein gro=C3=9Fes, altes Schloss. =C3=9Cb= +er den D=C3=A4chern sieht man oft V=C3=B6gel fliegen. M=C3=BCller und = +Schr=C3=B6der sind typische deutsche Nachnamen. Die Stra=C3=9Fe, in der = +ich wohne, hei=C3=9Ft =E2=80=9EBachstra=C3=9Fe=E2=80=9C und ist sehr = +ruhig. =C3=9Cberall im Wald wachsen B=C3=A4ume mit kr=C3=A4ftigen = +=C3=84sten. K=C3=B6nnen wir uns =C3=BCber die Pl=C3=A4ne f=C3=BCr das = +n=C3=A4chste Wochenende unterhalten?= +--00000000000005d9da05fa394cc0-- +""" + + +MAIL_SINGLE_BINARY = r"""X-Original-To: raoul@grosbedon.fr +Delivered-To: raoul@grosbedon.fr +Received: by mail1.grosbedon.com (Postfix, from userid 10002) + id E8166BFACA; Fri, 23 Aug 2013 13:18:01 +0200 (CEST) +From: "Bruce Wayne" +Content-Type: application/pdf; +Content-Disposition: filename=thetruth.pdf +Content-Transfer-Encoding: base64 +Message-Id: <6BB1FAB2-2104-438E-9447-07AE2C8C4A92@sexample.com> +Mime-Version: 1.0 (Mac OS X Mail 7.3 \(1878.6\)) + +SSBhbSB0aGUgQmF0TWFuCg==""" + + +MAIL_MULTIPART_WEIRD_FILENAME = """X-Original-To: john@doe.com +Delivered-To: johndoe@example.com +Received: by mail.example.com (Postfix, from userid 10002) + id E8166BFACB; Fri, 23 Aug 2013 13:18:02 +0200 (CEST) +From: "Bruce Wayne" +Subject: test +Message-ID: +Date: Mon, 26 Aug 2019 16:55:09 +0200 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="------------FACA7766210AAA981EAE01F3" +Content-Language: en-US + +This is a multi-part message in MIME format. +--------------FACA7766210AAA981EAE01F3 +Content-Type: text/plain; charset=utf-8; format=flowed +Content-Transfer-Encoding: 7bit + +plop + + +--------------FACA7766210AAA981EAE01F3 +Content-Type: text/plain; charset=UTF-8; + name="=?UTF-8?B?NjJfQDssXVspPS4ow4fDgMOJLnR4dA==?=" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename*0*=utf-8'en-us'%36%32%5F%40%3B%2C%5D%5B%29%3D%2E%28%C3%87%C3%80%C3%89; + filename*1*=%2E%74%78%74 + +SSBhbSBhIGZpbGUgd2l0aCBhIHZhbGlkIHdpbmRvd3MgZmlsZW5hbWUK +--------------FACA7766210AAA981EAE01F3-- +""" + + +MAIL_MULTIPART_IMAGE = """X-Original-To: raoul@example.com +Delivered-To: micheline@example.com +Received: by mail1.example.com (Postfix, from userid 99999) + id 9DFB7BF509; Thu, 17 Dec 2015 15:22:56 +0100 (CET) +X-Spam-Checker-Version: SpamAssassin 3.4.0 (2014-02-07) on mail1.example.com +X-Spam-Level: * +X-Spam-Status: No, score=1.1 required=5.0 tests=FREEMAIL_FROM, + HTML_IMAGE_ONLY_08,HTML_MESSAGE,RCVD_IN_DNSWL_LOW,RCVD_IN_MSPIKE_H3, + RCVD_IN_MSPIKE_WL,T_DKIM_INVALID autolearn=no autolearn_force=no version=3.4.0 +Received: from mail-lf0-f44.example.com (mail-lf0-f44.example.com [209.85.215.44]) + by mail1.example.com (Postfix) with ESMTPS id 1D80DBF509 + for ; Thu, 17 Dec 2015 15:22:56 +0100 (CET) +Authentication-Results: mail1.example.com; dkim=pass + reason="2048-bit key; unprotected key" + header.d=example.com header.i=@example.com header.b=kUkTIIlt; + dkim-adsp=pass; dkim-atps=neutral +Received: by mail-lf0-f44.example.com with SMTP id z124so47959461lfa.3 + for ; Thu, 17 Dec 2015 06:22:56 -0800 (PST) +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=example.com; s=20120113; + h=mime-version:date:message-id:subject:from:to:content-type; + bh=GdrEuMrz6vxo/Z/F+mJVho/1wSe6hbxLx2SsP8tihzw=; + b=kUkTIIlt6fe4dftKHPNBkdHU2rO052o684R0e2bqH7roGUQFb78scYE+kqX0wo1zlk + zhKPVBR1TqTsYlqcHu+D3aUzai7L/Q5m40sSGn7uYGkZJ6m1TwrWNqVIgTZibarqvy94 + NWhrjjK9gqd8segQdSjCgTipNSZME4bJCzPyBg/D5mqe07FPBJBGoF9SmIzEBhYeqLj1 + GrXjb/D8J11aOyzmVvyt+bT+oeLUJI8E7qO5g2eQkMncyu+TyIXaRofOOBA14NhQ+0nS + w5O9rzzqkKuJEG4U2TJ2Vi2nl2tHJW2QPfTtFgcCzGxQ0+5n88OVlbGTLnhEIJ/SYpem + O5EA== +MIME-Version: 1.0 +X-Received: by 10.25.167.197 with SMTP id q188mr22222517lfe.129.1450362175493; + Thu, 17 Dec 2015 06:22:55 -0800 (PST) +Received: by 10.25.209.145 with HTTP; Thu, 17 Dec 2015 06:22:55 -0800 (PST) +Date: Thu, 17 Dec 2015 15:22:55 +0100 +Message-ID: +Subject: {subject} +From: =?UTF-8?Q?Thibault_Delavall=C3=A9e?= +To: {to} +Content-Type: multipart/related; boundary=001a11416b9e9b229a05272b7052 + +--001a11416b9e9b229a05272b7052 +Content-Type: multipart/alternative; boundary=001a11416b9e9b229805272b7051 + +--001a11416b9e9b229805272b7051 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +Premi=C3=A8re image, orang=C3=A9e. + +[image: Inline image 1] + +Seconde image, rosa=C3=A7=C3=A9e. + +[image: Inline image 2] + +Troisi=C3=A8me image, verte!=C2=B5 + +[image: Inline image 3] + +J'esp=C3=A8re que tout se passera bien. +--=20 +Thibault Delavall=C3=A9e + +--001a11416b9e9b229805272b7051 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
Premi=C3=A8re image, orang=C3=A9e.

3D"Inline

Seconde image, rosa=C3=A7=C3= +=A9e.

3D"=

Troisi= +=C3=A8me image, verte!=C2=B5

3D"Inline

J'esp=C3=A8re que tout se passera bien.
--
Thibault Delavall=C3=A9e
+
+ +--001a11416b9e9b229805272b7051-- +--001a11416b9e9b229a05272b7052 +Content-Type: image/gif; name="=?UTF-8?B?b3JhbmfDqWUuZ2lm?=" +Content-Disposition: inline; filename="=?UTF-8?B?b3JhbmfDqWUuZ2lm?=" +Content-Transfer-Encoding: base64 +Content-ID: +X-Attachment-Id: ii_151b519fc025fdd3 + +R0lGODdhAgACALMAAAAAAP///wAAAP//AP8AAP+AAAD/AAAAAAAA//8A/wAAAAAAAAAAAAAAAAAA +AAAAACwAAAAAAgACAAAEA7DIEgA7 +--001a11416b9e9b229a05272b7052 +Content-Type: image/gif; name="=?UTF-8?B?dmVydGUhwrUuZ2lm?=" +Content-Disposition: inline; filename="=?UTF-8?B?dmVydGUhwrUuZ2lm?=" +Content-Transfer-Encoding: base64 +Content-ID: +X-Attachment-Id: ii_151b51a37e5eb7a6 + +R0lGODlhCgAKALMAAAAAAIAAAACAAICAAAAAgIAAgACAgMDAwICAgP8AAAD/AP//AAAA//8A/wD/ +/////ywAAAAACgAKAAAEClDJSau9OOvNe44AOw== +--001a11416b9e9b229a05272b7052 +Content-Type: image/gif; name="=?UTF-8?B?cm9zYcOnw6llLmdpZg==?=" +Content-Disposition: inline; filename="=?UTF-8?B?cm9zYcOnw6llLmdpZg==?=" +Content-Transfer-Encoding: base64 +Content-ID: +X-Attachment-Id: ii_151b51a290ed6a91 + +R0lGODdhAgACALMAAAAAAP///wAAAP//AP8AAP+AAAD/AAAAAAAA//8A/wAAAP+AgAAAAAAAAAAA +AAAAACwAAAAAAgACAAAEA3DJFQA7 +--001a11416b9e9b229a05272b7052-- +""" + +MAIL_EML_ATTACHMENT = """Subject: Re: test attac +From: {email_from} +To: {to} +References: +Message-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 +MIME-Version: 1.0 +In-Reply-To: +Content-Type: multipart/mixed; + boundary="------------A6B5FD5F68F4D73ECD739009" +Content-Language: en-US + +This is a multi-part message in MIME format. +--------------A6B5FD5F68F4D73ECD739009 +Content-Type: text/plain; charset=utf-8; format=flowed +Content-Transfer-Encoding: 7bit + + + +On 14/03/18 14:20, Anon wrote: +> Some nice content +> + + +--------------A6B5FD5F68F4D73ECD739009 +Content-Type: message/rfc822; + name="original_msg.eml" +Content-Transfer-Encoding: 8bit +Content-Disposition: attachment; + filename="original_msg.eml" + +Delivered-To: anon2@gmail1.openerp.com +Received: by 10.46.1.170 with SMTP id f42csp2379722lji; + Mon, 5 Mar 2018 01:19:23 -0800 (PST) +X-Google-Smtp-Source: AG47ELsYTlAcblMxfnaEENQuF+MFoac5Q07wieyw0cybq/qOX4+DmayqoQILkiWT+NiTOcnr/ACO +X-Received: by 10.28.154.213 with SMTP id c204mr7237750wme.64.1520241563503; + Mon, 05 Mar 2018 01:19:23 -0800 (PST) +ARC-Seal: i=1; a=rsa-sha256; t=1520241563; cv=none; + d=google.com; s=arc-20160816; + b=BqgMSbqmbpYW1ZtfGTVjj/654MBmabw4XadNZEaI96hDaub6N6cP8Guu3PoxscI9os + 0OLYVP1s/B+Vv9rIzulCwHyHsgnX+aTxGYepTDN6x8SA9Qeb9aQoNSVvQLryTAoGpaFr + vXhw8aPWyr28edE03TDFA/s7X65Bf6dV5zJdMiUPVqGkfYfcTHMf3nDER5vk8vQj7tve + Cfyy0h9vLU9RSEtdFwmlEkLmgT9NQ3GDf0jQ97eMXPgR2q6duCPoMcz15KlWOno53xgH + EiV7aIZ5ZMN/m+/2xt3br/ubJ5euFojWhDnHUZoaqd08TCSQPd4fFCCx75MjDeCnwYMn + iKSg== +ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816; + h=content-language:mime-version:user-agent:date:message-id:subject + :from:to:dkim-signature:arc-authentication-results; + bh=/UIFqhjCCbwBLsI4w7YY98QH6G/wxe+2W4bbMDCskjM=; + b=Wv5jt+usnSgWI96GaZWUN8/VKl1drueDpU/4gkyX/iK4d6S4CuSDjwYAc3guz/TjeW + GoKCqT30IGZoStpXQbuLry7ezXNK+Fp8MJKN2n/x5ClJWHxIsxIGlP2QC3TO8RI0P5o0 + GXG9izW93q1ubkdPJFt3unSjjwSYf5XVQAZQtRm9xKjqA+lbtFbsnbjJ4wgYBURnD8ma + Qxb2xsxXDelaZvtdlzHRDn5SEkbqhcCclEYw6oRLpVQFZeYtPxcCleVybtj2owJxdaLp + 7wXuo/gpYe6E2cPuS2opei8AzjEhYTNzlYXTPvaoxCCTTjfGTaPv22TeRDehuIXngSEl + Nmmw== +ARC-Authentication-Results: i=1; mx.google.com; + dkim=pass header.i=@odoo.com header.s=mail header.b=MCzhjB9b; + spf=pass (google.com: domain of soup@odoo.com designates 149.202.180.44 as permitted sender) smtp.mailfrom=soup@odoo.com; + dmarc=pass (p=NONE sp=NONE dis=NONE) header.from=odoo.com +Return-Path: +Received: from mail2.odoo.com (mail2.odoo.com. [149.202.180.44]) + by mx.google.com with ESMTPS id y4si4279200wmy.148.2018.03.05.01.19.22 + (version=TLS1_2 cipher=ECDHE-RSA-AES128-GCM-SHA256 bits=128/128); + Mon, 05 Mar 2018 01:19:23 -0800 (PST) +Received-SPF: pass (google.com: domain of soup@odoo.com designates 149.202.180.44 as permitted sender) client-ip=149.202.180.44; +Authentication-Results: mx.google.com; + dkim=pass header.i=@odoo.com header.s=mail header.b=MCzhjB9b; + spf=pass (google.com: domain of soup@odoo.com designates 149.202.180.44 as permitted sender) smtp.mailfrom=soup@odoo.com; + dmarc=pass (p=NONE sp=NONE dis=NONE) header.from=odoo.com +Received: from [10.10.31.24] (unknown [91.183.114.50]) + (Authenticated sender: soup) + by mail2.odoo.com (Postfix) with ESMTPSA id 7B571A4085 + for ; Mon, 5 Mar 2018 10:19:21 +0100 (CET) +DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=odoo.com; s=mail; + t=1520241562; bh=L2r7Sp/vjogIdM1k8H9zDGDjnhKolsTTLLjndnFC4Jc=; + h=To:From:Subject:Date:From; + b=MCzhjB9bnsrJ3uKjq+GjujFxmtrq3fc7Vv7Vg2C72EPKnkxgqy6yPjWKtXbBlaiT3 + YjKI24aiSQlOeOPQiqFgiDzeqqemNDp+CRuhoYz1Vbz+ESRaHtkWRLb7ZjvohS2k7e + RTq7tUxY2nUL2YrNHV7DFYtJVBwiTuyLP6eAiJdE= +To: what@odoo.com +From: Soup +Subject: =?UTF-8?Q?Soupe_du_jour_:_Pois_cass=c3=a9s?= +Message-ID: +Date: Mon, 5 Mar 2018 10:19:21 +0100 +User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:52.0) Gecko/20100101 + Thunderbird/52.6.0 +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="------------1F2D18B1129FC2F0B9EECF50" +Content-Language: en-US +X-Spam-Status: No, score=-1.2 required=5.0 tests=ALL_TRUSTED,BAYES_00, + HTML_IMAGE_ONLY_08,HTML_MESSAGE,T_REMOTE_IMAGE autolearn=no + autolearn_force=no version=3.4.0 +X-Spam-Checker-Version: SpamAssassin 3.4.0 (2014-02-07) on mail2.odoo.com + +This is a multi-part message in MIME format. +--------------1F2D18B1129FC2F0B9EECF50 +Content-Type: text/plain; charset=utf-8; format=flowed +Content-Transfer-Encoding: 8bit + +Résultat de recherche d'images pour "dessin la princesse au petit pois" + +-- +Soup + +Odoo S.A. +Chaussée de Namur, 40 +B-1367 Grand Rosière +Web: http://www.odoo.com + + +--------------1F2D18B1129FC2F0B9EECF50 +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: 8bit + + + + + + + +

Résultat de recherche d'images pour "dessin la
+        princesse au petit pois"

+
--
+Soup
+
+Odoo S.A.
+Chaussée de Namur, 40
+B-1367 Grand Rosière
+Web: http://www.odoo.com 
+ + + +--------------1F2D18B1129FC2F0B9EECF50-- + +--------------A6B5FD5F68F4D73ECD739009--""" + +MAIL_EML_ATTACHMENT_BOUNCE_HEADERS="""\ +Date: Tue, 24 Dec 2019 11:32:07 +0100 (CET) +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary=16063919151.b32bE0eD.7 +Content-Transfer-Encoding: 7bit +Subject: Undelivered Mail Returned to Sender +From: {email_from} +To: {to} +Message-Id: <20191224103207.415713014C@example.com> +Return-Path: +Delivered-To: odoo+82240-account.invoice-19177@mycompany.example.com +Received: by example.com (Postfix) id 415713014C; Tue, 24 Dec + 2019 11:32:07 +0100 (CET) +Auto-Submitted: auto-replied + + +--16063919151.b32bE0eD.7 +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary=16063919150.2cD3F37.7 +Content-Transfer-Encoding: 7bit +Content-ID: <16063919152.fD96.7@8f286b7b7880> + + +--16063919150.2cD3F37.7 +Content-Type: text/plain; charset=US-ASCII +Content-Disposition: inline +Content-Transfer-Encoding: 8bit + +This is the mail system at host example.com. + +I'm sorry to have to inform you that your message could not +be delivered to one or more recipients. It's attached below. + +For further assistance, please send mail to postmaster. + +If you do so, please include this problem report. You can +delete your own text from the attached returned message. + + +--16063919151.b32bE0eD.7 +Content-Type: text/rfc822-headers +Content-Transfer-Encoding: 7bit + +Return-Path: +Received: by example.com (Postfix) id 415713014C; Tue, 24 Dec +Content-Type: multipart/mixed; boundary="===============3600759226158551994==" +MIME-Version: 1.0 +Message-Id: {msg_id} +references: <1571814481.189281940460205.799582441238467-openerp-19177-account.invoice@mycompany.example.com> +Subject: Test +From: "Test" +Reply-To: "MY COMPANY" +To: "Test" +Date: Tue, 24 Dec 2019 10:32:05 -0000 +X-Odoo-Objects: account.invoice-19177 + +--16063919151.b32bE0eD.7--""" + +MAIL_XHTML = """Return-Path: +Received: from xxxx.internal (xxxx.xxxx.internal [1.1.1.1]) + by xxxx (xxxx 1.1.1-111-g972eecc-slipenbois) with LMTPA; + Fri, 13 Apr 2018 22:11:52 -0400 +X-Cyrus-Session-Id: sloti35d1t38-1111111-11111111111-5-11111111111111111111 +X-Sieve: CMU Sieve 1.0 +X-Spam-known-sender: no ("Email failed DMARC policy for domain"); in-addressbook +X-Spam-score: 0.0 +X-Spam-hits: ALL_TRUSTED -1, BAYES_00 -1.9, FREEMAIL_FROM 0.001, + HTML_FONT_LOW_CONTRAST 0.001, HTML_MESSAGE 0.001, SPF_SOFTFAIL 0.665, + LANGUAGES en, BAYES_USED global, SA_VERSION 1.1.0 +X-Spam-source: IP='1.1.1.1', Host='unk', Country='unk', FromHeader='com', + MailFrom='com' +X-Spam-charsets: plain='utf-8', html='utf-8' +X-IgnoreVacation: yes ("Email failed DMARC policy for domain") +X-Resolved-to: catchall@xxxx.xxxx +X-Delivered-to: catchall@xxxx.xxxx +X-Mail-from: xxxx@xxxx.com +Received: from mx4 ([1.1.1.1]) + by xxxx.internal (LMTPProxy); Fri, 13 Apr 2018 22:11:52 -0400 +Received: from xxxx.xxxx.com (localhost [127.0.0.1]) + by xxxx.xxxx.internal (Postfix) with ESMTP id E1111C1111; + Fri, 13 Apr 2018 22:11:51 -0400 (EDT) +Received: from xxxx.xxxx.internal (localhost [127.0.0.1]) + by xxxx.xxxx.com (Authentication Milter) with ESMTP + id BBDD1111D1A; + Fri, 13 Apr 2018 22:11:51 -0400 +ARC-Authentication-Results: i=1; xxxx.xxxx.com; arc=none (no signatures found); + dkim=pass (2048-bit rsa key sha256) header.d=xxxx.com header.i=@xxxx.com header.b=P1aaAAaa x-bits=2048 x-keytype=rsa x-algorithm=sha256 x-selector=fm2; + dmarc=fail (p=none,d=none) header.from=xxxx.com; + iprev=pass policy.iprev=1.1.1.1 (out1-smtp.xxxx.com); + spf=softfail smtp.mailfrom=xxxx@xxxx.com smtp.helo=out1-smtp.xxxx.com; + x-aligned-from=pass (Address match); + x-cm=none score=0; + x-ptr=pass x-ptr-helo=out1-smtp.xxxx.com x-ptr-lookup=out1-smtp.xxxx.com; + x-return-mx=pass smtp.domain=xxxx.com smtp.result=pass smtp_is_org_domain=yes header.domain=xxxx.com header.result=pass header_is_org_domain=yes; + x-tls=pass version=TLSv1.2 cipher=ECDHE-RSA-AES128-GCM-SHA256 bits=128/128; + x-vs=clean score=0 state=0 +Authentication-Results: xxxx.xxxx.com; + arc=none (no signatures found); + dkim=pass (2048-bit rsa key sha256) header.d=xxxx.com header.i=@xxxx.com header.b=P1awJPiy x-bits=2048 x-keytype=rsa x-algorithm=sha256 x-selector=fm2; + dmarc=fail (p=none,d=none) header.from=xxxx.com; + iprev=pass policy.iprev=66.111.4.25 (out1-smtp.xxxx.com); + spf=softfail smtp.mailfrom=xxxx@xxxx.com smtp.helo=out1-smtp.xxxx.com; + x-aligned-from=pass (Address match); + x-cm=none score=0; + x-ptr=pass x-ptr-helo=out1-smtp.xxxx.com x-ptr-lookup=out1-smtp.xxxx.com; + x-return-mx=pass smtp.domain=xxxx.com smtp.result=pass smtp_is_org_domain=yes header.domain=xxxx.com header.result=pass header_is_org_domain=yes; + x-tls=pass version=TLSv1.2 cipher=ECDHE-RSA-AES128-GCM-SHA256 bits=128/128; + x-vs=clean score=0 state=0 +X-ME-VSCategory: clean +X-ME-CMScore: 0 +X-ME-CMCategory: none +Received-SPF: softfail + (gmail.com ... _spf.xxxx.com: Sender is not authorized by default to use 'xxxx@xxxx.com' in 'mfrom' identity, however domain is not currently prepared for false failures (mechanism '~all' matched)) + receiver=xxxx.xxxx.com; + identity=mailfrom; + envelope-from="xxxx@xxxx.com"; + helo=out1-smtp.xxxx.com; + client-ip=1.1.1.1 +Received: from xxxx.xxxx.internal (gateway1.xxxx.internal [1.1.1.1]) + (using TLSv1.2 with cipher ECDHE-RSA-AES128-GCM-SHA256 (128/128 bits)) + (No client certificate requested) + by xxxx.xxxx.internal (Postfix) with ESMTPS; + Fri, 13 Apr 2018 22:11:51 -0400 (EDT) +Received: from compute3.internal (xxxx.xxxx.internal [10.202.2.43]) + by xxxx.xxxx.internal (Postfix) with ESMTP id 8BD5B21BBD; + Fri, 13 Apr 2018 22:11:51 -0400 (EDT) +Received: from xxxx ([10.202.2.163]) + by xxxx.internal (MEProxy); Fri, 13 Apr 2018 22:11:51 -0400 +X-ME-Sender: +Received: from [1.1.1.1] (unknown [1.1.1.1]) + by mail.xxxx.com (Postfix) with ESMTPA id BF5E1111D + for ; Fri, 13 Apr 2018 22:11:50 -0400 (EDT) +From: Sylvie Lelitre +To: generic@mydomain.com +Subject: Re: xxxx (Ref PO1) +Date: Sat, 14 Apr 2018 02:11:42 +0000 +Message-Id: +In-Reply-To: <829228111124527.1111111602.256611118262939-openerp-129-xxxx.xxxx@ip-1-1-1-1> +References: <867911111953277.1523671337.187951111160400-openerp-129-xxxx.xxxx@ip-1-1-1-1> + <867911111953277.1523671337.256611118262939-openerp-129-xxxx.xxxx@ip-1-1-1-1> +Reply-To: "xxxx xxxx" +User-Agent: eM_Client/7.0.26687.0 +Mime-Version: 1.0 +Content-Type: multipart/alternative; + boundary="------=_MB48E455BD-1111-42EC-1111-886CDF48905E" + +--------=_MB48E455BD-1111-42EC-1111-886CDF48905E +Content-Type: text/plain; format=flowed; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +xxxx + + +------ Original Message ------ +From: "xxxx" +To: "xxxx" +Sent: 4/13/2018 7:06:43 PM +Subject: xxxx + +>xxxx + +--------=_MB48E455BD-1111-42EC-1111-886CDF48905E +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +
this is a reply to PO200109 from emClient
--
xxxx
xxxx
xxxx



+
------ Original Message ------
+
From: "xxxx" <xxxx= +@xxxx.com>
+
To: "xxxx" <a= +xxxx@xxxx.com>
+
Sent: 4/13/2018 7:06:43 PM
+
Subject: xxxx

+
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + mangez des saucisses + + + 3D= +
+
+ + + + +
+

xxxx.=20 +,

+

+xxxx. +

+ +

You can reply = +to this email if you have any questions.

+

Thank you,

+
+
+ + + + + +
+ xxxx
+ +1-801-980-4240 +
+ info@aust-mfg.com= +
+ + http://www.xxxx.com + +
+
+ Powered by Odo= +o. +
+ =20 +
xxxx.
+
+
+ +--------=_MB48E455BD-2850-42EC-B1CA-886CDF48905E--""" + + +MAIL_BOUNCE = """Return-Path: <> +X-Original-To: {to} +Delivered-To: {to} +Received: by mail2.test.ironsky (Postfix) + id 93A83A5F0D; Mon, 15 Apr 2019 15:41:06 +0200 (CEST) +Date: Mon, 15 Apr 2019 15:41:06 +0200 (CEST) +From: MAILER-DAEMON@mail2.test.ironsky (Mail Delivery System) +Subject: {subject} +To: {to} +Auto-Submitted: auto-replied +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=delivery-status; + boundary="92726A5F09.1555335666/mail2.test.ironsky" +Message-Id: <20190415134106.93A83A5F0D@mail2.test.ironsky> + +This is a MIME-encapsulated message. + +--92726A5F09.1555335666/mail2.test.ironsky +Content-Description: Notification +Content-Type: text/plain; charset=us-ascii + +This is the mail system at host mail2.test.ironsky. + +I'm sorry to have to inform you that your message could not +be delivered to one or more recipients. It's attached below. + +For further assistance, please send mail to postmaster. + +If you do so, please include this problem report. You can +delete your own text from the attached returned message. + + The mail system + +<{email_from}>: host tribulant.com[23.22.38.89] said: 550 No such + person at this address. (in reply to RCPT TO command) + +--92726A5F09.1555335666/mail2.test.ironsky +Content-Description: Delivery report +Content-Type: message/delivery-status + +Reporting-MTA: dns; mail2.test.ironsky +X-Postfix-Queue-ID: 92726A5F09 +X-Postfix-Sender: rfc822; {to} +Arrival-Date: Mon, 15 Apr 2019 15:40:24 +0200 (CEST) + +Final-Recipient: rfc822; {email_from} +Original-Recipient: rfc822;{email_from} +Action: failed +Status: 5.0.0 +Remote-MTA: dns; tribulant.com +Diagnostic-Code: smtp; 550 No such person at this address. + +--92726A5F09.1555335666/mail2.test.ironsky +Content-Description: Undelivered Message +Content-Type: message/rfc822 + +Return-Path: <{to}> +Received: from [127.0.0.1] (host-212-68-194-133.dynamic.voo.be [212.68.194.133]) + (Authenticated sender: aaa) + by mail2.test.ironsky (Postfix) with ESMTPSA id 92726A5F09 + for <{email_from}>; Mon, 15 Apr 2019 15:40:24 +0200 (CEST) +DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=test.ironsky; s=mail; + t=1555335624; bh=x6cSjphxNDiRDMmm24lMAUKtdCFfftM8w/fdUyfoeFs=; + h=references:Subject:From:Reply-To:To:Date:From; + b=Bo0BsXAHgKiBfBtMvvO/+KaS9PuuS0+AozL4SxU05jHZcJFc7qFIPEpqkJIdbzNcQ + wq0PJYclgX7QZDOMm3VHQwcwOxBDXAbdnpfkPM9/wa+FWKfr6ikowMTHHT3CA1qNbe + h+BQVyBKIvr/LDFPSN2hQmfXWwWupm1lgUhJ07T4= +Content-Type: multipart/mixed; boundary="===============7355787381227985247==" +MIME-Version: 1.0 +Message-Id: {extra} +references: <670034078674109.1555335454.587288856506348-openerp-32-project.task@aaa> +Subject: Re: Test +From: Mitchell Admin +Reply-To: YourCompany Research & Development +To: Raoul <{email_from}> +Date: Mon, 15 Apr 2019 13:40:24 -0000 +X-Odoo-Objects: project.project-3, ,project.task-32 +X-Spam-Status: No, score=-2.0 required=5.0 tests=ALL_TRUSTED,BAYES_00, + DKIM_ADSP_NXDOMAIN,HEADER_FROM_DIFFERENT_DOMAINS,HTML_MESSAGE + shortcircuit=no autolearn=no autolearn_force=no version=3.4.2 +X-Spam-Checker-Version: SpamAssassin 3.4.2 (2018-09-13) on mail2.test.ironsky + +--===============7355787381227985247== +Content-Type: multipart/alternative; boundary="===============8588563873240298690==" +MIME-Version: 1.0 + +--===============8588563873240298690== +Content-Type: text/plain; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: base64 + +CgpaYm91bGl1b2l1b2l6ZWYKCi0tCkFkbWluaXN0cmF0b3IKU2VudApieQpbMV0gWW91ckNvbXBh +bnkKCnVzaW5nCk9kb28gWzJdIC4KCgoKWzFdIGh0dHA6Ly93d3cuZXhhbXBsZS5jb20KWzJdIGh0 +dHBzOi8vd3d3Lm9kb28uY29tP3V0bV9zb3VyY2U9ZGImdXRtX21lZGl1bT1lbWFpbAo= + +--===============8588563873240298690== +Content-Type: text/html; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: base64 + +CjxkaXY+CgoKPGRpdj48cD5aYm91bGl1b2l1b2l6ZWY8L3A+PC9kaXY+Cgo8ZGl2IGNsYXNzPSJm +b250LXNpemU6IDEzcHg7Ij48c3BhbiBkYXRhLW8tbWFpbC1xdW90ZT0iMSI+LS0gPGJyIGRhdGEt +by1tYWlsLXF1b3RlPSIxIj4KQWRtaW5pc3RyYXRvcjwvc3Bhbj48L2Rpdj4KPHAgc3R5bGU9ImNv +bG9yOiAjNTU1NTU1OyBtYXJnaW4tdG9wOjMycHg7Ij4KICAgIFNlbnQKICAgIDxzcGFuPgogICAg +YnkKICAgIDxhIHN0eWxlPSJ0ZXh0LWRlY29yYXRpb246bm9uZTsgY29sb3I6ICM4NzVBN0I7IiBo +cmVmPSJodHRwOi8vd3d3LmV4YW1wbGUuY29tIj4KICAgICAgICA8c3Bhbj5Zb3VyQ29tcGFueTwv +c3Bhbj4KICAgIDwvYT4KICAgIAogICAgPC9zcGFuPgogICAgdXNpbmcKICAgIDxhIHRhcmdldD0i +X2JsYW5rIiBocmVmPSJodHRwczovL3d3dy5vZG9vLmNvbT91dG1fc291cmNlPWRiJmFtcDt1dG1f +bWVkaXVtPWVtYWlsIiBzdHlsZT0idGV4dC1kZWNvcmF0aW9uOm5vbmU7IGNvbG9yOiAjODc1QTdC +OyI+T2RvbzwvYT4uCjwvcD4KPC9kaXY+CiAgICAgICAg + +--===============8588563873240298690==-- + +--===============7355787381227985247==-- + +--92726A5F09.1555335666/mail2.test.ironsky-- +""" + + +MAIL_BOUNCE_QP_RFC822_HEADERS = """\ +Received: by mailserver.odoo.com (Postfix) + id EA0B917B8E4; Tue, 29 Feb 2023 11:11:11 +0100 (CET) +From: {email_from} +Subject: Undelivered Mail Returned to Sender +To: {email_to} +Auto-Submitted: auto-replied +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=delivery-status; + boundary="DFFDC17AA03.1673346179/mailserver.odoo.com" +Message-Id: <40230110102259.EA0B917B8E4@mailserver.odoo.com> +Content-Transfer-Encoding: 7bit +Delivered-To: {delivered_to} +Return-Path: <> + +--DFFDC17AA03.1673346179/mailserver.odoo.com +Content-Description: Notification +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +I'm sorry to have to inform you that your message could not +be delivered to one or more recipients. + +: host + outlook-com.olc.protection.outlook.com[104.47.56.33] said: 550 5.5.0 + Requested action not taken: mailbox unavailable (S2017062302). (in re= +ply to + RCPT TO command) + +--DFFDC17AA03.1673346179/mailserver.odoo.com +Content-Description: Delivery report +Content-Type: message/delivery-status + +Reporting-MTA: dns; mailserver.odoo.com +X-Postfix-Queue-ID: DFFDC17AA03 +X-Postfix-Sender: rfc822; bounce@xxx.odoo.com +Arrival-Date: Tue, 29 Feb 2023 10:10:10 +0100 (CET) + +Final-Recipient: rfc822; rdesfrdgtfdrfesd@outlook.com +Original-Recipient: rfc822;rdesfrdgtfdrfesd@outlook.com +Action: failed +Status: 5.5.0 +Remote-MTA: dns; outlook-com.olc.protection.outlook.com +Diagnostic-Code: smtp; 550 5.5.0 Requested action not taken: mailbox + unavailable (S2017062302). + +--DFFDC17AA03.1673346179/mailserver.odoo.com +Content-Description: Undelivered Message Headers +Content-Type: text/rfc822-headers +Content-Transfer-Encoding: quoted-printable + +Return-Path: +Received: from eupp00.odoo.com (00.72.79.34.bc.googleusercontent.com [34.= +79.72.00]) + by mailserver.odoo.com (Postfix) with ESMTPS id DFFDC17AA03; + Tue, 10 Jan 2023 11:22:57 +0100 (CET) +DKIM-Signature: v=3D1; a=3Drsa-sha256; c=3Dsimple/simple; d=3Dxxx.be; + s=3Dodoo; t=3D1673346178; + bh=3DYPJOqkUi8B28X1MrRUsgmsL8KRz/ZIkpbYyc6wNITXA=3D; + h=3Dreferences:Subject:From:Reply-To:To:Date:From; + b=3DCMqh7mUvpgUw+JpCeGluv1+MZ3y6EsXd0acmsfzpYBjcoy1InvD6FLT1/lQCcgetf + cGyL/8R4vvDKATyE0AtOIpoYDsbpnMoiYWqaSXnDVuLTrEZzyrK/2j10ZTnHZ2uDTC + b7wPjFfQ9pted/t6CAUhVT1XydDNalSwEZovy/QI=3D +Message-Id: <368396033905967.1673346177.695352554321289-openerp-11-sale.o= +rder@eupp00> +references: <792105153140463.1673746527.352018594741821-openerp-11-sale.o= +rder@xxx.odoo.com> <368396033905967.1673346177.695352554321289-openerp-11= +-sale.order@eupp00> +Subject: Thi is a SO (Ref SO/11) +From: info@xxx.odoo.com +Reply-To: "SO/11" +To: "rdesfrdgtfdrfesd@outlook.com" +Date: Tue, 29 Feb 2023 06:09:06 -0000 +X-Odoo-Objects: sale.order-11 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary=3D"=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D= +=3D=3D=3D=3D=3D5706316606908750110=3D=3D" + +--DFFDC17AA03.1673346179/mailserver.odoo.com-- + +""" + +MAIL_NO_BODY = '''\ +Return-Path: <{email_from}> +Delivered-To: catchall@xxxx.xxxx +Received: from in66.mail.ovh.net (unknown [10.101.4.66]) + by vr38.mail.ovh.net (Postfix) with ESMTP id 4GLCGr70Kyz1myr75 + for ; Thu, 8 Jul 2021 10:30:12 +0000 (UTC) +X-Comment: SPF check N/A for local connections - client-ip=213.186.33.59; helo=mail663.ha.ovh.net; envelope-from={email_from}; receiver=catchall@xxxx.xxxx +Authentication-Results: in66.mail.ovh.net; dkim=none; dkim-atps=neutral +Delivered-To: xxxx.xxxx-{email_to} +X-ME-Helo: opme11oxm23aub.bagnolet.francetelecom.fr +X-ME-Auth: ZnJlZGVyaWMuYmxhY2hvbjA3QG9yYW5nZS5mcg== +X-ME-Date: Thu, 08 Jul 2021 12:30:11 +0200 +X-ME-IP: 86.221.151.111 +Date: Thu, 8 Jul 2021 12:30:11 +0200 (CEST) +From: =?UTF-8?Q?Fr=C3=A9d=C3=A9ric_BLACHON?= <{email_from}> +Reply-To: + =?UTF-8?Q?Fr=C3=A9d=C3=A9ric_BLACHON?= <{email_from}> +To: {email_to} +Message-ID: <1024471522.82574.1625740211606.JavaMail.open-xchange@opme11oxm23aub.bagnolet.francetelecom.fr> +Subject: transport autorisation 19T +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="----=_Part_82573_178179506.1625740211587" + +------=_Part_82573_178179506.1625740211587 +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + + + + + + +''' + +MAIL_NO_FINAL_RECIPIENT = """\ +Return-Path: +Delivered-To: catchall@xxxx.xxxx +Received: from in58.mail.ovh.net (unknown [10.101.4.58]) + by vr46.mail.ovh.net (Postfix) with ESMTP id 4GvFsq2QLYz1t0N7r + for ; Tue, 24 Aug 2021 17:07:43 +0000 (UTC) +Received-SPF: Softfail (mailfrom) identity=mailfrom; client-ip=46.105.72.169; helo=40.mo36.mail-out.ovh.net; envelope-from=bounce-md_9656353.6125275c.v1-f28f7746389e45f0bfbf9faefe9e0dc8@mandrillapp.com; receiver=catchall@xxxx.xxxx +Authentication-Results: in58.mail.ovh.net; + dkim=pass (1024-bit key; unprotected) header.d=mandrillapp.com header.i=bounces-noreply@mandrillapp.com header.b="TDzUcdJs"; + dkim=pass (1024-bit key) header.d=mandrillapp.com header.i=@mandrillapp.com header.b="MyjddTY5"; + dkim-atps=neutral +Delivered-To: xxxx.xxxx-{email_to} +Authentication-Results: in62.mail.ovh.net; + dkim=pass (1024-bit key; unprotected) header.d=mandrillapp.com header.i=bounces-noreply@mandrillapp.com header.b="TDzUcdJs"; + dkim=pass (1024-bit key) header.d=mandrillapp.com header.i=@mandrillapp.com header.b="MyjddTY5"; + dkim-atps=neutral +From: MAILER-DAEMON +Subject: Undelivered Mail Returned to Sender +To: {email_to} +X-Report-Abuse: Please forward a copy of this message, including all headers, to abuse@mandrill.com +X-Report-Abuse: You can also report abuse here: http://mandrillapp.com/contact/abuse?id=9656353.f28f7746389e45f0bfbf9faefe9e0dc8 +X-Mandrill-User: md_9656353 +Feedback-ID: 9656353:9656353.20210824:md +Message-Id: <9656353.20210824170740.6125275cf21879.17950539@mail9.us4.mandrillapp.com> +Date: Tue, 24 Aug 2021 17:07:40 +0000 +MIME-Version: 1.0 +Content-Type: multipart/report; boundary="_av-UfLe6y6qxNo54-urtAxbJQ" + +--_av-UfLe6y6qxNo54-urtAxbJQ +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 7bit + + --- The following addresses had delivery problems --- + +<{email_from}> (5.7.1 <{email_from}>: Recipient address rejected: Access denied) + + +--_av-UfLe6y6qxNo54-urtAxbJQ +Content-Type: message/delivery-status +Content-Transfer-Encoding: 7bit + +Original-Recipient: <{email_from}> +Action: failed +Diagnostic-Code: smtp; 554 5.7.1 <{email_from}>: Recipient address rejected: Access denied +Remote-MTA: 10.245.192.40 + + + +--_av-UfLe6y6qxNo54-urtAxbJQ--""" + +MAIL_FORWARDED = """X-Original-To: lucie@petitebedaine.fr +Delivered-To: raoul@grosbedon.fr +Delivered-To: lucie@petitebedaine.fr +To: lucie@petitebedaine.fr +From: "Bruce Wayne" + +SSBhbSB0aGUgQmF0TWFuCg==""" + +MAIL_PDF_MIME_TEMPLATE = """\ +To: {to} +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 + +------=_Part_4200734_24778174.1344608186754 +Content-Type: {pdf_mime}; name="scan_soraya.lernout_1691652648.pdf" +Content-Transfer-Encoding: base64 + +JVBERi0xLjEKJcKlwrHDqwoKMSAwIG9iagogIDw8IC9UeXBlIC9DYXRhbG9nCiAgICAgL1BhZ2VzIDIgMCBSCiAgPj4KZW5kb2JqCgoyIDAgb2JqCiAgPDwgL1R5cGUgL1BhZ2VzCiAgICAgL0tpZHMgWzMgMCBSXQogICAgIC9Db3VudCAxCiAgICAgL01lZGlhQm94IFswIDAgMzAwIDE0NF0KICA+PgplbmRvYmoKCjMgMCBvYmoKICA8PCAgL1R5cGUgL1BhZ2UKICAgICAgL1BhcmVudCAyIDAgUgogICAgICAvUmVzb3VyY2VzCiAgICAgICA8PCAvRm9udAogICAgICAgICAgIDw8IC9GMQogICAgICAgICAgICAgICA8PCAvVHlwZSAvRm9udAogICAgICAgICAgICAgICAgICAvU3VidHlwZSAvVHlwZTEKICAgICAgICAgICAgICAgICAgL0Jhc2VGb250IC9UaW1lcy1Sb21hbgogICAgICAgICAgICAgICA+PgogICAgICAgICAgID4+CiAgICAgICA+PgogICAgICAvQ29udGVudHMgNCAwIFIKICA+PgplbmRvYmoKCjQgMCBvYmoKICA8PCAvTGVuZ3RoIDU1ID4+CnN0cmVhbQogIEJUCiAgICAvRjEgMTggVGYKICAgIDAgMCBUZAogICAgKEhlbGxvIFdvcmxkKSBUagogIEVUCmVuZHN0cmVhbQplbmRvYmoKCnhyZWYKMCA1CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAxOCAwMDAwMCBuIAowMDAwMDAwMDc3IDAwMDAwIG4gCjAwMDAwMDAxNzggMDAwMDAgbiAKMDAwMDAwMDQ1NyAwMDAwMCBuIAp0cmFpbGVyCiAgPDwgIC9Sb290IDEgMCBSCiAgICAgIC9TaXplIDUKICA+PgpzdGFydHhyZWYKNTY1CiUlRU9GCg== + +------=_Part_4200734_24778174.1344608186754-- +""" + +PDF_PARSED = b'''%PDF-1.1\n%\xc2\xa5\xc2\xb1\xc3\xab\n\n1 0 obj\n << /Type /Catalog\n /Pages 2 0 R\n >>\nendobj\n\n2 0 obj\n << /Type /Pages\n /Kids [3 0 R]\n /Count 1\n /MediaBox [0 0 300 144]\n >>\nendobj\n\n3 0 obj\n << /Type /Page\n /Parent 2 0 R\n /Resources\n << /Font\n << /F1\n << /Type /Font\n /Subtype /Type1\n /BaseFont /Times-Roman\n >>\n >>\n >>\n /Contents 4 0 R\n >>\nendobj\n\n4 0 obj\n << /Length 55 >>\nstream\n BT\n /F1 18 Tf\n 0 0 Td\n (Hello World) Tj\n ET\nendstream\nendobj\n\nxref\n0 5\n0000000000 65535 f \n0000000018 00000 n \n0000000077 00000 n \n0000000178 00000 n \n0000000457 00000 n \ntrailer\n << /Root 1 0 R\n /Size 5\n >>\nstartxref\n565\n%%EOF\n''' + +THAI_EMAIL_WINDOWS_874 = '''\ +From: Thai Customer +To: "Thai Odoo User" +Subject: =?windows-874?B?4MPX6M2n?= +Thread-Topic: =?windows-874?B?4MPX6M2n?= +Thread-Index: AQHahRQ4qiMBoXtK0U2XwaGg8w9Y9g== +X-MS-Exchange-MessageSentRepresentingType: 1 +Date: Tue, 2 Apr 2024 15:42:24 +0000 +Message-ID: +Content-Language: en-US +X-MS-Has-Attach: +X-MS-Exchange-Organization-SCL: -1 +X-MS-TNEF-Correlator: +X-MS-Exchange-Organization-RecordReviewCfmType: 0 +msip_labels: +Content-Type: text/plain; charset="windows-874" +Content-Transfer-Encoding: quoted-printable +MIME-Version: 1.0 + +=C3=E8=D2=A7=A1=D2=C2=''' 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 new file mode 100644 index 0000000..fc2f562 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import mail_test_access +from . import test_mail_corner_case_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 new file mode 100644 index 0000000..10c93dd --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/models/mail_test_access.py @@ -0,0 +1,60 @@ +from odoo import exceptions, fields, models + + +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. """ + _description = 'Mail Access Test' + _name = 'mail.test.access' + _inherit = ['mail.thread.blacklist'] + _mail_post_access = 'write' # default value but ease mock + _order = 'id DESC' + _primary_email = 'email_from' + + name = fields.Char() + email_from = fields.Char() + phone = fields.Char() + customer_id = fields.Many2one('res.partner', 'Customer') + access = fields.Selection( + [ + ('public', 'public'), + ('logged', 'Logged'), + ('logged_ro', 'Logged readonly for portal'), + ('followers', 'Followers'), + ('internal', 'Internal'), + ('internal_ro', 'Internal readonly'), + ('admin', 'Admin'), + ], + name='Access', default='public') + + def _mail_get_partner_fields(self): + return ['customer_id'] + + +class MailTestAccessCusto(models.Model): + """ Test access on mail models without depending on real models like channel + or partner which have their own set of ACLs. """ + _description = 'Mail Access Test with Custo' + _name = 'mail.test.access.custo' + _inherit = ['mail.thread.blacklist'] + _mail_post_access = 'write' # default value but ease mock + _order = 'id DESC' + _primary_email = 'email_from' + + name = fields.Char() + email_from = fields.Char() + phone = fields.Char() + customer_id = fields.Many2one('res.partner', 'Customer') + is_locked = fields.Boolean() + + def _mail_get_partner_fields(self): + 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) 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 new file mode 100644 index 0000000..f093e3e --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_corner_case_models.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ + + +class MailPerformanceThread(models.Model): + _name = 'mail.performance.thread' + _description = 'Performance: mail.thread' + _inherit = ['mail.thread'] + + name = fields.Char() + value = fields.Integer() + value_pc = fields.Float(compute="_value_pc", store=True) + track = fields.Char(default='test', tracking=True) + partner_id = fields.Many2one('res.partner', string='Customer') + + @api.depends('value') + def _value_pc(self): + for record in self: + record.value_pc = float(record.value) / 100 + + +class MailPerformanceTracking(models.Model): + _name = 'mail.performance.tracking' + _description = 'Performance: multi tracking' + _inherit = ['mail.thread'] + + name = fields.Char(required=True, tracking=True) + field_0 = fields.Char(tracking=True) + field_1 = fields.Char(tracking=True) + field_2 = fields.Char(tracking=True) + + +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' + _inherit = ['mail.thread'] + + name = fields.Char() + email_from = fields.Char() + datetime = fields.Datetime(default=fields.Datetime.now) + customer_id = fields.Many2one('res.partner', 'Customer') + type = fields.Selection([('first', 'First'), ('second', 'Second')]) + user_id = fields.Many2one('res.users', 'Responsible', tracking=True) + + @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'): + self = self.with_context(default_type='first') + return super(MailTestFieldType, self).create(vals_list) + + def _mail_get_partner_fields(self): + return ['customer_id'] + + +class MailTestLang(models.Model): + """ A simple chatter model with lang-based capabilities, allowing to + test translations. """ + _description = 'Lang Chatter Model' + _name = 'mail.test.lang' + _inherit = ['mail.thread'] + + name = fields.Char() + email_from = fields.Char() + customer_id = fields.Many2one('res.partner') + lang = fields.Char('Lang') + + def _mail_get_partner_fields(self): + 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 {}) + + 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 MailTestTrackCompute(models.Model): + _name = 'mail.test.track.compute' + _description = "Test tracking with computed fields" + _inherit = ['mail.thread'] + + partner_id = fields.Many2one('res.partner', tracking=True) + partner_name = fields.Char(related='partner_id.name', store=True, tracking=True) + partner_email = fields.Char(related='partner_id.email', store=True, tracking=True) + partner_phone = fields.Char(related='partner_id.phone', tracking=True) + + +class MailTestTrackMonetary(models.Model): + _name = 'mail.test.track.monetary' + _description = 'Test tracking monetary field' + _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): + """ Test tracking for selection fields """ + _description = 'Test Selection Tracking' + _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' + _description = "Test Multi Company Mail" + _inherit = 'mail.thread' + + name = fields.Char() + company_id = fields.Many2one('res.company') + + +class MailTestMultiCompanyRead(models.Model): + """ Just mail.test.simple, but multi company and supporting posting + even if the user has no write access. """ + _description = 'Simple Chatter Model ' + _name = 'mail.test.multi.company.read' + _inherit = ['mail.test.multi.company'] + _mail_post_access = 'read' + + +class MailTestNotMailThread(models.Model): + """ Models not inheriting from mail.thread but using some cross models + capabilities of mail. """ + _name = 'mail.test.nothread' + _description = "NoThread Model" + + name = fields.Char() + company_id = fields.Many2one('res.company') + customer_id = fields.Many2one('res.partner') 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 new file mode 100644 index 0000000..7770208 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_models.py @@ -0,0 +1,330 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ + + +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' + _inherit = ['mail.thread'] + + name = fields.Char() + email_from = fields.Char() + + +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' + _inherit = ['mail.thread.blacklist'] + _primary_email = 'email_from' + + name = fields.Char() + email_from = fields.Char() + custom_field = fields.Char() + + @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'), + } + defaults.update(custom_values or {}) + return super().message_new(msg_dict, custom_values=defaults) + + +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' + _inherit = ['mail.thread.blacklist', 'mail.alias.mixin'] + _mail_flat_thread = False + _primary_email = 'email_from' + + name = fields.Char() + email_from = fields.Char() + custom_field = fields.Char() + customer_id = fields.Many2one('res.partner', 'Customer') + + def _alias_get_creation_values(self): + values = super(MailTestGatewayGroups, self)._alias_get_creation_values() + values['alias_model_id'] = self.env['ir.model']._get('mail.test.gateway.groups').id + if self.id: + values['alias_force_thread_id'] = self.id + values['alias_parent_thread_id'] = self.id + return values + + 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 + ) + + +class MailTestStandard(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' + _inherit = ['mail.thread'] + + name = fields.Char() + email_from = fields.Char() + user_id = fields.Many2one('res.users', 'Responsible', tracking=True) + container_id = fields.Many2one('mail.test.container', tracking=True) + company_id = fields.Many2one('res.company') + + +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' + _inherit = ['mail.thread', 'mail.activity.mixin'] + + name = fields.Char() + date = fields.Date() + email_from = fields.Char() + active = fields.Boolean(default=True) + + def action_start(self, action_summary): + return self.activity_schedule( + 'test_mail.mail_act_test_todo', + summary=action_summary + ) + + def action_close(self, action_feedback, attachment_ids=None): + self.activity_feedback(['test_mail.mail_act_test_todo'], + feedback=action_feedback, + 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' + _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) + source_ids = fields.Many2many('mail.test.composer.source', string='Invite source') + + def _compute_render_model(self): + self.render_model = 'mail.test.composer.source' + + +class MailTestComposerSource(models.Model): + """ A simple model on which invites are sent. """ + _description = 'Invite-like Wizard' + _name = 'mail.test.composer.source' + _inherit = ['mail.thread.blacklist'] + _primary_email = 'email_from' + + name = fields.Char('Name') + customer_id = fields.Many2one('res.partner', 'Main customer') + email_from = fields.Char( + 'Email', + compute='_compute_email_from', readonly=False, store=True) + + @api.depends('customer_id') + def _compute_email_from(self): + 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): + 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 new file mode 100644 index 0000000..29bbe72 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/models/test_mail_thread_models.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class MailTestCC(models.Model): + _name = 'mail.test.cc' + _description = "Test Email CC Thread" + _inherit = ['mail.thread.cc'] + + name = fields.Char() 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 new file mode 100644 index 0000000..2dca354 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/security/ir.model.access.csv @@ -0,0 +1,51 @@ +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_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_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_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_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 +access_mail_test_track_user,mail.test.track.user.employee,model_mail_test_track,base.group_user,1,1,1,1 +access_mail_test_activity_portal,mail.test.activity.portal,model_mail_test_activity,base.group_portal,1,0,0,0 +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_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_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_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 +access_mail_test_container_mc_portal,mail.test.container.mc.portal,model_mail_test_container_mc,base.group_portal,1,0,0,0 +access_mail_test_container_mc_user,mail.test.container.mc.user,model_mail_test_container_mc,base.group_user,1,1,1,1 +access_mail_test_cc_portal,mail.test.cc.portal,model_mail_test_cc,base.group_portal,1,0,0,0 +access_mail_test_cc_user,mail.test.cc.user,model_mail_test_cc,base.group_user,1,1,1,1 +access_mail_test_lang_portal,mail.test.lang.portal,model_mail_test_lang,base.group_portal,1,0,0,0 +access_mail_test_lang_user,mail.test.lang.user,model_mail_test_lang,base.group_user,1,1,1,1 +access_mail_test_multi_company_user,mail.test.multi.company.user,model_mail_test_multi_company,base.group_user,1,1,1,1 +access_mail_test_multi_company_portal,mail.test.multi.company.portal,model_mail_test_multi_company,base.group_portal,1,0,0,0 +access_mail_test_multi_company_read_user,mail.test.multi.company.read.user,model_mail_test_multi_company_read,base.group_user,1,1,1,1 +access_mail_test_multi_company_read_portal,mail.test.multi.company.read.portal,model_mail_test_multi_company_read,base.group_portal,1,0,0,0 +access_mail_test_multi_company_with_activity_user,mail.test.multi.company.with.activity.user,model_mail_test_multi_company_with_activity,base.group_user,1,1,1,1 +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_track_all,mail.test.track.all,model_mail_test_track_all,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_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 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 new file mode 100644 index 0000000..293777d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/security/test_mail_security.xml @@ -0,0 +1,132 @@ + + + + + Public: public only + + [('access', '=', 'public')] + + + + Portal: public/logged/logged readonly only + + [ + '|', ('access', 'in', ('public', 'logged', 'logged_ro')), + '&', ('access', '=', 'followers'), ('message_partner_ids', 'in', [user.partner_id.id])] + + + + + + + + Portal: update logged only + + [ + '|', ('access', '=', 'logged'), + '&', ('access', '=', 'followers'), ('message_partner_ids', 'in', [user.partner_id.id])] + + + + + Internal: read not admin + + [('access', '!=', 'admin')] + + + + + + + + Internal: update not admin and not readonly + + [('access', 'not in', ('internal_ro', 'admin'))] + + + + + + + + Admin: all + + [(1, '=', 1)] + + + + + + Mail Test Multi Company + + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + Mail Test Multi Company + + + [('company_id', 'in', company_ids + [False])] + + + + MC Readonly Rule + + + [('company_id', 'in', company_ids + [False])] + + + + + Mail Test Multi Company With Activity + + + [('company_id', 'in', company_ids + [False])] + + + + + Portal Mail Test Ticket + + [('message_partner_ids', 'in', [user.partner_id.id])] + + + + + + Mail Test Ticket Multi Company + + + [('company_id', 'in', company_ids + [False])] + + + Portal Mail Test Ticket Multi Company + + [('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 + + [('company_id', 'in', company_ids + [False])] + + + + Portal Mail Test Container Multi Company + + [('message_partner_ids', 'in', [user.partner_id.id])] + + + + 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 new file mode 100644 index 0000000..081dd0c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/activity_tests.js @@ -0,0 +1,676 @@ +/** @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/chatter_tests.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/chatter_tests.js new file mode 100644 index 0000000..f6aab64 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/chatter_tests.js @@ -0,0 +1,77 @@ +/** @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 new file mode 100644 index 0000000..dba01ff --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/helpers/model_definitions_setup.js @@ -0,0 +1,5 @@ +/** @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 new file mode 100644 index 0000000..2d7dc26 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/mobile/activity_tests.js @@ -0,0 +1,48 @@ +/** @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/systray_activity_menu_tests.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/systray_activity_menu_tests.js new file mode 100644 index 0000000..7315746 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/systray_activity_menu_tests.js @@ -0,0 +1,165 @@ +/** @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/tracking_value_tests.js b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/tracking_value_tests.js new file mode 100644 index 0000000..c3d04ea --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/static/tests/tracking_value_tests.js @@ -0,0 +1,438 @@ +/** @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 new file mode 100644 index 0000000..15b05ee --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/__init__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- + +from . import test_invite +from . import test_ir_actions +from . import test_mail_activity +from . import test_mail_composer +from . import test_mail_composer_mixin +from . import test_mail_followers +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_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 new file mode 100644 index 0000000..e928cbb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/common.py @@ -0,0 +1,37 @@ +# -*- 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 + def setUpClass(cls): + super(TestRecipients, cls).setUpClass() + Partner = cls.env['res.partner'].with_context({ + 'mail_create_nolog': True, + 'mail_create_nosubscribe': True, + 'mail_notrack': True, + 'no_reset_password': True, + }) + cls.partner_1 = Partner.create({ + 'name': 'Valid Lelitre', + 'email': 'valid.lelitre@agrolait.com', + 'country_id': cls.env.ref('base.be').id, + 'mobile': '0456001122', + 'phone': False, + }) + 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, + }) 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 new file mode 100644 index 0000000..718481a --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_invite.py @@ -0,0 +1,33 @@ +# -*- 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.tests import tagged +from odoo.tools import mute_logger + + +@tagged('mail_followers') +class TestInvite(TestMailCommon): + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_invite_email(self): + test_record = self.env['mail.test.simple'].with_context(self._test_context).create({'name': 'Test', 'email_from': 'ignasse@example.com'}) + test_partner = self.env['res.partner'].with_context(self._test_context).create({ + 'name': 'Valid Lelitre', + 'email': 'valid.lelitre@agrolait.com'}) + + mail_invite = self.env['mail.wizard.invite'].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() + + # check added followers and that emails were sent + self.assertEqual(test_record.message_partner_ids, + test_partner | self.user_admin.partner_id) + self.assertSentEmail(self.partner_employee, [test_partner]) + self.assertSentEmail(self.partner_employee, [self.partner_admin]) + self.assertEqual(len(self._mails), 2) 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 new file mode 100644 index 0000000..d214283 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_ir_actions.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# 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.tests import tagged +from odoo.tools import mute_logger + + +@tagged('ir_actions') +class TestServerActionsEmail(TestMailCommon, TestServerActionsBase): + + def setUp(self): + super(TestServerActionsEmail, self).setUp() + self.template = self._create_template( + 'res.partner', + {'email_from': '{{ object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted }}', + 'partner_to': '%s' % self.test_partner.id, + } + ) + + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_action_email(self): + # initial state + self.assertEqual(len(self.test_partner.message_ids), 1, + 'Contains Contact created message') + self.assertFalse(self.test_partner.message_partner_ids) + + # update action: send an email + self.action.write({ + 'mail_post_method': 'email', + 'state': 'mail_post', + 'template_id': self.template.id, + }) + self.assertFalse(self.action.mail_post_autofollow, 'Email action does not support autofollow') + + with self.mock_mail_app(): + self.action.with_context(self.context).run() + + # check an email is waiting for sending + mail = self.env['mail.mail'].sudo().search([('subject', '=', 'About TestingPartner')]) + self.assertEqual(len(mail), 1) + self.assertTrue(mail.auto_delete) + self.assertEqual(mail.body_html, '

Hello TestingPartner

') + self.assertFalse(mail.is_notification) + with self.mock_mail_gateway(mail_unlink_sent=True): + mail.send() + + # no archive (message) + self.assertEqual(len(self.test_partner.message_ids), 1, + 'Contains Contact created message') + self.assertFalse(self.test_partner.message_partner_ids) + + def test_action_followers(self): + self.test_partner.message_unsubscribe(self.test_partner.message_partner_ids.ids) + random_partner = self.env['res.partner'].create({'name': 'Thierry Wololo'}) + self.action.write({ + 'state': 'followers', + 'partner_ids': [(4, self.env.ref('base.partner_admin').id), (4, random_partner.id)], + }) + 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_message_post(self): + # initial state + self.assertEqual(len(self.test_partner.message_ids), 1, + 'Contains Contact created message') + self.assertFalse(self.test_partner.message_partner_ids) + + # test without autofollow and comment + self.action.write({ + 'mail_post_autofollow': False, + 'mail_post_method': 'comment', + 'state': 'mail_post', + 'template_id': self.template.id + }) + + with self.assertSinglePostNotifications( + [{'partner': self.test_partner, 'type': 'email', 'status': 'ready'}], + message_info={'content': 'Hello %s' % self.test_partner.name, + 'message_type': 'notification', + 'subtype': 'mail.mt_comment', + } + ): + self.action.with_context(self.context).run() + # NOTE: template using current user will have funny email_from + self.assertEqual(self.test_partner.message_ids[0].email_from, self.partner_root.email_formatted) + self.assertFalse(self.test_partner.message_partner_ids) + + # test with autofollow and note + self.action.write({ + 'mail_post_autofollow': True, + 'mail_post_method': 'note' + }) + with self.assertSinglePostNotifications( + [{'partner': self.test_partner, 'type': 'email', 'status': 'ready'}], + message_info={'content': 'Hello %s' % self.test_partner.name, + 'message_type': 'notification', + 'subtype': 'mail.mt_note', + } + ): + self.action.with_context(self.context).run() + self.assertEqual(len(self.test_partner.message_ids), 3, + '2 new messages produced') + self.assertEqual(self.test_partner.message_partner_ids, self.test_partner) + + def test_action_next_activity(self): + self.action.write({ + 'state': 'next_activity', + 'activity_user_type': 'specific', + '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.assertEqual(self.env['mail.activity'].search_count([('summary', '=', 'TestNew')]), 1) + + 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({ + 'state': 'next_activity', + 'activity_user_type': 'specific', + 'activity_type_id': self.env.ref('mail.mail_activity_data_meeting').id, + 'activity_summary': 'TestNew', + 'activity_date_deadline_range': 1, + 'activity_date_deadline_range_type': False, + }) + 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.assertEqual(self.env['mail.activity'].search_count([('summary', '=', 'TestNew')]), 1) 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 new file mode 100644 index 0000000..ac10710 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_activity.py @@ -0,0 +1,762 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +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 + +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.test_mail.models.test_mail_models import MailTestActivity +from odoo.tools import mute_logger +from odoo.tests.common import Form, users + + +class TestActivityCommon(TestMailCommon): + + @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) + + +@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}) + + @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}) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_activity_security_user_noaccess_automated(self): + def _employee_crash(*args, **kwargs): + """ 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.') + return DEFAULT + + with patch.object(MailTestActivity, 'check_access_rights', 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.write({'user_id': self.user_employee.id}) + + def test_activity_security_user_noaccess_manual(self): + def _employee_crash(*args, **kwargs): + """ 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.') + 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_admin.id, + 'summary': 'Summary', + }) + test_activity.flush_recordset() + + # can _search activities if access to the document + self.env['mail.activity'].with_user(self.user_employee)._search( + [('id', '=', test_activity.id)], count=False) + + # cannot _search activities if no access to the document + with patch.object(MailTestActivity, 'check_access_rights', 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) + + # can read_group activities if access to the document + read_group_result = self.env['mail.activity'].with_user(self.user_employee).read_group( + [('id', '=', test_activity.id)], + ['summary'], + ['summary'], + ) + self.assertEqual(1, read_group_result[0]['summary_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 self.assertRaises(exceptions.AccessError): + self.env['mail.activity'].with_user(self.user_employee).read_group( + [('id', '=', test_activity.id)], + ['summary'], + ['summary'], + ) + + # cannot read activities if no access to the document + with patch.object(MailTestActivity, 'check_access_rights', 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 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, + }) + + # cannot create activities if no access to the document + with patch.object(MailTestActivity, 'check_access_rights', 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) + + +@tests.tagged('mail_activity') +class TestActivityFlow(TestActivityCommon): + + def test_activity_flow_employee(self): + with self.with_user('employee'): + test_record = self.env['mail.test.activity'].browse(self.test_record.id) + self.assertEqual(test_record.activity_ids, self.env['mail.activity']) + + # employee record an activity and check the deadline + 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, + 'res_model_id': self.env['ir.model']._get(test_record._name).id, + 'res_id': test_record.id, + }) + self.assertEqual(test_record.activity_summary, 'Test Activity') + self.assertEqual(test_record.activity_state, 'planned') + + test_record.activity_ids.write({'date_deadline': date.today() - relativedelta(days=1)}) + self.assertEqual(test_record.activity_state, 'overdue') + + test_record.activity_ids.write({'date_deadline': date.today()}) + self.assertEqual(test_record.activity_state, 'today') + + # activity is done + test_record.activity_ids.action_feedback(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')) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_activity_notify_other_user(self): + self.user_admin.notification_type = 'email' + rec = self.test_record.with_user(self.user_employee) + with self.assertSinglePostNotifications( + [{'partner': self.partner_admin, 'type': 'email'}], + message_info={'content': 'assigned you the following activity', 'subtype': 'mail.mt_note', 'message_type': 'user_notification'}): + activity = rec.activity_schedule( + 'test_mail.mail_act_test_todo', + user_id=self.user_admin.id) + self.assertEqual(activity.create_uid, self.user_employee) + self.assertEqual(activity.user_id, self.user_admin) + + def test_activity_notify_same_user(self): + self.user_employee.notification_type = 'email' + rec = self.test_record.with_user(self.user_employee) + with self.assertNoNotifications(): + activity = rec.activity_schedule( + 'test_mail.mail_act_test_todo', + user_id=self.user_employee.id) + self.assertEqual(activity.create_uid, self.user_employee) + self.assertEqual(activity.user_id, self.user_employee) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_activity_dont_notify_no_user_change(self): + self.user_employee.notification_type = 'email' + activity = self.test_record.activity_schedule('test_mail.mail_act_test_todo', user_id=self.user_employee.id) + with self.assertNoNotifications(): + activity.with_user(self.user_admin).write({'user_id': self.user_employee.id}) + self.assertEqual(activity.user_id, self.user_employee) + + 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'] + email_activity_type = ActivityType.create({ + 'name': 'email', + 'summary': 'Email Summary', + }) + 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: + # `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) + + ActivityForm.activity_type_id = email_activity_type + # activity summary should be replaced with email's default summary + self.assertEqual(ActivityForm.summary, email_activity_type.summary) + + ActivityForm.activity_type_id = call_activity_type + # 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) + + @mute_logger('odoo.sql_db') + def test_activity_values(self): + """ Test activities are created with right model / res_id values linking + to records without void values. 0 as res_id especially is not wanted. """ + # creating activities on a temporary record generates activities with res_id + # being 0, which is annoying -> never create activities in transient mode + temp_record = self.env['mail.test.activity'].new({'name': 'Test'}) + with self.assertRaises(IntegrityError): + activity = temp_record.activity_schedule('test_mail.mail_act_test_todo', user_id=self.user_employee.id) + + test_record = self.env['mail.test.activity'].browse(self.test_record.ids) + + with self.assertRaises(IntegrityError): + self.env['mail.activity'].create({ + 'res_model_id': self.env['ir.model']._get_id(test_record._name), + }) + with self.assertRaises(IntegrityError): + self.env['mail.activity'].create({ + 'res_model_id': self.env['ir.model']._get_id(test_record._name), + 'res_id': False, + }) + with self.assertRaises(IntegrityError): + self.env['mail.activity'].create({ + 'res_id': test_record.id, + }) + + activity = self.env['mail.activity'].create({ + 'res_id': test_record.id, + 'res_model_id': self.env['ir.model']._get_id(test_record._name), + }) + with self.assertRaises(IntegrityError): + activity.write({'res_model_id': False}) + self.env.flush_all() + with self.assertRaises(IntegrityError): + activity.write({'res_id': False}) + self.env.flush_all() + with self.assertRaises(IntegrityError): + activity.write({'res_id': 0}) + self.env.flush_all() + + +@tests.tagged('mail_activity') +class TestActivityMixin(TestActivityCommon): + + @classmethod + def setUpClass(cls): + super(TestActivityMixin, cls).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(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)) + 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, + }) + + 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, + }) + + 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': 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, + }) + + 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 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({ + '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), + ) + 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({ + '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), + ) + + 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"]) 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 new file mode 100644 index 0000000..af91354 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_composer.py @@ -0,0 +1,2395 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from itertools import 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.tools import email_normalize, mute_logger, formataddr + + +@tagged('mail_composer') +class TestMailComposer(TestMailCommon, TestRecipients): + """ Test Composer internals """ + + @classmethod + def setUpClass(cls): + super(TestMailComposer, 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_2 = mail_new_test_user( + cls.env, login='employee2', groups='base.group_user', + notification_type='email', email='eglantine@example.com', + name='Eglantine Employee', signature='--\nEglantine') + cls.partner_employee_2 = cls.user_employee_2.partner_id + + # User without the group "mail.group_mail_template_editor" + cls.user_rendering_restricted = mail_new_test_user( + cls.env, login='user_rendering_restricted', + groups='base.group_user', + company_id=cls.company_admin.id, + name='Code Template Restricted User', + notification_type='inbox', + signature='--\nErnest' + ) + cls.env.ref('mail.group_mail_template_editor').users -= cls.user_rendering_restricted + + 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}, + ) + + 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_from = '"John Doe" ' + + cls.template = cls.env['mail.template'].create({ + 'auto_delete': True, + 'name': 'TestTemplate', + 'subject': 'TemplateSubject {{ object.name }}', + 'body_html': '

TemplateBody

', + 'partner_to': '{{ object.customer_id.id if object.customer_id else "" }}', + 'email_to': '{{ (object.email_from if not object.customer_id else "") }}', + '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, + 'reply_to': '{{ ctx.get("custom_reply_to") or "info@test.example.com" }}', + }) + + # activate translations + cls._activate_multi_lang( + layout_arch_db=None, # use default mail.test_layout + test_record=cls.test_records, + test_template=cls.template, + ) + + def _get_web_context(self, records, add_web=True, **values): + """ Helper to generate composer context. Will make tests a bit less + verbose. + + :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 + + +@tagged('mail_composer') +class TestComposerForm(TestMailComposer): + + def test_assert_initial_data(self): + """ Ensure class initial data to ease understanding """ + self.assertTrue(self.template.auto_delete) + + 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) + + @users('employee') + def test_mail_composer_comment(self): + 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.assertEqual(composer_form.author_id, self.env.user.partner_id) + self.assertFalse(composer_form.body) + self.assertEqual(composer_form.composition_mode, 'comment') + self.assertEqual(composer_form.email_from, self.env.user.email_formatted) + self.assertFalse(composer_form.mail_server_id) + self.assertEqual(composer_form.model, self.test_record._name) + self.assertFalse(composer_form.partner_ids) + self.assertEqual(composer_form.record_name, self.test_record.name, 'MailComposer: comment mode should compute record name') + 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.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) + + @users('employee') + def test_mail_composer_comment_attachments(self): + """Tests that all attachments are added to the composer, static attachments + are not duplicated and while reports are re-generated, and that intermediary + attachments are dropped.""" + 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, + }) + 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, + }) + + # begins without attachments + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_record, add_web=True, default_attachment_ids=[]) + )) + self.assertEqual(len(composer_form.attachment_ids), 0) + + # change template: 2 static (attachment_ids) and 1 dynamic (report) + composer_form.template_id = template_1 + self.assertEqual(len(composer_form.attachment_ids), 3) + 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(tpl_attachments, template_1_attachments) + + # change template: 0 static (attachment_ids) and 1 dynamic (report) + composer_form.template_id = template_2 + 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] + self.assertFalse(tpl_attachments) + + # change back to template 1 + composer_form.template_id = template_1 + self.assertEqual(len(composer_form.attachment_ids), 3) + 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(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_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.assertEqual(composer_form.composition_mode, 'comment') + self.assertEqual(composer_form.email_from, self.user_employee_2.email_formatted) + self.assertEqual(composer_form.mail_server_id, self.mail_server_domain) + self.assertEqual(composer_form.model, self.test_record._name) + 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.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.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) + + @users('employee') + def test_mail_composer_comment_wtpl_norecords(self): + """ Test specific case when running without records, to see the rendering + 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_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

') + self.assertEqual(composer_form.composition_mode, 'comment') + self.assertEqual(composer_form.email_from, self.env.user.partner_id.email_formatted) + self.assertEqual(composer_form.mail_server_id, self.mail_server_domain) + self.assertEqual(composer_form.model, self.test_record._name) + self.assertFalse(composer_form.partner_ids[:]) + self.assertFalse(composer_form.record_name) + 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.assertEqual(composer_form.subject, 'TemplateSubject ') + self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) + + @users('employee') + def test_mail_composer_mass(self): + 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.assertEqual(composer_form.author_id, self.env.user.partner_id) + self.assertFalse(composer_form.body) + self.assertEqual(composer_form.composition_mode, 'mass_mail') + self.assertEqual(composer_form.email_from, self.env.user.email_formatted) + self.assertFalse(composer_form.mail_server_id) + 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.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.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')) + + @users('employee') + def test_mail_composer_mass_wtpl(self): + 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.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.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.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.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.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')) + + @users('employee') + def test_mail_composer_mass_wtpl_norecords(self): + """ Test specific case when running without records, to see the rendering + 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_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, self.template.body_html, + 'MailComposer: mass mode should have template raw body if template') + 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.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.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.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')) + + @users('employee') + def test_mail_composer_template_switching(self): + """ Ensure that the current user's identity serves as the sender, + particularly when transitioning from a template with a designated sender to one lacking such specifications. + Moreover, we verify that switching to a template lacking any subject maintains the existing subject intact. """ + # Setup: Prepare Templates + template_complete = self.template.copy({ + "email_from": "not_current_user@template_complete.com", + "subject": "subject: template_complete", + }) + template_no_sender = template_complete.copy({"email_from": ""}) + template_no_subject = template_no_sender.copy({"subject": ""}) + forms = { + 'comment': Form(self.env['mail.compose.message'].with_context(default_composition_mode='comment')), + 'mass_mail': Form(self.env['mail.compose.message'].with_context(default_composition_mode='mass_mail')), + } + for composition_mode, form in forms.items(): + with self.subTest(composition_mode=composition_mode): + # Use Template with Sender and Subject + form.template_id = template_complete + self.assertEqual(form.email_from, template_complete.email_from, "email_from not set correctly to form this test") + self.assertEqual(form.subject, template_complete.subject, "subject not set correctly to form this test") + + # Switch to Template without Sender + form.template_id = template_no_sender + self.assertEqual(form.email_from, self.env.user.email_formatted, "email_from not updated to current user") + + # Switch to Template without Subject + form.template_id = template_no_subject + self.assertEqual(form.subject, template_complete.subject, "subject should be kept unchanged") + + +@tagged('mail_composer') +class TestComposerInternals(TestMailComposer): + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_composer_attachments(self): + """ Test attachments management in both comment and mass mail mode. """ + 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, + }) + template_void = self.template.copy(default={ + 'attachment_ids': False, + 'report_name': False, + 'report_template': False, + }) + attachs = self.env['ir.attachment'].search([('name', 'in', [a['name'] for a in attachment_data])]) + self.assertEqual(len(attachs), 3) + + for composition_mode, batch in (('comment', False), ('mass_mail', True)): + with self.subTest(composition_mode=composition_mode, batch=batch): + 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 + ) + + 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) + 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) + # values coming from template: attachment_ids only (report is dynamic) + else: + self.assertEqual( + sorted(composer.attachment_ids.ids), + sorted(attachs.ids) + ) + + # update with template with void values: values are kept + 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 ?)') + else: + self.assertEqual(composer.attachment_ids, attachs, + 'TODO: Values are kept (should be reset ?)') + + # reset template: values are kept + 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 ?)') + else: + self.assertEqual(composer.attachment_ids, attachs, + 'TODO: Values are kept (should be reset ?)') + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_composer_author(self): + """ Test author_id / email_from synchronization, in both comment and mass + mail modes. """ + template_void = self.template.copy(default={ + 'email_from': False, + }) + + for composition_mode, batch in (('comment', False), ('mass_mail', True)): + with self.subTest(composition_mode=composition_mode, batch=batch): + 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 + ) + + composer = self.env['mail.compose.message'].with_context(ctx).create({ + 'body': '

Test Body

', + }) + + # default values are current user + self.assertEqual(composer.author_id, self.env.user.partner_id) + self.assertEqual(composer.email_from, self.env.user.email_formatted) + + # author values 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.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': + self.assertEqual(composer.email_from, self.test_record.user_id.email_formatted, + 'MailComposer: should take email_from rendered from template') + else: + self.assertEqual(composer.email_from, self.template.email_from, + 'MailComposer: should take email_from raw from template') + + # manual values are kept over template values + 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) + + # 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) + self.assertEqual(composer.email_from, self.env.user.email_formatted) + else: + self.assertEqual(composer.author_id, self.env.user.partner_id) + 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) + self.assertEqual(composer.email_from, self.env.user.email_formatted) + else: + self.assertEqual(composer.author_id, self.env.user.partner_id) + self.assertEqual(composer.email_from, self.env.user.email_formatted) + + @users('employee') + def test_mail_composer_configuration(self): + """ Test content configuration (auto_delete_*, email_*, message_type, + subtype) in both comment and mass mailing mode. Template update is also + tested when it applies. """ + template_falsy = self.template.copy(default={ + 'auto_delete': False, + }) + + for composition_mode, batch in (('comment', False), ('mass_mail', True)): + with self.subTest(composition_mode=composition_mode, batch=batch): + 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 + ) + + # 1. check without template (default values) + template update + composer = self.env['mail.compose.message'].with_context(ctx).create({ + 'email_layout_xmlid': 'mail.test_layout', + }) + + # default creation values + self.assertFalse(composer.auto_delete) + self.assertFalse(composer.auto_delete_message) + self.assertTrue(composer.email_add_signature) + 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.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.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')) + + # manual update + composer.write({ + 'message_type': 'notification', + 'subtype_id': self.env.ref('mail.mt_note').id, + }) + self.assertEqual(composer.message_type, 'notification') + self.assertEqual(composer.subtype_id, self.env.ref('mail.mt_note')) + + # 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.assertEqual(composer.message_type, 'notification') + self.assertEqual(composer.subtype_id, self.env.ref('mail.mt_note')) + else: + # self.assertFalse(composer.auto_delete, 'TODO: should be updated') + self.assertTrue(composer.auto_delete) + self.assertEqual(composer.message_type, 'notification') + self.assertEqual(composer.subtype_id, self.env.ref('mail.mt_note')) + + @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. """ + template_void = self.template.copy(default={ + 'body_html': False, + 'mail_server_id': False, + 'subject': False, + }) + + for composition_mode, batch in (('comment', False), ('mass_mail', True)): + with self.subTest(composition_mode=composition_mode, batch=batch): + 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 + ) + + # 1. check without template + template update + composer = self.env['mail.compose.message'].with_context(ctx).create({ + 'body': '

Test Body />

') + self.assertEqual(composer.mail_server_id, self.mail_server_global) + 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': + 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(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.subject, self.template.subject) + + # manual values is kept over template + composer.write({ + 'body': '

Back to my amazing body />

') + self.assertEqual(composer.mail_server_id, self.mail_server_global) + self.assertEqual(composer.record_name, 'Manual update') + 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 + 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') + self.assertEqual(composer.subject, 'Back to my amazing subject for {{ record.name }}') + else: + self.assertEqual(composer.body, '

Back to my amazing body />

'}) + composer.write({'template_id': False}) + # currently onchange necessary + composer._onchange_template_id_wrapper() + + # values are reset with default_get call, if it returns value + # (aka subject for comment mode), and not record_name because + # it was forgotten probably + if composition_mode == 'comment': + self.assertFalse(composer.body) + # self.assertFalse(composer.mail_server_id.id) + self.assertEqual(composer.mail_server_id, self.mail_server_global, + 'TODO: Values are kept (should be reset ?)') + # self.assertEqual(composer.record_name, self.test_record.name) + self.assertEqual(composer.record_name, 'Manual update', + 'TODO: Reset not called') + self.assertEqual(composer.subject, 'Re: %s' % self.test_record.name) + else: + self.assertFalse(composer.body) + # self.assertFalse(composer.mail_server_id.id) + self.assertEqual(composer.mail_server_id, self.mail_server_global, + 'TODO: Values are kept (should be reset ?)') + # self.assertFalse(composer.record_name) + self.assertEqual(composer.record_name, 'Manual update', + 'TODO: Reset not called') + # self.assertFalse(composer.subject) + self.assertEqual(composer.subject, 'Back to my amazing subject for {{ record.name }}', + 'TODO: Values are kept (should be reset ?)') + + # 2. check with default + ctx['default_template_id'] = self.template.id + composer = self.env['mail.compose.message'].with_context(ctx).create({ + 'template_id': self.template.id, + }) + # currently onchange necessary + composer._onchange_template_id_wrapper() + + # values come from template + if composition_mode == 'comment': + 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(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.subject, self.template.subject) + + # 3. check at create + ctx.pop('default_template_id') + composer = self.env['mail.compose.message'].with_context(ctx).create({ + 'template_id': self.template.id, + }) + # currently onchange necessary + composer._onchange_template_id_wrapper() + + # values come from template + if composition_mode == 'comment': + 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(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.subject, self.template.subject) + + # 4. template + user input + ctx['default_template_id'] = self.template.id + composer = self.env['mail.compose.message'].with_context(ctx).create({ + 'body': '

Test Body

', + 'mail_server_id': False, + 'record_name': 'CustomName', + 'subject': 'My amazing subject', + }) + + # creation values are taken + self.assertEqual(composer.body, '

Test Body

') + self.assertEqual(composer.mail_server_id.id, False) + self.assertEqual(composer.record_name, 'CustomName') + self.assertEqual(composer.subject, 'My amazing subject') + + @users('employee') + @mute_logger('odoo.models.unlink') + def test_mail_composer_recipients(self): + """ Test content management (partner_ids, reply_to) in both comment and + mass mailing mode. Template update is also tested. Add some tests for + partner creation based on unknown emails as this is part of the process. """ + base_recipients = self.partner_1 + self.partner_2 + self.template.write({ + 'email_cc': 'test.cc.1@test.example.com, test.cc.2@test.example.com' + }) + template_void = self.template.copy(default={ + 'email_cc': False, + 'email_to': False, + 'partner_to': False, + 'reply_to': False, + }) + + for composition_mode, batch in (('comment', False), ('mass_mail', True)): + with self.subTest(composition_mode=composition_mode, batch=batch): + self.assertFalse( + self.env['res.partner'].search([ + ('email_normalized', 'in', ['test.cc.1@test.example.com', + 'test.cc.2@test.example.com']) + ]) + ) + + 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 + ) + + # 1. check without template + template update + composer = self.env['mail.compose.message'].with_context(ctx).create({ + 'body': '

Test Body

', + 'partner_ids': base_recipients.ids, + 'reply_to': 'my_reply_to@test.example.com', + 'subject': 'My amazing subject', + }) + + # creation values are taken + self.assertEqual(composer.partner_ids, base_recipients) + self.assertEqual(composer.reply_to, 'my_reply_to@test.example.com') + self.assertFalse(composer.reply_to_force_new) + self.assertEqual(composer.reply_to_mode, 'update') + + # update with template with void values: void value is not forced in + # rendering mode as well as when copying template values (and recipients + # are not computed until sending in rendering mode) + composer.write({'template_id': template_void.id}) + # currently onchange necessary + composer._onchange_template_id_wrapper() + + if composition_mode == 'comment': + self.assertEqual(composer.partner_ids, base_recipients) + self.assertEqual(composer.reply_to, 'my_reply_to@test.example.com') + self.assertFalse(composer.reply_to_force_new) + else: + self.assertEqual(composer.partner_ids, base_recipients) + self.assertEqual(composer.reply_to, 'my_reply_to@test.example.com') + self.assertFalse(composer.reply_to_force_new) + + # changing template should update its content + composer.write({'template_id': self.template.id}) + # currently onchange necessary + composer._onchange_template_id_wrapper() + new_partners = self.env['res.partner'].search( + [('email_normalized', 'in', ['test.cc.1@test.example.com', + 'test.cc.2@test.example.com']) + ] + ) + + # values come from template + if composition_mode == 'comment': + self.assertEqual(len(new_partners), 2) + self.assertEqual(composer.partner_ids, self.partner_1 + new_partners, 'Template took customer_id as set on record') + self.assertEqual(composer.reply_to, 'info@test.example.com', 'Template was rendered') + self.assertFalse(composer.reply_to_force_new) # should not change in comment mode + else: + self.assertEqual(len(new_partners), 0) + self.assertEqual(composer.partner_ids, base_recipients, 'Mass mode: kept original values') + self.assertEqual(composer.reply_to, self.template.reply_to, 'Mass mode: raw template value') + self.assertFalse(composer.reply_to_force_new) # should probably become True, not supported currently + + # manual values is kept over template + composer.write({'partner_ids': [(5, 0), (4, self.partner_admin.id)]}) + self.assertEqual(composer.partner_ids, self.partner_admin) + + # reset template should reset values + composer.write({'template_id': False}) + # currently onchange necessary + composer._onchange_template_id_wrapper() + + # values are kept, should probably be reset + if composition_mode == 'comment': + self.assertEqual(composer.partner_ids, self.partner_admin, + 'TODO: Values are kept (should be reset ?)') + self.assertEqual(composer.reply_to, 'info@test.example.com', + 'TODO: Values are kept (should be reset ?)') + self.assertFalse(composer.reply_to_force_new) + else: + self.assertEqual(composer.partner_ids, self.partner_admin, + 'TODO: Values are kept (should be reset ?)') + self.assertEqual(composer.reply_to, self.template.reply_to, + 'TODO: Values are kept (should be reset ?)') + self.assertFalse(composer.reply_to_force_new) + + # 2. check with default + ctx['default_template_id'] = self.template.id + composer = self.env['mail.compose.message'].with_context(ctx).create({ + 'template_id': self.template.id, + }) + # currently onchange necessary + composer._onchange_template_id_wrapper() + + # values come from template + if composition_mode == 'comment': + self.assertEqual(composer.partner_ids, self.partner_1 + new_partners) + else: + self.assertFalse(composer.partner_ids) + if composition_mode == 'comment': + self.assertEqual(composer.reply_to, "info@test.example.com") + else: + self.assertEqual(composer.reply_to, self.template.reply_to) + self.assertFalse(composer.reply_to_force_new) # note: this should be updated with reply-to + self.assertEqual(composer.reply_to_mode, 'update') # note: this should be updated with reply-to + + # 3. check at create + ctx.pop('default_template_id') + composer = self.env['mail.compose.message'].with_context(ctx).create({ + 'template_id': self.template.id, + }) + # currently onchange necessary + composer._onchange_template_id_wrapper() + + # values come from template + if composition_mode == 'comment': + self.assertEqual(composer.partner_ids, self.partner_1 + new_partners) + else: + self.assertFalse(composer.partner_ids) + if composition_mode == 'comment': + self.assertEqual(composer.reply_to, "info@test.example.com") + else: + self.assertEqual(composer.reply_to, self.template.reply_to) + self.assertFalse(composer.reply_to_force_new) + self.assertEqual(composer.reply_to_mode, 'update') + + # 4. template + user input + ctx['default_template_id'] = self.template.id + composer = self.env['mail.compose.message'].with_context(ctx).create({ + 'body': '

Test Body

', + 'partner_ids': base_recipients.ids, + 'subject': 'My amazing subject', + 'reply_to': False, + }) + + # creation values are taken + self.assertEqual(composer.partner_ids, base_recipients) + self.assertFalse(composer.reply_to) + self.assertFalse(composer.reply_to_force_new) + self.assertEqual(composer.reply_to_mode, 'update') + + self.env['res.partner'].search([ + ('email_normalized', 'in', ['test.cc.1@test.example.com', + 'test.cc.2@test.example.com']) + ]).unlink() + + @users('employee') + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_mail_composer_parent(self): + """ Test specific management in comment mode when having parent_id set: + record_name, subject, parent's partners. """ + parent = self.test_record.message_post(body='Test', partner_ids=(self.partner_1 + self.partner_2).ids) + + composer = self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_record, add_web=False, default_parent_id=parent.id) + ).create({ + 'body': '

Test Body

', + }) + + # creation values taken from parent + self.assertEqual(composer.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) + + @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, + }) + attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id) + template_1.write({ + 'attachment_ids': [(0, 0, dict(a, res_model="mail.template", res_id=template_1.id)) for a in attachment_data] + }) + with self.assertRaises(AccessError): + # ensure user_rendering_restricted has no write access + template_1.with_user(self.env.user).write({'name': 'New Name'}) + + 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"] + + 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( + self.test_record.message_ids[0].subject, + f'TemplateSubject {self.test_record.name}') + self.assertEqual( + sorted(self.test_record.message_ids[0].attachment_ids.mapped('name')), + sorted(template_1_attachment_name)) + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_mail_composer_rights_portal(self): + portal_user = self._create_portal_user() + # give read access to the record to portal (for check access rule) + 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) + + @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({ + 'subject': 'Template Subject', + 'body': '

Template Body

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

Template Body

', 'email_template incorrect body_html') + + +@tagged('mail_composer', 'multi_lang') +class TestComposerResultsComment(TestMailComposer): + """ Test global output of composer used in comment mode. Test notably + notification and emails generated during this process. """ + + def test_assert_initial_data(self): + """ Ensure class initial data to ease understanding """ + self.assertTrue(self.template.auto_delete) + + 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(len(self.test_partners), 2) + + self.assertEqual(self.user_employee.lang, 'en_US') + self.assertEqual(self.user_employee_2.lang, 'en_US') + + @users('employee') + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_mail_composer_notifications_delete(self): + """ Notifications are correctly deleted once sent """ + composer = self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_record) + ).create({ + '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) + 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.assertFalse(self._new_mails.exists(), 'Should have deleted mail.mail records') + + # Check ``auto_delete`` field usage (note: currently not correctly managed) + composer = self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_record), + ).create({ + 'auto_delete': False, + '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) + 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)] + }) + 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()), 2, 'Should not have deleted mail.mail records') + + @users('employee') + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_mail_composer_post_parameters(self): + """ Test various fields and tweaks in comment mode used for message_post + parameters and process.. """ + # default behavior + composer = self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_record) + ).create({ + 'body': '

Test Body

', + }) + _mail, message = composer._action_send_mail() + self.assertEqual(message.body, '

Test Body

') + 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 + composer = self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_record) + ).create({ + '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, + }) + _mail, message = composer._action_send_mail() + self.assertEqual(message.subtype_id, self.env.ref('mail.mt_note')) + + @users('employee') + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_mail_composer_recipients(self): + """ Test partner_ids given to composer are given to the final message. """ + composer = self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_record) + ).create({ + 'body': '

Test Body

', + 'partner_ids': [(4, self.partner_1.id), (4, self.partner_2.id)] + }) + composer._action_send_mail() + + 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.subtype_id, self.env.ref('mail.mt_comment')) + self.assertEqual(message.partner_ids, self.partner_1 | self.partner_2) + + @users('employee') + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_mail_composer_wtpl_complete(self): + """ Test a posting process using a complex template, holding several + additional recipients and attachments. + + This tests notifies: 2 new email_to (+ 1 duplicated), 1 email_cc, + test_record followers and partner_admin added in partner_to. + + Test with and without notification layout specified. + + Test with and without languages. + """ + attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id) + email_to_1 = 'test.to.1@test.example.com' + email_to_2 = 'test.to.2@test.example.com' + email_to_3 = 'test.to.1@test.example.com' # duplicate: should not sent twice the email + email_cc_1 = 'test.cc.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_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, + }) + attachs = self.env['ir.attachment'].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( + (False, 'mail.test_layout'), + (False, True), + ): + with self.subTest(email_layout_xmlid=email_layout_xmlid, + use_lang=use_lang): + # update test configuration + if use_lang: + exp_lang = 'es_ES' + self.partner_1.lang = exp_lang + if not use_lang: + exp_lang = False + self.partner_1.lang = False + + test_record = self.test_record.with_env(self.env) + + # ensure initial data + self.assertEqual(test_record.user_id, self.user_employee_2) + self.assertEqual(test_record.message_partner_ids, self.partner_employee_2) + + ctx = { + 'default_model': test_record._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, + } + if email_layout_xmlid: + ctx['default_email_layout_xmlid'] = email_layout_xmlid + + # open a composer and run it in comment mode + composer_form = Form(self.env['mail.compose.message'].with_context(ctx)) + composer = composer_form.save() + + # 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') + + with self.mock_mail_gateway(mail_unlink_sent=False), \ + self.mock_mail_app(): + composer._action_send_mail() + + # 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') + + # check new partners have been created based on emails given + 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( + set(new_partners.mapped('email')), + {'test.to.1@test.example.com', 'test.to.2@test.example.com', 'test.cc.1@test.example.com'}, + ) + self.assertEqual( + set(new_partners.mapped('lang')), + {'en_US'}, + ) + + message = test_record.message_ids[0] + + # 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, + }, + ) + + # 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') + + # 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: + 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') + 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']) + ) + 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_recipients_email_fields(self): + """ Test various combinations of corner case / not standard filling of + email fields: multi email, formatted emails, ... on template, used to + post a message using the composer.""" + existing_partners = self.env['res.partner'].search([]) + partner_format_tofind, partner_multi_tofind, partner_at_tofind = self.env['res.partner'].create([ + { + 'email': '"FindMe Format" ', + 'name': 'FindMe Format', + }, { + 'email': 'find.me.multi.1@test.example.com, "FindMe Multi" ', + 'name': 'FindMe Multi', + }, { + 'email': '"Bike@Home" ', + 'name': 'NotBike@Home', + } + ]) + email_ccs = ['"Raoul" ', '"Raoulette" ', 'test.cc.2.2@example.com>', 'invalid', ' '] + email_tos = ['"Micheline, l\'Immense" ', 'test.to.2@example.com', 'wrong', ' '] + + self.template.write({ + 'email_cc': ', '.join(email_ccs), + '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', + }) + self.user_employee.write({'email': 'email.from.1@test.mycompany.com, email.from.2@test.mycompany.com'}) + self.partner_1.write({'email': '"Valid Formatted" '}) + self.partner_2.write({'email': 'valid.other.1@agrolait.com, valid.other.cc@agrolait.com'}) + # ensure values used afterwards for testing + self.assertEqual( + self.partner_employee.email_formatted, + '"Ernest Employee" ', + 'Formatting: wrong formatting due to multi-email') + self.assertEqual( + self.partner_1.email_formatted, + '"Valid Lelitre" ', + 'Formatting: avoid wrong double encapsulation') + self.assertEqual( + self.partner_2.email_formatted, + '"Valid Poilvache" ', + 'Formatting: wrong formatting due to multi-email') + + # instantiate composer, post message + 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, + ) + )) + composer = composer_form.save() + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + 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 + # not work + new_partners = self.env['res.partner'].search([]).search([('id', 'not in', existing_partners.ids)]) + self.assertEqual(len(new_partners), 9, + 'Mail (FIXME): multiple partner creation due to formatted / multi emails: 1 extra partner') + self.assertIn(partner_format_tofind, new_partners) + self.assertIn(partner_multi_tofind, new_partners) + self.assertIn(partner_at_tofind, new_partners) + self.assertEqual(new_partners[0:3].ids, (partner_format_tofind + partner_multi_tofind + partner_at_tofind).ids) + self.assertEqual( + sorted(new_partners.mapped('email')), + sorted(['"Bike@Home" ', + '"FindMe Format" ', + 'find.me.multi.1@test.example.com, "FindMe Multi" ', + 'find.me.multi.2@test.example.com', + 'test.cc.1@example.com', + 'test.cc.2@example.com', + 'test.cc.2.2@example.com', + 'test.to.1@example.com', + 'test.to.2@example.com']), + 'Mail: created partners for valid emails (wrong / invalid not taken into account) + did not find corner cases (FIXME)' + ) + self.assertEqual( + sorted(new_partners.mapped('email_formatted')), + sorted(['"NotBike@Home" ', + '"FindMe Format" ', + '"FindMe Multi" ', + '"find.me.multi.2@test.example.com" ', + '"test.cc.1@example.com" ', + '"test.cc.2@example.com" ', + '"test.cc.2.2@example.com" ', + '"test.to.1@example.com" ', + '"test.to.2@example.com" ']), + ) + self.assertEqual( + sorted(new_partners.mapped('name')), + sorted(['NotBike@Home', + 'FindMe Format', + 'FindMe Multi', + 'find.me.multi.2@test.example.com', + 'test.cc.1@example.com', + 'test.to.1@example.com', + 'test.to.2@example.com', + 'test.cc.2@example.com', + 'test.cc.2.2@example.com']), + 'Mail: currently setting name = email, not taking into account formatted emails' + ) + + # global outgoing: two mail.mail (all customer recipients, then all employee recipients) + # and 11 emails, and 1 inbox notification (admin) + # FIXME template is sent only to partners (email_to are transformed) -> + # wrong / weird emails (see email_formatted of partners) is kept + # FIXME: more partners created than real emails (see above) -> due to + # transformation from email -> partner in template 'generate_recipients' + # there are more partners than email to notify; + self.assertEqual(len(self._new_mails), 2, 'Should have created 2 mail.mail') + self.assertEqual( + len(self._mails), len(new_partners) + 3, + f'Should have sent {len(new_partners) + 3} emails, one / recipient ({len(new_partners)} mailed partners + partner_1 + partner_2 + partner_employee)') + self.assertMailMail( + self.partner_employee_2, 'sent', + author=self.partner_employee, + email_values={ + 'body_content': f'TemplateBody {self.test_record.name}', + # single email event if email field is multi-email + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.mycompany.com')), + 'subject': f'TemplateSubject {self.test_record.name}', + }, + fields_values={ + # currently holding multi-email 'email_from' + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.mycompany.com,email.from.2@test.mycompany.com')), + }, + mail_message=self.test_record.message_ids[0], + ) + recipients = self.partner_1 + self.partner_2 + new_partners + self.assertMailMail( + recipients, + 'sent', + author=self.partner_employee, + email_to_recipients=[ + [self.partner_1.email_formatted], + [f'"{self.partner_2.name}" ', f'"{self.partner_2.name}" '], + ] + [[new_partners[0]['email_formatted']], + ['"FindMe Multi" ', '"FindMe Multi" '] + ] + [[email] for email in new_partners[2:].mapped('email_formatted')], + email_values={ + 'body_content': f'TemplateBody {self.test_record.name}', + # single email event if email field is multi-email + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.mycompany.com')), + 'subject': f'TemplateSubject {self.test_record.name}', + }, + fields_values={ + # currently holding multi-email 'email_from' + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.mycompany.com,email.from.2@test.mycompany.com')), + }, + mail_message=self.test_record.message_ids[0], + ) + # actual emails sent through smtp + for recipient in recipients: + # multi emails -> send multiple emails (smart) + if recipient == self.partner_2: + smtp_to_list = ['valid.other.1@agrolait.com', 'valid.other.cc@agrolait.com'] + # find.me.format + elif recipient == new_partners[0]: + self.assertEqual(recipient, partner_format_tofind) + smtp_to_list = ['find.me.format@test.example.com'] + # find.me.multi was split into two partners + elif recipient == new_partners[1]: + self.assertEqual(recipient, partner_multi_tofind) + smtp_to_list = ['find.me.multi.1@test.example.com', 'find.me.multi.2@test.example.com'] + elif recipient == new_partners[3]: + smtp_to_list = ['find.me.multi.2@test.example.com'] + # bike@home: name is not recognized as email anymore + elif recipient == new_partners[2]: + self.assertEqual(recipient, partner_at_tofind) + smtp_to_list = ['find.me.at@test.example.com'] + else: + smtp_to_list = [recipient.email_normalized] + self.assert_email_sent_smtp( + smtp_from=f'{self.alias_bounce}@{self.alias_domain}', + smtp_to_list=smtp_to_list, + mail_server=self.mail_server_domain, + # msg_from takes only first found normalized email to make a valid email_from + message_from=formataddr( + (self.user_employee.name, + 'email.from.1@test.mycompany.com', + )), + # similar envelope, assert_email_sent_smtp cannot distinguish + # records (would have to dive into content, too complicated) + emails_count=1, + ) + + +@tagged('mail_composer', 'mail_blacklist') +class TestComposerResultsCommentStatus(TestMailComposer): + """ Test cases involving blacklist, opt-out, state management, ... specific + class to avoid bloating the base comment-based composer tests. """ + + @classmethod + def setUpClass(cls): + """ Test data: 4 records with a customer set, then some additional + records based on emails, duplicates, ... + + Record0: partner is blacklisted + Record1: Record4 has the same email (but no customer set) + Record5 and Record6 have same email (notlinked to any customer) + """ + 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, + additional_values={'user_id': cls.user_employee_2.id}, + prefix='el_' + ) + # create bl / optout / duplicates, see docstring + cls.env['mail.blacklist']._add( + cls.test_partners[0].email_formatted + ) + cls.test_records += cls.env[cls.test_records._name].create([ + { + 'email_from': cls.test_records[1].email_from, + 'name': 'Email of Record2', + 'user_id': cls.user_employee_2.id, + }, + { + 'email_from': 'test.duplicate@test.example.com', + 'name': 'Dupe email (first)', + 'user_id': cls.user_employee_2.id, + }, + { + 'email_from': 'test.duplicate@test.example.com', + 'name': 'Dupe email (second)', + 'user_id': cls.user_employee_2.id, + }, + ]) + cls.template.write({ + 'auto_delete': False, + 'model_id': cls.env['ir.model']._get_id(cls.test_records._name), + }) + + def test_assert_initial_data(self): + """ Ensure class initial data to ease understanding """ + self.assertFalse(self.template.auto_delete) + + self.assertEqual(len(self.test_records), 7) + 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[1].email_from, self.test_records[4].email_from) + self.assertEqual(self.test_records[5].email_from, self.test_records[6].email_from) + + self.assertEqual(len(self.test_partners), 4) + self.assertTrue(self.test_partners[0].is_blacklisted) + + @users('employee') + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_comment_blacklist(self): + """ Tests a document-based comment with the excluded emails. It is + currently bypassed, as we consider posting bypasses the exclusion list. + """ + test_record = self.test_records[0].with_env(self.env) + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context(test_record, add_web=True, + default_template_id=self.template.id) + )) + composer = composer_form.save() + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + composer._action_send_mail() + + # one mail to the customer, one mail to the follower + message = test_record.message_ids[0] + for recipient in test_record.customer_id + self.partner_employee_2: + with self.subTest(recipient=recipient): + self.assertMailMail( + recipient, 'sent', + mail_message=message, + author=self.partner_employee, # author != email_from (template sets only email_from) + email_values={ + 'email_from': self.user_employee_2.email_formatted, # set by template + }, + ) + self.assertEqual(len(self._mails), 2, 'Should have sent 2 emails, skipping the exclusion list') + + +@tagged('mail_composer', 'multi_lang') +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)], + }) + + @users('employee') + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_mail_composer_delete(self): + """ Check mail / msg delete support """ + # ensure initial data + self.assertTrue(self.template.auto_delete) + self.assertEqual(self.test_records.user_id, self.user_employee_2) + self.assertEqual(self.test_records.message_partner_ids, self.partner_employee_2) + + 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() + self.assertFalse(composer.auto_delete, 'Fixme: should take composer value') + self.assertFalse(composer.auto_delete_message) + with self.mock_mail_gateway(mail_unlink_sent=True), self.mock_mail_app(): + composer._action_send_mail() + + 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(self._new_msgs.exists(), self._new_msgs, 'Should not have deleted mail.message records') + + # force composer auto_delete field + 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 = False + with self.mock_mail_gateway(mail_unlink_sent=True), self.mock_mail_app(): + composer._action_send_mail() + + 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(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') + + # check composer auto_delete_message + 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 + with self.mock_mail_gateway(mail_unlink_sent=True), self.mock_mail_app(): + composer._action_send_mail() + + # global outgoing + 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.assertFalse(self._new_msgs.exists(), 'Should have deleted mail.message records') + + @users('employee') + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_mail_composer_wtpl(self): + self.template.auto_delete = False # keep sent emails to check content + + # 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) + + 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() + self.assertFalse(composer.reply_to_force_new, 'Mail: thread-enabled models should use auto thread by default') + 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') + + for record in self.test_records: + # message copy is kept + message = record.message_ids[0] + + # template is sent directly using customer field, meaning we have recipients + self.assertMailMail(record.customer_id, 'sent', + mail_message=message, + author=self.partner_employee, + email_values={ + 'email_from': self.partner_employee_2.email_formatted, + }) + + # 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) + # post-related fields are void + self.assertFalse(message.subtype_id) + self.assertFalse(message.partner_ids) + + @users('employee') + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_mail_composer_wtpl_complete(self): + """ Test a composer in mass mode with a quite complete template, containing + notably email-based recipients and attachments. + + Translations and email layout supported are also tested. + """ + # as we use the email queue, don't have failing tests due to other outgoing emails + self.env['mail.mail'].sudo().search([]).unlink() + + attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id) + email_to_1 = 'test.to.1@test.example.com' + email_to_2 = 'test.to.2@test.example.com' + email_to_3 = 'test.to.1@test.example.com' # duplicate: should not sent twice the email + email_cc_1 = 'test.cc.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_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, + }) + attachs = self.env['ir.attachment'].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( + (False, 'mail.test_layout'), + (False, True), + ): + with self.subTest(email_layout_xmlid=email_layout_xmlid, + use_lang=use_lang): + # update test configuration + if use_lang: + langs = ('es_ES', 'en_US') + self.test_partners[0].lang = langs[0] + self.test_partners[1].lang = langs[1] + else: + langs = (False, False) + 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, + } + if email_layout_xmlid: + ctx['default_email_layout_xmlid'] = email_layout_xmlid + + # launch composer in mass mode + composer_form = Form(self.env['mail.compose.message'].with_context(ctx)) + composer = composer_form.save() + + # ensure some parameters used afterwards + 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) + + with self.mock_mail_gateway(mail_unlink_sent=False): + composer._action_send_mail() + + 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) + + # global outgoing: emails sent + 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') + + 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': + 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}' + + # template is sent only to partners (email_to are transformed) + self.assertMailMail(record.customer_id + new_partners + self.partner_admin, + 'sent', + mail_message=message, + author=author, + email_values={ + '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': 'TestReport for %s.html' % record.name, 'type': 'text/plain'}, + ], + 'body_content': exp_body, + '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, + '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}' + )), + 'subject': exp_subject, + }, + ) + + # 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. + sent_mail = self._find_sent_email( + self.partner_employee_2.email_formatted, + [formataddr((record.customer_id.name, email_normalize(record.customer_id.email, strict=False)))] + ) + debug_info = '' + if not sent_mail: + debug_info = '-'.join('From: %s-To: %s' % (mail['email_from'], mail['email_to']) for mail in self._mails) + self.assertTrue( + 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}

' + ) + + @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, + active_ids, ... to ensure fallback behavior are working. """ + # 1: active_domain + 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)]) + )) + composer = composer_form.save() + 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') + + 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 + 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, + default_use_active_domain=False, + default_active_domain=[('id', 'in', -1)]) + )) + composer = composer_form.save() + 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') + + # 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=[]) + )) + composer = composer_form.save() + with self.mock_mail_gateway(mail_unlink_sent=False): + composer._action_send_mail() + + # global outgoing + 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 + composer_form = Form(self.env['mail.compose.message'].with_context( + default_model='mail.test.ticket', + default_template_id=self.template.id + )) + composer = composer_form.save() + self.assertEqual(composer.composition_mode, 'comment') + with self.mock_mail_gateway(mail_unlink_sent=False), self.assertRaises(ValueError): + composer._action_send_mail() + self.assertNotSentEmail() + + composer_form = Form(self.env['mail.compose.message'].with_context( + default_composition_mode='mass_mail', + default_model='mail.test.ticket', + default_template_id=self.template.id + )) + composer = composer_form.save() + self.assertEqual(composer.composition_mode, 'mass_mail') + with self.mock_mail_gateway(mail_unlink_sent=False): + composer._action_send_mail() + self.assertNotSentEmail() + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_composer_wtpl_recipients_email_fields(self): + """ Test various combinations of corner case / not standard filling of + email fields: multi email, formatted emails, ... """ + existing_partners = self.env['res.partner'].search([]) + partner_format_tofind, partner_multi_tofind, partner_at_tofind = self.env['res.partner'].create([ + { + 'email': '"FindMe Format" ', + 'name': 'FindMe Format', + }, { + 'email': 'find.me.multi.1@test.example.com, "FindMe Multi" ', + 'name': 'FindMe Multi', + }, { + 'email': '"Bike@Home" ', + 'name': 'NotBike@Home', + } + ]) + email_ccs = ['"Raoul" ', '"Raoulette" ', 'test.cc.2.2@example.com>', 'invalid', ' '] + email_tos = ['"Micheline, l\'Immense" ', 'test.to.2@example.com', 'wrong', ' '] + + self.template.write({ + 'email_cc': ', '.join(email_ccs), + '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', + }) + self.user_employee.write({'email': 'email.from.1@test.mycompany.com, email.from.2@test.mycompany.com'}) + self.partner_1.write({'email': '"Valid Formatted" '}) + self.partner_2.write({'email': 'valid.other.1@agrolait.com, valid.other.cc@agrolait.com'}) + # ensure values used afterwards for testing + self.assertEqual( + self.partner_employee.email_formatted, + '"Ernest Employee" ', + 'Formatting: wrong formatting due to multi-email') + self.assertEqual( + self.partner_1.email_formatted, + '"Valid Lelitre" ', + 'Formatting: avoid wrong double encapsulation') + self.assertEqual( + self.partner_2.email_formatted, + '"Valid Poilvache" ', + 'Formatting: wrong formatting due to multi-email') + + # instantiate composer, send mailing + 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() + 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() + + # find partners created during sending (as emails are transformed into partners) + # FIXME: currently email finding based on formatted / multi emails does + # not work + new_partners = self.env['res.partner'].search([]).search([('id', 'not in', existing_partners.ids)]) + self.assertEqual(len(new_partners), 9, + 'Mail (FIXME): did not find existing partners for formatted / multi emails: 1 extra partner') + self.assertIn(partner_format_tofind, new_partners) + self.assertIn(partner_multi_tofind, new_partners) + self.assertIn(partner_at_tofind, new_partners) + self.assertEqual(new_partners[0:3].ids, (partner_format_tofind + partner_multi_tofind + partner_at_tofind).ids) + self.assertEqual( + sorted(new_partners.mapped('email')), + sorted(['"Bike@Home" ', + '"FindMe Format" ', + 'find.me.multi.1@test.example.com, "FindMe Multi" ', + 'find.me.multi.2@test.example.com', + 'test.cc.1@example.com', 'test.cc.2@example.com', 'test.cc.2.2@example.com', + 'test.to.1@example.com', 'test.to.2@example.com']), + 'Mail: created partners for valid emails (wrong / invalid not taken into account) + did not find corner cases (FIXME)' + ) + self.assertEqual( + sorted(new_partners.mapped('email_formatted')), + sorted(['"NotBike@Home" ', + '"FindMe Format" ', + '"FindMe Multi" ', + '"find.me.multi.2@test.example.com" ', + '"test.cc.1@example.com" ', + '"test.cc.2@example.com" ', + '"test.cc.2.2@example.com" ', + '"test.to.1@example.com" ', + '"test.to.2@example.com" ']), + ) + self.assertEqual( + sorted(new_partners.mapped('name')), + sorted(['NotBike@Home', + 'FindMe Format', + 'FindMe Multi', + 'find.me.multi.2@test.example.com', + 'test.cc.1@example.com', + 'test.to.1@example.com', + 'test.to.2@example.com', + 'test.cc.2@example.com', + 'test.cc.2.2@example.com']), + 'Mail: when possible, find name in formatted emails, otherwise fallback on email' + ) + + # global outgoing: one mail.mail (all customer recipients), * 2 records + # Note that employee is not mailed here compared to 'comment' mode as he + # is not in the template recipients, only a follower + # FIXME template is sent only to partners (email_to are transformed) -> + # wrong / weird emails (see email_formatted of partners) is kept + # FIXME: more partners created than real emails (see above) -> due to + # transformation from email -> partner in template 'generate_recipients' + # there are more partners than email to notify; + self.assertEqual(len(self._new_mails), 2, 'Should have created 2 mail.mail') + self.assertEqual( + len(self._mails), (len(new_partners) + 2) * 2, + f'Should have sent {(len(new_partners) + 2) * 2} emails, one / recipient ({len(new_partners)} mailed partners + partner_1 + partner_2) * 2 records') + for record in self.test_records: + recipients = self.partner_1 + self.partner_2 + new_partners + self.assertMailMail( + recipients, + 'sent', + author=self.partner_employee, + email_to_recipients=[ + [self.partner_1.email_formatted], + [f'"{self.partner_2.name}" ', f'"{self.partner_2.name}" '], + ] + [[new_partners[0]['email_formatted']], + ['"FindMe Multi" ', '"FindMe Multi" '] + ] + [[email] for email in new_partners[2:].mapped('email_formatted')], + email_values={ + 'body_content': f'TemplateBody {record.name}', + # 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}', + f'{self.alias_catchall}@{self.alias_domain}' + )), + 'subject': f'TemplateSubject {record.name}', + }, + fields_values={ + # 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}', + f'{self.alias_catchall}@{self.alias_domain}' + )), + }, + mail_message=record.message_ids[0], # message copy is kept + ) + + # actual emails sent through smtp + for recipient in recipients: + # multi emails -> send multiple emails (smart) + if recipient == self.partner_2: + smtp_to_list = ['valid.other.1@agrolait.com', 'valid.other.cc@agrolait.com'] + # find.me.format + elif recipient == new_partners[0]: + self.assertEqual(recipient, partner_format_tofind) + smtp_to_list = ['find.me.format@test.example.com'] + # find.me.multi was split into two partners + elif recipient == new_partners[1]: + self.assertEqual(recipient, partner_multi_tofind) + smtp_to_list = ['find.me.multi.1@test.example.com', 'find.me.multi.2@test.example.com'] + elif recipient == new_partners[3]: + smtp_to_list = ['find.me.multi.2@test.example.com'] + # bike@home: name is not recognized as email anymore + elif recipient == new_partners[2]: + self.assertEqual(recipient, partner_at_tofind) + smtp_to_list = ['find.me.at@test.example.com'] + else: + smtp_to_list = [recipient.email_normalized] + self.assert_email_sent_smtp( + smtp_from=f'{self.alias_bounce}@{self.alias_domain}', + smtp_to_list=smtp_to_list, + mail_server=self.mail_server_domain, + # msg_from takes only first found normalized email to make a valid email_from + message_from=formataddr( + (self.user_employee.name, + 'email.from.1@test.mycompany.com', + )), + # similar envelope, assert_email_sent_smtp 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): + # 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_template_id=self.template.id) + )) + composer = composer_form.save() + 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: + # template is sent only to partners (email_to are transformed) + self.assertMailMail(record.customer_id, + 'sent', + mail_message=record.message_ids[0], + author=self.partner_employee, + email_values={ + 'email_from': self.partner_employee_2.email_formatted, + 'reply_to': self.partner_employee_2.email_formatted, + }, + fields_values={ + 'email_from': self.partner_employee_2.email_formatted, + 'reply_to': self.partner_employee_2.email_formatted, + }, + ) + + @users('employee') + @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. """ + # 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 = composer_form.save() + self.assertTrue(composer.reply_to_force_new) + with self.mock_mail_gateway(mail_unlink_sent=False): + composer.action_send_mail() + + for record in self.test_records: + self.assertMailMail(record.customer_id, + 'sent', + mail_message=record.message_ids[0], + author=self.partner_employee, + email_values={ + 'body_content': 'TemplateBody %s' % record.name, + 'email_from': self.partner_employee_2.email_formatted, + 'reply_to': formataddr(( + f'{record.name}', + 'dynamic.reply.to@test.com' + )), + 'subject': 'TemplateSubject %s' % record.name, + }, + fields_values={ + 'email_from': self.partner_employee_2.email_formatted, + 'reply_to': formataddr(( + f'{record.name}', + 'dynamic.reply.to@test.com' + )), + }, + ) + +@tagged('mail_composer', 'mail_blacklist') +class TestComposerResultsMassStatus(TestMailComposer): + """ Test cases involving blacklist, opt-out, state management, ... specific + class to avoid bloating the base mailing-based composer tests. """ + + @classmethod + def setUpClass(cls): + """ Test data: 4 records with a customer set, then some additional + records based on emails, duplicates, ... + + Record0: partner is blacklisted + Record1: Record4 has the same email (but no customer set) + Record5 and Record6 have same email (notlinked to any customer) + """ + 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, + additional_values={'user_id': cls.user_employee_2.id}, + prefix='el_' + ) + # create bl / optout / duplicates, see docstring + cls.env['mail.blacklist']._add( + cls.test_partners[0].email_formatted + ) + cls.test_records += cls.env[cls.test_records._name].create([ + { + 'email_from': cls.test_records[1].email_from, + 'name': 'Email of Record2', + 'user_id': cls.user_employee_2.id, + }, + { + 'email_from': 'test.duplicate@test.example.com', + 'name': 'Dupe email (first)', + 'user_id': cls.user_employee_2.id, + }, + { + 'email_from': 'test.duplicate@test.example.com', + 'name': 'Dupe email (second)', + 'user_id': cls.user_employee_2.id, + }, + ]) + cls.template.write({ + 'model_id': cls.env['ir.model']._get_id(cls.test_records._name), + }) + + def test_assert_initial_data(self): + """ Ensure class initial data to ease understanding """ + self.assertTrue(self.template.auto_delete) + + self.assertEqual(len(self.test_records), 7) + 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[1].email_from, self.test_records[4].email_from) + self.assertEqual(self.test_records[5].email_from, self.test_records[6].email_from) + + self.assertEqual(len(self.test_partners), 4) + self.assertTrue(self.test_partners[0].is_blacklisted) + + @users('employee') + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_mailing_blacklist_mixin(self): + """ Tests a document-based mass mailing with excluded emails. Their emails + are canceled if the model inherits from the blacklist mixin. """ + test_records = self.test_records[:2].with_env(self.env) + 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) + )) + composer = composer_form.save() + 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'], + ['mail_bl', False] + ): + with self.subTest(record=record, expected_state=expected_state, expected_ft=expected_ft): + 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={ + '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), 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) + 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) + )) + 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'] + ): + 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), 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') 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 new file mode 100644 index 0000000..9591646 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_composer_mixin.py @@ -0,0 +1,113 @@ +# -*- 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.tests import tagged +from odoo.tests.common import users + + +@tagged('mail_composer_mixin') +class TestMailComposerMixin(TestMailCommon, 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)], + }) + + cls.mail_template = cls.env['mail.template'].create({ + 'body_html': '

EnglishBody for

', + 'model_id': cls.env['ir.model']._get('mail.test.composer.source').id, + 'name': 'Test Template for mail.test.composer.source', + 'lang': '{{ object.customer_id.lang }}', + 'subject': 'EnglishSubject for {{ object.name }}', + }) + cls.test_record = cls.env['mail.test.composer.source'].create({ + 'name': cls.partner_1.name, + 'customer_id': cls.partner_1.id, + }) + + cls._activate_multi_lang( + layout_arch_db=' English Layout for ', + lang_code='es_ES', + test_record=cls.test_record, + test_template=cls.mail_template, + ) + + @users("employee") + def test_content_sync(self): + """ Test updating template updates the dynamic fields accordingly. """ + source = self.test_record.with_env(self.env) + composer = self.env['mail.test.composer.mixin'].create({ + '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}

') + + @users("employee") + def test_rendering_custom(self): + """ Test rendering with custom strings (not coming from template) """ + source = self.test_record.with_env(self.env) + composer = self.env['mail.test.composer.mixin'].create({ + 'description': '

Description for

', + 'body': '

SpecificBody from

', + 'name': 'Invite', + 'subject': 'SpecificSubject for {{ object.name }}', + }) + self.assertEqual(composer.body, '

SpecificBody from

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

SpecificBody from {self.env.user.name}

') + description = composer._render_field('description', source.ids)[source.id] + self.assertEqual(description, f'

Description for {source.name}

') + + @users("employee") + def test_rendering_lang(self): + """ Test rendering with language involved """ + template = self.mail_template.with_env(self.env) + customer = self.partner_1.with_env(self.env) + customer.lang = 'es_ES' + 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)], + }) + self.assertEqual(composer.body, template.body_html) + self.assertEqual(composer.subject, template.subject) + self.assertEqual(composer.lang, '{{ object.customer_id.lang }}') + + # do not specifically ask for language computation + subject = composer._render_field('subject', source.ids, compute_lang=False)[source.id] + self.assertEqual(subject, f'EnglishSubject for {source.name}') + body = composer._render_field('body', source.ids, compute_lang=False)[source.id] + self.assertEqual(body, f'

EnglishBody for {source.name}

') + description = composer._render_field('description', source.ids)[source.id] + self.assertEqual(description, f'

Description for {source.name}

') + + # 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') + 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' + ) + description = composer._render_field('description', source.ids)[source.id] + self.assertEqual(description, f'

Description for {source.name}

') 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 new file mode 100644 index 0000000..68e9241 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_followers.py @@ -0,0 +1,790 @@ +# -*- 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.exceptions import AccessError +from odoo.tests import tagged, users +from odoo.tools import mute_logger + + +@tagged('mail_followers') +class BaseFollowersTest(TestMailCommon): + + @classmethod + def setUpClass(cls): + super(BaseFollowersTest, cls).setUpClass() + 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}) + cls.mt_al_nodef = Subtype.create({'name': 'mt_al_nodef', 'default': False, 'res_model': False}) + # mail.test.simple + cls.mt_mg_def = Subtype.create({'name': 'mt_mg_def', 'default': True, 'res_model': 'mail.test.simple'}) + cls.mt_mg_nodef = Subtype.create({'name': 'mt_mg_nodef', 'default': False, 'res_model': 'mail.test.simple'}) + cls.mt_mg_def_int = Subtype.create({'name': 'mt_mg_def', 'default': True, 'res_model': 'mail.test.simple', 'internal': True}) + # mail.test.container + cls.mt_cl_def = Subtype.create({'name': 'mt_cl_def', 'default': True, 'res_model': 'mail.test.container'}) + + cls.default_group_subtypes = Subtype.search([('default', '=', True), '|', ('res_model', '=', 'mail.test.simple'), ('res_model', '=', False)]) + cls.default_group_subtypes_portal = Subtype.search([('internal', '=', False), ('default', '=', True), '|', ('res_model', '=', 'mail.test.simple'), ('res_model', '=', False)]) + + def test_field_message_is_follower(self): + test_record = self.test_record.with_user(self.user_employee) + followed_before = test_record.search([('message_is_follower', '=', True)]) + self.assertFalse(test_record.message_is_follower) + test_record.message_subscribe(partner_ids=[self.user_employee.partner_id.id]) + followed_after = test_record.search([('message_is_follower', '=', True)]) + self.assertTrue(test_record.message_is_follower) + self.assertEqual(followed_before | test_record, followed_after) + + def test_field_message_partner_ids(self): + test_record = self.test_record.with_user(self.user_employee) + partner = self.user_employee.partner_id + followed_before = self.env['mail.test.simple'].search([('message_partner_ids', 'in', partner.ids)]) + self.assertFalse(partner in test_record.message_partner_ids) + self.assertNotIn(test_record, followed_before) + test_record.message_subscribe(partner_ids=[partner.id]) + 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) + + def test_field_followers(self): + test_record = self.test_record.with_user(self.user_employee) + test_record.message_subscribe(partner_ids=[self.user_employee.partner_id.id, self.user_admin.partner_id.id]) + followers = self.env['mail.followers'].search([ + ('res_model', '=', 'mail.test.simple'), + ('res_id', '=', test_record.id)]) + self.assertEqual(followers, test_record.message_follower_ids) + self.assertEqual(test_record.message_partner_ids, self.user_employee.partner_id | self.user_admin.partner_id) + + def test_followers_subtypes_default(self): + test_record = self.test_record.with_user(self.user_employee) + test_record.message_subscribe(partner_ids=[self.user_employee.partner_id.id]) + self.assertEqual(test_record.message_partner_ids, self.user_employee.partner_id) + follower = self.env['mail.followers'].search([ + ('res_model', '=', 'mail.test.simple'), + ('res_id', '=', test_record.id), + ('partner_id', '=', self.user_employee.partner_id.id)]) + self.assertEqual(follower, test_record.message_follower_ids) + self.assertEqual(follower.subtype_ids, self.default_group_subtypes) + + def test_followers_subtypes_default_internal(self): + test_record = self.test_record.with_user(self.user_employee) + test_record.message_subscribe(partner_ids=[self.partner_portal.id]) + self.assertEqual(test_record.message_partner_ids, self.partner_portal) + follower = self.env['mail.followers'].search([ + ('res_model', '=', 'mail.test.simple'), + ('res_id', '=', test_record.id), + ('partner_id', '=', self.partner_portal.id)]) + self.assertEqual(follower.subtype_ids, self.default_group_subtypes_portal) + + def test_followers_subtypes_specified(self): + test_record = self.test_record.with_user(self.user_employee) + test_record.message_subscribe(partner_ids=[self.user_employee.partner_id.id], subtype_ids=[self.mt_mg_nodef.id]) + self.assertEqual(test_record.message_partner_ids, self.user_employee.partner_id) + follower = self.env['mail.followers'].search([ + ('res_model', '=', 'mail.test.simple'), + ('res_id', '=', test_record.id), + ('partner_id', '=', self.user_employee.partner_id.id)]) + self.assertEqual(follower, test_record.message_follower_ids) + self.assertEqual(follower.subtype_ids, self.mt_mg_nodef) + + def test_followers_multiple_subscription_force(self): + test_record = self.test_record.with_user(self.user_employee) + + test_record.message_subscribe(partner_ids=[self.user_admin.partner_id.id], subtype_ids=[self.mt_mg_nodef.id]) + self.assertEqual(test_record.message_partner_ids, self.user_admin.partner_id) + self.assertEqual(test_record.message_follower_ids.subtype_ids, self.mt_mg_nodef) + + test_record.message_subscribe(partner_ids=[self.user_admin.partner_id.id], subtype_ids=[self.mt_mg_nodef.id, self.mt_al_nodef.id]) + self.assertEqual(test_record.message_partner_ids, self.user_admin.partner_id) + self.assertEqual(test_record.message_follower_ids.subtype_ids, self.mt_mg_nodef | self.mt_al_nodef) + + def test_followers_multiple_subscription_noforce(self): + """ Calling message_subscribe without subtypes on an existing subscription should not do anything (default < existing) """ + test_record = self.test_record.with_user(self.user_employee) + + test_record.message_subscribe(partner_ids=[self.user_admin.partner_id.id], subtype_ids=[self.mt_mg_nodef.id, self.mt_al_nodef.id]) + self.assertEqual(test_record.message_partner_ids, self.user_admin.partner_id) + self.assertEqual(test_record.message_follower_ids.subtype_ids, self.mt_mg_nodef | self.mt_al_nodef) + + # set new subtypes with force=False, meaning no rewriting of the subscription is done -> result should not change + test_record.message_subscribe(partner_ids=[self.user_admin.partner_id.id]) + self.assertEqual(test_record.message_partner_ids, self.user_admin.partner_id) + self.assertEqual(test_record.message_follower_ids.subtype_ids, self.mt_mg_nodef | self.mt_al_nodef) + + def test_followers_multiple_subscription_update(self): + """ Calling message_subscribe with subtypes on an existing subscription should replace them (new > existing) """ + test_record = self.test_record.with_user(self.user_employee) + test_record.message_subscribe(partner_ids=[self.user_employee.partner_id.id], subtype_ids=[self.mt_mg_def.id, self.mt_cl_def.id]) + self.assertEqual(test_record.message_partner_ids, self.user_employee.partner_id) + follower = self.env['mail.followers'].search([ + ('res_model', '=', 'mail.test.simple'), + ('res_id', '=', test_record.id), + ('partner_id', '=', self.user_employee.partner_id.id)]) + self.assertEqual(follower, test_record.message_follower_ids) + self.assertEqual(follower.subtype_ids, self.mt_mg_def | self.mt_cl_def) + + # remove one subtype `mt_mg_def` and set new subtype `mt_al_def` + test_record.message_subscribe(partner_ids=[self.user_employee.partner_id.id], subtype_ids=[self.mt_cl_def.id, self.mt_al_def.id]) + self.assertEqual(follower.subtype_ids, self.mt_cl_def | self.mt_al_def) + + @users('employee') + def test_followers_inactive(self): + """ Test standard API does not subscribe inactive partners """ + customer = self.env['res.partner'].create({ + 'name': 'Valid Lelitre', + 'email': 'valid.lelitre@agrolait.com', + 'country_id': self.env.ref('base.be').id, + 'mobile': '0456001122', + 'active': False, + }) + document = self.env['mail.test.simple'].browse(self.test_record.id) + self.assertEqual(document.message_partner_ids, self.env['res.partner']) + document.message_subscribe(partner_ids=(self.partner_portal | customer).ids) + self.assertEqual(document.message_partner_ids, self.partner_portal) + self.assertEqual(document.message_follower_ids.partner_id, self.partner_portal) + + # works through low-level API + document._message_subscribe(partner_ids=(self.partner_portal | customer).ids) + self.assertEqual(document.message_partner_ids, self.partner_portal, 'No active test: customer not visible') + self.assertEqual(document.message_follower_ids.partner_id, self.partner_portal | customer) + + @users('employee') + @mute_logger('odoo.models.unlink') + def test_followers_inverse_message_partner(self): + test_record = self.test_record.with_env(self.env) + partner0, partner1, partner2, partner3 = self.env['res.partner'].create( + [{'email': f'partner.{n}@test.lan', 'name': f'partner{n}'} for n in range(4)] + ) + self.assertFalse(test_record.message_follower_ids) + self.assertFalse(test_record.message_partner_ids) + + # fillup with API + test_record.message_subscribe(partner_ids=partner3.ids) + self.assertEqual(test_record.message_follower_ids.partner_id, partner3) + # set empty + test_record.message_partner_ids = None + self.assertFalse(test_record.message_follower_ids.partner_id) + # set 1 + test_record.message_partner_ids = partner0 + self.assertEqual(test_record.message_follower_ids.partner_id, partner0) + # set multiple when non-empty + test_record.message_partner_ids = partner1 + partner2 + self.assertEqual(test_record.message_follower_ids.partner_id, partner1 + partner2) + # remove 1 + test_record.message_partner_ids -= partner1 + self.assertEqual(test_record.message_follower_ids.partner_id, partner2) + # add multiple with one already set + test_record.message_partner_ids += partner1 + partner2 + self.assertEqual(test_record.message_follower_ids.partner_id, partner1 + partner2) + # remove outside of existing + test_record.message_partner_ids -= partner3 + self.assertEqual(test_record.message_follower_ids.partner_id, partner1 + partner2) + # reset + test_record.message_partner_ids = False + self.assertFalse(test_record.message_follower_ids.partner_id) + + # test with inactive and commands + partner0.write({'active': False}) + test_record.write({'message_partner_ids': [(4, partner0.id), (4, partner1.id)]}) + self.assertEqual(test_record.message_follower_ids.partner_id, partner1) + + @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 + instead of a follower """ + test_record = self.test_record.with_user(self.user_portal) + partner0 = self.env['res.partner'].create({ + 'email': 'partner1@test.lan', + 'name': 'partner1', + }) + _name = test_record.name # check portal user can read + + # set empty + with self.assertRaises(AccessError): + test_record.message_partner_ids = None + # set 1 + with self.assertRaises(AccessError): + test_record.message_partner_ids = partner0 + # remove 1 + with self.assertRaises(AccessError): + test_record.message_partner_ids -= partner0 + + @users('employee') + def test_followers_private_address(self): + """ Test standard API does not subscribe private addresses """ + private_address = self.env['res.partner'].sudo().create({ + 'name': 'Private Address', + 'type': 'private', + }) + 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) + + # works through low-level API + document._message_subscribe(partner_ids=(self.partner_portal | private_address).ids) + self.assertEqual(document.message_follower_ids.partner_id, self.partner_portal | private_address) + + @users('employee') + def test_create_multi_followers(self): + documents = self.env['mail.test.simple'].create([{'name': 'ninja'}] * 5) + for document in documents: + self.assertEqual(document.message_follower_ids.partner_id, self.env.user.partner_id) + self.assertEqual(document.message_follower_ids.subtype_ids, self.default_group_subtypes) + + @users('employee') + def test_subscriptions_data_fetch(self): + """ Test that _get_subscription_data gives correct values when modifying followers manually.""" + test_record = self.test_record + test_record_copy = self.test_record.copy() + test_records = test_record + test_record_copy + test_record.message_subscribe([self.user_employee.partner_id.id]) + subscription_data = self.env['mail.followers']._get_subscription_data([(test_records._name, test_records.ids)], None) + self.assertEqual(len(subscription_data), 1) + self.assertEqual(subscription_data[0][1], test_record.id) + self.env['mail.followers'].browse(subscription_data[0][0]).sudo().res_id = test_record_copy + subscription_data = self.env['mail.followers']._get_subscription_data([(test_records._name, test_records.ids)], None) + self.assertEqual(len(subscription_data), 1) + self.assertEqual(subscription_data[0][1], test_record_copy.id) + + +@tagged('mail_followers') +class AdvancedFollowersTest(TestMailCommon): + + @classmethod + def setUpClass(cls): + super(AdvancedFollowersTest, cls).setUpClass() + cls._create_portal_user() + + cls.test_track = cls.env['mail.test.track'].with_user(cls.user_employee).create({ + 'name': 'Test', + }) + + Subtype = cls.env['mail.message.subtype'] + + # clean demo data to avoid interferences + Subtype.search([('res_model', 'in', ['mail.test.container', 'mail.test.track'])]).unlink() + + # mail.test.track subtypes (aka: task records) + cls.sub_track_1 = Subtype.create({ + 'name': 'Track (with child relation) 1', 'default': False, + 'res_model': 'mail.test.track' + }) + cls.sub_track_2 = Subtype.create({ + 'name': 'Track (with child relation) 2', 'default': False, + 'res_model': 'mail.test.track' + }) + cls.sub_track_nodef = Subtype.create({ + 'name': 'Generic Track subtype', 'default': False, 'internal': False, + 'res_model': 'mail.test.track' + }) + cls.sub_track_def = Subtype.create({ + 'name': 'Default track subtype', 'default': True, 'internal': False, + 'res_model': 'mail.test.track' + }) + + # mail.test.container subtypes (aka: project records) + cls.umb_nodef = Subtype.create({ + 'name': 'Container NoDefault', 'default': False, + 'res_model': 'mail.test.container' + }) + cls.umb_def = Subtype.create({ + 'name': 'Container Default', 'default': True, + 'res_model': 'mail.test.container' + }) + cls.umb_def_int = Subtype.create({ + 'name': 'Container Default', 'default': True, 'internal': True, + 'res_model': 'mail.test.container' + }) + # -> subtypes for auto subscription from container to sub records + cls.umb_autosub_def = Subtype.create({ + 'name': 'Container AutoSub (default)', 'default': True, 'res_model': 'mail.test.container', + 'parent_id': cls.sub_track_1.id, 'relation_field': 'container_id' + }) + cls.umb_autosub_nodef = Subtype.create({ + 'name': 'Container AutoSub 2', 'default': False, 'res_model': 'mail.test.container', + 'parent_id': cls.sub_track_2.id, 'relation_field': 'container_id' + }) + + # generic subtypes + cls.sub_comment = cls.env.ref('mail.mt_comment') + cls.sub_generic_int_nodef = Subtype.create({ + 'name': 'Generic internal subtype', + 'default': False, + 'internal': True, + }) + cls.sub_generic_int_def = Subtype.create({ + 'name': 'Generic internal subtype (default)', + 'default': True, + 'internal': True, + }) + + 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) + + @mute_logger('odoo.models.unlink') + def test_auto_subscribe_inactive(self): + """ Test inactive are not added as followers in automated subscription """ + self.test_track.user_id = False + self.user_admin.active = False + self.user_admin.flush_recordset() + self.partner_admin.active = False + self.partner_admin.flush_recordset() + + 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.assertEqual(self.test_track.message_follower_ids.partner_id, self.user_employee.partner_id) + + self.test_track.write({'user_id': self.user_admin.id}) + self.assertEqual(self.test_track.message_partner_ids, self.user_employee.partner_id) + self.assertEqual(self.test_track.message_follower_ids.partner_id, self.user_employee.partner_id) + + new_record = self.env['mail.test.track'].with_user(self.user_admin).create({ + 'name': 'Test', + }) + self.assertFalse(new_record.message_partner_ids, + 'Filters out inactive partners') + self.assertFalse(new_record.message_follower_ids.partner_id, + '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) + + def test_auto_subscribe_responsible(self): + """ Responsibles are tracked and added as followers """ + sub = self.env['mail.test.track'].with_user(self.user_employee).create({ + 'name': 'Test', + 'user_id': self.user_admin.id, + }) + self.assertEqual(sub.message_partner_ids, (self.user_employee.partner_id | self.user_admin.partner_id)) + + @mute_logger('odoo.models.unlink') + def test_auto_subscribe_defaults(self): + """ Test auto subscription based on an container record. This mimics + the behavior of addons like project and task where subscribing to + some project's subtypes automatically subscribe the follower to its tasks. + + Functional rules applied here + + * subscribing to an container subtype with parent_id / relation_field set + automatically create subscription with matching subtypes + * subscribing to a sub-record as creator applies default subtype values + * portal user should not have access to internal subtypes + + Inactive partners should not be auto subscribed. + """ + container = self.env['mail.test.container'].with_context(self._test_context).create({ + 'name': 'Project-Like', + }) + + # have an inactive partner to check auto subscribe does not subscribe it + user_root = self.env.ref('base.user_root') + self.assertFalse(user_root.active) + self.assertFalse(user_root.partner_id.active) + + container.message_subscribe(partner_ids=(self.partner_portal | user_root.partner_id).ids) + container.message_subscribe(partner_ids=self.partner_admin.ids, subtype_ids=(self.sub_comment | self.umb_autosub_nodef | self.sub_generic_int_nodef).ids) + self.assertEqual(container.message_partner_ids, self.partner_portal | self.partner_admin) + follower_por = container.message_follower_ids.filtered(lambda f: f.partner_id == self.partner_portal) + follower_adm = container.message_follower_ids.filtered(lambda f: f.partner_id == self.partner_admin) + self.assertEqual( + follower_por.subtype_ids, + self.sub_comment | self.umb_def | self.umb_autosub_def, + 'Subscribe: Default subtypes: comment (default generic) and two model-related defaults') + self.assertEqual( + follower_adm.subtype_ids, + self.sub_comment | self.umb_autosub_nodef | self.sub_generic_int_nodef, + 'Subscribe: Asked subtypes when subscribing') + + sub1 = self.env['mail.test.track'].with_user(self.user_employee).create({ + 'name': 'Task-Like Test', + 'container_id': container.id, + }) + + self.assertEqual( + sub1.message_partner_ids, self.partner_portal | self.partner_admin | self.user_employee.partner_id, + 'Followers: creator (employee) + auto subscribe from parent (portal)') + follower_por = sub1.message_follower_ids.filtered(lambda fol: fol.partner_id == self.partner_portal) + follower_adm = sub1.message_follower_ids.filtered(lambda fol: fol.partner_id == self.partner_admin) + follower_emp = sub1.message_follower_ids.filtered(lambda fol: fol.partner_id == self.user_employee.partner_id) + self.assertEqual( + follower_por.subtype_ids, self.sub_comment | self.sub_track_1, + 'AutoSubscribe: comment (generic checked), Track (with child relation) 1 as Umbrella AutoSub (default) was checked' + ) + self.assertEqual( + follower_adm.subtype_ids, self.sub_comment | self.sub_track_2 | self.sub_generic_int_nodef, + 'AutoSubscribe: comment (generic checked), Track (with child relation) 2) as Umbrella AutoSub 2 was checked, Generic internal subtype (generic checked)' + ) + self.assertEqual( + follower_emp.subtype_ids, self.sub_comment | self.sub_track_def | self.sub_generic_int_def, + 'AutoSubscribe: only default one as no subscription on parent' + ) + + # check portal generic subscribe + sub1.message_unsubscribe(partner_ids=self.partner_portal.ids) + sub1.message_subscribe(partner_ids=self.partner_portal.ids) + follower_por = sub1.message_follower_ids.filtered(lambda fol: fol.partner_id == self.partner_portal) + + self.assertEqual( + follower_por.subtype_ids, self.sub_comment | self.sub_track_def, + 'AutoSubscribe: only default one as no subscription on parent (no internal as portal)' + ) + + # check auto subscribe as creator + auto subscribe as parent follower takes both subtypes + container.message_subscribe( + partner_ids=self.user_employee.partner_id.ids, + subtype_ids=(self.sub_comment | self.sub_generic_int_nodef | self.umb_autosub_nodef).ids) + sub2 = self.env['mail.test.track'].with_user(self.user_employee).create({ + 'name': 'Task-Like Test', + 'container_id': container.id, + }) + follower_emp = sub2.message_follower_ids.filtered(lambda fol: fol.partner_id == self.user_employee.partner_id) + defaults = self.sub_comment | self.sub_track_def | self.sub_generic_int_def + parents = self.sub_generic_int_nodef | self.sub_track_2 + self.assertEqual( + follower_emp.subtype_ids, defaults + parents, + 'AutoSubscribe: at create auto subscribe as creator + from parent take both subtypes' + ) + + +class AdvancedResponsibleNotifiedTest(TestMailCommon): + def setUp(self): + super(AdvancedResponsibleNotifiedTest, self).setUp() + + # patch registry to simulate a ready environment so that _message_auto_subscribe_notify + # will be executed with the associated notification + old = self.env.registry.ready + self.env.registry.ready = True + self.addCleanup(setattr, self.env.registry, 'ready', old) + + def test_auto_subscribe_notify_email(self): + """ Responsible is notified when assigned """ + partner = self.env['res.partner'].create({"name": "demo1", "email": "demo1@test.com"}) + notified_user = self.env['res.users'].create({ + 'login': 'demo1', + 'partner_id': partner.id, + 'notification_type': 'email', + }) + + # TODO master: add a 'state' selection field on 'mail.test.track' with a 'done' value to have a complete test + # check that 'default_state' context does not collide with mail.mail default values + sub = self.env['mail.test.track'].with_user(self.user_employee).with_context({ + 'default_state': 'done', + 'mail_notify_force_send': False + }).create({ + 'name': 'Test', + 'user_id': notified_user.id, + }) + + self.assertEqual(sub.message_partner_ids, (self.user_employee.partner_id | notified_user.partner_id)) + # fetch created "You have been assigned to 'Test'" mail.message + mail_message = self.env['mail.message'].search([ + ('model', '=', 'mail.test.track'), + ('res_id', '=', sub.id), + ('partner_ids', 'in', partner.id), + ]) + self.assertEqual(1, len(mail_message)) + + # verify that a mail.mail is attached to it with the correct state ('outgoing') + mail_notification = mail_message.notification_ids + self.assertEqual(1, len(mail_notification)) + self.assertTrue(bool(mail_notification.mail_mail_id)) + self.assertEqual(mail_notification.mail_mail_id.state, 'outgoing') + + +@tagged('mail_followers', 'post_install', '-at_install') +class RecipientsNotificationTest(TestMailCommon): + """ 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.""" + + @classmethod + def setUpClass(cls): + super(RecipientsNotificationTest, cls).setUpClass() + + # portal user for testing share status / internal subtypes + cls.user_portal = cls._create_portal_user() + cls.partner_portal = cls.user_portal.partner_id + + # simple customer + cls.customer = cls.env['res.partner'].create({ + 'email': 'customer@test.customer.com', + 'name': 'Customer', + 'phone': '+32455778899', + }) + + # Simulate case of 2 users that got their partner merged + cls.common_partner = cls.env['res.partner'].create({ + 'email': 'common.partner@test.customer.com', + 'name': 'Common Partner', + '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)], + 'login': '_login_portal', + 'notification_type': 'email', + 'partner_id': cls.common_partner.id, + }, + {'groups_id': [(4, cls.env.ref('base.group_user').id)], + 'login': '_login_internal', + 'notification_type': 'inbox', + 'partner_id': cls.common_partner.id, + } + ]) + cls.env.flush_all() + + def assertRecipientsData(self, recipients_data, records, partners, partner_to_users=None): + """ Custom assert as recipients structure is custom and may change due + to some implementation choice. """ + if records: + self.assertEqual(set(recipients_data.keys()), set(records.ids)) + record_ids = records.ids + else: + records, record_ids = [False], [0] + for record, record_id in zip(records, record_ids): + record_data = recipients_data[record_id] + self.assertEqual(set(record_data.keys()), set(partners.ids)) + for partner in partners: + partner_data = record_data[partner.id] + if partner_to_users and partner_to_users.get(partner.id): #helps making test explicit + user = partner_to_users[partner.id] + else: + user = next((user for user in partner.user_ids if not user.share), self.env['res.users']) + if not user: + user = next((user for user in partner.user_ids), self.env['res.users']) + self.assertEqual(partner_data['active'], partner.active) + if user: + self.assertEqual(partner_data['groups'], set(user.groups_id.ids)) + self.assertEqual(partner_data['notif'], user.notification_type) + self.assertEqual(partner_data['uid'], user.id) + else: + self.assertEqual(partner_data['groups'], set()) + self.assertEqual(partner_data['notif'], 'email') + self.assertFalse(partner_data['uid']) + if record: + self.assertEqual(partner_data['is_follower'], partner in record.message_partner_ids) + else: + self.assertFalse(partner_data['is_follower']) + self.assertEqual(partner_data['share'], partner.partner_share) + self.assertEqual(partner_data['ushare'], user.share) + + @users('employee') + def test_notification_nodupe(self): + """ Check that we only create one mail.notification per partner. """ + # Trigger auto subscribe notification + test = self.env['mail.test.track'].create({"name": "Test Track", "user_id": self.user_2.id}) + mail_message = self.env['mail.message'].search([ + ('res_id', '=', test.id), + ('model', '=', 'mail.test.track'), + ('message_type', '=', 'user_notification') + ]) + notif = self.env['mail.notification'].search([ + ('mail_message_id', '=', mail_message.id), + ('res_partner_id', '=', self.common_partner.id) + ]) + self.assertEqual(len(notif), 1) + self.assertEqual(notif.notification_type, 'inbox', 'Multi users should take internal users if possible') + + recipients_data = self.env['mail.followers']._get_recipient_data( + test, 'comment', self.env.ref('mail.mt_comment').id, + pids=self.common_partner.ids) + self.assertRecipientsData(recipients_data, test, self.common_partner + self.partner_employee, + partner_to_users={self.common_partner.id: self.user_2}) + + @users('employee') + @mute_logger('odoo.models.unlink') + def test_notification_unlink(self): + """ Check that we unlink the created user_notification after unlinked the + related document. """ + test = self.env['mail.test.track'].create({"name": "Test Track", "user_id": self.user_1.id}) + mail_message = self.env['mail.message'].search([ + ('res_id', '=', test.id), + ('model', '=', 'mail.test.track'), + ('message_type', '=', 'user_notification') + ]) + self.assertEqual(len(mail_message), 1) + test.unlink() + self.assertEqual( + self.env['mail.message'].search_count([ + ('res_id', '=', test.id), + ('model', '=', 'mail.test.track'), + ('message_type', '=', 'user_notification') + ]), 0 + ) + + @users('employee') + def test_notification_user_choice(self): + """ Check fetching user information when notifying someone with multiple + users (more complex use case). """ + company_other = self.env['res.company'].sudo().create({ + 'currency_id': self.env.ref('base.CAD').id, + 'email': 'company_other@test.example.com', + 'name': 'Company Other', + }) + shared_partner = self.env['res.partner'].sudo().create({ + 'email': 'common.partner@test.customer.com', + 'name': 'Common Partner', + 'phone': '+32455998877', + }) + cids = (company_other + self.company_admin).ids + 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)], + '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)], + '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)], + 'login': '_login2_manager', + 'notification_type': 'inbox', + 'partner_id': shared_partner.id, + } + ]) + (user_2_1 + user_2_2 + user_2_3).flush_recordset() + + # just ensure current share status + self.assertFalse(shared_partner.partner_share) + self.assertTrue(user_2_1.share) + self.assertFalse(user_2_2.share or user_2_3.share) + + test = self.env['mail.test.track'].create({"name": "Test Track", "user_id": False}) + self.assertEqual(test.message_partner_ids, self.partner_employee) + + with self.assertSinglePostNotifications( + [{'group': 'customer', 'partner': shared_partner, + 'status': 'sent', 'type': 'inbox'}], + message_info={'content': 'User Choice Notification'}): + test.message_post( + body='

User Choice Notification

', + message_type='comment', + partner_ids=shared_partner.ids, + subtype_xmlid='mail.mt_comment', + ) + + recipients_data = self.env['mail.followers']._get_recipient_data( + test, 'comment', self.env.ref('mail.mt_comment').id, + pids=shared_partner.ids) + self.assertRecipientsData(recipients_data, test, self.partner_employee + shared_partner, + partner_to_users={shared_partner.id: user_2_2}) + + @users('employee') + def test_recipients_fetch(self): + test_records = self.env['mail.test.simple'].create([ + {'email_from': 'ignasse@example.com', + 'name': 'Test %s' % idx, + } for idx in range(5) + ]) + # make followers listen to notes to use it and check portal will never be notified of it (internal) + test_records.message_follower_ids.sudo().write({'subtype_ids': [(4, self.env.ref('mail.mt_note').id)]}) + for test_record in test_records: + self.assertEqual(test_record.message_partner_ids, self.env.user.partner_id) + + test_records[0].message_subscribe(self.partner_portal.ids) + self.assertNotIn( + self.env.ref('mail.mt_note'), + test_records[0].message_follower_ids.filtered(lambda fol: fol.partner_id == self.partner_portal).subtype_ids, + 'Portal user should not follow notes by default') + + # just fetch followers + recipients_data = self.env['mail.followers']._get_recipient_data( + test_records[0], 'comment', self.env.ref('mail.mt_comment').id, + pids=None + ) + self.assertRecipientsData(recipients_data, test_records[0], self.env.user.partner_id + self.partner_portal) + + # followers + additional recipients + recipients_data = self.env['mail.followers']._get_recipient_data( + test_records[0], 'comment', self.env.ref('mail.mt_comment').id, + pids=(self.customer + self.common_partner + self.partner_admin).ids + ) + self.assertRecipientsData(recipients_data, test_records[0], + self.env.user.partner_id + self.partner_portal + self.customer + self.common_partner + self.partner_admin) + + # ensure filtering on internal: should exclude Portal even if misconfiguration + follower_portal = test_records[0].message_follower_ids.filtered(lambda fol: fol.partner_id == self.partner_portal).sudo() + follower_portal.write({'subtype_ids': [(4, self.env.ref('mail.mt_note').id)]}) + follower_portal.flush_recordset() + recipients_data = self.env['mail.followers']._get_recipient_data( + test_records[0], 'comment', self.env.ref('mail.mt_note').id, + pids=(self.common_partner + self.partner_admin).ids + ) + self.assertRecipientsData(recipients_data, test_records[0], self.env.user.partner_id + self.common_partner + self.partner_admin) + + # ensure filtering on subtype: should exclude Portal as it does not follow comment anymore + follower_portal.write({'subtype_ids': [(3, self.env.ref('mail.mt_comment').id)]}) + recipients_data = self.env['mail.followers']._get_recipient_data( + test_records[0], 'comment', self.env.ref('mail.mt_comment').id, + pids=(self.common_partner + self.partner_admin).ids + ) + self.assertRecipientsData(recipients_data, test_records[0], self.env.user.partner_id + self.common_partner + self.partner_admin) + + # check without subtype + recipients_data = self.env['mail.followers']._get_recipient_data( + test_records[0], 'comment', False, + pids=(self.common_partner + self.partner_admin).ids + ) + self.assertRecipientsData(recipients_data, test_records[0], self.common_partner + self.partner_admin) + + # multi mode + test_records[1].message_subscribe(self.partner_portal.ids) + test_records[0:4].message_subscribe(self.common_partner.ids) + recipients_data = self.env['mail.followers']._get_recipient_data( + test_records, 'comment', self.env.ref('mail.mt_comment').id, + pids=self.partner_admin.ids + ) + # 0: portal is follower but does not follow comment + common partner (+ admin as pid) + recipients_data_1 = dict((r, recipients_data[r]) for r in recipients_data if r in test_records[0:1].ids) + self.assertRecipientsData(recipients_data_1, test_records[0:1], self.env.user.partner_id + self.common_partner + self.partner_admin) + # 1: portal is follower with comment + common partner (+ admin as pid) + recipients_data_1 = dict((r, recipients_data[r]) for r in recipients_data if r in test_records[1:2].ids) + self.assertRecipientsData(recipients_data_1, test_records[1:2], self.env.user.partner_id + self.common_partner + self.partner_portal + self.partner_admin) + # 2-3: common partner (+ admin as pid) + recipients_data_2 = dict((r, recipients_data[r]) for r in recipients_data if r in test_records[2:4].ids) + self.assertRecipientsData(recipients_data_2, test_records[2:4], self.env.user.partner_id + self.common_partner + self.partner_admin) + # 4+: env user partner (+ admin as pid) + recipients_data_3 = dict((r, recipients_data[r]) for r in recipients_data if r in test_records[4:].ids) + self.assertRecipientsData(recipients_data_3, test_records[4:], self.env.user.partner_id + self.partner_admin) + + # multi mode, pids only + recipients_data = self.env['mail.followers']._get_recipient_data( + test_records, 'comment', False, + pids=(self.env.user.partner_id + self.partner_admin).ids + ) + self.assertRecipientsData(recipients_data, test_records, self.env.user.partner_id + self.partner_admin) + + # on mail.thread, False everywhere: pathologic case + test_partners = self.partner_admin + self.partner_employee + self.common_partner + recipients_data = self.env['mail.followers']._get_recipient_data( + self.env['mail.thread'], False, False, + pids=test_partners.ids + ) + self.assertRecipientsData(recipients_data, False, test_partners) 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 new file mode 100644 index 0000000..3a2261a --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_gateway.py @@ -0,0 +1,2473 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 +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_thread import MailThread +from odoo.addons.mail.tests.common import mail_new_test_user +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.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 + + +@tagged('mail_gateway') +class TestEmailParsing(TestMailCommon): + + 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) + + self.assertEqual(len(extracted_mail['attachments']), 1) + attachment = extracted_mail['attachments'][0] + self.assertEqual(attachment.fname, 'hello_world.dat') + self.assertEqual(attachment.content, b'Hello world\n') + self.assertEqual(capture.output, [ + ("WARNING:odoo.addons.mail.models.mail_thread:Message containing an unexpected " + "Content-Type 'binary/octet-stream', 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" ') + res = self.env['mail.thread'].message_parse(self.from_string(plaintext)) + self.assertIn('Please call me as soon as possible this afternoon!', res['body']) + + # test pure html + html = self.format(test_mail_data.MAIL_TEMPLATE_HTML, email_from='"Sylvie Lelitre" ') + res = self.env['mail.thread'].message_parse(self.from_string(html)) + self.assertIn('

Please call me as soon as possible this afternoon!

', res['body']) + self.assertNotIn(' html has priority + multipart = self.format(MAIL_TEMPLATE, email_from='"Sylvie Lelitre" ') + res = self.env['mail.thread'].message_parse(self.from_string(multipart)) + self.assertIn('

Please call me as soon as possible this afternoon!

', res['body']) + + # test multipart / mixed + res = self.env['mail.thread'].message_parse(self.from_string(test_mail_data.MAIL_MULTIPART_MIXED)) + self.assertNotIn( + 'Should create a multipart/mixed: from gmail, *bold*, with attachment', res['body'], + 'message_parse: text version should not be in body after parsing multipart/mixed') + self.assertIn( + '
Should create a multipart/mixed: from gmail, bold, with attachment.

', res['body'], + 'message_parse: html version should be in body after parsing multipart/mixed') + + res = self.env['mail.thread'].message_parse(self.from_string(test_mail_data.MAIL_MULTIPART_MIXED_TWO)) + self.assertNotIn('First and second part', res['body'], + 'message_parse: text version should not be in body after parsing multipart/mixed') + self.assertIn('First part', res['body'], + 'message_parse: first part of the html version should be in body after parsing multipart/mixed') + self.assertIn('Second part', res['body'], + 'message_parse: second part of the html version should be in body after parsing multipart/mixed') + + res = self.env['mail.thread'].message_parse(self.from_string(test_mail_data.MAIL_SINGLE_BINARY)) + self.assertEqual(res['body'], '') + self.assertEqual(res['attachments'][0][0], 'thetruth.pdf') + + res = self.env['mail.thread'].message_parse(self.from_string(test_mail_data.MAIL_FORWARDED)) + self.assertIn(res['recipients'], ['lucie@petitebedaine.fr,raoul@grosbedon.fr', 'raoul@grosbedon.fr,lucie@petitebedaine.fr']) + + res = self.env['mail.thread'].message_parse(self.from_string(test_mail_data.MAIL_MULTIPART_WEIRD_FILENAME)) + self.assertEqual(res['attachments'][0][0], '62_@;,][)=.(ÇÀÉ.txt') + + def test_message_parse_attachment_pdf_nonstandard_mime(self): + # This test checks if aliasing content-type (mime type) of "pdf" with "application/pdf" works correctly. (i.e. Treat "pdf" as "application/pdf") + + # Baseline check. Parsing mail with "application/pdf" + mail_with_standard_mime = self.format(test_mail_data.MAIL_PDF_MIME_TEMPLATE, pdf_mime="application/pdf") + res_std = self.env['mail.thread'].message_parse(self.from_string(mail_with_standard_mime)) + self.assertEqual(res_std['attachments'][0].content, test_mail_data.PDF_PARSED, "Attachment with Content-Type: application/pdf must parse without error") + + # Parsing the same email, but with content-type set to "pdf" + mail_with_aliased_mime = self.format(test_mail_data.MAIL_PDF_MIME_TEMPLATE, pdf_mime="pdf") + res_alias = self.env['mail.thread'].message_parse(self.from_string(mail_with_aliased_mime)) + self.assertEqual(res_alias['attachments'][0].content, test_mail_data.PDF_PARSED, "Attachment with aliased Content-Type: pdf must parse without error") + + def test_message_parse_bugs(self): + """ Various corner cases or message parsing """ + # message without Final-Recipient + self.env['mail.thread'].message_parse(self.from_string(test_mail_data.MAIL_NO_FINAL_RECIPIENT)) + + # message with empty body (including only void characters) + res = self.env['mail.thread'].message_parse(self.from_string(test_mail_data.MAIL_NO_BODY)) + self.assertEqual(res['body'], '\n \n', 'Gateway should not crash with void content') + + 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') + self.env['mail.thread'].message_parse(self.from_string(mail)) + + def test_message_parse_eml_bounce_headers(self): + # Test Text/RFC822-Headers MIME content-type + msg_id = '<861878175823148.1577183525.736005783081055-openerp-19177-account.invoice@mycompany.example.com>' + mail = self.format( + test_mail_data.MAIL_EML_ATTACHMENT_BOUNCE_HEADERS, + email_from='MAILER-DAEMON@example.com (Mail Delivery System)', + to='test_bounce+82240-account.invoice-19177@mycompany.example.com', + # msg_id goes to the attachment's Message-Id header + msg_id=msg_id, + ) + 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") + + def test_message_parse_extract_bounce_rfc822_headers_qp(self): + # Incoming bounce for unexisting Outlook address + # bounce back sometimes with a Content-Type `text/rfc822-headers` + # and Content-Type-Encoding `quoted-printable` + partner = self.env['res.partner'].create({ + 'name':'Mitchelle Admine', + 'email':'rdesfrdgtfdrfesd@outlook.com' + }) + message = self.env['mail.message'].create({ + 'message_id' : '<368396033905967.1673346177.695352554321289-openerp-11-sale.order@eupp00>' + }) + incoming_bounce = self.format( + test_mail_data.MAIL_BOUNCE_QP_RFC822_HEADERS, + email_from='MAILER-DAEMON@mailserver.odoo.com (Mail Delivery System)', + 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) + 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_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') + 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']) + + def test_message_parse_xhtml(self): + # 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): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.test_model = cls.env['ir.model']._get('mail.test.gateway') + cls.email_from = '"Sylvie Lelitre" ' + + cls.test_record = cls.env['mail.test.gateway'].with_context(mail_create_nolog=True).create({ + 'name': 'Test', + 'email_from': 'ignasse@example.com', + }) + + cls.partner_1 = cls.env['res.partner'].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, + 'alias_model_id': cls.env['ir.model']._get_id('mail.test.gateway'), + 'alias_contact': 'everyone'}) + + # Set a first message on public group to test update and hierarchy + cls.fake_email = cls._create_gateway_message(cls.test_record, '123456') + + def _reinject(self, force_msg_id=False, debug_log=False): + """ Tool to automatically 'inject' an outgoing mail into the gateway. + Content changes. + + :param str force_msg_id: allow to change the msg_id to simulate stupid + email providers that change message IDs; + """ + self.assertEqual(len(self._mails), 1) + mail = self._mails[0] + extra = f'References: {mail["references"]}' + with self.mock_mail_gateway(), self.mock_mail_app(): + self.format_and_process( + MAIL_TEMPLATE, mail['email_from'], ','.join(mail['email_to']), + msg_id=force_msg_id or mail['message_id'], extra=extra, + debug_log=debug_log, + ) + + @classmethod + def _create_gateway_message(cls, record, msg_id_prefix, **values): + msg_values = { + 'author_id': cls.partner_1.id, + 'email_from': cls.partner_1.email_formatted, + 'body': '

Generic body

', + 'message_id': f'<{msg_id_prefix}-openerp-{record.id}-{record._name}@{socket.gethostname()}>', + 'message_type': 'email', + 'model': record._name, + 'res_id': record.id, + 'subject': 'Generic Message', + 'subtype_id': cls.env.ref('mail.mt_comment').id, + } + msg_values.update(**values) + return cls.env['mail.message'].create(msg_values) + + +@tagged('mail_gateway') +class TestMailgateway(MailGatewayCommon): + + def test_assert_initial_values(self): + """ Just some basics checks to ensure tests coherency """ + self.assertEqual(len(self.test_record.message_ids), 1) + + # -------------------------------------------------- + # Base low-level tests + # -------------------------------------------------- + + @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') + + # 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') + res = record.get_metadata()[0].get('create_uid') or [None] + self.assertEqual(res[0], self.env.uid) + + # Test: one message that is the incoming email + self.assertEqual(len(record.message_ids), 1) + msg = record.message_ids[0] + self.assertEqual(msg.subject, 'Specific') + self.assertIn('Please call me as soon as possible this afternoon!', msg.body) + self.assertEqual(msg.message_type, 'email') + self.assertEqual(msg.subtype_id, self.env.ref('mail.mt_comment')) + + @mute_logger('odoo.addons.mail.models.mail_thread') + def test_message_process_cid(self): + origin_message_parse_extract_payload = MailThread._message_parse_extract_payload + + def _message_parse_extract_payload(this, *args, **kwargs): + res = origin_message_parse_extract_payload(this, *args, **kwargs) + self.assertTrue(isinstance(res['body'], str), 'Body from extracted payload should still be a string.') + 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') + + message = record.message_ids[0] + for attachment in message.attachment_ids: + self.assertIn(f'/web/image/{attachment.id}', message.body) + self.assertEqual( + set(message.attachment_ids.mapped('name')), + set(['rosaçée.gif', 'verte!µ.gif', 'orangée.gif'])) + + @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', + }, + ]) + + with self.mock_mail_gateway(): + record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email_formatted, 'groups@test.com') + + 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') + + # just an email -> no follower + with self.mock_mail_gateway(): + record2 = self.format_and_process( + MAIL_TEMPLATE, self.email_from, 'groups@test.com', + subject='Another Email') + + self.assertEqual(record2.message_ids[0].author_id, self.env['res.partner']) + self.assertEqual(record2.message_ids[0].email_from, self.email_from) + self.assertEqual(record2.message_follower_ids.partner_id, self.env['res.partner'], + 'message_process: unrecognized email -> no follower') + self.assertEqual(record2.message_partner_ids, self.env['res.partner'], + 'message_process: unrecognized email -> no follower') + + # archived partner -> no follower + with self.mock_mail_gateway(): + record3 = self.format_and_process( + MAIL_TEMPLATE, partner_archived.email_formatted, 'groups@test.com', + subject='Archived Partner') + + self.assertEqual(record3.message_ids[0].author_id, self.env['res.partner']) + self.assertEqual(record3.message_ids[0].email_from, partner_archived.email_formatted) + self.assertEqual(record3.message_follower_ids.partner_id, self.env['res.partner'], + 'message_process: archived partner -> no follower') + self.assertEqual(record3.message_partner_ids, self.env['res.partner'], + 'message_process: archived partner -> no follower') + + # partner_root -> never again + odoobot = self.env.ref('base.partner_root') + odoobot.active = True + odoobot.email = 'odoobot@example.com' + with self.mock_mail_gateway(): + record4 = self.format_and_process( + MAIL_TEMPLATE, odoobot.email_formatted, 'groups@test.com', + subject='Odoobot Automatic Answer') + + self.assertEqual(record4.message_ids[0].author_id, odoobot) + self.assertEqual(record4.message_ids[0].email_from, odoobot.email_formatted) + self.assertEqual(record4.message_follower_ids.partner_id, self.env['res.partner'], + 'message_process: odoobot -> no follower') + self.assertEqual(record4.message_partner_ids, self.env['res.partner'], + 'message_process: odoobot -> no follower') + + # private partner + with self.mock_mail_gateway(): + record5 = self.format_and_process( + MAIL_TEMPLATE, partner_private.email_formatted, 'groups@test.com', + subject='Private Partner') + + 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') + + # -------------------------------------------------- + # Author recognition + # -------------------------------------------------- + + @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') + 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, + 'message_process: newly create group should not have any follower') + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + 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') + + 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.assertNotSentEmail() # No notification / bounce should be sent + + # Email recognized if partner has a formatted email + self.partner_1.write({'email': f'"Valid Lelitre" <{self.partner_1.email}>'}) + record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email, f'groups@{self.alias_domain}', subject='Test2') + + 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) + self.assertNotSentEmail() # No notification / bounce should be sent + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_process_email_author_multiemail(self): + """ Incoming email: recognized author: check multi/formatted email in field """ + test_email = 'valid.lelitre@agrolait.com' + # Email not recognized if partner has a multi-email (source = formatted email) + self.partner_1.write({'email': f'{test_email}, "Valid Lelitre" '}) + with self.mock_mail_gateway(): + record = self.format_and_process( + MAIL_TEMPLATE, f'"Valid Lelitre" <{test_email}>', f'groups@{self.alias_domain}', subject='Test3') + + self.assertEqual(record.message_ids[0].author_id, self.partner_1, + 'message_process: found author based on first found email normalized, even with multi emails') + self.assertEqual(record.message_ids[0].email_from, f'"Valid Lelitre" <{test_email}>') + self.assertNotSentEmail() # No notification / bounce should be sent + + # Email not recognized if partner has a multi-email (source = std email) + with self.mock_mail_gateway(): + record = self.format_and_process( + MAIL_TEMPLATE, test_email, f'groups@{self.alias_domain}', subject='Test4') + + self.assertEqual(record.message_ids[0].author_id, self.partner_1, + 'message_process: found author based on first found email normalized, even with multi emails') + 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): + """ 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.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.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.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_name': 'from.test', + 'alias_model_id': self.env['ir.model']._get('mail.test.gateway').id + }) + + 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) + + # -------------------------------------------------- + # Alias configuration + # -------------------------------------------------- + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_message_process_alias_config_bounced_content(self): + """ Custom bounced message for the alias => Received this custom message """ + self.alias.write({ + 'alias_contact': 'partners', + 'alias_bounced_content': '

What Is Dead May Never Die

' + }) + + # 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') + 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

') + + for empty_content in [ + '


', '


', '


', + '

', + '
', + '


', + '


', + ]: + self.alias.write({ + 'alias_contact': 'partners', + 'alias_bounced_content': empty_content, + }) + + # 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') + 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()}', + ) + + @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 is To + with self.mock_mail_gateway(): + self.format_and_process( + MAIL_TEMPLATE, self.email_from, f'groups@{self.alias_domain}', + cc='other@gmail.com', subject='Should Bounce') + self.assertIn(bounce_message_with_alias, self._mails[0].get('body')) + + # Bounce is CC + with self.mock_mail_gateway(): + self.format_and_process( + MAIL_TEMPLATE, self.email_from, 'other@gmail.com', + cc=f'groups@{self.alias_domain}', subject='Should Bounce') + self.assertIn(bounce_message_with_alias, self._mails[0].get('body')) + + # Bounce is part of To + with self.mock_mail_gateway(): + self.format_and_process( + MAIL_TEMPLATE, self.email_from, f'other@gmail.com, groups@{self.alias_domain}', + subject='Should Bounce') + self.assertIn(bounce_message_with_alias, self._mails[0].get('body')) + + @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'}" + }) + + record = self.format_and_process(MAIL_TEMPLATE, self.email_from, 'groups@test.com', subject='Specific') + 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(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) + + @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') + self.assertEqual(len(record), 1) + self.assertEqual(len(record.message_ids), 1) + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_message_process_alias_partners_bounce(self): + """ Incoming email from an unknown partner on a Partners only alias -> bounce + test bounce email """ + self.alias.write({'alias_contact': 'partners'}) + + # 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') + self.assertFalse(record) + self.assertSentEmail('"MAILER-DAEMON" ', ['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): + """ Incoming email from unknown partner / not follower partner on a Followers only alias -> bounce """ + self.alias.write({ + 'alias_contact': 'followers', + 'alias_parent_model_id': self.env['ir.model']._get('mail.test.gateway').id, + 'alias_parent_thread_id': self.test_record.id, + }) + + # 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') + self.assertFalse(record, 'message_process: should have bounced') + self.assertSentEmail('"MAILER-DAEMON" ', ['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') + self.assertFalse(record, 'message_process: should have bounced') + self.assertSentEmail('"MAILER-DAEMON" ', ['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') + + # Test: one group created by alias user + self.assertEqual(len(record), 1) + self.assertEqual(len(record.message_ids), 1) + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_process_alias_followers(self): + """ Incoming email from a parent document follower on a Followers only alias -> ok """ + self.alias.write({ + 'alias_contact': 'followers', + 'alias_parent_model_id': self.env['ir.model']._get('mail.test.gateway').id, + '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') + + # Test: one group created by Raoul (or Sylvie maybe, if we implement it) + self.assertEqual(len(record), 1) + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models', 'odoo.tests') + def test_message_process_alias_followers_multiemail(self): + """ Incoming email from a parent document follower on a Followers only + alias depends on email_from / partner recognition, to be tested when + dealing with multi emails / formatted emails. """ + self.alias.write({ + 'alias_contact': 'followers', + 'alias_parent_model_id': self.env['ir.model']._get('mail.test.gateway').id, + 'alias_parent_thread_id': self.test_record.id, + }) + self.test_record.message_subscribe(partner_ids=[self.partner_1.id]) + email_from = formataddr(("Another Name", self.partner_1.email_normalized)) + + for partner_email, passed in [ + (formataddr((self.partner_1.name, self.partner_1.email_normalized)), True), + (f'{self.partner_1.email_normalized}, "Multi Email" ', True), + (f'"Multi Email" , {self.partner_1.email_normalized}', False), + ]: + with self.subTest(partner_email=partner_email): + self.partner_1.write({'email': partner_email}) + record = self.format_and_process( + MAIL_TEMPLATE, email_from, f'groups@{self.alias_domain}', + subject=f'Test for {partner_email}') + + if passed: + self.assertEqual(len(record), 1) + self.assertEqual(record.email_from, email_from) + self.assertEqual(record.message_partner_ids, self.partner_1) + # multi emails not recognized (no normalized email, recognition) + else: + self.assertEqual(len(record), 0, + 'Alias check (FIXME): multi-emails bad support for recognition') + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_message_process_alias_update(self): + """ Incoming email update discussion + notification email """ + self.alias.write({'alias_force_thread_id': self.test_record.id}) + + 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>', + msg_id='<1198923581.41972151344608186799.JavaMail.diff1@agrolait.com>', subject='Re: cats') + + # Test: no new group + new message + self.assertFalse(record, 'message_process: alias update should not create new records') + self.assertEqual(len(self.test_record.message_ids), 2) + # Test: sent emails: 1 (Sylvie copy of the incoming email) + self.assertSentEmail(self.email_from, [self.partner_1], subject='Re: cats') + + # -------------------------------------------------- + # Creator recognition + # -------------------------------------------------- + + @mute_logger('odoo.addons.mail.models.mail_thread') + def test_message_process_create_uid_crash(self): + def _employee_crash(*args, **kwargs): + """ 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 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') + 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].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') + 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].author_id, self.user_employee.partner_id) + + record = self.format_and_process( + MAIL_TEMPLATE, f'Another name <{self.user_employee.email}>', + f'groups@{self.alias_domain}', + 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].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') + 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].author_id, self.user_employee.partner_id) + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink') + def test_message_process_create_uid_email_follower(self): + self.alias.write({ + 'alias_parent_model_id': self.env['ir.model']._get_id(self.test_record._name), + 'alias_parent_thread_id': self.test_record.id, + }) + 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') + 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].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') + 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].author_id, self.user_employee.partner_id) + + # -------------------------------------------------- + # Alias routing management + # -------------------------------------------------- + + @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', '') + + new_record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email_formatted, 'groups@another.domain.com', subject='Test Subject') + # Test: one group created + self.assertEqual(len(new_record), 1, 'message_process: a new mail.test.simple should have been created') + + @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 """ + self.assertEqual(len(self.test_record.message_ids), 1) + + # test@.. will cause the creation of new mail.test + new_alias_2 = self.env['mail.alias'].create({ + 'alias_name': 'test', + 'alias_user_id': False, + 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, + 'alias_contact': 'everyone', + }) + new_rec = self.format_and_process( + MAIL_TEMPLATE, self.partner_1.email_formatted, + f'{new_alias_2.display_name}, {self.alias.display_name}', + subject='Test Subject', + extra=f'In-Reply-To:\r\n\t{self.fake_email.message_id}\n', + target_model=new_alias_2.alias_model_id.model + ) + # Forward created a new record in mail.test + self.assertEqual(len(new_rec), 1, 'message_process: a new mail.test should have been created') + self.assertEqual(new_rec._name, new_alias_2.alias_model_id.model) + # No new post on test_record, no new record in mail.test.simple either + self.assertEqual(len(self.test_record.message_ids), 1, 'message_process: should not post on replied record as forward should bypass it') + new_simple = self.env['mail.test.simple'].search([('name', '=', 'Test Subject')]) + self.assertEqual(len(new_simple), 0, 'message_process: a new mail.test should not have been created') + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_route_alias_forward_bypass_reply_second(self): + """ Incoming email: write to two "new thread" alias, one as a reply, one being another model -> consider as a forward """ + self.assertEqual(len(self.test_record.message_ids), 1) + + # test@.. will cause the creation of new mail.test + new_alias_2 = self.env['mail.alias'].create({ + 'alias_name': 'test', + 'alias_user_id': False, + 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, + 'alias_contact': 'everyone', + }) + new_rec = self.format_and_process( + MAIL_TEMPLATE, self.partner_1.email_formatted, + f'{self.alias.display_name}, {new_alias_2.display_name}', + subject='Test Subject', + extra=f'In-Reply-To:\r\n\t{self.fake_email.message_id}\n', + target_model=new_alias_2.alias_model_id.model + ) + # Forward created a new record in mail.test + self.assertEqual(len(new_rec), 1, 'message_process: a new mail.test should have been created') + self.assertEqual(new_rec._name, new_alias_2.alias_model_id.model) + # No new post on test_record, no new record in mail.test.simple either + self.assertEqual(len(self.test_record.message_ids), 1, 'message_process: should not post on replied record as forward should bypass it') + new_simple = self.env['mail.test.simple'].search([('name', '=', 'Test Subject')]) + self.assertEqual(len(new_simple), 0, 'message_process: a new mail.test should not have been created') + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_route_alias_forward_bypass_update_alias(self): + """ Incoming email: write to one "update", one "new thread" alias, one as a reply, one being another model -> consider as a forward """ + self.assertEqual(len(self.test_record.message_ids), 1) + self.alias.write({ + 'alias_force_thread_id': self.test_record.id, + }) + + # test@.. will cause the creation of new mail.test + new_alias_2 = self.env['mail.alias'].create({ + 'alias_name': 'test', + 'alias_user_id': False, + 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, + 'alias_contact': 'everyone', + }) + new_rec = self.format_and_process( + MAIL_TEMPLATE, self.partner_1.email_formatted, + f'{new_alias_2.display_name}, {self.alias.display_name}', + subject='Test Subject', + extra=f'In-Reply-To:\r\n\t{self.fake_email.message_id}\n', + target_model=new_alias_2.alias_model_id.model + ) + # Forward created a new record in mail.test + self.assertEqual(len(new_rec), 1, 'message_process: a new mail.test should have been created') + self.assertEqual(new_rec._name, new_alias_2.alias_model_id.model) + # No new post on test_record, no new record in mail.test.simple either + self.assertEqual(len(self.test_record.message_ids), 1, 'message_process: should not post on replied record as forward should bypass it') + # No new record on first alias model + new_simple = self.env['mail.test.gateway'].search([('name', '=', 'Test Subject')]) + self.assertEqual(len(new_simple), 0, 'message_process: a new mail.test should not have been created') + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_route_alias_multiple_new(self): + """ 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_name': 'test', + 'alias_user_id': False, + 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, + 'alias_contact': 'everyone', + }) + new_rec = self.format_and_process( + MAIL_TEMPLATE, self.partner_1.email_formatted, + f'{self.alias.display_name}, {new_alias_2.display_name}', + subject='Test Subject', + target_model=new_alias_2.alias_model_id.model + ) + # New record in both mail.test (new_alias_2) and mail.test.simple (self.alias) + self.assertEqual(len(new_rec), 1, 'message_process: a new mail.test should have been created') + self.assertEqual(new_rec._name, new_alias_2.alias_model_id.model) + new_simple = self.env['mail.test.gateway'].search([('name', '=', 'Test Subject')]) + self.assertEqual(len(new_simple), 1, 'message_process: a new mail.test should have been created') + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_route_alias_with_allowed_domains(self): + """ 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 + new_alias_2 = self.env['mail.alias'].create({ + 'alias_contact': 'everyone', + 'alias_model_id': self.env['ir.model']._get('mail.test.container').id, + 'alias_name': 'test', + 'alias_user_id': False, + }) + + allowed_domain = 'hello.com' + for (alias_right_part, allowed_domain), (gateway_created, container_created) in zip( + [ + # 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), + # 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), + ] + ): + 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) + + subject = f'Test wigh {alias_right_part}-{allowed_domain}' + email_to = f'{self.alias.alias_name}@{self.alias_domain}, {new_alias_2.alias_name}@{alias_right_part}' + + self.format_and_process( + MAIL_TEMPLATE, self.partner_1.email_formatted, email_to, + subject=subject, + 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) + self.assertEqual(bool(res_alias_2), container_created) + + # -------------------------------------------------- + # Email Management + # -------------------------------------------------- + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_route_bounce(self): + """Incoming email: bounce using bounce alias: no record creation """ + with self.mock_mail_gateway(): + new_recs = self.format_and_process( + MAIL_TEMPLATE, self.partner_1.email_formatted, + f'{self.alias_bounce}@{self.alias_domain}', + subject='Should bounce', + ) + self.assertFalse(new_recs) + self.assertNotSentEmail() + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_route_bounce_other_recipients(self): + """Incoming email: bounce processing: bounce should be computed even if not first recipient """ + with self.mock_mail_gateway(): + new_recs = self.format_and_process( + MAIL_TEMPLATE, self.partner_1.email_formatted, + f'{self.alias.alias_name}@{self.alias_domain}, {self.alias_bounce}@{self.alias_domain}', + subject='Should bounce', + ) + self.assertFalse(new_recs) + self.assertNotSentEmail() + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_message_route_write_to_catchall(self): + """ Writing directly to catchall should bounce """ + # Test: no group created, email bounced + with self.mock_mail_gateway(): + record = self.format_and_process( + MAIL_TEMPLATE, self.partner_1.email_formatted, + f'"My Super Catchall" <{self.alias_catchall}@{self.alias_domain}', + subject='Should Bounce') + self.assertFalse(record) + self.assertSentEmail( + self.mailer_daemon_email, + ['whatever-2a840@postmaster.twitter.com'], + subject='Re: Should Bounce' + ) + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_route_write_to_catchall_other_recipients_first(self): + """ Writing directly to catchall and a valid alias should take alias """ + # Test: no group created, email bounced + with self.mock_mail_gateway(): + record = self.format_and_process( + MAIL_TEMPLATE, self.partner_1.email_formatted, + f'{self.alias_catchall}@{self.alias_domain}, {self.alias.alias_name}@{self.alias_domain}', + subject='Catchall Not Blocking' + ) + # Test: one group created + self.assertEqual(len(record), 1, 'message_process: a new mail.test should have been created') + # No bounce email + self.assertNotSentEmail() + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_route_write_to_catchall_other_recipients_second(self): + """ Writing directly to catchall and a valid alias should take alias """ + # Test: no group created, email bounced + with self.mock_mail_gateway(): + record = self.format_and_process( + MAIL_TEMPLATE, self.partner_1.email_formatted, + f'{self.alias.alias_name}@{self.alias_domain}, {self.alias_catchall}@{self.alias_domain}', + subject='Catchall Not Blocking' + ) + # Test: one group created + self.assertEqual(len(record), 1, 'message_process: a new mail.test should have been created') + # No bounce email + self.assertNotSentEmail() + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_message_route_write_to_catchall_other_recipients_invalid(self): + """ Writing to catchall and other unroutable recipients should bounce. """ + # Test: no group created, email bounced + 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), + subject='Should Bounce') + self.assertFalse(record) + self.assertSentEmail('"MAILER-DAEMON" ', ['whatever-2a840@postmaster.twitter.com'], subject='Re: Should Bounce') + + @mute_logger('odoo.addons.mail.models.mail_thread') + def test_message_process_bounce_alias(self): + """ Writing to bounce alias is considered as a bounce even if not multipart/report bounce structure """ + 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') + 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 + 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_from_mailer_demon(self): + """ MAILER_DAEMON emails are considered as bounce """ + 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') + 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_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') + 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 + 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_multipart_alias_reply(self): + """ Multipart/report bounce correctly make related partner and record found in bounce email 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') + 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) + + @mute_logger('odoo.addons.mail.models.mail_thread') + def test_message_process_bounce_multipart_alias_whatever_from(self): + """ Multipart/report bounce correctly make related record found in bounce email 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') + 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) + self.assertEqual(self.partner_1.message_bounce, 0) + self.assertEqual(self.test_record.message_bounce, 1) + + @mute_logger('odoo.addons.mail.models.mail_thread') + def test_message_process_bounce_multipart_whatever_to_and_from(self): + """ Multipart/report bounce correctly make related record found in bounce email bounce """ + self.assertEqual(self.partner_1.message_bounce, 0) + 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) + self.assertFalse(record) + self.assertEqual(self.partner_1.message_bounce, 0) + self.assertEqual(self.test_record.message_bounce, 1) + + @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 """ + 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({ + 'name': 'Test', + 'channel_partner_ids': [(4, self.partner_1.id)], + }) + self.fake_email.write({ + 'model': 'mail.channel', + 'res_id': test_channel.id, + }) + self.assertIn(self.partner_1, test_channel.channel_partner_ids) + self.assertEqual(self.partner_1.message_bounce, 0) + self.assertEqual(other_record.message_bounce, 0) + self.assertEqual(yet_other_record.message_bounce, 0) + + extra = self.fake_email.message_id + 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}', + subject='Undelivered Mail Returned to Sender', + extra=extra) + self.assertFalse(record) + self.assertEqual(self.partner_1.message_bounce, 10) + self.assertEqual(self.test_record.message_bounce, 0) + self.assertEqual(other_record.message_bounce, 10) + self.assertEqual(yet_other_record.message_bounce, 10) + self.assertNotIn(self.partner_1, test_channel.channel_partner_ids) + + @mute_logger('odoo.addons.mail.models.mail_thread') + def test_message_process_bounce_records_partner(self): + """ Test blacklist + bounce on ``res.partner`` model """ + self.assertEqual(self.partner_1.message_bounce, 0) + self.fake_email.write({ + 'model': 'res.partner', + 'res_id': self.partner_1.id, + }) + + 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) + self.assertFalse(record) + self.assertEqual(self.partner_1.message_bounce, 1) + self.assertEqual(self.test_record.message_bounce, 0) + + # -------------------------------------------------- + # Thread formation + # -------------------------------------------------- + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_message_process_external_notification_reply(self): + """Ensure responses bot messages are discussions.""" + bot_notification_message = self._create_gateway_message( + self.test_record, + 'bot_notif_message', + author_id=self.env.ref('base.partner_root').id, + message_type='auto_comment', + is_internal=True, + subtype_id=self.env.ref('mail.mt_note').id, + ) + + self.format_and_process( + MAIL_TEMPLATE, self.email_from, '', + subject='Reply to bot notif', + extra=f'References: {bot_notification_message.message_id}' + ) + new_msg = self.test_record.message_ids[0] + self.assertFalse(new_msg.is_internal, "Responses to messages sent by odoobot should always be public.") + self.assertEqual(new_msg.parent_id, bot_notification_message) + self.assertEqual(new_msg.subtype_id, self.env.ref('mail.mt_comment')) + + # Also check the regular case + some_notification_message = self._create_gateway_message( + self.test_record, + 'some_notif_message', + message_type='notification', + is_internal=True, + subtype_id=self.env.ref('mail.mt_note').id, + ) + + self.format_and_process( + MAIL_TEMPLATE, self.email_from, '', + subject='Reply to some notif', + extra=f'References: {some_notification_message.message_id}' + ) + new_msg = self.test_record.message_ids[0] + self.assertTrue(new_msg.is_internal, "Responses to messages sent by anyone but odoobot should keep" + "the 'is_internal' value of the parent.") + self.assertEqual(new_msg.parent_id, some_notification_message) + self.assertEqual(new_msg.subtype_id, self.env.ref('mail.mt_note')) + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_process_in_reply_to(self): + """ Incoming email using in-rely-to should go into the right destination even with a wrong destination """ + init_msg_count = len(self.test_record.message_ids) + self.format_and_process( + MAIL_TEMPLATE, 'valid.other@gmail.com', f'erroneous@{self.alias_domain}', + subject='Re: news', extra=f'In-Reply-To:\r\n\t{self.fake_email.message_id}\n') + + self.assertEqual(len(self.test_record.message_ids), init_msg_count + 1) + self.assertEqual(self.fake_email.child_ids, self.test_record.message_ids[0]) + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_process_references(self): + """ Incoming email using references should go into the right destination even with a wrong destination """ + init_msg_count = len(self.test_record.message_ids) + self.format_and_process( + MAIL_TEMPLATE, self.email_from, f'erroneous@{self.alias_domain}', + extra=f'References: <2233@a.com>\r\n\t<3edss_dsa@b.com> {self.fake_email.message_id}') + + self.assertEqual(len(self.test_record.message_ids), init_msg_count + 1) + self.assertEqual(self.fake_email.child_ids, self.test_record.message_ids[0]) + + @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, + ) + + # 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}' + ) + 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: 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}' + ) + 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: 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}' + ) + 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_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)') + + @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). """ + test_record = self.env['mail.test.gateway.groups'].create({ + 'alias_name': 'test.gateway', + 'name': 'Test', + 'email_from': 'ignasse@example.com', + }) + + # Set a first message on public group to test update and hierarchy + first_msg = self._create_gateway_message(test_record, 'first_msg') + 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', + subject='Reply to reply1', + extra=f'References: {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.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', + subject='New thread', + extra='References:' + ) + new_thread = test_record.message_ids[0] + self.assertFalse(new_thread.parent_id, 'Mail: pseudo no flattening: no parent means new thread') + 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 """ + new_message_id = '' + self.fake_email.write({ + 'message_id': new_message_id + }) + init_msg_count = len(self.test_record.message_ids) + self.format_and_process( + MAIL_TEMPLATE, self.email_from, f'erroneous@{self.alias_domain}', + extra=f'References: <2233@a.com>\r\n\t<3edss_dsa@b.com> {self.fake_email.message_id}') + + self.assertEqual(len(self.test_record.message_ids), init_msg_count + 1) + self.assertEqual(self.fake_email.child_ids, self.test_record.message_ids[0]) + + @mute_logger('odoo.addons.mail.models.mail_thread') + def test_message_process_references_external_buggy_message_id(self): + """ + Incoming email being a reply to an external email processed by + odoo should update thread accordingly. Special case when the + external mail service wrongly folds the message_id on several + lines. + """ + new_message_id = '' + buggy_message_id = new_message_id.replace('MonsterEmail', 'Monster\r\n Email') + self.fake_email.write({ + 'message_id': new_message_id + }) + init_msg_count = len(self.test_record.message_ids) + self.format_and_process( + MAIL_TEMPLATE, self.email_from, f'erroneous@{self.alias_domain}', + extra=f'References: <2233@a.com>\r\n\t<3edss_dsa@b.com> {buggy_message_id}') + + self.assertEqual(len(self.test_record.message_ids), init_msg_count + 1) + self.assertEqual(self.fake_email.child_ids, self.test_record.message_ids[0]) + + @mute_logger('odoo.addons.mail.models.mail_thread') + 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_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}', + target_model='mail.test.container') + + self.assertEqual(len(self.test_record.message_ids), init_msg_count) + self.assertEqual(len(self.fake_email.child_ids), 0) + self.assertEqual(res_test.name, 'My Dear Forward') + self.assertEqual(len(res_test.message_ids), 1) + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + 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_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}', + target_model='mail.test.container') + + self.assertEqual(len(self.test_record.message_ids), init_msg_count + 1) + self.assertEqual(len(self.fake_email.child_ids), 1) + self.assertFalse(res_test) + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + 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_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, + target_model='mail.test.container') + + self.assertEqual(len(self.test_record.message_ids), init_msg_count + 1) + self.assertEqual(len(self.fake_email.child_ids), 1) + self.assertFalse(res_test) + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_message_process_reply_to_new_thread(self): + """ Test replies not being considered as replies but use destination information instead (aka, mass post + specific reply to using aliases) """ + first_record = self.env['mail.test.simple'].with_user(self.user_employee).create({'name': 'Replies to Record'}) + record_msg = first_record.message_post( + subject='Discussion', + 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')))) + mail_msg = first_record.message_post( + subject='Replies to Record', + reply_to='groups@test.com', + reply_to_force_new=True, + subtype_xmlid='mail.mt_comment', + ) + self.assertEqual(mail_msg.reply_to, 'groups@test.com') + + # reply to mail but should be considered as a new mail for alias + msgID = '' + res_test = self.format_and_process( + MAIL_TEMPLATE, self.email_from, record_msg.reply_to, cc='', + subject='Re: Replies to Record', extra=f'In-Reply-To: {record_msg.message_id}', + msg_id=msgID, target_model='mail.test.simple') + 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.assertTrue(incoming_msg.res_id == first_record.id) + + # reply to mail but should be considered as a new mail for alias + msgID = '' + res_test = self.format_and_process( + MAIL_TEMPLATE, self.email_from, mail_msg.reply_to, cc='', + subject='Re: Replies to Record', extra=f'In-Reply-To: {mail_msg.message_id}', + msg_id=msgID, target_model='mail.test.gateway') + incoming_msg = self.env['mail.message'].search([('message_id', '=', msgID)]) + self.assertEqual(len(res_test), 1) + self.assertEqual(res_test.name, 'Re: Replies to Record') + self.assertEqual(incoming_msg.model, 'mail.test.gateway') + self.assertFalse(incoming_msg.parent_id) + self.assertTrue(incoming_msg.res_id == res_test.id) + + # -------------------------------------------------- + # Gateway / Record synchronization + # -------------------------------------------------- + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_gateway_values_base64_image(self): + """New record with mail that contains base64 inline image.""" + target_model = "mail.test.field.type" + alias = self.env["mail.alias"].create({ + "alias_name": "base64-lover", + "alias_model_id": self.env["ir.model"]._get(target_model).id, + "alias_defaults": "{}", + "alias_contact": "everyone", + }) + record = self.format_and_process( + test_mail_data.MAIL_TEMPLATE_EXTRA_HTML, self.email_from, + f'{alias.alias_name}@{self.alias_domain}', + subject='base64 image to alias', + target_model=target_model, + extra_html='', + ) + self.assertEqual(record.type, "first") + self.assertEqual(len(record.message_ids[0].attachment_ids), 1) + self.assertEqual(record.message_ids[0].attachment_ids[0].name, "image0") + self.assertEqual(record.message_ids[0].attachment_ids[0].type, "binary") + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_gateway_values_base64_image_walias(self): + """New record with mail that contains base64 inline image + default values + coming from alias.""" + target_model = "mail.test.field.type" + alias = self.env["mail.alias"].create({ + "alias_name": "base64-lover", + "alias_model_id": self.env["ir.model"]._get(target_model).id, + "alias_defaults": "{'type': 'second'}", + "alias_contact": "everyone", + }) + record = self.format_and_process( + test_mail_data.MAIL_TEMPLATE_EXTRA_HTML, self.email_from, + f'{alias.alias_name}@{self.alias_domain}', + subject='base64 image to alias', + target_model=target_model, + extra_html='', + ) + self.assertEqual(record.type, "second") + self.assertEqual(len(record.message_ids[0].attachment_ids), 1) + self.assertEqual(record.message_ids[0].attachment_ids[0].name, "image0") + self.assertEqual(record.message_ids[0].attachment_ids[0].type, "binary") + + # -------------------------------------------------- + # Thread formation: mail gateway corner cases + # -------------------------------------------------- + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_process_extra_model_res_id(self): + """ Incoming email with ref holding model / res_id but that does not match any message in the thread: must raise since OpenERP saas-3 """ + self.assertRaises(ValueError, + self.format_and_process, MAIL_TEMPLATE, + self.partner_1.email_formatted, f'noone@{self.alias_domain}', subject='spam', + extra=f'In-Reply-To: <12321321-openerp-{self.test_record.id}-{self.test_record._name}@{socket.gethostname()}>') + + # when 6.1 messages are present, compat mode is available + # Odoo 10 update: compat mode has been removed and should not work anymore + self.fake_email.write({'message_id': False}) + # Do: compat mode accepts partial-matching emails + self.assertRaises( + ValueError, + self.format_and_process, MAIL_TEMPLATE, + self.partner_1.email_formatted, f'noone@{self.alias_domain}>', subject='spam', + extra=f'In-Reply-To: <12321321-openerp-{self.test_record.id}-mail.test.gateway@{socket.gethostname()}>') + + # Test created messages + self.assertEqual(len(self.test_record.message_ids), 1) + self.assertEqual(len(self.test_record.message_ids[0].child_ids), 0) + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_process_duplicate(self): + """ Duplicate emails (same message_id) are not processed """ + 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>') + 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') + 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>')]) + self.assertEqual(no_of_msg, 1, + 'message_process: message with already existing message_id should not have been duplicated') + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_process_crash_wrong_model(self): + """ 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', + subject='spam', extra='', model='res.country') + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_process_crash_no_data(self): + """ Incoming email without model and without alias must raise """ + self.assertRaises(ValueError, + self.format_and_process, + MAIL_TEMPLATE, self.email_from, 'noone@test.com', + 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', + subject='Spammy', extra='', model='mail.test.gateway') + self.assertEqual(len(record), 1) + self.assertEqual(record.name, 'Spammy') + self.assertEqual(record._name, 'mail.test.gateway') + + @mute_logger('odoo.addons.mail.models.mail_thread') + 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']: + 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}', + subject=f'Test Charset {encoding or "Unset"}', + charset=f'; charset="{encoding}"' if encoding else '', + content=file_content_b64 + ) + attachment = record.message_ids.attachment_ids + self.assertEqual(file_content, attachment.raw.decode(encoding or 'utf-8')) + if encoding not in ['', 'UTF-8']: + self.assertNotEqual(file_content, attachment.raw.decode('utf-8')) + + def test_message_hebrew_iso8859_8_i(self): + # This subject was found inside an email of one of our customer. + # The charset is iso-8859-8-i which isn't natively supported by + # python, check that Odoo is still capable of decoding it. + subject = "בוקר טוב! צריך איימק ושתי מסכים" + encoded_subject = "=?iso-8859-8-i?B?4eX3+CDo5eEhIPb46eog4Onp7vcg5fn66SDu8evp7Q==?=" + + # This content was made up using google translate. The charset + # is iso-8859-8 which is natively supported by python. + charset = "iso-8859-8" + content = "שלום וברוכים הבאים למקרה המבחן הנפלא הזה" + encoded_content = base64.b64encode(content.encode(charset)).decode() + + with RecordCapturer(self.env['mail.test.gateway'], []) as capture: + mail = test_mail_data.MAIL_FILE_ENCODING.format( + msg_id="", + subject=encoded_subject, + charset=f'; charset="{charset}"', + content=encoded_content, + ) + self.env['mail.thread'].message_process('mail.test.gateway', mail) + + capture.records.ensure_one() + self.assertEqual(capture.records.name, subject) + self.assertEqual( + capture.records.message_ids.attachment_ids.raw.decode(charset), + content + ) + + def test_message_windows_874(self): + # Email for Thai customers who use Microsoft email service. + # The charset is windows-874 which isn't natively supported by + # 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: + self.env['mail.thread'].message_process('mail.test.gateway', THAI_EMAIL_WINDOWS_874) + capture.records.ensure_one() + self.assertEqual(capture.records.name, 'เรื่อง') + self.assertEqual(str(capture.records.message_ids.body), '

ร่างกาย
\n') + + # -------------------------------------------------- + # Corner cases / Bugs during message process + # -------------------------------------------------- + + @mute_logger('odoo.addons.mail.models.mail_thread') + def test_message_process_file_encoding_ascii(self): + """ 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') + + self.assertEqual(record.message_main_attachment_id.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 + # the email, triggering the decoding of the base64 attachment, so b"Chauss\xef\xbf\xbd\xef\xbf\xbde" is + # first retrieved. Then, `get_text_content` in `email` tries to decode this using the charset of the email + # 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()) + + @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()) + + @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()) + + @mute_logger('odoo.addons.mail.models.mail_thread') + def test_message_process_file_omitted_charset_txt(self): + """ For incoming email containing a txt attachment with omitted charset and containing an UTF8 payload we + should parse the attachment using UTF-8. + """ + test_string = ("Äpfel und Birnen sind Früchte, die im Herbst geerntet werden. In der Nähe des Flusses steht ein großes, " + "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()) + + @mute_logger('odoo.addons.mail.models.mail_thread') + def test_message_route_reply_model_none(self): + """ + Test the message routing and reply functionality when the model is None. + + This test case verifies the behavior of the message routing and reply process + when the 'model' field of a mail.message is set to None. It checks that the + message is correctly processed and associated with the appropriate record. + The code invokes function `format_and_process` to automatically test rounting + and then makes checks on created record. + + """ + message = self.env['mail.message'].create({ + 'body': '

test

', + 'email_from': self.email_from, + 'message_type': 'email', + '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}) + record = self.format_and_process( + MAIL_TEMPLATE, self.email_from, 'test@test.com', + subject=message.message_id, extra=f'In-Reply-To:\r\n\t{message.message_id}\n', + model=None) + + self.assertTrue(record) + self.assertEqual(record._name, 'mail.test.gateway') + self.assertEqual(record.message_ids.subject, message.message_id) + self.assertFalse(record.message_ids.parent_id) + + +@tagged('mail_gateway', 'mail_loop') +class TestMailGatewayLoops(MailGatewayCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env['ir.config_parameter'].sudo().set_param('mail.gateway.loop.minutes', 30) + cls.env['ir.config_parameter'].sudo().set_param('mail.gateway.loop.threshold', 5) + + cls.env['mail.gateway.allowed'].create([ + {'email': 'Bob@EXAMPLE.com'}, + {'email': '"Alice From Example" '}, + {'email': '"Eve From Example" '}, + ]) + + cls.alias_ticket = cls.env['mail.alias'].create({ + 'alias_contact': 'everyone', + '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_model_id': cls.env['ir.model']._get_id('mail.test.gateway'), + 'alias_user_id': False, + 'alias_name': 'test.gateway', + }) + + # recipients + cls.customer_email = "customer@test.example.com" + cls.alias_partner, cls.other_partner = cls.env['res.partner'].create([ + { + 'email': f'"Stupid Idea" <{cls.alias_other.alias_name}@{cls.alias_other.alias_domain}>', + 'name': 'Stupid Idea', + }, { + 'email': '"Other Customer" ', + 'name': 'Other Customer', + } + ]) + + @mute_logger('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.""" + # Send an email 2 hours ago, should not have an impact on more recent emails + with patch.object(Cursor, 'now', lambda *args, **kwargs: datetime(2022, 1, 1, 8, 0, 0)): + self.format_and_process( + MAIL_TEMPLATE, + self.email_from, + f'{self.alias_ticket.alias_name}@{self.alias_domain}', + subject='Test alias loop old', + target_model=self.alias_ticket.alias_model_id.model, + ) + + for i in range(5): + self.format_and_process( + MAIL_TEMPLATE, + self.email_from, + f'{self.alias_ticket.alias_name}@{self.alias_domain}', + subject=f'Test alias loop {i}', + target_model=self.alias_ticket.alias_model_id.model, + ) + + records = self.env['mail.test.ticket'].search([('name', 'ilike', 'Test alias loop %')]) + self.assertEqual(len(records), 6, 'Should have created 6 ') + self.assertEqual(set(records.mapped('email_from')), {self.email_from}, + msg='Should have automatically filled the email field') + + for email_from, exp_to in [ + (self.email_from, formataddr(("Sylvie Lelitre", "test.sylvie.lelitre@agrolait.com"))), + (self.email_from.upper(), formataddr(("SYLVIE LELITRE", "test.sylvie.lelitre@agrolait.com"))), + ]: + with self.mock_mail_gateway(): + self.format_and_process( + MAIL_TEMPLATE, + email_from, + f'{self.alias_ticket.alias_name}@{self.alias_domain}', + subject='Test alias loop X', + target_model=self.alias_ticket.alias_model_id.model, + return_path=email_from, + ) + + new_record = self.env['mail.test.ticket'].search([('name', '=', 'Test alias loop X')]) + self.assertFalse( + new_record, + msg='The loop should have been detected and the record should not have been created') + + self.assertSentEmail('"MAILER-DAEMON" ', [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') + + # The reply to the bounce email must be ignored + with self.mock_mail_gateway(): + self.format_and_process( + MAIL_TEMPLATE, + 'alice@example.com', # whitelisted from, should be taken into account + f'{self.alias_ticket.alias_name}@{self.alias_domain}', + subject='Test alias loop X', + target_model=self.alias_ticket.alias_model_id.model, + return_path=self.email_from, + extra=f'References: {bounce_references}', + ) + self.assertNotSentEmail() + + with self.mock_mail_gateway(): + self.format_and_process( + MAIL_TEMPLATE, + 'alice@example.com', # whitelisted from, should be taken into account + f'{self.alias_ticket.alias_name}@{self.alias_domain}', + subject='Test alias loop X', + target_model=self.alias_ticket.alias_model_id.model, + return_path=self.email_from, + extra=f'In-Reply-To: {bounce_references}', + ) + self.assertNotSentEmail() + + # Email address in the whitelist should not have the restriction + for i in range(10): + self.format_and_process( + MAIL_TEMPLATE, + 'alice@example.com', + f'{self.alias_ticket.alias_name}@{self.alias_domain}', + subject=f'Whitelist test alias loop {i}', + target_model=self.alias_ticket.alias_model_id.model, + ) + 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') + 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. """ + # create 2 update-records aliases and 1 new-record alias on same model + test_updates = self.env['mail.test.gateway.groups'].create([ + { + 'alias_name': 'test.update1', + 'name': 'Update1', + }, { + 'alias_name': 'test.update2', + 'name': 'Update2', + }, + ]) + alias_gateway_group, alias_ticket_other = self.env['mail.alias'].create([ + { + 'alias_contact': 'everyone', + 'alias_model_id': self.env['ir.model']._get_id('mail.test.gateway.groups'), + 'alias_name': 'test.new', + }, { + 'alias_contact': 'everyone', + 'alias_model_id': self.env['ir.model']._get_id('mail.test.ticket'), + 'alias_name': 'test.ticket.other', + } + ]) + + _original_ticket_sc = MailTestTicket.search_count + _original_groups_sc = MailTestGatewayGroups.search_count + _original_rgr = Message._read_group_raw + 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: + self.format_and_process( + MAIL_TEMPLATE, + self.other_partner.email_formatted, + f'"Super Help" <{self.alias_ticket.alias_name}@{self.alias_ticket.alias_domain}>,' + f'{test_updates[0].alias_id.display_name}, {test_updates[1].alias_id.display_name}, ' + f'{alias_gateway_group.display_name}, {alias_ticket_other.display_name}', + subject='Valid Inquiry', + return_path=self.other_partner.email_formatted, + target_model='mail.test.ticket', + ) + self.assertEqual(mock_ticket_sc.call_count, 1, 'Two alias creating tickets but one check anyway') + self.assertEqual(mock_groups_sc.call_count, 1, 'One alias creating groups') + self.assertEqual(mock_msg_rgr.call_count, 1, 'Only one model updating records, one call even if two aliases') + self.assertEqual( + len(self.env['mail.test.ticket'].search([('name', '=', 'Valid Inquiry')])), + 2, 'One by creating alias, as no loop was detected' + ) + + # create 'looping' history by pre-creating messages on a thread -> should block future incoming emails + self.env['mail.message'].create([ + { + 'author_id': self.other_partner.id, + 'model': test_updates[0]._name, + 'res_id': test_updates[0].id, + } for x in range(4) # 4 + 1 posted before = 5 aka threshold + ]) + with self.mock_mail_gateway(): + self.format_and_process( + MAIL_TEMPLATE, + self.other_partner.email_formatted, + f'"Super Help" <{self.alias_ticket.alias_name}@{self.alias_ticket.alias_domain}>,' + f'{test_updates[0].alias_id.display_name}, {test_updates[1].alias_id.display_name}, ' + f'{alias_gateway_group.display_name}, {alias_ticket_other.display_name}', + subject='Looping Inquiry', + return_path=self.other_partner.email_formatted, + target_model='mail.test.ticket', + ) + self.assertFalse( + self.env['mail.test.ticket'].search([('name', '=', 'Looping Inquiry')]), + 'Even if other routes are ok, one looping route is sufficient to block the incoming email' + ) + + @mute_logger('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(): + record = self.format_and_process( + MAIL_TEMPLATE, + self.other_partner.email_formatted, + f'"Super Help" <{self.alias_ticket.alias_name}@{self.alias_ticket.alias_domain}>', + subject='Inquiry', + return_path=self.other_partner.email_formatted, + 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', + subtype_xmlid='mail.mt_comment', + ) + capture_messages = self.gateway_mail_reply_last_email(MAIL_TEMPLATE) + msg = capture_messages.records + self.assertTrue(msg) + # first messages are accepted -> post a message on record + if incoming_count < 4: # which makes 5 accepted messages + self.assertIn(msg, record.message_ids) + # other attempts triggers only a bounce + else: + self.assertFalse(msg.model) + self.assertFalse(msg.res_id) + 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') + def test_routing_loop_follower_alias(self): + """ Use case: managing follower that are aliases. """ + with self.mock_mail_gateway(): + record = self.format_and_process( + MAIL_TEMPLATE, + f'"Annoying Customer" <{self.customer_email}>', + f'"Super Help" <{self.alias_ticket.alias_name}@{self.alias_ticket.alias_domain}>', + cc=f'{self.alias_partner.email_normalized}, {self.other_partner.email_normalized}', + subject='Inquiry', + return_path=self.customer_email, + target_model='mail.test.ticket', + ) + self.assertEqual(record.name, 'Inquiry') + self.assertFalse(record.message_partner_ids, 'Inquiry') + self.assertNotSentEmail() + self.assertEqual(record.message_ids.partner_ids, self.other_partner, + 'MailGateway: recipients = alias should not be linked to message') + + # for some stupid reason, people add an alias as follower + with self.mock_mail_gateway(): + _message = record.with_user(self.user_employee).message_post( + body='Answer', + partner_ids=self.alias_partner.ids, + ) + self.assertSentEmail(self.user_employee.email_formatted, [self.alias_partner.email_formatted]) + + # 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: + 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') + 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 + replies to bounce: to=bounce. It is forwarded to catchall. It bounces, + and hop we have a loop. """ + customer_email = "customer@test.example.com" + + with self.mock_mail_gateway(): + self.format_and_process( + MAIL_TEMPLATE, + f'"Annoying Customer" <{customer_email}>', + f'"No Reply" <{self.alias_catchall}@{self.alias_domain}>, Unroutable ', + subject='Should Bounce (initial)', + return_path=customer_email, + ) + self.assertSentEmail( + f'"MAILER-DAEMON" <{self.alias_bounce}@{self.alias_domain}>', + [customer_email], + subject='Re: Should Bounce (initial)') + 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.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}') + # TDE FIXME: this should not bounce again + # self.assertNotSentEmail() + self.assertSentEmail( + f'"MAILER-DAEMON" <{self.alias_bounce}@{self.alias_domain}>', + [customer_email], + subject=f'Re: Re: Re: Should Bounce (initial)') + + +@tagged('mail_gateway', 'mail_loop', 'mail_reply') +class TestMailGatewayReplies(MailGatewayCommon): + """ Check routing of replies, using headers, references, ... """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.user_employee.notification_type = "email" + + cls.test_records, _partners = cls._create_records_for_batch('mail.test.gateway', 5) + for idx, rec in enumerate(cls.test_records): + rec.email_from = f'test.gateway.{idx}@test.example.com' + + def test_routing_reply_incoming_email(self): + """ Test routing after receiving starting email on a thread: references + should include it as it is the "common ancestor" to discussions """ + with self.mock_mail_gateway(): + gateway_record = self.format_and_process( + MAIL_TEMPLATE, self.email_from, self.alias.display_name, + subject='Gateway Creation', + ) + self.assertEqual(len(gateway_record.message_ids), 1) + gateway_record._message_log(body='Some log') + with self.mock_mail_gateway(): + gateway_record.with_user(self.user_employee).message_post( + body='Odoo Reply', + message_type='comment', + partner_ids=self.partner_1.ids, + subtype_id=self.env.ref('mail.mt_comment').id, + ) + reply, log, email = gateway_record.message_ids + self.assertMailNotifications( + reply, + [{ + 'content': 'Odoo Reply', + 'email_values': { + 'message_id': reply.message_id, + 'references': f'{email.message_id} {log.message_id} {reply.message_id}', # should contain reference to OdooExternal message, logs to fill up history + }, + 'fields_values': { + 'notified_partner_ids': self.partner_1, + 'parent_id': email, # log serves as thread ancestor + }, + 'notif': [ + {'partner': self.partner_1, 'type': 'email',}, + ], + }], + ) + + def test_routing_reply_internal_messages(self): + """ Test routing notably between two Odoos when internal messages + are involved. We don't know which message is the ancestor one and + we should ensure some shared message IDs are present in references + to help thread formation. + + Action Odoo1 Odoo2 + RFQ-like creation log + initial_msg + Odoo2 replies creation log + reply reply + -some internal work- user_notification + Odoo1 replies reply_2 reply_2 (incoming email) + -some internal work- log + Odoo2 replies reply_3 reply_3 (outgoing email) + + Purpose: have references from Odoo2 containing message IDs to try to + correclty route thread. + """ + gateway_record = self.env['mail.test.gateway'].create({ + 'name': 'Created through Form', + }) + gateway_record.message_subscribe(partner_ids=self.partner_admin.ids) + self.assertEqual(gateway_record.message_partner_ids, self.partner_admin) + gateway_record.message_post( + author_id=self.env.ref('base.partner_root').id, + body='OdooExternal Inquiry', + email_from=self.partner_1.email_normalized, + message_type='comment', + subtype_id=self.env.ref('mail.mt_comment').id, + ) + self.assertEqual(gateway_record.message_partner_ids, self.partner_admin) + log, odooext_msg = gateway_record.message_ids[1], gateway_record.message_ids[0] + self.assertEqual(odooext_msg.parent_id, log, 'Log serves as thread ancestor') + + # Odoo2 reply + with self.mock_mail_gateway(): + gateway_record.with_user(self.user_employee).message_post( + body='Odoo Reply', + message_type='comment', + partner_ids=self.partner_1.ids, + subtype_id=self.env.ref('mail.mt_comment').id, + ) + self.assertEqual(gateway_record.message_partner_ids, self.partner_admin + self.partner_employee) + reply = gateway_record.message_ids[0] + self.assertMailNotifications( + reply, + [{ + 'content': 'Odoo Reply', + 'email_values': { + 'message_id': reply.message_id, + 'references': f'{log.message_id} {odooext_msg.message_id} {reply.message_id}', # should contain reference to OdooExternal message + }, + 'fields_values': { + 'notified_partner_ids': self.partner_1 + self.partner_admin, + 'parent_id': log, # log serves as thread ancestor + }, + 'notif': [ + {'partner': self.partner_1, 'type': 'email',}, + {'partner': self.partner_admin, 'type': 'inbox',}, + ], + }], + ) + + _user_notif = gateway_record.message_notify( + body='User Notification', + partner_ids=self.partner_employee.ids, + subtype_id=self.env.ref('mail.mt_comment').id, + ) + + # coming from Odoo1: their reply as an incoming email + with self.mock_mail_gateway(): + self.format_and_process( + MAIL_TEMPLATE, self.email_from, reply.reply_to, + subject='Gateway Creation', + extra=f'References: {reply.message_id} ', + debug_log=True, + ) + reply_2 = gateway_record.message_ids[0] + self.assertMailNotifications( + reply_2, + [{ + 'content': 'Please call me', + 'email_values': { + 'email_from': self.email_from, + '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': { + 'author_id': self.env['res.partner'], + 'notified_partner_ids': self.partner_employee + self.partner_admin, + 'parent_id': log, # log serves as thread ancestor + }, + 'message_type': 'email', + 'notif': [ + {'partner': self.partner_employee, 'type': 'email',}, + {'partner': self.partner_admin, 'type': 'inbox',}, + ], + }], + ) + + _other_log = gateway_record._message_log( + body='Internal log', + ) + + with self.mock_mail_gateway(): + gateway_record.with_user(self.user_employee).message_post( + body='Odoo Reply 2', + message_type='comment', + partner_ids=self.partner_1.ids, + subtype_id=self.env.ref('mail.mt_comment').id, + ) + self.assertEqual(gateway_record.message_partner_ids, self.partner_admin + self.partner_employee) + reply_3 = gateway_record.message_ids[0] + self.assertMailNotifications( + reply_3, + [{ + 'content': 'Odoo Reply 2', + 'email_values': { + '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': { + 'notified_partner_ids': self.partner_1 + self.partner_admin, + 'parent_id': log, # log serves as thread ancestor + }, + 'notif': [ + {'partner': self.partner_1, 'type': 'email',}, + {'partner': self.partner_admin, 'type': 'inbox',}, + ], + }], + ) + + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_thread') + def test_routing_reply_mailing_references(self): + """ 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 ?) + ]: + with self.subTest(reply_to_mode=reply_to_mode, auto_delete_message=auto_delete_message): + 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_composition_mode': 'mass_mail', + 'default_email_from': self.user_employee.email_formatted, + 'default_model': self.test_records._name, + 'default_subject': 'Coucou Hibou', + })) + composer_form.body = f'

Hello

' + composer_form.reply_to_mode = reply_to_mode + 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() + self.assertFalse(mails.exists()) + + # check reply using references + # TDE TODO: update tooling + outgoing_message_ids = [outgoing['message_id'] for outgoing in self._mails] + self.assertEqual(len(set(outgoing_message_ids)), len(self.test_records), + 'All message IDs should be different') + for record in self.test_records: + outgoing = self._find_sent_email(self.user_employee.email_formatted, [record.email_from]) + # 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: + 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})', + 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: + self.assertFalse(new_message) + self.assertFalse(gateway_record) + continue + self.assertTrue(new_message) + if reply_to_mode == 'update': + self.assertFalse(gateway_record, 'No record created based on subject, as it replies to the thread') + self.assertMessageFields(new_message, { + 'email_from': record.email_from, + 'model': record._name, + 'res_id': record.id, + }) + else: + self.assertNotEqual(gateway_record, record) + self.assertMessageFields(new_message, { + 'email_from': record.email_from, + 'model': gateway_record._name, + 'res_id': gateway_record.id, + }) + + +@tagged('mail_gateway', 'mail_thread') +class TestMailThreadCC(TestMailCommon): + + @classmethod + def setUpClass(cls): + super(TestMailThreadCC, cls).setUpClass() + + cls.email_from = 'Sylvie Lelitre ' + cls.alias = cls.env['mail.alias'].create({ + 'alias_contact': 'everyone', + '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', + 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']) + + @mute_logger('odoo.addons.mail.models.mail_thread') + def test_message_cc_update_with_old(self): + 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', + 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)') + + @mute_logger('odoo.addons.mail.models.mail_thread') + def test_message_cc_update_no_old(self): + 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', + 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 new file mode 100644 index 0000000..fc595bb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_mail.py @@ -0,0 +1,907 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import psycopg2 +import pytz +import smtplib + +from datetime import datetime, timedelta +from freezegun import freeze_time +from OpenSSL.SSL import Error as SSLError +from socket import gaierror, timeout +from unittest.mock import call, patch + +from odoo import api, Command, tools +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.tests import common, tagged, users +from odoo.tools import mute_logger, DEFAULT_SERVER_DATETIME_FORMAT + + +@tagged('mail_mail') +class TestMailMail(TestMailCommon): + + @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_mail = cls.env['mail.mail'].create([{ + 'body': '

Body

', + 'email_from': False, + 'email_to': 'test@example.com', + 'is_notification': True, + 'subject': 'Subject', + }]) + cls.test_notification = cls.env['mail.notification'].create({ + 'is_read': False, + 'mail_mail_id': cls.test_mail.id, + 'mail_message_id': cls.test_message.id, + 'notification_type': 'email', + 'res_partner_id': cls.partner_employee.id, # not really used for matching except multi-recipients + }) + + cls.emails_falsy = [False, '', ' '] + cls.emails_invalid = ['buggy', 'buggy, wrong'] + cls.emails_invalid_ascii = ['raoul@example¢¡.com'] + cls.emails_valid = ['raoul¢¡@example.com', 'raoul@example.com'] + + def _reset_data(self): + self._init_mail_mock() + self.test_mail.write({'failure_reason': False, 'failure_type': False, 'state': 'outgoing'}) + self.test_notification.write({'failure_reason': False, 'failure_type': False, 'notification_status': 'ready'}) + + @users('admin') + def test_mail_mail_attachment_access(self): + mail = self.env['mail.mail'].create({ + 'body_html': 'Test', + 'email_to': 'test@example.com', + 'partner_ids': [(4, self.user_employee.partner_id.id)], + 'attachment_ids': [ + (0, 0, {'name': 'file 1', 'datas': 'c2VjcmV0'}), + (0, 0, {'name': 'file 2', 'datas': 'c2VjcmV0'}), + (0, 0, {'name': 'file 3', 'datas': 'c2VjcmV0'}), + (0, 0, {'name': 'file 4', 'datas': 'c2VjcmV0'}), + ], + }) + + 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') + + mail.invalidate_recordset() + + new_attachment = self.env['ir.attachment'].create({ + 'name': 'new file', + 'datas': 'c2VjcmV0', + }) + + with patch.object(type(self.env['ir.attachment']), 'check', _patched_check): + # Sanity check + self.assertEqual(mail.restricted_attachment_count, 2) + self.assertEqual(len(mail.unrestricted_attachment_ids), 2) + self.assertEqual(mail.unrestricted_attachment_ids.mapped('name'), ['file 1', 'file 3']) + + # Add a new attachment + mail.write({ + 'unrestricted_attachment_ids': [Command.link(new_attachment.id)], + }) + self.assertEqual(mail.restricted_attachment_count, 2) + self.assertEqual(len(mail.unrestricted_attachment_ids), 3) + self.assertEqual(mail.unrestricted_attachment_ids.mapped('name'), ['file 1', 'file 3', 'new file']) + self.assertEqual(len(mail.attachment_ids), 5) + + # Remove an attachment + mail.write({ + 'unrestricted_attachment_ids': [Command.unlink(new_attachment.id)], + }) + self.assertEqual(mail.restricted_attachment_count, 2) + self.assertEqual(len(mail.unrestricted_attachment_ids), 2) + self.assertEqual(mail.unrestricted_attachment_ids.mapped('name'), ['file 1', 'file 3']) + self.assertEqual(len(mail.attachment_ids), 4) + + # Reset command + mail.invalidate_recordset() + mail.write({'unrestricted_attachment_ids': [Command.clear()]}) + self.assertEqual(len(mail.unrestricted_attachment_ids), 0) + self.assertEqual(len(mail.attachment_ids), 2) + + # Read in SUDO + mail.invalidate_recordset() + 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_recipients(self): + """ Partner_ids is a field used from mail_message, but not from mail_mail. """ + mail = self.env['mail.mail'].sudo().create({ + 'body_html': '

Test

', + 'email_to': 'test@example.com', + 'partner_ids': [(4, self.user_employee.partner_id.id)] + }) + with self.mock_mail_gateway(): + mail.send() + self.assertSentEmail(mail.env.user.partner_id, ['test@example.com']) + self.assertEqual(len(self._mails), 1) + + mail = self.env['mail.mail'].sudo().create({ + 'body_html': '

Test

', + 'email_to': 'test@example.com', + 'recipient_ids': [(4, self.user_employee.partner_id.id)], + }) + with self.mock_mail_gateway(): + mail.send() + self.assertSentEmail(mail.env.user.partner_id, ['test@example.com']) + self.assertSentEmail(mail.env.user.partner_id, [self.user_employee.email_formatted]) + self.assertEqual(len(self._mails), 2) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_recipients_cc(self): + """ Partner_ids is a field used from mail_message, but not from mail_mail. """ + mail = self.env['mail.mail'].sudo().create({ + 'body_html': '

Test

', + 'email_cc': 'test.cc.1@example.com, "Herbert" ', + 'email_to': 'test.rec.1@example.com, "Raoul" ', + 'recipient_ids': [(4, self.user_employee.partner_id.id)], + }) + + with self.mock_mail_gateway(): + mail.send() + # note that formatting is lost for cc + 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) + self.assertSentEmail(mail.env.user.partner_id, [self.user_employee.email_formatted], + email_cc=['test.cc.1@example.com', '"Herbert" ']) + self.assertEqual(len(self._mails), 2) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_recipients_formatting(self): + """ Check support of email / formatted email """ + mail = self.env['mail.mail'].sudo().create({ + 'author_id': False, + 'body_html': '

Test

', + 'email_cc': 'test.cc.1@example.com, "Herbert" ', + 'email_from': '"Ignasse" ', + 'email_to': 'test.rec.1@example.com, "Raoul" ', + }) + + with self.mock_mail_gateway(): + mail.send() + # note that formatting is lost for cc + self.assertSentEmail('"Ignasse" ', + ['test.rec.1@example.com', '"Raoul" '], + email_cc=['test.cc.1@example.com', '"Herbert" ']) + self.assertEqual(len(self._mails), 1) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_return_path(self): + # mail without thread-enabled record + base_values = { + 'body_html': '

Test

', + 'email_to': 'test@example.com', + } + + mail = self.env['mail.mail'].create(base_values) + with self.mock_mail_gateway(): + mail.send() + self.assertEqual(self._mails[0]['headers']['Return-Path'], '%s@%s' % (self.alias_bounce, self.alias_domain)) + + # mail on thread-enabled record + mail = self.env['mail.mail'].create(dict(base_values, **{ + 'model': self.test_record._name, + 'res_id': self.test_record.id, + })) + with self.mock_mail_gateway(): + mail.send() + self.assertEqual(self._mails[0]['headers']['Return-Path'], '%s@%s' % (self.alias_bounce, self.alias_domain)) + + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests') + def test_mail_mail_schedule(self): + """Test that a mail scheduled in the past/future are sent or not""" + now = datetime(2022, 6, 28, 14, 0, 0) + scheduled_datetimes = [ + # falsy values + False, '', 'This is not a date format', + # 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), + (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", + # tz: is actually 1 hour after now in UTC + (now + timedelta(hours=-3)).strftime("%H:%M:%S %d-%m-%Y") + " -0400", + ] + expected_datetimes = [ + False, False, False, + now, now - pytz.timezone('Australia/Brisbane').utcoffset(now), + now - timedelta(days=1), now + timedelta(days=1), now + timedelta(days=1), + now + timedelta(hours=-1), + now + timedelta(hours=1), + ] + expected_states = [ + # falsy values = send now + 'sent', 'sent', 'sent', + 'sent', 'sent', + 'sent', 'outgoing', 'outgoing', + 'sent', 'outgoing' + ] + + mails = self.env['mail.mail'].create([ + {'body_html': '

Test

', + 'email_to': 'test@example.com', + 'scheduled_date': scheduled_datetime, + } for scheduled_datetime in scheduled_datetimes + ]) + + for mail, expected_datetime, scheduled_datetime in zip(mails, expected_datetimes, scheduled_datetimes): + self.assertEqual(mail.scheduled_date, expected_datetime, + '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): + 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') + 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.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') + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_send_exceptions_recipients_emails(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 + + 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') + 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) + 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') + + # 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') + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_send_exceptions_recipients_partners(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 + + mail.write({'email_from': 'test.user@test.example.com', 'email_to': False}) + partners_falsy = self.env['res.partner'].create([ + {'name': 'Name %s' % email, 'email': email} + for email in self.emails_falsy + ]) + partners_invalid = self.env['res.partner'].create([ + {'name': 'Name %s' % email, 'email': email} + for email in self.emails_invalid + ]) + partners_invalid_ascii = self.env['res.partner'].create([ + {'name': 'Name %s' % email, 'email': email} + for email in self.emails_invalid_ascii + ]) + partners_valid = self.env['res.partner'].create([ + {'name': 'Name %s' % email, 'email': email} + for email in self.emails_valid + ]) + + # 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') + + # 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') + + # 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') + + # 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') + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_send_exceptions_recipients_partners_mixed(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 + + mail.write({'email_to': 'test@example.com'}) + partners_falsy = self.env['res.partner'].create([ + {'name': 'Name %s' % email, 'email': email} + for email in self.emails_falsy + ]) + partners_invalid = self.env['res.partner'].create([ + {'name': 'Name %s' % email, 'email': email} + for email in self.emails_invalid + ]) + partners_valid = self.env['res.partner'].create([ + {'name': 'Name %s' % email, 'email': email} + for email in self.emails_valid + ]) + + # valid to, missing email for recipient or wrong email for recipient + for partner in partners_falsy + 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.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_type, 'mail_email_invalid', 'Mail: void email considered as invalid') + self.assertEqual(notification.notification_status, 'exception') + + # update to have valid partner and invalid partner + mail.write({'recipient_ids': [(5, 0), (4, partners_valid[1].id), (4, partners_falsy[0].id)]}) + notification.write({'res_partner_id': partners_valid[1].id}) + notification2 = notification.create({ + 'is_read': False, + 'mail_mail_id': mail.id, + 'mail_message_id': self.test_message.id, + 'notification_type': 'email', + 'res_partner_id': partners_falsy[0].id, + }) + + # 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') + + # 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) + + 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): + """ 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 + mail.write({'email_from': 'test.user@test.example.com', 'email_to': 'test@example.com'}) + + # SMTP connecting issues + with self.mock_mail_gateway(): + _connect_current = self.connect_mocked.side_effect + + # classic errors that may be raised during sending, just to test their current support + for error, msg in [ + (smtplib.SMTPServerDisconnected('SMTPServerDisconnected'), 'SMTPServerDisconnected'), + (smtplib.SMTPResponseException('code', 'SMTPResponseException'), 'code\nSMTPResponseException'), + (smtplib.SMTPNotSupportedError('SMTPNotSupportedError'), 'SMTPNotSupportedError'), + (smtplib.SMTPException('SMTPException'), 'SMTPException'), + (SSLError('SSLError'), 'SSLError'), + (gaierror('gaierror'), 'gaierror'), + (timeout('timeout'), 'timeout')]: + + def _connect(*args, **kwargs): + raise error + self.connect_mocked.side_effect = _connect + + mail.send(raise_exception=False) + self.assertEqual(mail.failure_reason, msg) + self.assertFalse(mail.failure_type) + self.assertEqual(mail.state, 'exception') + self.assertFalse(notification.failure_reason, 'Mail: failure reason not propagated') + self.assertEqual(notification.failure_type, 'mail_smtp') + self.assertEqual(notification.notification_status, 'exception') + self._reset_data() + + self.connect_mocked.side_effect = _connect_current + + # SMTP sending issues + with self.mock_mail_gateway(): + _send_current = self.send_email_mocked.side_effect + self._reset_data() + mail.write({'email_to': 'test@example.com'}) + + # should always raise for those errors, even with raise_exception=False + 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 + + with self.assertRaises(error_class): + mail.send(raise_exception=False) + self.assertFalse(mail.failure_reason, 'SMTPServerDisconnected/MemoryError during Send raises and lead to a rollback') + self.assertFalse(mail.failure_type, 'SMTPServerDisconnected/MemoryError during Send raises and lead to a rollback') + self.assertEqual(mail.state, 'outgoing', 'SMTPServerDisconnected/MemoryError during Send raises and lead to a rollback') + self.assertFalse(notification.failure_reason, 'SMTPServerDisconnected/MemoryError during Send raises and lead to a rollback') + self.assertFalse(notification.failure_type, 'SMTPServerDisconnected/MemoryError during Send raises and lead to a rollback') + self.assertEqual(notification.notification_status, 'ready', 'SMTPServerDisconnected/MemoryError during Send raises and lead to a rollback') + + # 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 + + 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.state, 'exception') + self.assertEqual(notification.failure_reason, msg) + self.assertEqual(notification.failure_type, 'unknown', 'Mail: generic failure type') + self.assertEqual(notification.notification_status, 'exception') + + self.send_email_mocked.side_effect = _send_current + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_send_server(self): + """Test that the mails are send in batch. + + 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') + + mail_values = { + 'body_html': '

Test

', + 'email_to': 'user@example.com', + } + + # Should be encapsulated in the notification email + mails = self.env['mail.mail'].create([{ + **mail_values, + 'email_from': 'test@unknown_domain.com', + } for _ in range(5)]) | self.env['mail.mail'].create([{ + **mail_values, + 'email_from': 'test_2@unknown_domain.com', + } for _ in range(5)]) + + # 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([{ + **mail_values, + 'email_from': 'user_1@test_2.com', + } 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([{ + **mail_values, + 'email_from': 'user_1@test_2.com', + 'mail_server_id': self.server_domain.id, + } for _ in range(5)]) + + with self.mock_smtplib_connection(): + mails.send() + + self.assertEqual(self.find_mail_server_mocked.call_count, 4, 'Must be called only once per "mail from" when the mail server is not forced') + self.assertEqual(len(self.emails), 25) + + # Check call to the connect method to ensure that we authenticate + # to the right mail server with the right login + 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), + ], + 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) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_values_email_formatted(self): + """ Test outgoing email values, with formatting """ + customer = self.env['res.partner'].create({ + 'name': 'Tony Customer', + 'email': '"Formatted Emails" ', + }) + mail = self.env['mail.mail'].create({ + 'body_html': '

Test

', + 'email_cc': '"Ignasse, le Poilu" ', + 'email_to': '"Raoul, le Grand" , "Micheline, l\'immense" ', + 'recipient_ids': [(4, self.user_employee.partner_id.id), (4, customer.id)] + }) + with self.mock_mail_gateway(): + mail.send() + self.assertEqual(len(self._mails), 3, 'Mail: sent 3 emails: 1 for email_to, 1 / recipient') + 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'))] + ]), + 'Mail: formatting issues should have been removed as much as possible' + ) + # Currently broken: CC are added to ALL emails (spammy) + self.assertEqual( + [_mail['email_cc'] for _mail in self._mails], + [['"Ignasse, le Poilu" ']] * 3, + 'Mail: currently always removing formatting in email_cc' + ) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_values_email_multi(self): + """ Test outgoing email values, with email field holding multi emails """ + # Multi + customer = self.env['res.partner'].create({ + 'name': 'Tony Customer', + 'email': 'tony.customer@test.example.com, norbert.customer@test.example.com', + }) + mail = self.env['mail.mail'].create({ + 'body_html': '

Test

', + 'email_cc': 'test.cc.1@test.example.com, test.cc.2@test.example.com', + 'email_to': 'test.email.1@test.example.com, test.email.2@test.example.com', + 'recipient_ids': [(4, self.user_employee.partner_id.id), (4, customer.id)] + }) + with self.mock_mail_gateway(): + mail.send() + self.assertEqual(len(self._mails), 3, 'Mail: sent 3 emails: 1 for email_to, 1 / recipient') + 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'))]), + ]), + '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) + self.assertEqual( + [_mail['email_cc'] for _mail in self._mails], + [['test.cc.1@test.example.com', 'test.cc.2@test.example.com']] * 3, + ) + + # Multi + formatting + customer = self.env['res.partner'].create({ + 'name': 'Tony Customer', + 'email': 'tony.customer@test.example.com, "Norbert Customer" ', + }) + mail = self.env['mail.mail'].create({ + 'body_html': '

Test

', + 'email_cc': 'test.cc.1@test.example.com, test.cc.2@test.example.com', + 'email_to': 'test.email.1@test.example.com, test.email.2@test.example.com', + 'recipient_ids': [(4, self.user_employee.partner_id.id), (4, customer.id)] + }) + with self.mock_mail_gateway(): + mail.send() + self.assertEqual(len(self._mails), 3, 'Mail: sent 3 emails: 1 for email_to, 1 / recipient') + 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'))]), + ]), + '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) + self.assertEqual( + [_mail['email_cc'] for _mail in self._mails], + [['test.cc.1@test.example.com', 'test.cc.2@test.example.com']] * 3, + ) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_values_email_unicode(self): + """ Unicode should be fine. """ + mail = self.env['mail.mail'].create({ + 'body_html': '

Test

', + 'email_cc': 'test.😊.cc@example.com', + 'email_to': 'test.😊@example.com', + }) + with self.mock_mail_gateway(): + mail.send() + self.assertEqual(len(self._mails), 1) + self.assertEqual(self._mails[0]['email_cc'], ['test.😊.cc@example.com']) + self.assertEqual(self._mails[0]['email_to'], ['test.😊@example.com']) + + @users('admin') + 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. """ + customer = self.env['res.partner'].create({ + 'name': 'Uppercase Partner', + 'email': 'Uppercase.Partner.youpie@example.gov.uni', + }) + for recipient_values, (exp_to, exp_cc) 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'}, + ], [ + (['uppercase.customer.to@example.gov.uni'], []), + (['"Formatted Customer" '], []), + (['"Uppercase Partner" '], ['uppercase.customer.cc@example.gov.uni']), + ] + ): + with self.subTest(values=recipient_values): + mail = self.env['mail.mail'].create({ + 'body_html': '

Test

', + 'email_from': '"Forced From" ', + **recipient_values, + }) + with self.mock_mail_gateway(): + mail.send() + self.assertSentEmail('"Forced From" ', exp_to, email_cc=exp_cc) + + +@tagged('mail_mail') +class TestMailMailRace(common.TransactionCase): + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_bounce_during_send(self): + self.partner = self.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({ + 'body_html': '

Test

', + 'is_notification': True, + 'state': 'outgoing', + 'recipient_ids': [(4, self.partner.id)] + }) + mail_message = mail.mail_message_id + + message = self.env['mail.message'].create({ + 'subject': 'S', + 'body': 'B', + 'subtype_id': self.ref('mail.mt_comment'), + 'notification_ids': [(0, 0, { + 'res_partner_id': self.partner.id, + 'mail_mail_id': mail.id, + 'notification_type': 'email', + 'is_read': True, + 'notification_status': 'ready', + })], + }) + notif = self.env['mail.notification'].search([('res_partner_id', '=', self.partner.id)]) + # we need to commit transaction or cr will keep the lock on notif + self.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 + bounce_deferred = [] + @api.model + def send_email(self, message, *args, **kwargs): + 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: + # record already locked by send, all good + bounce_deferred.append(True) + else: + # this should trigger psycopg2.extensions.TransactionRollbackError in send(). + # Only here to simulate the initial use case + # If the record is lock, this line would create a deadlock since we are in the same thread + # 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() + + 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) 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 new file mode 100644 index 0000000..c80f57e --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_management.py @@ -0,0 +1,53 @@ +# -*- 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.tests import tagged + + +@tagged('mail_management') +class TestMailManagement(TestMailCommon, TestRecipients): + + @classmethod + def setUpClass(cls): + super(TestMailManagement, cls).setUpClass() + cls.test_record = cls.env['mail.test.simple'].with_context(cls._test_context).create({'name': 'Test'}) + cls._reset_mail_context(cls.test_record) + cls.msg = cls.test_record.message_post(body='TEST BODY', author_id=cls.partner_employee.id) + cls.notif_p1 = cls.env['mail.notification'].create({ + 'author_id': cls.msg.author_id.id, + 'mail_message_id': cls.msg.id, + 'res_partner_id': cls.partner_1.id, + 'notification_type': 'email', + 'notification_status': 'exception', + 'failure_type': 'mail_smtp', + }) + cls.notif_p2 = cls.env['mail.notification'].create({ + 'author_id': cls.msg.author_id.id, + 'mail_message_id': cls.msg.id, + 'res_partner_id': cls.partner_2.id, + 'notification_type': 'email', + 'notification_status': 'bounce', + 'failure_type': 'unknown', + }) + cls.partner_3 = cls.env['res.partner'].create({ + 'name': 'Partner3', + 'email': 'partner3@example.com', + }) + cls.notif_p3 = cls.env['mail.notification'].create({ + 'author_id': cls.msg.author_id.id, + 'mail_message_id': cls.msg.id, + 'res_partner_id': cls.partner_3.id, + 'notification_type': 'email', + 'notification_status': 'sent', + 'failure_type': None, + }) + + def test_mail_notify_cancel(self): + self._reset_bus() + + self.test_record.with_user(self.user_employee).notify_cancel_by_type('email') + self.assertEqual((self.notif_p1 | self.notif_p2 | self.notif_p3).mapped('notification_status'), + ['canceled', 'canceled', 'sent']) + + self.assertMessageBusNotifications(self.msg) 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 new file mode 100644 index 0000000..8001be4 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_message.py @@ -0,0 +1,307 @@ +# -*- 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.exceptions import UserError +from odoo.tools import is_html_empty, mute_logger, formataddr +from odoo.tests import tagged, users + + +@tagged('mail_message') +class TestMessageValues(TestMailCommon): + + @classmethod + def setUpClass(cls): + super(TestMessageValues, cls).setUpClass() + + cls.alias_record = cls.env['mail.test.container'].with_context(cls._test_context).create({ + 'name': 'Pigs', + 'alias_name': 'pigs', + 'alias_contact': 'followers', + }) + cls.Message = cls.env['mail.message'].with_user(cls.user_employee) + + @users('employee') + def test_empty_message(self): + """ Test that message is correctly considered as empty (see `_filter_empty()`). + Message considered as empty if: + - no body or empty body + - AND no subtype or no subtype description + - AND no tracking values + - AND no attachment + + Check _update_content behavior when voiding messages (cleanup side + records: stars, notifications). + """ + note_subtype = self.env.ref('mail.mt_note') + _attach_1 = self.env['ir.attachment'].with_user(self.user_employee).create({ + 'name': 'Attach1', + 'datas': 'bWlncmF0aW9uIHRlc3Q=', + 'res_id': 0, + 'res_model': 'mail.compose.message', + }) + record = self.env['mail.test.track'].create({'name': 'EmptyTesting'}) + self.flush_tracking() + record.message_subscribe(partner_ids=self.partner_admin.ids, subtype_ids=note_subtype.ids) + message = record.message_post( + attachment_ids=_attach_1.ids, + body='Test', + message_type='comment', + subtype_id=note_subtype.id, + ) + message.write({'starred_partner_ids': [(4, self.partner_admin.id)]}) + + # check content + self.assertEqual(len(message.attachment_ids), 1) + self.assertFalse(is_html_empty(message.body)) + self.assertEqual(len(message.sudo().notification_ids), 1) + self.assertEqual(message.notified_partner_ids, self.partner_admin) + self.assertEqual(message.starred_partner_ids, self.partner_admin) + self.assertFalse(message.sudo().tracking_value_ids) + + # Reset body case + record._message_update_content(message, '


', 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, '', []) + 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 + 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) + + # test tracking values + record.write({'user_id': self.user_admin.id}) + self.flush_tracking() + tracking_message = record.message_ids[0] + self.assertFalse(tracking_message.attachment_ids) + self.assertTrue(is_html_empty(tracking_message.body)) + 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, '', []) + + @mute_logger('odoo.models.unlink') + def test_mail_message_format_access(self): + """ + User that doesn't have access to a record should still be able to fetch + the record_name inside message_format. + """ + company_2 = self.env['res.company'].create({'name': 'Second Test Company'}) + record1 = self.env['mail.test.multi.company'].create({ + 'name': 'Test1', + 'company_id': company_2.id, + }) + 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. + 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') + + record1.write({"name": "Test2"}) + res = message.with_user(self.user_employee).message_format() + self.assertEqual(res[0].get('record_name'), 'Test2') + + 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) + self.assertEqual( + msg.body, + '

taratata image0 ' + 'image0

'.format(attachment=msg.attachment_ids[0]) + ) + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.models') + @users('employee') + def test_mail_message_values_fromto_long_name(self): + """ Long headers may break in python if above 68 chars for certain + DKIM verification stacks as folding is not done correctly + (see ``_notify_get_reply_to_formatted_email`` docstring + + 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({ + '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 + }) + reply_to_email = f"{test_record.alias_name}@{self.alias_domain}" + 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, + 'res_id': test_record.id + }) + 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') + + @mute_logger('odoo.models.unlink') + def test_mail_message_values_fromto_no_document_values(self): + msg = self.Message.create({ + 'reply_to': 'test.reply@example.com', + 'email_from': 'test.from@example.com', + }) + 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, 'test.reply@example.com') + self.assertEqual(msg.email_from, 'test.from@example.com') + + @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_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() + + 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))) + + @mute_logger('odoo.models.unlink') + def test_mail_message_values_fromto_document_alias(self): + 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.user.company_id.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))) + + # no alias domain -> author + self.env['ir.config_parameter'].search([('key', '=', 'mail.catchall.domain')]).unlink() + + 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]) + 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() + + 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))) + + @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'}) + + msg = self.Message.create({ + 'model': 'mail.test.simple', + '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_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))) + + @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_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, + }) + + msg = self.Message.create({ + 'model': 'mail.test.simple', + '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_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))) + + def test_mail_message_values_fromto_reply_to_force_new(self): + msg = self.Message.create({ + 'model': 'mail.test.container', + 'res_id': self.alias_record.id, + 'reply_to_force_new': True, + }) + 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]) 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 new file mode 100644 index 0000000..f15516d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_message_security.py @@ -0,0 +1,778 @@ +import base64 + +from unittest.mock import patch + +from odoo.addons.mail.tests.common import mail_new_test_user +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 + + +class MessageAccessCommon(TestMailCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.user_public = mail_new_test_user( + cls.env, + groups='base.group_public', + login='bert', + name='Bert Tartignole', + ) + cls.user_portal = mail_new_test_user( + cls.env, + groups='base.group_portal', + login='chell', + name='Chell Gladys', + ) + cls.user_portal_2 = mail_new_test_user( + cls.env, + groups='base.group_portal', + login='portal2', + name='Chell Gladys', + ) + + ( + cls.record_public, cls.record_portal, cls.record_portal_ro, + cls.record_followers, + cls.record_internal, cls.record_internal_ro, + cls.record_admin + ) = cls.env['mail.test.access'].create([ + {'access': 'public', 'name': 'Public Record'}, + {'access': 'logged', 'name': 'Portal Record'}, + {'access': 'logged_ro', 'name': 'Portal RO Record'}, + {'access': 'followers', 'name': 'Followers Record'}, + {'access': 'internal', 'name': 'Internal Record'}, + {'access': 'internal_ro', 'name': 'Internal Readonly Record'}, + {'access': 'admin', 'name': 'Admin Record'}, + ]) + for record in (cls.record_public + cls.record_portal + cls.record_portal_ro + cls.record_followers + + cls.record_internal + cls.record_internal_ro + cls.record_admin): + record.message_post( + body='Test Comment', + message_type='comment', + subtype_id=cls.env.ref('mail.mt_comment').id, + ) + record.message_post( + body='Test Answer', + message_type='comment', + subtype_id=cls.env.ref('mail.mt_comment').id, + ) + + +@tagged('mail_message', 'security', 'post_install', '-at_install') +class TestMailMessageAccess(MessageAccessCommon): + + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule') + def test_assert_initial_values(self): + """ Just ensure tests data """ + for record in ( + self.record_public + self.record_portal + self.record_portal_ro + self.record_followers + + self.record_internal + self.record_internal_ro + self.record_admin): + self.assertFalse(record.message_follower_ids) + self.assertEqual(len(record.message_ids), 3) + + for index, msg in enumerate(record.message_ids): + body = ['

Test Answer

', '

Test Comment

', '

Mail Access Test created

'][index] + message_type = ['comment', 'comment', 'notification'][index] + subtype_id = [self.env.ref('mail.mt_comment'), self.env.ref('mail.mt_comment'), self.env.ref('mail.mt_note')][index] + self.assertEqual(msg.author_id, self.partner_root) + self.assertEqual(msg.body, body) + self.assertEqual(msg.message_type, message_type) + self.assertFalse(msg.notified_partner_ids) + self.assertFalse(msg.partner_ids) + self.assertEqual(msg.subtype_id, subtype_id) + + # public user access check + for allowed in self.record_public: + allowed.with_user(self.user_public).read(['name']) + for forbidden in self.record_portal + self.record_portal_ro + self.record_followers + self.record_internal + self.record_internal_ro + self.record_admin: + with self.assertRaises(AccessError): + forbidden.with_user(self.user_public).read(['name']) + for forbidden in self.record_public + self.record_portal + self.record_portal_ro + self.record_followers + self.record_internal + self.record_internal_ro + self.record_admin: + with self.assertRaises(AccessError): + forbidden.with_user(self.user_public).write({'name': 'Update'}) + + # portal user access check + for allowed in self.record_public + self.record_portal + self.record_portal_ro: + allowed.with_user(self.user_portal).read(['name']) + for forbidden in self.record_internal + self.record_internal_ro + self.record_admin: + with self.assertRaises(AccessError): + forbidden.with_user(self.user_portal).read(['name']) + for allowed in self.record_portal: + allowed.with_user(self.user_portal).write({'name': 'Update'}) + for forbidden in self.record_public + self.record_portal_ro + self.record_followers + self.record_internal + self.record_internal_ro + self.record_admin: + with self.assertRaises(AccessError): + forbidden.with_user(self.user_portal).write({'name': 'Update'}) + self.record_followers.message_subscribe(self.user_portal.partner_id.ids) + self.record_followers.with_user(self.user_portal).read(['name']) + self.record_followers.with_user(self.user_portal).write({'name': 'Update'}) + + # internal user access check + for allowed in self.record_public + self.record_portal + self.record_portal_ro + self.record_followers + self.record_internal + self.record_internal_ro: + allowed.with_user(self.user_employee).read(['name']) + for forbidden in self.record_admin: + with self.assertRaises(AccessError): + forbidden.with_user(self.user_employee).read(['name']) + for allowed in self.record_public + self.record_portal + self.record_portal_ro + self.record_followers + self.record_internal: + allowed.with_user(self.user_employee).write({'name': 'Update'}) + for forbidden in self.record_internal_ro + self.record_admin: + with self.assertRaises(AccessError): + forbidden.with_user(self.user_employee).write({'name': 'Update'}) + + # elevated user access check + for allowed in self.record_public + self.record_portal + self.record_portal_ro + self.record_followers + self.record_internal + self.record_internal_ro + self.record_admin: + allowed.with_user(self.user_admin).read(['name']) + + # ------------------------------------------------------------ + # CREATE + # - Criterions + # - "private message" (no model, no res_id) -> deprecated + # - follower of document + # - document-based (write or create, using '_get_mail_message_access' + # hence '_mail_post_access' by default) + # - notified of parent message + # ------------------------------------------------------------ + + @mute_logger('odoo.addons.base.models.ir_rule') + 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.write({'partner_ids': [(4, self.user_employee.partner_id.id)]}) + + # prepare 'followers' condition + record_admin_fol = self.env['mail.test.access'].create({ + 'access': 'admin', + 'name': 'Admin Record Follower', + }) + record_admin_fol.message_subscribe(self.user_employee.partner_id.ids) + + for record, msg_vals, should_crash, reason in [ + # private-like + (self.env["mail.test.access"], {}, False, 'Private message like is ok'), + # document based + (self.record_internal, {}, False, 'W Access on record'), + (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, { + 'reply_to': 'avoid.catchall@my.test.com', # otherwise crashes + }, False, 'Followers > no access on record'), + # parent based + (self.record_admin, { # note: force reply_to normally computed by message_post avoiding ACLs issues + 'parent_id': admin_msg.id, + }, False, 'No access on record but reply to notified parent'), + ]: + with self.subTest(record=record, msg_vals=msg_vals, reason=reason): + 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, + }) + 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, + ) + 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, + }) + if record: + # TDE note: due to parent_id flattening, doing message_post + # with parent_id which should allow posting crashes, as + # parent_id is changed to an older message the employee cannot + # access. Won't fix that in stable. + 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, + ) + + def test_access_create_customized(self): + """ Test '_get_mail_message_access' 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' + record.write({'is_locked': True}) + for user in self.user_employee + self.user_portal: + with self.assertRaises(AccessError): + _message_portal = record.with_user(self.user_portal).message_post( + body='Another portal message', + subtype_id=self.env.ref('mail.mt_comment').id, + ) + + def test_access_create_mail_post_access(self): + """ Test 'mail_post_access' support that allows creating a message with + other rights than 'write' access on document """ + for post_value, should_crash in [ + ('read', False), + ('write', True), + ]: + with self.subTest(post_value=post_value): + with patch.object(MailTestAccess, '_mail_post_access', post_value): + if should_crash: + with self.assertRaises(AccessError): + self.env['mail.message'].with_user(self.user_employee).create({ + 'model': self.record_internal_ro._name, + 'res_id': self.record_internal_ro.id, + 'body': 'Test', + }) + else: + self.env['mail.message'].with_user(self.user_employee).create({ + 'model': self.record_internal_ro._name, + 'res_id': self.record_internal_ro.id, + 'body': 'Test', + }) + + @mute_logger('odoo.addons.base.models.ir_rule') + def test_access_create_portal(self): + """ Test group_portal creation rules """ + # prepare 'notified of parent' condition + admin_msg = self.record_admin.message_ids[-1] + admin_msg.write({'partner_ids': [(4, self.user_portal.partner_id.id)]}) + + # prepare 'followers' condition + record_admin_fol = self.env['mail.test.access'].create({ + 'access': 'admin', + 'name': 'Admin Record', + }) + record_admin_fol.message_subscribe(self.user_portal.partner_id.ids) + + for record, msg_vals, should_crash, reason in [ + # private-like + (self.env["mail.test.access"], {}, False, 'Private message like is ok'), + # document based + (self.record_portal, {}, False, 'W Access on record'), + (self.record_portal_ro, {}, True, 'No W Access on record'), + (self.record_internal, {}, True, 'No R/W Access on record'), + (record_admin_fol, { + 'reply_to': 'avoid.catchall@my.test.com', # otherwise crashes + }, False, 'Followers > no access on record'), + # parent based + (self.record_admin, { + 'parent_id': admin_msg.id, + }, False, 'No access on record but reply to notified parent'), + ]: + with self.subTest(record=record, msg_vals=msg_vals, reason=reason): + if should_crash: + with self.assertRaises(AccessError): + self.env['mail.message'].with_user(self.user_portal).create({ + 'model': record._name if record else False, + 'res_id': record.id if record else False, + 'body': 'Test', + **msg_vals, + }) + else: + _message = self.env['mail.message'].with_user(self.user_portal).create({ + 'model': record._name if record else False, + 'res_id': record.id if record else False, + 'body': 'Test', + **msg_vals, + }) + + # check '_mail_post_access', reducing W to R + with patch.object(MailTestAccess, '_mail_post_access', 'read'): + _message = self.env['mail.message'].with_user(self.user_portal).create({ + 'model': self.record_portal._name, + 'res_id': self.record_portal.id, + 'body': 'Test', + }) + + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule') + def test_access_create_public(self): + """ Public can never create messages """ + for record in [ + self.env['mail.test.access'], # old private message: no document + self.record_public, # read access + self.record_portal, # read access + ]: + with self.subTest(record=record): + # can never create message, simple + with self.assertRaises(AccessError): + self.env['mail.message'].with_user(self.user_public).create({ + 'model': record._name if record else False, + 'res_id': record.id if record else False, + 'body': 'Test', + }) + + @mute_logger('odoo.tests') + def test_access_create_wo_parent_access(self): + """ Purpose is to test posting a message on a record whose first message / parent + is not accessible by current user. This cause issues notably when computing + references, checking ancestors message_ids. """ + test_record = self.env['mail.test.simple'].with_context(self._test_context).create({ + 'email_from': 'ignasse@example.com', + 'name': 'Test', + }) + partner_1 = self.env['res.partner'].create({ + 'name': 'Not Jitendra Prajapati', + 'email': 'not.jitendra@mycompany.example.com', + }) + test_record.message_subscribe((partner_1 | self.user_admin.partner_id).ids) + + message = test_record.message_post( + body='

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']) + + with patch.object(MailTestSimple, 'check_access_rights', return_value=True): + with self.assertRaises(AccessError): + 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 + with self.mock_mail_gateway(): + new_msg = test_record.with_user(self.user_portal).message_post( + body='

This is Second Message

', + subject='Subject', + parent_id=message.id, + mail_auto_delete=False, + message_type='comment', + subtype_xmlid='mail.mt_comment', + ) + self.assertEqual(new_msg.sudo().parent_id, message) + + new_mail = self.env['mail.mail'].sudo().search([ + ('mail_message_id', '=', new_msg.id), + ]) + self.assertEqual( + new_mail.references, f'{message.message_id} {new_msg.message_id}', + 'References should not include message parent message_id, even if internal note, to help thread formation') + self.assertTrue(new_mail) + self.assertEqual(new_msg.parent_id, message) + + # ------------------------------------------------------------ + # READ + # - Criterions + # - author + # - recipients / notified + # - document-based: read, using '_get_mail_message_access' + # - share users: limited to 'not internal' (flag or subtype) + # ------------------------------------------------------------ + + def test_access_read(self): + """ Read access check for internal users. """ + for msg, msg_vals, should_crash, reason in [ + # document based + (self.record_internal.message_ids[0], {}, False, 'R Access on record'), + (self.record_internal_ro.message_ids[0], {}, False, 'R Access on record'), + (self.record_admin.message_ids[0], {}, True, 'No access on record'), + # author + (self.record_admin.message_ids[0], { + 'author_id': self.user_employee.partner_id.id, + }, False, 'Author > no access on record'), + # notified + (self.record_admin.message_ids[0], { + 'notification_ids': [(0, 0, { + 'res_partner_id': self.user_employee.partner_id.id, + })], + }, False, 'Notified > no access on record'), + (self.record_admin.message_ids[0], { + 'partner_ids': [(4, self.user_employee.partner_id.id)], + }, False, 'Recipients > no access on record'), + ]: + original_vals = { + 'author_id': msg.author_id.id, + 'notification_ids': [(6, 0, {})], + 'parent_id': msg.parent_id.id, + } + with self.subTest(msg=msg, reason=reason): + if msg_vals: + msg.write(msg_vals) + if should_crash: + with self.assertRaises(AccessError): + msg.with_user(self.user_employee).read(['body']) + else: + msg.with_user(self.user_employee).read(['body']) + if msg_vals: + msg.write(original_vals) + + def test_access_read_portal(self): + """ Read access check for portal users """ + for msg, msg_vals, should_crash, reason in [ + # document based + (self.record_portal.message_ids[0], {}, False, 'Access on record'), + (self.record_internal.message_ids[0], {}, True, 'No access on record'), + # author + (self.record_internal.message_ids[0], { + 'author_id': self.user_portal.partner_id.id, + }, False, 'Author > no access on record'), + # notified + (self.record_admin.message_ids[0], { + 'notification_ids': [(0, 0, { + 'res_partner_id': self.user_portal.partner_id.id, + })], + }, False, 'Notified > no access on record'), + # forbidden + (self.record_portal.message_ids[0], { + 'subtype_id': self.env.ref('mail.mt_note').id, + }, True, 'Note cannot be read by portal users'), + (self.record_portal.message_ids[0], { + 'is_internal': True, + }, True, 'Internal message cannot be read by portal users'), + ]: + original_vals = { + 'author_id': msg.author_id.id, + 'is_internal': False, + 'notification_ids': [(6, 0, {})], + 'parent_id': msg.parent_id.id, + 'subtype_id': self.env.ref('mail.mt_comment').id, + } + with self.subTest(msg=msg, reason=reason): + if msg_vals: + msg.write(msg_vals) + if should_crash: + with self.assertRaises(AccessError): + msg.with_user(self.user_portal).read(['body']) + else: + msg.with_user(self.user_portal).read(['body']) + if msg_vals: + msg.write(original_vals) + + def test_access_read_public(self): + """ Read access check for public users """ + for msg, msg_vals, should_crash, reason in [ + # document based + (self.record_public.message_ids[0], {}, False, 'Access on record'), + (self.record_portal.message_ids[0], {}, True, 'No access on record'), + # author + (self.record_internal.message_ids[0], { + 'author_id': self.user_public.partner_id.id, + }, False, 'Author > no access on record'), + # notified + (self.record_admin.message_ids[0], { + 'notification_ids': [(0, 0, { + 'res_partner_id': self.user_public.partner_id.id, + })], + }, False, 'Notified > no access on record'), + # forbidden + (self.record_public.message_ids[0], { + 'subtype_id': self.env.ref('mail.mt_note').id, + }, True, 'Note cannot be read by public users'), + (self.record_public.message_ids[0], { + 'is_internal': True, + }, True, 'Internal message cannot be read by public users'), + ]: + original_vals = { + 'author_id': msg.author_id.id, + 'is_internal': False, + 'notification_ids': [(6, 0, {})], + 'parent_id': msg.parent_id.id, + 'subtype_id': self.env.ref('mail.mt_comment').id, + } + with self.subTest(msg=msg, reason=reason): + if msg_vals: + msg.write(msg_vals) + if should_crash: + with self.assertRaises(AccessError): + msg.with_user(self.user_public).read(['body']) + else: + msg.with_user(self.user_public).read(['body']) + if msg_vals: + msg.write(original_vals) + + # ------------------------------------------------------------ + # UNLINK + # - Criterion: document-based (write or create), using '_get_mail_message_access' + # ------------------------------------------------------------ + + def test_access_unlink(self): + """ Unlink access check for internal users """ + for msg, msg_vals, should_crash, reason in [ + # document based + (self.record_portal.message_ids[0], {}, False, 'W Access on record'), + (self.record_internal_ro.message_ids[0], {}, True, 'R Access on record'), + # notified + (self.record_admin.message_ids[0], { + 'notification_ids': [(0, 0, { + 'res_partner_id': self.user_portal.partner_id.id, + })], + }, True, 'Even notified, cannot remove'), + ]: + with self.subTest(msg=msg, reason=reason): + if msg_vals: + msg.write(msg_vals) + if should_crash: + with self.assertRaises(AccessError): + msg.with_user(self.user_portal).unlink() + else: + msg.with_user(self.user_portal).unlink() + + def test_access_unlink_portal(self): + """ Unlink access check for portal users. """ + for msg, msg_vals, should_crash, reason in [ + # document based + (self.record_portal.message_ids[0], {}, False, 'W Access on record but unlink limited'), + (self.record_portal_ro.message_ids[0], {}, True, 'R Access on record'), + # notified + (self.record_admin.message_ids[0], { + 'notification_ids': [(0, 0, { + 'res_partner_id': self.user_portal.partner_id.id, + })], + }, True, 'Even notified, cannot remove'), + ]: + with self.subTest(msg=msg, reason=reason): + if msg_vals: + msg.write(msg_vals) + if should_crash: + with self.assertRaises(AccessError): + msg.with_user(self.user_portal).unlink() + else: + msg.with_user(self.user_portal).unlink() + + # ------------------------------------------------------------ + # WRITE + # - Criterions + # - author + # - recipients / notified + # - document-based (write or create), using '_get_mail_message_access' + # ------------------------------------------------------------ + + def test_access_write(self): + """ Test updating message content as internal user """ + for msg, msg_vals, should_crash, reason in [ + # document based + (self.record_internal.message_ids[0], {}, False, 'W Access on record'), + (self.record_internal_ro.message_ids[0], {}, True, 'No W Access on record'), + (self.record_admin.message_ids[0], {}, True, 'No access on record'), + # author + (self.record_admin.message_ids[0], { + 'author_id': self.user_employee.partner_id.id, + }, False, 'Author > no access on record'), + # notified + (self.record_admin.message_ids[0], { + 'notification_ids': [(0, 0, { + 'res_partner_id': self.user_employee.partner_id.id, + })], + }, False, 'Notified > no access on record'), + ]: + original_vals = { + 'author_id': msg.author_id.id, + 'notification_ids': [(6, 0, {})], + 'parent_id': msg.parent_id.id, + } + with self.subTest(msg=msg, reason=reason): + if msg_vals: + msg.write(msg_vals) + if should_crash: + with self.assertRaises(AccessError): + msg.with_user(self.user_employee).write({'body': 'Update'}) + else: + msg.with_user(self.user_employee).write({'body': 'Update'}) + if msg_vals: + msg.write(original_vals) + + @mute_logger('odoo.addons.base.models.ir_rule') + def test_access_write_envelope(self): + """ 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 + # 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}) + # To change in 18+ + message.write({'notification_ids': [ + (0, 0, {'res_partner_id': self.user_portal_2.partner_id.id}) + ]}) + + @mute_logger('odoo.addons.base.models.ir_rule') + def test_access_write_portal_notification(self): + """ Test updating message notification content as portal user """ + self.record_followers.message_subscribe(self.user_portal.partner_id.ids) + test_record = self.record_followers.with_user(self.user_portal) + test_record.read(['name']) + with self.assertRaises(AccessError): + test_record.with_user(self.user_portal_2).read(['name']) + message = test_record.message_ids[0].with_user(self.user_portal) + message.write({'body': 'Updated'}) + with self.assertRaises(AccessError): + message.with_user(self.user_portal_2).read(['subject']) + + # ------------------------------------------------------------ + # SEARCH + # ------------------------------------------------------------ + + def test_search(self): + """ Test custom 'search' implemented on 'mail.message' that replicates + custom rules defined on 'read' override """ + base_msg_vals = { + 'message_type': 'comment', + 'model': self.record_internal._name, + 'res_id': self.record_internal.id, + 'subject': '_ZTest', + } + + msgs = self.env['mail.message'].create([ + dict(base_msg_vals, + body='Private Comment (mention portal)', + model=False, + partner_ids=[(4, self.user_portal.partner_id.id)], + res_id=False, + subtype_id=self.ref('mail.mt_comment'), + ), + dict(base_msg_vals, + body='Internal Log', + subtype_id=False, + ), + dict(base_msg_vals, + body='Internal Note', + subtype_id=self.ref('mail.mt_note'), + ), + dict(base_msg_vals, + body='Internal Comment (mention portal)', + partner_ids=[(4, self.user_portal.partner_id.id)], + subtype_id=self.ref('mail.mt_comment'), + ), + dict(base_msg_vals, + body='Internal Comment (mention employee)', + partner_ids=[(4, self.user_employee.partner_id.id)], + subtype_id=self.ref('mail.mt_comment'), + ), + dict(base_msg_vals, + body='Internal Comment', + subtype_id=self.ref('mail.mt_comment'), + ), + ]) + msg_record_admin = self.env['mail.message'].create(dict(base_msg_vals, + body='Admin Comment', + model=self.record_admin._name, + res_id=self.record_admin.id, + subtype_id=self.ref('mail.mt_comment'), + )) + msg_record_portal = self.env['mail.message'].create(dict(base_msg_vals, + body='Portal Comment', + 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'), + )) + + for (test_user, add_domain), exp_messages in zip([ + (self.user_public, []), + (self.user_portal, []), + (self.user_employee, []), + (self.user_employee, [('body', 'ilike', 'Internal')]), + (self.user_admin, []), + ], [ + msg_record_public, + 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 + ]): + with self.subTest(test_user=test_user.name, add_domain=add_domain): + domain = [('subject', 'like', '_ZTest')] + add_domain + self.assertEqual(self.env['mail.message'].with_user(test_user).search(domain), exp_messages) + + +@tagged('mail_message', 'security', 'post_install', '-at_install') +class TestMessageSubModelAccess(MessageAccessCommon): + + def test_ir_attachment_read_message_notification(self): + message = self.record_admin.message_ids[0] + attachment = self.env['ir.attachment'].create({ + 'datas': base64.b64encode(b'My attachment'), + 'name': 'doc.txt', + 'res_model': message._name, + 'res_id': message.id}) + # attach the attachment to the message + message.write({'attachment_ids': [(4, attachment.id)]}) + message.write({'partner_ids': [(4, self.user_employee.partner_id.id)]}) + message.with_user(self.user_employee).read() + # Test: Employee has access to attachment, ok because they can read message + attachment.with_user(self.user_employee).read(['name', 'datas']) + + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule') + def test_mail_follower(self): + """ Read access check on sub entities of mail.message """ + internal_record = self.record_internal.with_user(self.user_employee) + internal_record.message_subscribe( + partner_ids=self.user_portal.partner_id.ids + ) + + # employee can access + follower = internal_record.message_follower_ids.filtered( + lambda f: f.partner_id == self.user_portal.partner_id + ) + self.assertTrue(follower) + with self.assertRaises(AccessError): + follower.with_user(self.user_portal).read(['partner_id']) + + # employee cannot update + with self.assertRaises(AccessError): + follower.write({'partner_id': self.user_admin.partner_id.id}) + follower.with_user(self.user_admin).write({'partner_id': self.user_admin.partner_id.id}) + + @mute_logger('odoo.addons.base.models.ir_rule') + def test_mail_notification(self): + """ Limit update of notifications for internal users """ + 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_portal.partner_id + self.user_employee.partner_id).ids, + subtype_id=self.env.ref('mail.mt_comment').id, + ) + notifications = message.with_user(self.user_employee).notification_ids + self.assertEqual(len(notifications), 2) + self.assertTrue(bool(notifications.read(['is_read'])), 'Internal can read') + + notif_other = notifications.filtered(lambda n: n.res_partner_id == self.user_portal.partner_id) + with self.assertRaises(AccessError): + notif_other.write({'is_read': True}) + + notif_own = notifications.filtered(lambda n: n.res_partner_id == self.user_employee.partner_id) + notif_own.write({'is_read': True}) + # with self.assertRaises(AccessError): + # notif_own.write({'author_id': self.user_portal.partner_id.id}) + with self.assertRaises(AccessError): + notif_own.write({'mail_message_id': self.record_internal.message_ids[0]}) + with self.assertRaises(AccessError): + notif_own.write({'res_partner_id': self.user_admin.partner_id.id}) + + 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)) + portal_record = self.record_portal.with_user(self.user_portal) + message = portal_record.message_post( + body='Hello People', + message_type='comment', + partner_ids=(self.user_portal_2.partner_id + self.user_employee.partner_id).ids, + subtype_id=self.env.ref('mail.mt_comment').id, + ) + notifications = message.notification_ids + 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) 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 new file mode 100644 index 0000000..be78d53 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_multicompany.py @@ -0,0 +1,436 @@ +# -*- 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 unittest.mock import patch +from werkzeug.urls import url_parse, url_decode + +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.tests import tagged, users, HttpCase +from odoo.tools import formataddr, mute_logger + + +@tagged('multi_company') +class TestMultiCompanySetup(TestMailCommon, TestRecipients): + + @classmethod + def setUpClass(cls): + super(TestMultiCompanySetup, cls).setUpClass() + cls._activate_multi_company() + + cls.test_model = cls.env['ir.model']._get('mail.test.gateway') + cls.email_from = '"Sylvie Lelitre" ' + + 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_records_mc = cls.env['mail.test.multi.company'].create([ + {'name': 'Test Company1', + 'company_id': cls.user_employee.company_id.id}, + {'name': 'Test Company2', + '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, + 'alias_model_id': cls.test_model.id, + 'alias_contact': 'everyone'}) + + # Set a first message on public group to test update and hierarchy + cls.fake_email = cls.env['mail.message'].create({ + 'model': 'mail.test.gateway', + 'res_id': cls.test_record.id, + 'subject': 'Public Discussion', + 'message_type': 'email', + 'subtype_id': cls.env.ref('mail.mt_comment').id, + 'author_id': cls.partner_1.id, + 'message_id': '<123456-openerp-%s-mail.test.gateway@%s>' % (cls.test_record.id, socket.gethostname()), + }) + + def setUp(self): + super(TestMultiCompanySetup, self).setUp() + # patch registry to simulate a ready environment + self.patch(self.env.registry, 'ready', True) + self.flush_tracking() + + @users('employee_c2') + @mute_logger('odoo.addons.base.models.ir_rule') + def test_post_with_read_access(self): + """ Check that with readonly access, a message with attachment can be + posted on a model with the attribute _mail_post_access = 'read'. """ + test_record_c1_su = self.env['mail.test.multi.company.read'].sudo().create([ + { + 'company_id': self.user_employee.company_id.id, + 'name': 'MC Readonly', + } + ]) + test_record_c1 = test_record_c1_su.with_env(self.env) + self.assertFalse(test_record_c1.message_main_attachment_id) + + self.assertEqual(test_record_c1.name, 'MC Readonly') + with self.assertRaises(AccessError): + test_record_c1.write({'name': 'Cannot Write'}) + + message = test_record_c1.message_post( + attachments=[('testAttachment', b'Test attachment')], + 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.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'), + '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')], + attachment_ids=new_attach.ids, + body='My Body', + message_type='comment', + subtype_xmlid='mail.mt_comment', + ) + self.assertEqual( + sorted(message.attachment_ids.mapped('name')), + ['TestAttachmentIDS.txt', 'testAttachment'], + ) + self.assertEqual(test_record_c1.message_main_attachment_id, first_attachment) + + @users('employee_c2') + @mute_logger('odoo.addons.base.models.ir_rule') + def test_post_wo_access(self): + test_records_mc_c1, test_records_mc_c2 = self.test_records_mc.with_env(self.env) + attachments_data = [ + ('ReportLike1', 'AttContent1'), + ('ReportLike2', 'AttContent2'), + ] + + # ------------------------------------------------------------ + # Other company (no access) + # ------------------------------------------------------------ + + _original_car = Message.check_access_rule + with patch.object(Message, 'check_access_rule', + autospec=True, side_effect=_original_car) as mock_msg_car: + with self.assertRaises(AccessError): + test_records_mc_c1.message_post( + body='

Hello

', + 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') + with self.assertRaises(AccessError): + _name = test_records_mc_c1.name + + # no access to company1, access to post through being notified of parent + with self.assertRaises(AccessError): + _subject = test_records_mc_c1.message_ids.subject + self.assertEqual(len(self.test_records_mc[0].message_ids), 1) + initial_message = self.test_records_mc[0].message_ids + + self.env['mail.notification'].sudo().create({ + 'mail_message_id': initial_message.id, + 'notification_status': 'sent', + 'res_partner_id': self.user_employee_c2.partner_id.id, + }) + # additional: works only if in partner_ids, not notified via followers + initial_message.write({ + 'partner_ids': [(4, self.user_employee_c2.partner_id.id)], + }) + # now able to post as was notified of parent message + test_records_mc_c1.message_post( + body='

Hello

', + 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, + subtype_xmlid='mail.mt_comment', + ) + + # ------------------------------------------------------------ + # User company (access granted) + # ------------------------------------------------------------ + + # can effectively link attachments with message to record of writable record + attachments = self.env['ir.attachment'].create( + self._generate_attachments_data( + 2, 'mail.compose.message', 0, + prefix='Same' + ) + ) + message = test_records_mc_c2.message_post( + attachments=attachments_data, + attachment_ids=attachments.ids, + body='

Hello

', + 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.assertTrue(attachments < message.attachment_ids) + self.assertEqual( + sorted(message.attachment_ids.mapped('name')), + ['ReportLike1', 'ReportLike2', 'SameAttFileName_00.txt', 'SameAttFileName_01.txt'], + ) + self.assertEqual( + message.attachment_ids.mapped('res_id'), + [test_records_mc_c2.id] * 4, + ) + self.assertEqual( + message.attachment_ids.mapped('res_model'), + [test_records_mc_c2._name] * 4, + ) + + # cannot link attachments of unreachable records when posting on a document + # they can access (aka no access delegation through posting message) + attachments = self.env['ir.attachment'].sudo().create( + self._generate_attachments_data( + 1, + test_records_mc_c1._name, + test_records_mc_c1.id, + prefix='NoAccessMC' + ) + ) + with self.assertRaises(AccessError): + message = test_records_mc_c2.message_post( + attachments=attachments_data, + attachment_ids=attachments.ids, + body='

Hello

', + 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", + } + ) + + +@tagged('-at_install', 'post_install', 'multi_company', 'mail_controller') +class TestMultiCompanyRedirect(TestMailCommon, HttpCase): + + @classmethod + def setUpClass(cls): + super(TestMultiCompanyRedirect, cls).setUpClass() + cls._activate_multi_company() + + def test_redirect_to_records(self): + """ Test mail/view redirection in MC environment, notably cids being + added in redirect if user has access to the record. """ + mc_record_c1, mc_record_c2 = 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', + } + ]) + + for (login, password), mc_record in product( + ((None, None), # not logged: redirect to web/login + ('employee', 'employee'), # access only main company + ('admin', 'admin'), # access both companies + ), + (mc_record_c1, mc_record_c2), + ): + with self.subTest(login=login, mc_record=mc_record): + self.authenticate(login, password) + response = self.url_open( + f'/mail/view?model={mc_record._name}&res_id={mc_record.id}', + timeout=15 + ) + self.assertEqual(response.status_code, 200) + + 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) + else: + user = self.env['res.users'].browse(self.session.uid) + self.assertEqual(user.login, login) + mc_error = login == 'employee' and mc_record == mc_record_c2 + if mc_error: + # 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') + 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'] + 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}') + + def test_redirect_to_records_nothread(self): + """ Test no thread models and redirection """ + nothreads = self.env['mail.test.nothread'].create([ + { + 'company_id': company.id, + 'name': f'Test with {company.name}', + } + for company in (self.company_admin, self.company_2, self.env['res.company']) + ]) + + # 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.user_admin.write({'company_id': user_company.id}) + response = self.url_open( + f'/mail/view?model={test_record._name}&res_id={test_record.id}', + timeout=15 + ) + 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)) + + # when being not logged, cids should not be added as redirection after + # logging will be 'mail/view' again + for test_record in nothreads: + with self.subTest(record_name=test_record.name): + self.authenticate(None, None) + response = self.url_open( + f'/mail/view?model={test_record._name}&res_id={test_record.id}', + timeout=15 + ) + self.assertEqual(response.status_code, 200) + decoded_fragment = url_decode(url_parse(response.url).fragment) + self.assertNotIn('cids', decoded_fragment) + + +@tagged("-at_install", "post_install", "multi_company", "mail_controller") +class TestMultiCompanyThreadData(TestMailCommon, HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._activate_multi_company() + + 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) 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 new file mode 100644 index 0000000..cf345c5 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_security.py @@ -0,0 +1,29 @@ +# -*- 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.exceptions import AccessError + + +class TestSubtypeAccess(TestMailCommon): + + def test_subtype_access(self): + """ + The function aims to formally verify the access restrictions on mail.message.subtype for + normal and admin users. It ensures that normal users are unable to modify it, + while admin users possess the necessary privileges to modify it successfully. + """ + + test_subtype = self.env['mail.message.subtype'].create({ + 'name': 'Test', + 'description': 'only description', + }) + + user = mail_new_test_user(self.env, 'Internal user', groups='base.group_user') + + with self.assertRaises(AccessError): + test_subtype.with_user(user).write({'description': 'changing description'}) + + test_subtype.with_user(self.user_admin).write({'description': 'testing'}) + self.assertEqual(test_subtype.description, 'testing') 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 new file mode 100644 index 0000000..a842621 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_template.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 +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.tools import mute_logger, safe_eval + + +class TestMailTemplateCommon(TestMailCommon, TestRecipients): + + @classmethod + def setUpClass(cls): + super(TestMailTemplateCommon, cls).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'), + 'res_model': 'res.partner', + 'res_id': cls.user_admin.partner_id.id + }, { + 'name': 'second.txt', + 'datas': base64.b64encode(b'My second attachment'), + 'res_model': 'res.partner', + 'res_id': cls.user_admin.partner_id.id + }] + + cls.email_1 = 'test1@example.com' + cls.email_2 = 'test2@example.com' + cls.email_3 = cls.partner_1.email + + # create a complete test template + cls.test_template = cls._create_template('mail.test.lang', { + 'attachment_ids': [(0, 0, cls._attachments[0]), (0, 0, cls._attachments[1])], + 'body_html': '

EnglishBody for

', + 'lang': '{{ object.customer_id.lang or object.lang }}', + 'email_to': '%s, %s' % (cls.email_1, cls.email_2), + '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 }}', + }) + + # activate translations + cls._activate_multi_lang( + layout_arch_db=' English Layout for ', + test_record=cls.test_record, test_template=cls.test_template + ) + + # admin should receive emails + cls.user_admin.write({'notification_type': 'email'}) + # Force the attachments of the template to be in the natural order. + cls.test_template.invalidate_recordset(['attachment_ids']) + + +@tagged('mail_template') +class TestMailTemplate(TestMailTemplateCommon): + + def test_template_add_context_action(self): + self.test_template.create_action() + + # check template act_window has been updated + self.assertTrue(bool(self.test_template.ref_ir_act_window)) + + # check those records + action = self.test_template.ref_ir_act_window + self.assertEqual(action.name, 'Send Mail (%s)' % self.test_template.name) + self.assertEqual(action.binding_model_id.model, 'mail.test.lang') + + @mute_logger('odoo.addons.mail.models.mail_mail') + @users('employee') + def test_template_schedule_email(self): + """ Test scheduling email sending from template. """ + now = datetime.datetime(2024, 4, 29, 10, 49, 59) + test_template = self.test_template.with_env(self.env) + + # schedule the mail in 3 days -> patch safe_eval.datetime access + safe_eval_orig = safe_eval.safe_eval + + def _safe_eval_hacked(*args, **kwargs): + """ safe_eval wraps 'datetime' and freeze_time does not mock it; + simplest solution found so far is to directly hack safe_eval just + for this test """ + if args[0] == "datetime.datetime.now() + datetime.timedelta(days=3)": + return now + datetime.timedelta(days=3) + return safe_eval_orig(*args, **kwargs) + + # patch datetime and safe_eval.datetime, as otherwise using standard 'now' + # might lead to errors due to test running right before minute switch it + # sometimes ends at minute+1 and assert fails - see runbot-54946 + with patch.object(safe_eval, "safe_eval", autospec=True, side_effect=_safe_eval_hacked): + test_template.scheduled_date = '{{datetime.datetime.now() + datetime.timedelta(days=3)}}' + with freeze_time(now): + mail_id = test_template.send_mail(self.test_record.id) + mail = self.env['mail.mail'].sudo().browse(mail_id) + self.assertEqual( + mail.scheduled_date.replace(second=0, microsecond=0), + (now + datetime.timedelta(days=3)).replace(second=0, microsecond=0), + ) + self.assertEqual(mail.state, 'outgoing') + + # check a wrong format + test_template.scheduled_date = '{{"test " * 5}}' + with freeze_time(now): + mail_id = test_template.send_mail(self.test_record.id) + mail = self.env['mail.mail'].sudo().browse(mail_id) + 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): + mail_id = self.test_template.send_mail(self.test_record.id) + mail = self.env['mail.mail'].sudo().browse(mail_id) + 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) + + @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_record.write({ + 'lang': 'es_ES', + }) + test_template = self.env['mail.template'].browse(self.test_template.ids) + + 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) + + @mute_logger('odoo.addons.mail.models.mail_mail') + 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) + + 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) 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 new file mode 100644 index 0000000..213f9e2 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_template_preview.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# 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 + +@tagged('mail_template', 'multi_lang') +class TestMailTemplateTools(TestMailTemplateCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.test_template_preview = cls.env['mail.template.preview'].create({ + 'mail_template_id': cls.test_template.id, + }) + + def test_initial_values(self): + self.assertTrue(self.test_template.email_to) + self.assertTrue(self.test_template.email_cc) + self.assertEqual(len(self.test_template.partner_to.split(',')), 2) + self.assertTrue(self.test_record.email_from) + + def test_mail_template_preview_force_lang(self): + test_record = self.env['mail.test.lang'].browse(self.test_record.ids) + test_record.write({ + 'lang': 'es_ES', + }) + test_template = self.env['mail.template'].browse(self.test_template.ids) + + preview = self.env['mail.template.preview'].create({ + 'mail_template_id': test_template.id, + 'resource_ref': test_record, + 'lang': 'es_ES', + }) + self.assertEqual(preview.body_html, '

SpanishBody for %s

' % test_record.name) + + preview.write({'lang': 'en_US'}) + self.assertEqual(preview.body_html, '

EnglishBody for %s

' % test_record.name) + + @users('employee') + def test_mail_template_preview_recipients(self): + form = Form(self.test_template_preview) + form.resource_ref = self.test_record + + self.assertEqual(form.email_to, self.test_template.email_to) + self.assertEqual(form.email_cc, self.test_template.email_cc) + self.assertEqual(set(record.id for record in form.partner_ids), + {int(pid) for pid in self.test_template.partner_to.split(',') if pid}) + + @users('employee') + def test_mail_template_preview_recipients_use_default_to(self): + self.test_template.use_default_to = True + form = Form(self.test_template_preview) + form.resource_ref = self.test_record + + self.assertEqual(form.email_to, self.test_record.email_from) + self.assertFalse(form.email_cc) + self.assertFalse(form.partner_ids) 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 new file mode 100644 index 0000000..71677f4 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_thread_internals.py @@ -0,0 +1,409 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from unittest.mock import patch +from unittest.mock import DEFAULT + +from odoo import exceptions +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.tools import mute_logger + + +@tagged('mail_thread') +class TestAPI(TestMailCommon, TestRecipients): + + @classmethod + def setUpClass(cls): + super(TestAPI, cls).setUpClass() + cls.ticket_record = cls.env['mail.test.ticket'].with_context(cls._test_context).create({ + 'email_from': '"Paulette Vachette" ', + 'name': 'Test', + 'user_id': cls.user_employee.id, + }) + + @mute_logger('openerp.addons.mail.models.mail_mail') + @users('employee') + def test_message_update_content(self): + """ Test updating message content. """ + ticket_record = self.ticket_record.with_env(self.env) + attachments = self.env['ir.attachment'].create( + self._generate_attachments_data(2, 'mail.compose.message', 0) + ) + + # post a note + message = ticket_record.message_post( + attachment_ids=attachments.ids, + body="

Initial Body

", + message_type="comment", + partner_ids=self.partner_1.ids, + ) + self.assertEqual(message.attachment_ids, 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, "

Initial Body

") + self.assertEqual(message.subtype_id, self.env.ref('mail.mt_note')) + + # 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 + ) + 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

") + + # void attachments + ticket_record._message_update_content( + message, "

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

") + + @mute_logger('openerp.addons.mail.models.mail_mail') + @users('employee') + def test_message_update_content_check(self): + """ 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

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

New Body

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

New Body

" + ) + + +@tagged('mail_thread') +class TestChatterTweaks(TestMailCommon, TestRecipients): + + @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'}) + + 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( + 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( + 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( + 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) + + @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 + induce wrong values for some fields, like parent_id. """ + parent = self.env['res.partner'].create({'name': 'Parent'}) + partner = self.env['res.partner'].with_context(default_parent_id=parent.id).create({'name': 'Contact'}) + self.assertFalse(partner.message_ids[-1].parent_id) + + def test_chatter_mail_create_nolog(self): + """ Test disable of automatic chatter message at create """ + rec = self.env['mail.test.simple'].with_user(self.user_employee).with_context({'mail_create_nolog': True}).create({'name': 'Test'}) + self.flush_tracking() + self.assertEqual(rec.message_ids, self.env['mail.message']) + + rec = self.env['mail.test.simple'].with_user(self.user_employee).with_context({'mail_create_nolog': False}).create({'name': 'Test'}) + self.flush_tracking() + self.assertEqual(len(rec.message_ids), 1) + + def test_chatter_mail_notrack(self): + """ Test disable of automatic value tracking at create and write """ + rec = self.env['mail.test.track'].with_user(self.user_employee).create({'name': 'Test', 'user_id': self.user_employee.id}) + self.flush_tracking() + self.assertEqual(len(rec.message_ids), 1, + "A creation message without tracking values should have been posted") + self.assertEqual(len(rec.message_ids.sudo().tracking_value_ids), 0, + "A creation message without tracking values should have been posted") + + rec.with_context({'mail_notrack': True}).write({'user_id': self.user_admin.id}) + self.flush_tracking() + self.assertEqual(len(rec.message_ids), 1, + "No new message should have been posted with mail_notrack key") + + rec.with_context({'mail_notrack': False}).write({'user_id': self.user_employee.id}) + self.flush_tracking() + self.assertEqual(len(rec.message_ids), 2, + "A tracking message should have been posted") + self.assertEqual(len(rec.message_ids.sudo().mapped('tracking_value_ids')), 1, + "New tracking message should have tracking values") + + def test_chatter_tracking_disable(self): + """ Test disable of all chatter features at create and write """ + rec = self.env['mail.test.track'].with_user(self.user_employee).with_context({'tracking_disable': True}).create({'name': 'Test', 'user_id': self.user_employee.id}) + self.flush_tracking() + self.assertEqual(rec.sudo().message_ids, self.env['mail.message']) + self.assertEqual(rec.sudo().mapped('message_ids.tracking_value_ids'), self.env['mail.tracking.value']) + + rec.write({'user_id': self.user_admin.id}) + self.flush_tracking() + self.assertEqual(rec.sudo().mapped('message_ids.tracking_value_ids'), self.env['mail.tracking.value']) + + rec.with_context({'tracking_disable': False}).write({'user_id': self.user_employee.id}) + self.flush_tracking() + self.assertEqual(len(rec.sudo().mapped('message_ids.tracking_value_ids')), 1) + + rec = self.env['mail.test.track'].with_user(self.user_employee).with_context({'tracking_disable': False}).create({'name': 'Test', 'user_id': self.user_employee.id}) + self.flush_tracking() + self.assertEqual(len(rec.sudo().message_ids), 1, + "Creation message without tracking values should have been posted") + self.assertEqual(len(rec.sudo().mapped('message_ids.tracking_value_ids')), 0, + "Creation message without tracking values should have been posted") + + def test_cache_invalidation(self): + """ Test that creating a mail-thread record does not invalidate the whole cache. """ + # make a new record in cache + record = self.env['res.partner'].new({'name': 'Brave New Partner'}) + self.assertTrue(record.name) + + # creating a mail-thread record should not invalidate the whole cache + self.env['res.partner'].create({'name': 'Actual Partner'}) + self.assertTrue(record.name) + + +@tagged('mail_thread') +class TestDiscuss(TestMailCommon, TestRecipients): + + @classmethod + def setUpClass(cls): + super(TestDiscuss, cls).setUpClass() + cls.test_record = cls.env['mail.test.simple'].with_context(cls._test_context).create({ + 'name': 'Test', + 'email_from': 'ignasse@example.com' + }) + + @mute_logger('openerp.addons.mail.models.mail_mail') + def test_mark_all_as_read(self): + def _employee_crash(*args, **kwargs): + """ 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 DEFAULT + + with patch.object(MailTestSimple, 'check_access_rights', 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']) + + employee_partner = self.env['res.partner'].with_user(self.user_employee).browse(self.partner_employee.ids) + + # 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=[{ + 'type': 'mail.message/mark_as_read', + 'payload': { + 'message_ids': [msg1.id], + 'needaction_inbox_counter': 0, + }, + }]): + employee_partner.env['mail.message'].mark_all_as_read(domain=[]) + na_count = employee_partner._get_needaction_count() + self.assertEqual(na_count, 0, "mark all as read should conclude all needactions") + + # mark all as read also clear inaccessible needactions + msg2 = self.test_record.message_post(body='Zest', message_type='comment', subtype_xmlid='mail.mt_comment', partner_ids=[employee_partner.id]) + needaction_accessible = len(employee_partner.env['mail.message'].search([['needaction', '=', True]])) + self.assertEqual(needaction_accessible, 1, "a new message to a partner is readable to that partner") + + msg2.sudo().partner_ids = self.env['res.partner'] + employee_partner.env['mail.message'].search([['needaction', '=', True]]) + needaction_length = len(employee_partner.env['mail.message'].search([['needaction', '=', True]])) + self.assertEqual(needaction_length, 1, "message should still be readable when notified") + + 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=[{ + 'type': 'mail.message/mark_as_read', + 'payload': { + 'message_ids': [msg2.id], + 'needaction_inbox_counter': 0, + }, + }]): + employee_partner.env['mail.message'].mark_all_as_read(domain=[]) + na_count = employee_partner._get_needaction_count() + self.assertEqual(na_count, 0, "mark all read should conclude all needactions even inacessible ones") + + def test_set_message_done_user(self): + with self.assertSinglePostNotifications([{'partner': self.partner_employee, 'type': 'inbox'}], message_info={'content': 'Test'}): + message = self.test_record.message_post( + body='Test', message_type='comment', subtype_xmlid='mail.mt_comment', + 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): + 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'}) + user2.notification_type = 'inbox' + message1 = self.test_record.with_user(self.user_admin).message_post(body='Message 1', partner_ids=[user1.partner_id.id, user2.partner_id.id]) + 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) + + # 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) + + def test_notification_has_error_filter(self): + """Ensure message_has_error filter is only returning threads for which + the current user is author of a failed message.""" + message = self.test_record.with_user(self.user_admin).message_post( + body='Test', message_type='comment', subtype_xmlid='mail.mt_comment', + partner_ids=[self.user_employee.partner_id.id] + ) + self.assertFalse(message.has_error) + + with self.mock_mail_gateway(): + def _connect(*args, **kwargs): + raise Exception("Some exception") + self.connect_mocked.side_effect = _connect + + self.user_admin.notification_type = 'email' + message2 = self.test_record.with_user(self.user_employee).message_post( + body='Test', message_type='comment', subtype_xmlid='mail.mt_comment', + partner_ids=[self.user_admin.partner_id.id] + ) + self.assertTrue(message2.has_error) + # employee is author of message which has a failure + threads_employee = self.test_record.with_user(self.user_employee).search([('message_has_error', '=', True)]) + self.assertEqual(len(threads_employee), 1) + # admin is also author of a message, but it doesn't have a failure + # and the failure from employee's message should not be taken into account for admin + 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], + ) + + with self.assertRaises(exceptions.AccessError): + notification_msg.with_env(self.env)._message_format(['id', 'body', 'date', 'author_id', 'email_from']) + + 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") + + channel.sudo().unlink() + remaining_message = channel_message.exists() + self.assertEqual(len(remaining_message), 0, "Test message should have been deleted") + + +@tagged('mail_thread') +class TestNoThread(TestMailCommon, TestRecipients): + """ Specific tests for cross models thread features """ + + @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', + }) + with self.assertPostNotifications([{ + 'content': 'Hello Paulo', + 'email_values': { + 'reply_to': self.company_admin.catchall_formatted, + }, + '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, + res_id=test_record.id, + 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 new file mode 100644 index 0000000..fb2c2e2 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_mail_thread_mixins.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import exceptions, tools +from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients +from odoo.tests.common import tagged +from odoo.tools import mute_logger + + +@tagged('mail_thread', 'mail_blacklist') +class TestMailThread(TestMailCommon, TestRecipients): + + @mute_logger('odoo.models.unlink') + def test_blacklist_mixin_email_normalized(self): + """ Test email_normalized and is_blacklisted fields behavior, notably + when dealing with encapsulated email fields and multi-email input. """ + base_email = 'test.email@test.example.com' + + # test data: source email, expected email normalized + valid_pairs = [ + (base_email, base_email), + (tools.formataddr(('Another Name', base_email)), base_email), + (f'Name That Should Be Escaped <{base_email}>', base_email), + ('test.😊@example.com', 'test.😊@example.com'), + ('"Name 😊" ', 'test.😊@example.com'), + ] + void_pairs = [(False, False), + ('', False), + (' ', False)] + multi_pairs = [ + (f'{base_email}, other.email@test.example.com', + base_email), # multi supports first found + (f'{tools.formataddr(("Another Name", base_email))}, other.email@test.example.com', + base_email), # multi supports first found + ] + for email_from, exp_email_normalized in valid_pairs + void_pairs + multi_pairs: + with self.subTest(email_from=email_from, exp_email_normalized=exp_email_normalized): + new_record = self.env['mail.test.gateway'].create({ + 'email_from': email_from, + 'name': 'BL Test', + }) + self.assertEqual(new_record.email_normalized, exp_email_normalized) + self.assertFalse(new_record.is_blacklisted) + + # blacklist email should fail as void + if email_from in [pair[0] for pair in void_pairs]: + with self.assertRaises(exceptions.UserError): + bl_record = self.env['mail.blacklist']._add(email_from) + # blacklist email currently fails but could not + elif email_from in [pair[0] for pair in multi_pairs]: + with self.assertRaises(exceptions.UserError): + bl_record = self.env['mail.blacklist']._add(email_from) + # blacklist email ok + else: + bl_record = self.env['mail.blacklist']._add(email_from) + self.assertEqual(bl_record.email, exp_email_normalized) + new_record.invalidate_recordset(fnames=['is_blacklisted']) + self.assertTrue(new_record.is_blacklisted) + + bl_record.unlink() 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 new file mode 100644 index 0000000..e1e5ab2 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_message_management.py @@ -0,0 +1,130 @@ +# -*- 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 new file mode 100644 index 0000000..fec97e2 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_message_post.py @@ -0,0 +1,1904 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 + +from datetime import datetime, timedelta +from freezegun import freeze_time +from itertools import product +from markupsafe import escape +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.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.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): + + @classmethod + def setUpClass(cls): + super(TestMessagePostCommon, cls).setUpClass() + + # portal user, notably for ACLS / notifications + cls.user_portal = cls._create_portal_user() + cls.partner_portal = cls.user_portal.partner_id + + # another standard employee to test follow and notifications between two + # users (and not admin / user) + cls.user_employee_2 = mail_new_test_user( + cls.env, login='employee2', + groups='base.group_user', + company_id=cls.company_admin.id, + email='eglantine@example.com', # check: use a formatted email + name='Eglantine Employee2', + notification_type='email', + signature='--\nEglantine', + ) + cls.partner_employee_2 = cls.user_employee_2.partner_id + + cls.test_record = cls.env['mail.test.simple'].with_context(cls._test_context).create({ + 'name': 'Test', + 'email_from': 'ignasse@example.com' + }) + cls.test_record_container = cls.env['mail.test.container.mc'].create({ + 'name': 'MC Container', + }) + cls.test_record_ticket = cls.env['mail.test.ticket.mc'].create({ + 'container_id': cls.test_record_container.id, + 'email_from': 'test.customer@test.example.com', + 'name': 'MC Ticket', + }) + cls._reset_mail_context(cls.test_record) + cls.test_message = cls.env['mail.message'].create({ + 'author_id': cls.partner_employee.id, + '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_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', + }) + 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`` + self.patch(self.env.registry, 'ready', True) + + +@tagged('mail_post') +class TestMailNotifyAPI(TestMessagePostCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._activate_multi_company() + + @mute_logger('odoo.models.unlink') + @users('employee') + def test_email_notifiction_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) + + recipients_data = self._generate_notify_recipients(self.partner_1 + self.partner_2 + self.partner_employee) + for email_xmlid in ['mail.mail_notification_light', + 'mail.mail_notification_layout', + 'mail.mail_notification_layout_with_responsible_signature']: + test_message.sudo().notification_ids.unlink() # otherwise partner/message constraint fails + test_message.write({'email_layout_xmlid': email_xmlid}) + with self.mock_mail_gateway(): + test_record._notify_thread_by_email( + test_message, + recipients_data, + force_send=False + ) + self.assertEqual(len(self._new_mails), 2, 'Should have 2 emails: one for customers, one for internal users') + + # check customer email + customer_email = self._new_mails.filtered(lambda mail: mail.recipient_ids == self.partner_1 + self.partner_2) + self.assertTrue(customer_email) + + # check internal user email + user_email = self._new_mails.filtered(lambda mail: mail.recipient_ids == self.partner_employee) + self.assertTrue(user_email) + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_notify_by_mail_add_signature(self): + test_track = self.env['mail.test.track'].with_context(self._test_context).with_user(self.user_employee).create({ + 'name': 'Test', + 'email_from': 'ignasse@example.com' + }) + test_track.user_id = self.env.user + + signature = self.env.user.signature + + template = self.env.ref('mail.mail_notification_layout_with_responsible_signature', raise_if_not_found=True).sudo() + self.assertIn("record.user_id.sudo().signature", template.arch) + + with self.mock_mail_gateway(): + test_track.message_post( + body="Test body", + email_add_signature=True, + email_layout_xmlid="mail.mail_notification_layout_with_responsible_signature", + mail_auto_delete=False, + partner_ids=[self.partner_1.id, self.partner_2.id], + ) + found_mail = self._new_mails + self.assertIn(signature, found_mail.body_html) + self.assertEqual(found_mail.body_html.count(signature), 1) + + with self.mock_mail_gateway(): + test_track.message_post( + body="Test body", + email_add_signature=False, + email_layout_xmlid="mail.mail_notification_layout_with_responsible_signature", + mail_auto_delete=False, + partner_ids=[self.partner_1.id, self.partner_2.id], + ) + found_mail = self._new_mails + self.assertNotIn(signature, found_mail.body_html) + self.assertEqual(found_mail.body_html.count(signature), 0) + + @users('employee') + def test_notify_by_email_add_signature_no_author_user_or_no_user(self): + test_record = self.env['mail.test.simple'].browse(self.test_record.ids) + test_message = self.env['mail.message'].browse(self.test_message.ids) + test_message.write({ + 'author_id': self.env['res.partner'].sudo().create({ + 'name': 'Steve', + }).id + }) + template_values = test_record._notify_by_email_prepare_rendering_context(test_message, {}) + self.assertNotEqual(escape(template_values['signature']), escape('

--
Steve

')) + + self.test_message.author_id = None + template_values = test_record._notify_by_email_prepare_rendering_context(test_message, {}) + self.assertEqual(template_values['signature'], '') + + @users('employee') + def test_notify_by_email_prepare_rendering_context(self): + """ Verify that the template context company value is right + after switching the env company or if a company_id is set + on mail record. + """ + current_user = self.env.user + main_company = current_user.company_id + other_company = self.env['res.company'].with_user(self.user_admin).create({'name': 'Company B'}) + current_user.sudo().write({'company_ids': [(4, other_company.id)]}) + test_record = self.env['mail.test.multi.company'].with_user(self.user_admin).create({ + 'name': 'Multi Company Record', + 'company_id': False, + }) + + # self.env.company.id = Main Company AND test_record.company_id = False + self.assertEqual(self.env.company.id, main_company.id) + self.assertEqual(test_record.company_id.id, False) + template_values = test_record._notify_by_email_prepare_rendering_context(test_record.message_ids, {}) + self.assertEqual(template_values.get('company').id, self.env.company.id) + + # self.env.company.id = Other Company AND test_record.company_id = False + current_user.company_id = other_company + test_record = self.env['mail.test.multi.company'].browse(test_record.id) + self.assertEqual(self.env.company.id, other_company.id) + self.assertEqual(test_record.company_id.id, False) + template_values = test_record._notify_by_email_prepare_rendering_context(test_record.message_ids, {}) + self.assertEqual(template_values.get('company').id, self.env.company.id) + + # self.env.company.id = Other Company AND test_record.company_id = Main Company + test_record.company_id = main_company + test_record = self.env['mail.test.multi.company'].browse(test_record.id) + self.assertEqual(self.env.company.id, other_company.id) + self.assertEqual(test_record.company_id.id, main_company.id) + template_values = test_record._notify_by_email_prepare_rendering_context(test_record.message_ids, {}) + self.assertEqual(template_values.get('company').id, main_company.id) + + def test_notify_recipients_internals(self): + 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, + 'subject': 'Message subject', + } + link_vals = { + 'token': 'token_val', + 'access_token': 'access_token_val', + 'auth_signup_token': 'auth_signup_token_val', + '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) + + # 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.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), + ('mail.thread', False), + ('mail.thread', self.test_record.id)): + with self.subTest(model=model, res_id=res_id): + 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) + # 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']) + + # 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}")) + ) + + +@tagged('mail_post', 'mail_notify') +class TestMessageNotify(TestMessagePostCommon): + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_notify(self): + test_record = self.env['mail.test.simple'].browse(self.test_record.ids) + + with self.assertSinglePostNotifications( + [{'partner': self.partner_1, 'type': 'email',}, + {'partner': self.partner_admin, 'type': 'email',}, + {'partner': self.partner_employee_2, 'type': 'email',}, + ], message_info={ + 'content': '

You have received a notification

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

You have received a notification

', + 'email_from': formataddr((self.partner_employee.name, self.partner_employee.email_normalized)), + 'message_type': 'user_notification', + 'model': test_record._name, + 'notified_partner_ids': self.partner_1 | self.partner_employee_2 | self.partner_admin, + 'res_id': test_record.id, + 'subtype_id': self.env.ref('mail.mt_note'), + }, + 'subtype': 'mail.mt_note', + }, + ): + new_notification = test_record.message_notify( + body='

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', + ) + self.assertNotIn(new_notification, self.test_record.message_ids) + + # notified_partner_ids should be empty after copying the message + copy = new_notification.copy() + self.assertFalse(copy.notified_partner_ids) + + admin_mails = [mail for mail in self._mails if self.partner_admin.name in mail.get('email_to')[0]] + self.assertEqual(len(admin_mails), 1, 'There should be exactly one email sent to admin') + admin_mail_body = admin_mails[0].get('body') + + self.assertTrue('model=' in admin_mail_body, 'The email sent to admin should contain an access link') + admin_access_link = admin_mail_body[ + admin_mail_body.index('model='):admin_mail_body.index('/>', admin_mail_body.index('model=')) - 1] + self.assertIn(f'model={self.test_record._name}', admin_access_link, 'The access link should contain a valid model argument') + self.assertIn(f'res_id={self.test_record.id}', admin_access_link, 'The access link should contain a valid res_id argument') + + partner_mails = [x for x in self._mails if self.partner_1.name in x.get('email_to')[0]] + self.assertEqual(len(partner_mails), 1, 'There should be exactly one email sent to partner') + 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') + def test_notify_batch(self): + """ Test notify in batch. Currently not supported. """ + test_records, _partners = self._create_records_for_batch('mail.test.simple', 10) + + with self.assertRaises(ValueError): + test_records.message_notify( + body='

Nice notification content

', + partner_ids=self.partner_employee_2.ids, + subject='Notify Subject', + ) + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_notify_from_user_id(self): + """ Test notify coming from user_id assignment (in batch) """ + test_records, _ = self._create_records_for_batch( + 'mail.test.track', 10, { + 'company_id': self.env.user.company_id.id, + 'email_from': self.env.user.email_formatted, + 'user_id': False, + } + ) + test_records = self.env['mail.test.track'].browse(test_records.ids) + self.flush_tracking() + + with self.mock_mail_gateway(), self.mock_mail_app(): + test_records.write({'user_id': self.user_employee_2.id}) + self.flush_tracking() + + self.assertEqual(len(self._new_msgs), 20, 'Should have 20 messages: 10 tracking and 10 assignments') + model_name = self.env['ir.model'].sudo()._get(test_records._name).name + for test_record in test_records: + assign_notif = self._new_msgs.filtered(lambda msg: msg.message_type == 'user_notification' and msg.res_id == test_record.id) + self.assertTrue(assign_notif) + self.assertMailNotifications( + assign_notif, + [{ + 'content': f'You have been assigned to the {model_name}', + 'email_values': { + # used to distinguished outgoing emails + 'subject': f'You have been assigned to {test_record.name}', + }, + 'message_type': 'user_notification', + 'message_values': { + 'author_id': self.partner_employee, + 'email_from': formataddr((self.partner_employee.name, self.partner_employee.email_normalized)), + 'model': test_record._name, + 'notified_partner_ids': self.partner_employee_2, + 'res_id': test_record.id, + }, + 'notif': [ + {'partner': self.partner_employee_2, 'type': 'email',}, + ], + 'subtype': 'mail.mt_note', + }], + ) + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_notify_thread(self): + """ Test notify on ``mail.thread`` model, which is pushing a message to + people without having a document. """ + with self.mock_mail_gateway(): + new_notification = self.env['mail.thread'].message_notify( + body='

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', + ) + + self.assertMailNotifications( + new_notification, + [{ + 'content': '

You have received a notification

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

You have received a notification

', + 'email_from': formataddr((self.partner_employee.name, self.partner_employee.email_normalized)), + 'model': False, + 'res_id': False, + 'notified_partner_ids': self.partner_1 | self.partner_employee_2 | self.partner_admin, + 'subtype_id': self.env.ref('mail.mt_note'), + }, + 'notif': [ + {'partner': self.partner_1, 'type': 'email',}, + {'partner': self.partner_employee_2, 'type': 'email',}, + {'partner': self.partner_admin, 'type': 'email',}, + ], + 'subtype': 'mail.mt_note', + }], + ) + + +@tagged('mail_post') +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)], + }) + + cls.test_records, cls.test_partners = cls._create_records_for_batch( + 'mail.test.ticket', + 10, + ) + + @users('employee') + def test_message_log(self): + test_record = self.env['mail.test.simple'].browse(self.test_record.ids) + test_record.message_subscribe(self.partner_employee_2.ids) + + with self.mock_mail_gateway(): + new_note = test_record._message_log( + body='

Labrador

', + ) + self.assertMailNotifications( + new_note, + [{ + 'content': '

Labrador

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

Labrador

', + '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.env['res.partner'], + 'reply_to': formataddr((self.company_admin.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(self): + 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: '

Test _message_log_batch

' + for test_record in test_records + }, + ) + 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.env['res.partner'], + 'reply_to': formataddr((self.company_admin.name, f'{self.alias_catchall}@{self.alias_domain}')), + 'res_id': test_record.id, + }, + 'notif': [], + 'subtype': 'mail.mt_note', + }], + ) + + @users('employee') + def test_message_log_with_view(self): + 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_with_view( + 'test_mail.mail_template_simple_test', + values={'partner': self.user_employee.partner_id} + ) + for test_record, new_note in zip(test_records, new_notes): + self.assertMailNotifications( + new_note, + [{ + 'content': f'

Hello {self.user_employee.name}, this comes from {test_record.name}.

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

Hello {self.user_employee.name}, this comes from {test_record.name}.

', + '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'], + 'reply_to': formataddr((self.company_admin.name, f'{self.alias_catchall}@{self.alias_domain}')), + 'res_id': test_record.id, + }, + 'notif': [], + 'subtype': 'mail.mt_note', + }], + ) + + +@tagged('mail_post') +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) + self.assertFalse(self.test_record.message_follower_ids) + self.assertFalse(self.test_record.message_partner_ids) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_manual_send_user_notification_email_from_queue(self): + """ Test sending a mail from the queue that is not related to the admin user sending it. + Will throw a security error not having access to the mail.""" + + with self.mock_mail_gateway(): + new_notification = self.test_record.message_notify( + subject='This should be a subject', + body='

You have received a notification

', + partner_ids=[self.partner_1.id], + message_type='user_notification', + force_send=False + ) + + self.assertNotIn(self.user_admin.partner_id, new_notification.mail_ids.partner_ids, "Our admin user should not be within the partner_ids") + + with self.mock_mail_gateway(): + new_notification.mail_ids.with_user(self.user_admin).send() + + self.assertEqual(new_notification.mail_ids.state, 'exception', 'Email will be sent but with exception state - write access denied') + + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('employee') + 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) + + with self.assertSinglePostNotifications( + [{'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)), + '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}')), + 'res_id': test_record.id, + 'subtype_id': self.env.ref('mail.mt_comment'), + }, + } + ): + new_message = test_record.message_post( + body='Body', + message_type='comment', + subtype_xmlid='mail.mt_comment', + partner_ids=[self.partner_employee_2.id], + ) + self.assertEqual(test_record.message_partner_ids, self.partner_employee) + + # subscribe partner_1, check notifications + test_record.message_subscribe(self.partner_1.ids) + with self.assertSinglePostNotifications( + [{'partner': self.partner_employee_2, 'type': 'inbox'}, + {'partner': self.partner_1, 'type': 'email'}], + message_info={ + 'content': 'NewBody', + 'email_values': { + 'headers': { + 'Return-Path': f'{self.alias_bounce}@{self.alias_domain}', + }, + }, + 'message_values': { + 'notified_partner_ids': self.partner_1 + self.partner_employee_2, + }, + }, + mail_unlink_sent=True + ): + new_message = test_record.message_post( + body='NewBody', + message_type='comment', + 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'}], + message_info={ + 'content': 'ToPortal', + } + ): + test_record.message_post( + body='ToPortal', + message_type='comment', + subtype_xmlid='mail.mt_comment', + partner_ids=self.partner_portal.ids, + ) + + @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. """ + 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(): + new_message = test_record.message_post( + body='Body', + partner_ids=[self.partner_employee_2.id], + ) + + self.assertMailNotifications( + new_message, + [{ + 'content': '

Body

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

Body

', + 'email_from': formataddr((self.partner_employee.name, self.partner_employee.email_normalized)), + 'is_internal': False, + '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}')), + 'res_id': test_record.id, + 'subject': f'Re: {test_record.name}', + }, + 'notif': [ + {'partner': self.partner_employee_2, 'type': 'email',}, + ], + 'subtype': 'mail.mt_note', + }], + ) + + @users('employee') + @mute_logger('odoo.models.unlink') + def test_message_post_inactive_follower(self): + """ Test posting with inactive followers does not notify them (e.g. odoobot) """ + test_record = self.env['mail.test.simple'].browse(self.test_record.ids) + test_record._message_subscribe(self.user_employee_2.partner_id.ids) + self.user_employee_2.write({'active': False}) + self.partner_employee_2.write({'active': False}) + + with self.assertPostNotifications([{'content': 'Test', 'notif': []}]): + test_record.message_post( + body='Test', + message_type='comment', + subtype_xmlid='mail.mt_comment', + ) + + @mute_logger('odoo.addons.mail.models.mail_mail') + @users('employee') + def test_message_post_keep_emails(self): + test_record = self.env['mail.test.simple'].browse(self.test_record.ids) + test_record.message_subscribe(partner_ids=self.partner_employee_2.ids) + + with self.mock_mail_gateway(mail_unlink_sent=True): + msg = test_record.message_post( + body='Test', + mail_auto_delete=False, + message_type='comment', + partner_ids=[self.partner_1.id, self.partner_2.id], + subject='Test', + subtype_xmlid='mail.mt_comment', + ) + + # notifications emails should not have been deleted: one for customers, one for user + self.assertEqual(self.env['mail.mail'].sudo().search_count([('mail_message_id', '=', msg.id)]), 2) + + + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('erp_manager') + def test_message_post_mc(self): + """ Test posting in multi-company environment, notably with aliases """ + records = self.env['mail.test.ticket.mc'].create([ + { + 'name': 'No Specific Company', + }, { + 'company_id': self.company_admin.id, + 'name': 'Company1', + }, { + 'company_id': self.company_2.id, + 'name': 'Company2', + }, + ]) + expected_companies = [self.company_2, self.company_admin, self.company_2] + for record, expected_company in zip(records, expected_companies): + with self.subTest(record=record): + with self.assertSinglePostNotifications( + [{'partner': self.partner_employee_2, 'type': 'email'}], + message_info={ + 'content': 'Body', + 'email_values': { + 'headers': { + 'Return-Path': f'{self.alias_bounce}@{self.alias_domain}', + }, + }, + 'mail_mail_values': { + 'headers': repr({ + 'X-Odoo-Objects': f'{record._name}-{record.id}', + }), + }, + 'message_values': { + 'author_id': self.user_erp_manager.partner_id, + 'email_from': formataddr((self.user_erp_manager.name, self.user_erp_manager.email_normalized)), + '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}') + ), + }, + } + ): + _new_message = record.message_post( + body='Body', + message_type='comment', + subtype_xmlid='mail.mt_comment', + partner_ids=[self.partner_employee_2.id], + ) + + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests') + def test_message_post_recipients_email_field(self): + """ Test various combinations of corner case / not standard filling of + email fields: multi email, formatted emails, ... """ + partner_emails = [ + 'valid.lelitre@agrolait.com, valid.lelitre.cc@agrolait.com', # multi email + '"Valid Lelitre" ', # email contains formatted email + 'wrong', # wrong + False, '', ' ', # falsy + ] + expected_tos = [ + # Sends multi-emails + [f'"{self.partner_1.name}" ', + f'"{self.partner_1.name}" ',], + # Avoid double encapsulation + [f'"{self.partner_1.name}" ',], + # sent "normally": formats email based on wrong / falsy email + [f'"{self.partner_1.name}" <@wrong>',], + [f'"{self.partner_1.name}" <@False>',], + [f'"{self.partner_1.name}" <@False>',], + [f'"{self.partner_1.name}" <@ >',], + ] + + for partner_email, expected_to in zip(partner_emails, expected_tos): + with self.subTest(partner_email=partner_email, expected_to=expected_to): + self.partner_1.write({'email': partner_email}) + with self.mock_mail_gateway(): + self.test_record.with_user(self.user_employee).message_post( + body='Test multi email', + message_type='comment', + partner_ids=[self.partner_1.id], + subject='Exotic email', + subtype_xmlid='mt_comment', + ) + + self.assertSentEmail( + self.user_employee.partner_id, + [self.partner_1], + email_to=expected_to, + ) + + @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): + """ Test delaying notifications through scheduled_date usage """ + cron_id = self.env.ref('mail.ir_cron_send_scheduled_message').id + now = datetime.utcnow().replace(second=0, microsecond=0) + 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) + + with freeze_time(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') + 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) + + # trigger cron now -> should not sent as in future + with freeze_time(now): + self.env['mail.message.schedule'].sudo()._send_notifications_cron() + self.assertTrue(schedules.exists(), msg='Should not have sent the message') + + # 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): + self.env['mail.message.schedule'].sudo()._send_notifications_cron() + self.assertFalse(schedules.exists(), msg='Should have sent the message') + # 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) + + # manually create a new schedule date, resend it -> should not crash (aka + # don't create duplicate notifications, ...) + self.env['mail.message.schedule'].sudo().create({ + 'mail_message_id': msg.id, + 'scheduled_datetime': scheduled_datetime, + }) + + # Send the scheduled message from the CRON + with freeze_time(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), \ + self.mock_mail_gateway(mail_unlink_sent=False), \ + 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=now, + ) + self.assertFalse(capt.records) + recipients_info = [{'content': '

Test

', 'notif': [ + {'partner': self.partner_admin, 'type': 'inbox'}, + {'partner': self.partner_1, 'type': 'email'}, + ]}] + self.assertMailNotifications(msg, recipients_info) + + @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_update(self): + """ Test tools to update scheduled notifications """ + cron = self.env.ref('mail.ir_cron_send_scheduled_message') + now = datetime.utcnow().replace(second=0, microsecond=0) + 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) + + with freeze_time(now), \ + self.assertMsgWithoutNotifications(): + msg = test_record.message_post( + body='

Test

', + message_type='comment', + subject='Subject', + subtype_xmlid='mail.mt_comment', + scheduled_date=scheduled_datetime, + ) + schedules = self.env['mail.message.schedule'].sudo().search([('mail_message_id', '=', msg.id)]) + self.assertEqual(len(schedules), 1, msg='Should have scheduled the message') + + # update scheduled datetime, should create new triggers + with freeze_time(now), \ + self.assertNoNotifications(), \ + self.capture_triggers(cron.id) as capt: + self.env['mail.message.schedule'].sudo()._update_message_scheduled_datetime(msg, now - timedelta(hours=1)) + self.assertEqual(capt.records.call_at, now - timedelta(hours=1), + msg='Should have created a new cron trigger for the new scheduled sending') + self.assertTrue(schedules.exists(), msg='Should not have sent the message') + + # run cron, notifications have been sent + with freeze_time(now), self.mock_mail_gateway(mail_unlink_sent=False): + schedules._send_notifications_cron() + self.assertFalse(schedules.exists(), msg='Should have sent the message') + recipients_info = [{'content': '

Test

', 'notif': [ + {'partner': self.partner_admin, 'type': 'inbox'}, + {'partner': self.partner_1, 'type': 'email'}, + ]}] + self.assertMailNotifications(msg, recipients_info) + + 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') + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_message_schedule') + def test_message_post_w_attachments(self): + _attachments = [ + ('List1', b'My first attachment'), + ('List2', b'My second attachment'), + ] + _attachment_records = self.env['ir.attachment'].create( + self._generate_attachments_data(3, 'mail.compose.message', 0) + ) + _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) + self.assertFalse(test_record.message_main_attachment_id) + + with self.mock_mail_gateway(): + msg = test_record.message_post( + attachments=_attachments, + attachment_ids=_attachment_records.ids, + body='Test', + message_type='comment', + partner_ids=[self.partner_1.id], + subject='Test', + subtype_xmlid='mail.mt_comment', + ) + + # updated message main attachment + self.assertEqual(test_record.message_main_attachment_id, _attachment_records[1], + 'MailThread: main attachment should be set to image/png') + + # 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(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), + 'message_post: mail.message attachments duplicated') + + # notification email attachments + 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'), + ('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(): + msg = self.test_record.with_user(self.user_employee).message_post( + body='

Test Body

', + partner_ids=[self.partner_1.id, self.partner_2.id], + subject='1st line\n2nd line', + ) + self.assertEqual(msg.subject, '1st line 2nd line') + + @mute_logger('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) + + with self.assertPostNotifications( + [{'content': '

Test

', 'notif': [ + {'partner': self.partner_employee, 'type': 'inbox'}, + {'partner': self.partner_1, 'type': 'email'}]} + ] + ), patch.object(MailTestSimple, 'check_access_rights', return_value=True): + new_msg = self.test_record.with_user(self.user_portal).message_post( + body='

Test

', + message_type='comment', + subject='Subject', + subtype_xmlid='mail.mt_comment', + ) + self.assertEqual(new_msg.sudo().notified_partner_ids, (self.partner_1 | self.user_employee.partner_id)) + + with self.assertRaises(AccessError): + self.test_record.with_user(self.user_portal).message_post( + body='

Test

', + message_type='comment', + subject='Subject', + subtype_xmlid='mail.mt_comment', + ) + + @mute_logger('odoo.addons.mail.models.mail_mail') + @users('employee') + def test_post_answer(self): + for subtype in ( + self.env.ref('test_mail.st_mail_test_ticket_container_mc_upd'), # classic subtype creation msg like ticket + self.env.ref('mail.mt_note'), # internal notes + self.env['mail.message.subtype'], # classic 'note-like' default for mail.thread + self.env.ref('mail.mt_comment'), # would begin with incoming email for example + ): + with self.subTest(subtype_name=subtype.name if subtype else 'None'): + 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.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}) + + # post a tracking message + with self.mock_mail_gateway(): + log_msg = test_record._message_log( + body='

Blabla fake tracking

', + message_type='notification', + ) + self.assertFalse(log_msg.parent_id, 'FIXME: logs have no parent, strange but funny (somehow)') + self.assertNotSentEmail() + + # post an internal tracking/custom message + with self.mock_mail_gateway(): + internal_msg = test_record.message_post( + body='

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) + if subtype: + references = f'{initial_msg.message_id} {log_msg.message_id} {internal_msg.message_id}' + else: # no subtype = pure log = not in references + references = f'{log_msg.message_id} {internal_msg.message_id}' + self.assertSentEmail( + self.user_employee.partner_id, + [self.user_admin.partner_id], + body_content='

Blabla internal

', + reply_to=initial_msg.reply_to, + subject='Re: %s' % self.test_record_ticket.name, + # references contain even 'internal' messages, to help thread formation + references=references, + ) + + # post a first real reply + with self.assertPostNotifications( + [{'content': '

Test Answer

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

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.partner_ids, self.partner_1) + self.assertFalse(initial_msg.partner_ids) + if subtype: + references = f'{initial_msg.message_id} {log_msg.message_id} {internal_msg.message_id} {msg.message_id}' + else: # no subtype = pure log = not in references + references = f'{log_msg.message_id} {internal_msg.message_id} {msg.message_id}' + self.assertSentEmail( + self.user_employee.partner_id, + [self.partner_1], + # references contain even 'internal' messages, to help thread formation + references=references, + ) + + # post a reply to the reply: we fill up with 'public' subtypes if possible + if subtype in [self.env.ref('test_mail.st_mail_test_ticket_container_mc_upd'), self.env.ref('mail.mt_comment')]: + top_msg = initial_msg # not internal subtype -> wins + else: + top_msg = log_msg + with self.mock_mail_gateway(): + new_msg = test_record.message_post( + body='

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.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, + # 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}', + ) + + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_thread') + @users('employee') + def test_post_internal(self): + test_record = self.env['mail.test.simple'].browse(self.test_record.ids) + + test_record.message_subscribe([self.user_admin.partner_id.id]) + with self.mock_mail_gateway(): + msg = test_record.message_post( + body='My Body', + message_type='comment', + subject='My Subject', + subtype_xmlid='mail.mt_note', + ) + self.assertFalse(msg.is_internal, + 'Notes are not "internal" but replies will be. Subtype being internal should be sufficient from ACLs point of view.') + self.assertFalse(msg.partner_ids) + self.assertFalse(msg.notified_partner_ids) + + self.format_and_process( + MAIL_TEMPLATE_PLAINTEXT, self.user_admin.email, 'not_my_businesss@example.com', + msg_id='<1198923581.41972151344608186800.JavaMail.diff1@agrolait.example.com>', + extra=f'In-Reply-To:\r\n\t{msg.message_id}\n', + target_model='mail.test.simple') + reply = test_record.message_ids - msg + self.assertTrue(reply) + self.assertTrue(reply.is_internal) + self.assertEqual(reply.notified_partner_ids, self.user_employee.partner_id) + 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) + 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, + ) + 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')) + + +@tagged('mail_post') +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)], + }) + + cls.test_records, cls.test_partners = cls._create_records_for_batch( + 'mail.test.ticket', + 10, + ) + + cls._attachments = cls._generate_attachments_data(2, 'mail.template', 0) + cls.email_1 = 'test1@example.com' + cls.email_2 = 'test2@example.com' + cls.test_template = cls._create_template('mail.test.ticket', { + 'attachment_ids': [(0, 0, attach_vals) for attach_vals in cls._attachments], + 'auto_delete': True, + # After the HTML sanitizer, it will become "

Body for: link

" + 'body_html': 'Body for: link', + '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, + }) + 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_mail_with_template(self): + """ Test sending mass mail on documents based on a template """ + 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', + ) + + # created partners from inline email addresses + new_partners = self.env['res.partner'].search([('email', 'in', (self.email_1, self.email_2))]) + self.assertEqual(len(new_partners), 2, + 'Post with template: should have created partners based on template emails') + + # sent emails (mass mail mode) + for test_record in test_records: + self.assertMailMail( + new_partners + self.partner_1 + self.partner_2 + test_record.customer_id, + 'sent', + author=self.user_employee.partner_id, + email_values={ + 'attachments': [ + ('AttFileName_00.txt', b'AttContent_00', 'text/plain'), + ('AttFileName_01.txt', b'AttContent_01', 'text/plain'), + ], + 'subject': f'About {test_record.name}', + 'body_content': f'Body for: {test_record.name}', + }, + fields_values={ + 'auto_delete': True, + 'is_internal': False, + 'is_notification': True, # not auto_delete_message -> keep underlying mail.message + 'message_type': 'email', + 'model': test_record._name, + 'notified_partner_ids': self.env['res.partner'], + '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}')), + 'res_id': test_record.id, + } + ) + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_message_mail_with_view(self): + """ Test sending a mass mailing on documents based on a view """ + test_records = self.test_records.with_env(self.env) + for test_record in test_records: + test_record.message_subscribe(test_record.customer_id.ids) + + with self.mock_mail_gateway(): + new_messages = test_records.message_post_with_view( + 'test_mail.mail_template_simple_test', + values={'partner': self.user_employee.partner_id}, + composition_mode='mass_mail', + subject='About mass mailing', + ) + self.assertEqual(len(new_messages), 0) + self.assertEqual(len(self._new_mails), 10) + + # sent emails (mass mail mode) + for test_record in test_records: + self.assertMailMail( + [test_record.customer_id], 'sent', + author=self.user_employee.partner_id, + email_values={ + 'body_content': f'

Hello {self.user_employee.partner_id.name}, this comes from {test_record.name}.

', + 'subject': 'About mass mailing', + }, + fields_values={ + 'auto_delete': False, + 'is_internal': False, + 'is_notification': True, # not auto_delete_message -> keep underlying mail.message + 'message_type': 'email', + 'model': test_record._name, + 'notified_partner_ids': self.env['res.partner'], + '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}')), + 'res_id': test_record.id, + } + ) + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_message_post_with_template(self): + """ Test posting on a document based on a template content """ + 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, + composition_mode='comment', + message_type='comment', + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), + ) + + # created partners from inline email addresses + new_partners = self.env['res.partner'].search([('email', 'in', [self.email_1, self.email_2])]) + self.assertEqual(len(new_partners), 2, + 'Post with template: should have created partners based on template emails') + + # check notifications have been sent + self.assertMailNotifications( + new_message, + [{ + 'content': f'

Body for: {test_record.name}link

', + 'message_type': 'comment', + 'message_values': { + 'author_id': self.partner_employee, + '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}')), + 'res_id': test_record.id, + }, + 'notif': [ + {'partner': self.partner_1, 'type': 'email'}, + {'partner': self.partner_2, 'type': 'email'}, + {'partner': new_partners[0], 'type': 'email'}, + {'partner': new_partners[1], 'type': 'email'}, + {'partner': test_record.customer_id, 'type': 'email'}, + ], + 'subtype': 'mail.mt_comment', + }] + ) + + @users('employee') + @mute_logger('odoo.addons.mail.models.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, + ) + + # created partners from inline email addresses + new_partners = self.env['res.partner'].search([('email', 'in', [self.email_1, self.email_2])]) + self.assertEqual(len(new_partners), 2, + 'Post with template: should have created partners based on template emails') + + # check notifications have been sent + self.assertMailNotifications(new_message, [{ + 'content': f'

Body for: {test_record.name}link

', + 'message_type': 'notification', + 'message_values': { + 'author_id': self.partner_employee, + '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}')), + 'res_id': test_record.id, + }, + 'notif': [ + {'partner': self.partner_1, 'type': 'email'}, + {'partner': self.partner_2, 'type': 'email'}, + {'partner': new_partners[0], 'type': 'email'}, + {'partner': new_partners[1], 'type': 'email'}, + {'partner': test_record.customer_id, 'type': 'email'}, + ], + 'subtype': 'mail.mt_comment', + }]) + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests') + def test_message_post_with_view(self): + """ Test posting on documents based on a view """ + test_record = self.test_records.with_env(self.env)[0] + test_record.message_subscribe(test_record.customer_id.ids) + + with self.mock_mail_gateway(): + new_message = test_record.message_post_with_view( + 'test_mail.mail_template_simple_test', + message_type='comment', + values={'partner': self.user_employee.partner_id}, + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), + ) + + # check notifications have been sent + self.assertMailNotifications(new_message, [{ + 'content': f'

Hello {self.user_employee.partner_id.name}, this comes from {test_record.name}.

', + 'message_type': 'comment', + 'message_values': { + 'author_id': self.partner_employee, + 'email_from': formataddr((self.partner_employee.name, self.partner_employee.email_normalized)), + '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}')), + 'res_id': test_record.id, + }, + 'notif': [ + {'partner': test_record.customer_id, 'type': 'email'}, + ], + 'subtype': 'mail.mt_comment', + }]) + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests') + def test_message_post_with_view_defaults(self): + """ Test posting on documents based on a view, check default values """ + test_record = self.test_records.with_env(self.env)[0] + test_record.message_subscribe(test_record.customer_id.ids) + + # defaults is a note, take into account specified recipients + with self.mock_mail_gateway(): + new_message = test_record.message_post_with_view( + 'test_mail.mail_template_simple_test', + values={'partner': self.user_employee.partner_id}, + partner_ids=test_record.customer_id.ids, + ) + + # check notifications have been sent + self.assertMailNotifications(new_message, [{ + 'content': f'

Hello {self.user_employee.partner_id.name}, this comes from {test_record.name}.

', + 'message_type': 'notification', + 'message_values': { + 'author_id': self.partner_employee, + 'email_from': formataddr((self.partner_employee.name, self.partner_employee.email_normalized)), + '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}')), + 'res_id': test_record.id, + }, + 'notif': [ + {'partner': test_record.customer_id, 'type': 'email'}, + ], + 'subtype': 'mail.mt_comment', + }]) + + +@tagged('mail_post', 'post_install', '-at_install') +class TestMessagePostGlobal(TestMessagePostCommon): + + @users('employee') + def test_message_post_return(self): + """ Ensures calling message_post through RPC always return an 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)) + + +@tagged('mail_post', 'multi_lang') +class TestMessagePostLang(TestMailCommon, TestRecipients): + + @classmethod + def setUpClass(cls): + super(TestMessagePostLang, cls).setUpClass() + + cls.test_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_template = cls.env['mail.template'].create({ + 'auto_delete': True, + 'body_html': '

EnglishBody for

', + 'email_from': '{{ user.email_formatted }}', + 'email_to': '{{ (object.email_from if not object.customer_id else "") }}', + 'lang': '{{ object.customer_id.lang or object.lang }}', + 'model_id': cls.env['ir.model']._get('mail.test.lang').id, + '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)], + }) + + 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'}) + + def test_assert_initial_values(self): + """ Be sure of what we are testing """ + self.assertEqual(self.partner_1.lang, 'en_US') + self.assertEqual(self.partner_2.lang, 'es_ES') + + self.assertEqual(self.test_records[0].lang, 'es_ES') + self.assertEqual(self.test_records[0].customer_id.lang, False) + self.assertEqual(self.test_records[1].lang, False) + self.assertEqual(self.test_records[1].customer_id.lang, 'es_ES') + + self.assertFalse(self.test_records[0].message_follower_ids) + self.assertFalse(self.test_records[1].message_follower_ids) + + self.assertEqual(self.user_employee.lang, 'en_US') + + @users('employee') + @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). """ + 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}) + with self.mock_mail_gateway(): + 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, + ) + + if partner: + customer = partner + else: + customer = self.env['res.partner'].search([('email_normalized', '=', 'test.record.1@test.customer.com')], limit=1) + 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) + self.assertTrue(customer_email) + body = customer_email['body'] + # check content: depends on object.lang / object.customer_id.lang + if exp_content_lang == 'en_US': + self.assertIn(f'EnglishBody for {test_record.name}', body, + 'Body based on template should be translated') + else: + self.assertIn(f'SpanishBody for {test_record.name}', body, + 'Body based on template should be translated') + # check subject + if exp_content_lang == 'en_US': + self.assertEqual(f'EnglishSubject for {test_record.name}', customer_email['subject'], + 'Subject based on template should be translated') + 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': + self.assertNotIn('Spanish Layout para', body, 'Layout translation failed') + self.assertIn('English Layout for Lang Chatter Model', body, + 'Layout / model translation failed') + self.assertNotIn('Spanish Model Description', body, 'Model translation failed') + # check notification layout strings + self.assertNotIn('SpanishView Spanish Model Description', body, + '"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') + # 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') + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_composer_lang_template_mass(self): + 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(): + test_records.message_post_with_template( + test_template.id, + composition_mode='mass_mail', + email_layout_xmlid='mail.test_layout', + message_type='comment', + subtype_id=self.env.ref('mail.mt_comment').id, + ) + + 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) + 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') + # 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') + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_layout_email_lang_context(self): + test_records = self.test_records.with_user(self.env.user).with_context(lang='es_ES') + test_records[1].message_subscribe(self.partner_2.ids) + + with self.mock_mail_gateway(): + test_records[1].message_post( + body='

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) + self.assertTrue(customer_email) + body = customer_email['body'] + # check content + self.assertIn('

Hello

', body, 'Body of posted message should be present') + # check notification layout content + 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') + # 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) + + @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. """ + 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, + ) + + 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) + self.assertTrue(customer_email) + + # body and layouting are translated partly based on template. Bits + # of layout are not translated due to lang not being correctly + # propagate everywhere we need it + body = customer_email['body'] + # check content + 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') + # 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') + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_post_multi_lang_recipients(self): + """ Test posting on a document in a multilang environment. Currently + current user's lang determines completely language used for notification + layout notably, when no template is involved. + + Lang layout for this test (to better check various configuration and + check which lang wins the final output, if any) + + * current users: various between en and es; + * partner1: es + * partner2: en + """ + test_records = self.test_records.with_env(self.env) + test_records.message_subscribe(partner_ids=(self.partner_1 + self.partner_2).ids) + + for employee_lang, email_layout_xmlid in product( + ('en_US', 'es_ES'), + (False, 'mail.test_layout'), + ): + with self.subTest(employee_lang=employee_lang, email_layout_xmlid=email_layout_xmlid): + self.user_employee.write({ + 'lang': employee_lang, + }) + for record in test_records: + with self.mock_mail_gateway(mail_unlink_sent=False), \ + self.mock_mail_app(): + record.message_post( + body='

Hi there

', + email_layout_xmlid=email_layout_xmlid, + message_type='comment', + subject='TeDeum', + subtype_xmlid='mail.mt_comment', + ) + message = record.message_ids[0] + self.assertEqual( + message.notified_partner_ids, self.partner_1 + self.partner_2 + ) + + # 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', + }, + ) + + # 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: + email = self._find_sent_email( + self.partner_employee.email_formatted, + [partner.email_formatted] + ) + self.assertTrue(bool(email), 'Email not found, check recipients') + + 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': + 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': + 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']) 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 new file mode 100644 index 0000000..5838cf1 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_message_track.py @@ -0,0 +1,484 @@ +# -*- 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 +from odoo.tests.common import tagged +from odoo.tests import Form + + +@tagged('mail_track') +class TestTracking(TestMailCommon): + + def setUp(self): + super(TestTracking, self).setUp() + + record = self.env['mail.test.ticket'].with_user(self.user_employee).with_context(self._test_context).create({ + 'name': 'Test', + }) + self.flush_tracking() + self.record = record.with_context(mail_notrack=False) + + def test_message_track_message_type(self): + """Check that the right message type is applied for track templates.""" + self.record.message_subscribe( + partner_ids=[self.user_admin.partner_id.id], + subtype_ids=[self.env.ref('mail.mt_comment').id] + ) + mail_templates = self.env['mail.template'].create([{ + 'name': f'Template {n}', + 'subject': f'Template {n}', + 'model_id': self.env.ref('test_mail.model_mail_test_ticket').id, + 'body_html': f'

Template {n}

', + } for n in range(2)]) + + def _track_subtype(self, init_values): + return self.env.ref('mail.mt_note') + self.patch(self.registry('mail.test.ticket'), '_track_subtype', _track_subtype) + + def _track_template(self, changes): + if 'email_from' in changes: + return {'email_from': (mail_templates[0], {})} + 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) + + container = self.env['mail.test.container'].create({'name': 'Container'}) + + # default is auto_comment + with self.mock_mail_gateway(): + self.record.email_from = 'test@test.lan' + self.flush_tracking() + + first_message = self.record.message_ids.filtered(lambda message: message.subject == 'Template 0') + self.assertEqual(len(self.record.message_ids), 2, 'Should be one change message and one automated template') + self.assertEqual(first_message.message_type, 'auto_comment') + + # auto_comment can be overriden by _track_template + with self.mock_mail_gateway(mail_unlink_sent=False): + self.record.container_id = container + self.flush_tracking() + + second_message = self.record.message_ids.filtered(lambda message: message.subject == 'Template 1') + 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, + }) + self.flush_tracking() + self.assertEqual(self.record.message_ids, self.env['mail.message']) + + def test_message_track_no_subtype(self): + """ Update some tracked fields not linked to some subtype -> message with onchange """ + customer = self.env['res.partner'].create({'name': 'Customer', 'email': 'cust@example.com'}) + with self.mock_mail_gateway(): + self.record.write({ + 'name': 'Test2', + 'customer_id': customer.id, + }) + self.flush_tracking() + + # 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.subtype_id, self.env.ref('mail.mt_note')) + + # no specific recipients except those following notes, no email + self.assertEqual(self.record.message_ids.partner_ids, self.env['res.partner']) + self.assertEqual(self.record.message_ids.notified_partner_ids, self.env['res.partner']) + self.assertNotSentEmail() + + # verify tracked value + self.assertTracking( + self.record.message_ids, + [('customer_id', 'many2one', False, customer) # onchange tracked field + ]) + + def test_message_track_subtype(self): + """ Update some tracked fields linked to some subtype -> message with onchange """ + self.record.message_subscribe( + partner_ids=[self.user_admin.partner_id.id], + subtype_ids=[self.env.ref('test_mail.st_mail_test_ticket_container_upd').id] + ) + + container = self.env['mail.test.container'].with_context(mail_create_nosubscribe=True).create({'name': 'Container'}) + self.record.write({ + 'name': 'Test2', + 'email_from': 'noone@example.com', + 'container_id': container.id, + }) + 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.subtype_id, self.env.ref('test_mail.st_mail_test_ticket_container_upd')) + + # no specific recipients except those following container + self.assertEqual(self.record.message_ids.partner_ids, self.env['res.partner']) + self.assertEqual(self.record.message_ids.notified_partner_ids, self.user_admin.partner_id) + + # verify tracked value + self.assertTracking( + self.record.message_ids, + [('container_id', 'many2one', False, container) # onchange tracked field + ]) + + 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}) + self.assertEqual(self.record.message_ids, self.env['mail.message']) + + with self.mock_mail_gateway(): + self.record.write({ + 'name': 'Test2', + 'customer_id': self.user_admin.partner_id.id, + }) + self.flush_tracking() + + 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].subject, 'Test Template') + self.assertEqual(self.record.message_ids[0].body, '

Hello Test2

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

Hello Test2

') + + # one new message containing tracking; without subtype linked to tracking + self.assertEqual(self.record.message_ids[1].subtype_id, self.env.ref('mail.mt_note')) + self.assertTracking( + self.record.message_ids[1], + [('customer_id', 'many2one', False, self.user_admin.partner_id) # onchange tracked field + ]) + + def test_message_track_template_at_create(self): + """ Create a record with tracking template on create, template should be sent.""" + + Model = self.env['mail.test.ticket'].with_user(self.user_employee).with_context(self._test_context) + Model = Model.with_context(mail_notrack=False) + with self.mock_mail_gateway(): + record = Model.create({ + 'name': 'Test', + 'customer_id': self.user_admin.partner_id.id, + 'mail_template': self.env.ref('test_mail.mail_test_ticket_tracking_tpl').id, + }) + self.flush_tracking() + + 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].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): + company1 = self.env['res.company'].create({'name': 'company1'}) + self.env.user.write({'company_ids': [(4, company1.id, False)]}) + self.assertNotEqual(self.env.company, company1) + + email_new_partner = "diamonds@rust.com" + Partner = self.env['res.partner'] + self.assertFalse(Partner.search([('email', '=', email_new_partner)])) + + template = self.env['mail.template'].create({ + 'model_id': self.env['ir.model']._get('mail.test.track').id, + 'name': 'AutoTemplate', + 'subject': 'autoresponse', + 'email_from': self.env.user.email_formatted, + 'email_to': "{{ object.email_from }}", + 'body_html': "
A nice body
", + }) + + def patched_message_track_post_template(*args, **kwargs): + if args[0]._name == "mail.test.track": + args[0].message_post_with_template(template.id) + return True + + with patch('odoo.addons.mail.models.mail_thread.MailThread._message_track_post_template', patched_message_track_post_template): + self.env['mail.test.track'].create({ + 'email_from': email_new_partner, + 'company_id': company1.id, + 'user_id': self.env.user.id, # trigger track template + }) + self.flush_tracking() + + new_partner = Partner.search([('email', '=', email_new_partner)]) + 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 + magic_code = 'Up-Up-Down-Down-Left-Right-Left-Right-Square-Triangle' + + mt_name_changed = self.env['mail.message.subtype'].create({ + 'name': 'MAGIC CODE WOOP WOOP', + 'description': 'SPECIAL CONTENT UNLOCKED' + }) + self.env['ir.model.data'].create({ + 'name': 'mt_name_changed', + 'model': 'mail.message.subtype', + 'module': 'mail', + 'res_id': mt_name_changed.id + }) + mail_template = self.env['mail.template'].create({ + 'name': 'SPECIAL CONTENT UNLOCKED', + 'subject': 'SPECIAL CONTENT UNLOCKED', + 'model_id': self.env.ref('test_mail.model_mail_test_container').id, + 'auto_delete': True, + '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 + + test_mail_record = self.env['mail.test.container'].create({ + 'name': 'Zizizatestmailname', + 'description': 'Zizizatestmaildescription', + }) + 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), + ]) + + def test_tracked_compute(self): + # no tracking at creation + record = self.env['mail.test.track.compute'].create({}) + self.flush_tracking() + self.assertEqual(len(record.message_ids), 1) + self.assertEqual(len(record.message_ids[0].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({ + 'name': 'Foo', + 'email': 'foo@example.com', + 'phone': '1234567890', + }) + record.partner_id = partner + 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), + ('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'}) + 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], [ + ('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({ + '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.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], [ + ('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({ + '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 """ + + # Check if the tracking value have the correct currency and values + self.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), + ]) + + # Check if the tracking value have the correct currency and values after changing the value and the company + self.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), + ('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', + }) + self.flush_tracking() + self.record = record.with_context(mail_notrack=False) + + def test_track_groups(self): + field = self.record._fields['email_from'] + self.addCleanup(setattr, field, 'groups', field.groups) + field.groups = 'base.group_erp_manager' + + 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 = 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") + + # test editing the record with user not in the group of the field + self.env.invalidate_all() + self.record.clear_caches() + 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 + # if we fetch all the tracked fields, ignoring the group of the current user + # it will crash and it shouldn't + 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 + }) + 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) + + def test_unlinked_field(self): + record_sudo = self.record.sudo() + record_sudo.write({'email_from': 'new_value'}) # create a tracking value + self.flush_tracking() + self.assertEqual(len(record_sudo.message_ids.tracking_value_ids), 1) + ir_model_field = self.env['ir.model.fields'].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) 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 new file mode 100644 index 0000000..0fed627 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail/test_mail/tests/test_performance.py @@ -0,0 +1,1339 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from contextlib import nullcontext +from unittest.mock import patch + +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.tools import mute_logger, formataddr + + +@tagged('mail_performance', 'post_install', '-at_install') +class BaseMailPerformance(MailCommon, TransactionCaseWithUserDemo): + + @classmethod + def setUpClass(cls): + super(BaseMailPerformance, cls).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])], + }) + + cls.customer = cls.env['res.partner'].with_context(cls._test_context).create({ + 'country_id': cls.env.ref('base.be').id, + 'email': 'customer.test@example.com', + 'name': 'Test Customer', + 'mobile': '0456123456', + }) + + cls.test_attachments_vals = cls._generate_attachments_data(3, 'mail.compose.message', 0) + + def setUp(self): + super(BaseMailPerformance, self).setUp() + # patch registry to simulate a ready environment + self.patch(self.env.registry, 'ready', True) + self.flush_tracking() + + 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, + 'email_from': 'nopartner.test@example.com', + }) + test_template_full = self.env['mail.template'].create({ + 'name': 'TestTemplate', + 'model_id': self.env['ir.model']._get('mail.test.ticket').id, + 'subject': 'About {{ object.name }}', + 'body_html': '

Hello

', + 'email_from': '{{ object.user_id.email_formatted }}', + 'partner_to': '{{ object.customer_id.id }}', + 'email_to': '{{ ("%s Customer <%s>" % (object.name, object.email_from)) }}', + 'attachment_ids': [ + (0, 0, dict(attachment, res_model='mail.template')) + for attachment in self.test_attachments_vals + ], + }) + self.flush_tracking() + return test_record_full, test_template_full + + def _create_test_records_for_batch(self): + test_partners = self.env['res.partner'].create([{ + 'phone': f'0485{idx}{idx}1122', + 'email': f'test.customer.{idx}@test.example.com', + 'name': f'Test Customer {idx}', + } for idx in range(0, 10)]) + test_records = self.env['mail.test.ticket'].create([{ + 'customer_id': test_partners[idx].id, + 'email_from': test_partners[idx].email_formatted, + 'name': f'Test Ticket {idx}', + 'user_id': self.user_test.id, + } for idx in range(0, 10)]) + test_template_full = self.env['mail.template'].create({ + 'name': 'TestTemplate', + 'model_id': self.env['ir.model']._get('mail.test.ticket').id, + 'subject': 'About {{ object.name }}', + 'body_html': '

Hello

', + 'email_from': '{{ object.user_id.email_formatted }}', + 'partner_to': '{{ object.customer_id.id }}', + 'email_to': '{{ ("%s Customer <%s>" % (object.name, object.email_from)) }}', + 'attachment_ids': [ + (0, 0, dict(attachment, res_model='mail.template')) + 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() + + self.res_partner_3 = self.env['res.partner'].create({ + 'name': 'Gemini Furniture', + 'email': 'gemini.furniture39@example.com', + }) + self.res_partner_4 = self.env['res.partner'].create({ + 'name': 'Ready Mat', + 'email': 'ready.mat28@example.com', + }) + self.res_partner_10 = self.env['res.partner'].create({ + 'name': 'The Jackson Group', + 'email': 'jackson.group82@example.com', + }) + self.res_partner_12 = self.env['res.partner'].create({ + 'name': 'Azure Interior', + 'email': 'azure.Interior24@example.com', + }) + self.env['mail.performance.thread'].create([ + { + 'name': 'Object 0', + 'value': 0, + 'partner_id': self.res_partner_3.id, + }, { + 'name': 'Object 1', + 'value': 10, + 'partner_id': self.res_partner_3.id, + }, { + 'name': 'Object 2', + 'value': 20, + 'partner_id': self.res_partner_4.id, + }, { + 'name': 'Object 3', + 'value': 30, + 'partner_id': self.res_partner_10.id, + }, { + 'name': 'Object 4', + 'value': 40, + 'partner_id': self.res_partner_12.id, + } + ]) + + @users('admin', 'demo') + @warmup + def test_read_mail(self): + """ Read records inheriting from 'mail.thread'. """ + records = self.env['mail.performance.thread'].search([]) + self.assertEqual(len(records), 5) + + with self.assertQueryCount(admin=2, demo=2): + # without cache + for record in records: + record.partner_id.country_id.name + + with self.assertQueryCount(0): + # with cache + for record in records: + record.partner_id.country_id.name + + with self.assertQueryCount(0): + # value_pc must have been prefetched, too + for record in records: + record.value_pc + + @users('admin', 'demo') + @warmup + def test_write_mail(self): + """ Write records inheriting from 'mail.thread' (no recomputation). """ + records = self.env['mail.performance.thread'].search([]) + self.assertEqual(len(records), 5) + + with self.assertQueryCount(admin=2, demo=2): + records.write({'name': 'X'}) + + @users('admin', 'demo') + @warmup + def test_write_mail_with_recomputation(self): + """ Write records inheriting from 'mail.thread' (with recomputation). """ + records = self.env['mail.performance.thread'].search([]) + self.assertEqual(len(records), 5) + + with self.assertQueryCount(admin=2, demo=2): + records.write({'value': 42}) + + @users('admin', 'demo') + @warmup + def test_write_mail_with_tracking(self): + """ Write records inheriting from 'mail.thread' (with field tracking). """ + record = self.env['mail.performance.thread'].create({ + 'name': 'Test', + 'track': 'Y', + 'value': 40, + 'partner_id': self.res_partner_12.id, + }) + + with self.assertQueryCount(admin=3, demo=3): + record.track = 'X' + + @users('admin', 'demo') + @warmup + def test_create_mail(self): + """ Create records inheriting from 'mail.thread' (without field tracking). """ + model = self.env['mail.performance.thread'] + + with self.assertQueryCount(admin=2, demo=2): + model.with_context(tracking_disable=True).create({'name': 'X'}) + + @users('admin', 'demo') + @warmup + def test_create_mail_with_tracking(self): + """ Create records inheriting from 'mail.thread' (with field tracking). """ + with self.assertQueryCount(admin=8, demo=8): + 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): + 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): + self.env['mail.test.simple'].create([{'name': 'Test'}] * 5) + + @users('admin', 'employee') + @warmup + def test_write_mail_simple(self): + rec = self.env['mail.test.simple'].create({'name': 'Test'}) + with self.assertQueryCount(admin=1, employee=1): + rec.write({ + 'name': 'Test2', + 'email_from': 'test@test.com', + }) + + +@tagged('mail_performance', 'post_install', '-at_install') +class TestMailAPIPerformance(BaseMailPerformance): + + def setUp(self): + super(TestMailAPIPerformance, self).setUp() + + # automatically follow activities, for backward compatibility concerning query count + self.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): + model.create({'name': 'Test'}) + + @users('admin', 'employee') + @warmup + @mute_logger('odoo.models.unlink') + def test_adv_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', + }) + + with self.assertQueryCount(admin=6, employee=6): + activity = MailActivity.create({ + 'summary': 'Test Activity', + 'res_id': record.id, + 'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id, + }) + # 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_feedback + _category = activity.activity_type_id.category + + with self.assertQueryCount(admin=15, employee=15): + activity.action_feedback(feedback='Zizisse Done !') + + @warmup + def test_adv_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', + }) + activity_type = self.env.ref('mail.mail_activity_data_todo') + + MailActivity.create([{ + 'summary': 'Test Activity', + 'res_id': record.id, + 'activity_type_id': activity_type.id, + } for record in records]) + + self.env.invalidate_all() + with self.assertQueryCount(3): + records.mapped('activity_date_deadline') + + @users('admin', 'employee') + @warmup + @mute_logger('odoo.models.unlink') + def test_adv_activity_mixin(self): + record = self.env['mail.test.activity'].create({'name': 'Test'}) + + with self.assertQueryCount(admin=6, employee=6): + 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 + _category = activity.activity_type_id.category + + record.write({'name': 'Dupe write'}) + + with self.assertQueryCount(admin=17, employee=17): + record.action_close('Dupe feedback') + + self.assertEqual(record.activity_ids, self.env['mail.activity']) + + @users('admin', 'employee') + @warmup + @mute_logger('odoo.models.unlink') + def test_adv_activity_mixin_w_attachments(self): + record = self.env['mail.test.activity'].create({'name': 'Test'}) + + attachments = self.env['ir.attachment'].create([ + dict(values, + res_model='mail.activity', + res_id=0) + for values in self.test_attachments_vals + ]) + + with self.assertQueryCount(admin=6, employee=6): + 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 + _category = activity.activity_type_id.category + + record.write({'name': 'Dupe write'}) + + with self.assertQueryCount(admin=21, employee=21): # com+tm 20/20 + record.action_close('Dupe feedback', attachment_ids=attachments.ids) + + # notifications + message = record.message_ids[0] + self.assertEqual( + set(message.attachment_ids.mapped('name')), + set(['AttFileName_00.txt', 'AttFileName_01.txt', 'AttFileName_02.txt']) + ) + + @users('admin', 'employee') + @warmup + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') + 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): + composer = self.env['mail.compose.message'].with_context({ + 'default_composition_mode': 'comment', + 'default_model': test_record._name, + 'default_res_id': test_record.id, + }).create({ + 'body': '

Test Body

', + 'partner_ids': [(4, customer_id)], + }) + + with self.assertQueryCount(admin=41, employee=41): + composer._action_send_mail() + + @users('admin', 'employee') + @warmup + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') + def test_mail_composer_attachments(self): + 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): + composer = self.env['mail.compose.message'].with_context({ + 'default_composition_mode': 'comment', + 'default_model': test_record._name, + 'default_res_id': test_record.id, + }).create({ + 'attachment_ids': attachments.ids, + 'body': '

Test Body

', + 'partner_ids': [(4, customer.id)], + }) + + with self.assertQueryCount(admin=44, employee=44): + composer._action_send_mail() + + @users('admin', 'employee') + @warmup + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') + def test_mail_composer_form_attachments(self): + 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): + 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, + }) + ) + composer_form.body = '

Test Body

' + composer_form.partner_ids.add(customer) + for attachment in attachments: + composer_form.attachment_ids.add(attachment) + composer = composer_form.save() + + with self.assertQueryCount(admin=54, employee=54): # tm+com 47/47 + 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) + + @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() + + 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_template_id': test_template.id, + }).create({}) + composer._onchange_template_id_wrapper() + + with self.assertQueryCount(admin=157, employee=160), self.mock_mail_gateway(): + composer._action_send_mail() + + self.assertEqual(len(self._new_mails), 10) + + @users('admin', 'employee') + @warmup + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') + 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): + composer = self.env['mail.compose.message'].with_context({ + 'default_composition_mode': 'comment', + 'default_model': test_record._name, + 'default_res_id': test_record.id, + 'mail_auto_delete': False, + }).create({ + 'body': '

Test Body

', + 'partner_ids': [(4, customer_id)], + }) + + with self.assertQueryCount(admin=34, employee=34): + composer._action_send_mail() + + @users('admin', 'employee') + @warmup + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') + def test_mail_composer_w_template(self): + 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 + composer = self.env['mail.compose.message'].with_context({ + 'default_composition_mode': 'comment', + 'default_model': test_record._name, + 'default_res_id': test_record.id, + 'default_template_id': test_template.id, + }).create({}) + composer._onchange_template_id_wrapper() + + with self.assertQueryCount(admin=40, employee=40): + composer._action_send_mail() + + # notifications + message = test_record.message_ids[0] + self.assertFalse(message.attachment_ids) + + # remove created partner to ensure tests are the same each run + self.env['res.partner'].sudo().search([('email', '=', 'nopartner.test@example.com')]).unlink() + + @users('admin', 'employee') + @warmup + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') + 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 + composer = self.env['mail.compose.message'].with_context({ + 'default_composition_mode': 'comment', + 'default_model': test_record._name, + 'default_res_id': test_record.id, + 'default_template_id': test_template.id, + }).create({}) + composer._onchange_template_id_wrapper() + + with self.assertQueryCount(admin=51, employee=51): + composer._action_send_mail() + + # notifications + message = test_record.message_ids[0] + self.assertEqual( + set(message.attachment_ids.mapped('name')), + set(['AttFileName_00.txt', 'AttFileName_01.txt', 'AttFileName_02.txt']) + ) + + # remove created partner to ensure tests are the same each run + self.env['res.partner'].sudo().search([('email', '=', 'nopartner.test@example.com')]).unlink() + + @users('admin', 'employee') + @warmup + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') + def test_mail_composer_w_template_form(self): + test_record, test_template = self._create_test_records() + 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 + 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_template_id': test_template.id, + }) + ) + composer = composer_form.save() + + with self.assertQueryCount(admin=50, employee=50): + 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) + + # remove created partner to ensure tests are the same each run + new_partner.unlink() + + @users('admin', 'employee') + @warmup + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') + def test_mail_composer_w_template_form_attachments(self): + 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 + 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_template_id': test_template.id, + }) + ) + composer = composer_form.save() + + with self.assertQueryCount(admin=71, employee=71): + 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.assertEqual( + 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) + + # remove created partner to ensure tests are the same each run + new_partner.unlink() + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('admin', 'employee') + @warmup + def test_message_assignation_email(self): + self.user_test.write({'notification_type': 'email'}) + record = self.env['mail.test.track'].create({'name': 'Test'}) + with self.assertQueryCount(admin=42, employee=42): + record.write({ + 'user_id': self.user_test.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): + record.write({ + 'user_id': self.user_test.id, + }) + + @users('admin', 'employee') + @warmup + def test_message_log(self): + record = self.env['mail.test.simple'].create({'name': 'Test'}) + + with self.assertQueryCount(admin=1, employee=1): + record._message_log( + body='

Test _message_log

', + message_type='comment') + + @users('admin', 'employee') + @warmup + def test_message_log_batch(self): + records = self.env['mail.test.simple'].create([ + {'name': f'Test_{idx}'} + for idx in range(10) + ]) + + with self.assertQueryCount(admin=1, employee=1): + records._message_log_batch( + bodies=dict( + (record.id, '

Test _message_log

') + for record in records + ), + message_type='comment') + + @users('admin', 'employee') + @warmup + def test_message_log_with_view(self): + records = self.env['mail.test.simple'].create([ + {'name': f'Test_{idx}'} + for idx in range(10) + ]) + + with self.assertQueryCount(admin=11, employee=11): + records._message_log_with_view( + 'test_mail.mail_template_simple_test', + values={'partner': self.customer.with_env(self.env)} + ) + + @users('admin', 'employee') + @warmup + def test_message_log_with_post(self): + record = self.env['mail.test.simple'].create({'name': 'Test'}) + + with self.assertQueryCount(admin=7, employee=7): + record.message_post( + body='

Test message_post as log

', + subtype_xmlid='mail.mt_note', + message_type='comment') + + @users('admin', 'employee') + @warmup + def test_message_post_no_notification(self): + record = self.env['mail.test.simple'].create({'name': 'Test'}) + + with self.assertQueryCount(admin=7, employee=7): + record.message_post( + body='

Test Post Performances basic

', + partner_ids=[], + message_type='comment', + subtype_xmlid='mail.mt_comment') + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('admin', 'employee') + @warmup + def test_message_post_one_email_notification(self): + record = self.env['mail.test.simple'].create({'name': 'Test'}) + + with self.assertQueryCount(admin=34, employee=34): + record.message_post( + body='

Test Post Performances with an email ping

', + partner_ids=self.customer.ids, + message_type='comment', + subtype_xmlid='mail.mt_comment') + + @users('admin', 'employee') + @warmup + def test_message_post_one_inbox_notification(self): + record = self.env['mail.test.simple'].create({'name': 'Test'}) + + with self.assertQueryCount(admin=18, employee=18): + record.message_post( + body='

Test Post Performances with an inbox ping

', + partner_ids=self.user_test.partner_id.ids, + message_type='comment', + subtype_xmlid='mail.mt_comment') + + @mute_logger('odoo.models.unlink') + @users('admin', 'employee') + @warmup + def test_message_subscribe_default(self): + 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) + + with self.assertQueryCount(admin=3, employee=3): + record.message_subscribe(partner_ids=self.user_test.partner_id.ids) + + @mute_logger('odoo.models.unlink') + @users('admin', 'employee') + @warmup + def test_message_subscribe_subtypes(self): + record = self.env['mail.test.simple'].create({'name': 'Test'}) + 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) + + with self.assertQueryCount(admin=2, employee=2): + record.message_subscribe(partner_ids=self.user_test.partner_id.ids, subtype_ids=subtype_ids) + + @mute_logger('odoo.models.unlink') + @users('admin', 'employee') + @warmup + def test_message_track(self): + record = self.env['mail.performance.tracking'].create({'name': 'Zizizatestname'}) + with self.assertQueryCount(admin=3, employee=3): + record.write({'name': 'Zizizanewtestname'}) + + with self.assertQueryCount(admin=3, employee=3): + record.write({'field_%s' % (i): 'Tracked Char Fields %s' % (i) for i in range(3)}) + + with self.assertQueryCount(admin=4, employee=4): + record.write({'field_%s' % (i): 'Field Without Cache %s' % (i) for i in range(3)}) + record.flush_recordset() + record.write({'field_%s' % (i): 'Field With Cache %s' % (i) for i in range(3)}) + + @users('admin', 'employee') + @warmup + def test_notification_reply_to_batch(self): + # overwrite company name to keep it short/simple + # and not trigger the 68 character reply_to formatting + self.env.user.company_id.name = "Forced" + test_records_sudo = self.env['mail.test.container'].sudo().create([ + {'alias_name': 'a.%s.%d' % (self.env.user.name, index), + 'customer_id': self.customer.id, + 'name': 'T_%d' % index, + } for index in range(10) + ]) + + 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 + ) + + 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) + ) + ) + ) + + +@tagged('mail_performance', 'post_install', '-at_install') +class TestMailComplexPerformance(BaseMailPerformance): + + def setUp(self): + super(TestMailComplexPerformance, self).setUp() + self.user_portal = self.env['res.users'].with_context(self._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])], + }) + + self.container = self.env['mail.test.container'].with_context(mail_create_nosubscribe=True).create({ + 'name': 'Test Container', + 'customer_id': self.customer.id, + 'alias_name': 'test-alias', + }) + 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 + ]) + + # `test_complex_mail_mail_send` + self.env.flush_all() + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('admin', 'employee') + @warmup + def test_complex_mail_mail_send(self): + message = self.env['mail.message'].sudo().create({ + 'author_id': self.env.user.partner_id.id, + 'body': '

Test

', + 'email_from': self.env.user.partner_id.email, + 'message_type': 'comment', + 'model': 'mail.test.container', + 'res_id': self.container.id, + 'subject': 'Test', + }) + attachments = self.env['ir.attachment'].create([ + dict(attachment, res_id=self.container.id, res_model='mail.test.container') + for attachment in self.test_attachments_vals + ]) + mail = self.env['mail.mail'].sudo().create({ + 'attachment_ids': [(4, att.id) for att in attachments], + 'auto_delete': False, + 'body_html': '

Test

', + 'mail_message_id': message.id, + 'recipient_ids': [(4, pid) for pid in self.partners.ids], + }) + with self.assertQueryCount(admin=9, employee=9): + self.env['mail.mail'].sudo().browse(mail.ids).send() + + @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): + """ A more complete use case: 10 mails, attachments, servers, ... And + 2 failing emails. """ + message = self.env['mail.message'].sudo().create({ + 'author_id': self.env.user.partner_id.id, + 'email_from': self.env.user.partner_id.email, + 'message_type': 'comment', + 'model': 'mail.test.container', + 'res_id': self.container.id, + 'subject': 'Test', + }) + attachments = self.env['ir.attachment'].create([ + dict(attachment, res_id=self.container.id, res_model='mail.test.container') + for attachment in self.test_attachments_vals + ]) + mails = self.env['mail.mail'].sudo().create([{ + 'attachment_ids': [(4, att.id) for att in attachments], + 'auto_delete': True, + 'body_html': '

Test %s

' % idx, + 'email_cc': 'cc.1@test.example.com, cc.2@test.example.com', + 'email_to': 'customer.1@example.com, customer.2@example.com', + 'mail_message_id': message.id, + 'mail_server_id': self.mail_servers.ids[idx % len(self.mail_servers.ids)], + 'recipient_ids': [(4, pid) for pid in self.partners.ids], + } for idx in range(12)]) + mails[-2].write({'email_cc': False, 'email_to': 'strange@example¢¡.com', 'recipient_ids': [(5, 0)]}) + mails[-1].write({'email_cc': False, 'email_to': 'void', 'recipient_ids': [(5, 0)]}) + + def _patched_unlink(records): + nonlocal unlinked_mails + unlinked_mails |= set(records.ids) + unlinked_mails = set() + + with self.assertQueryCount(admin=43, employee=43), \ + patch.object(type(self.env['mail.mail']), 'unlink', _patched_unlink): + self.env['mail.mail'].sudo().browse(mails.ids).send() + + for mail in mails[:-2]: + self.assertEqual(mail.state, 'sent') + self.assertIn(mail.id, unlinked_mails, 'Mail: sent mails are to be unlinked') + self.assertEqual(mails[-2].state, 'exception') + self.assertIn(mails[-2].id, unlinked_mails, 'Mail: mails with invalid recipient are also to be unlinked') + self.assertEqual(mails[-1].state, 'exception') + self.assertIn(mails[-1].id, unlinked_mails, 'Mail: mails with invalid recipient are also to be unlinked') + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('admin', 'employee') + @warmup + def test_complex_message_post(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): + record.message_post( + body='

Test Post Performances

', + message_type='comment', + subtype_xmlid='mail.mt_comment') + + self.assertEqual(record.message_ids[0].body, '

Test Post Performances

') + self.assertEqual(record.message_ids[0].notified_partner_ids, self.partners | self.user_portal.partner_id) + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('admin', 'employee') + @warmup + def test_complex_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 + + # 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') + + 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) + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('admin', 'employee') + @warmup + def test_complex_message_post_view(self): + _partners, test_records, test_template = self._create_test_records_for_batch() + + 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_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( + 'test_mail.mail_template_simple_test', + values={'partner': self.user_test.partner_id}, + ) + + self.assertEqual(len(messages_as_sudo), 10) + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('admin', 'employee') + @warmup + def test_complex_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 + rec = self.env['mail.test.ticket'].create({ + 'name': 'Test', + 'container_id': False, + 'customer_id': False, + 'user_id': self.user_portal.id, + }) + rec1 = rec.with_context(active_test=False) # to see inactive records + + self.assertEqual(rec1.message_partner_ids, self.env.user.partner_id | self.user_portal.partner_id) + + # subscribe new followers with forced given subtypes + with self.assertQueryCount(admin=4, employee=4): + rec.message_subscribe( + partner_ids=pids[:4], + subtype_ids=subtype_ids + ) + + self.assertEqual(rec1.message_partner_ids, self.env.user.partner_id | self.user_portal.partner_id | self.partners[:4]) + + # subscribe existing and new followers with force=False, meaning only some new followers will be added + with self.assertQueryCount(admin=5, employee=5): + rec.message_subscribe( + partner_ids=pids[:6], + subtype_ids=None + ) + + self.assertEqual(rec1.message_partner_ids, self.env.user.partner_id | self.user_portal.partner_id | self.partners[:6]) + + # subscribe existing and new followers with force=True, meaning all will have the same subtypes + with self.assertQueryCount(admin=4, employee=4): + rec.message_subscribe( + partner_ids=pids, + subtype_ids=subtype_ids + ) + + self.assertEqual(rec1.message_partner_ids, self.env.user.partner_id | self.user_portal.partner_id | self.partners) + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('admin', 'employee') + @warmup + def test_complex_tracking_assignation(self): + """ Assignation performance test on already-created record """ + rec = self.env['mail.test.ticket'].create({ + 'name': 'Test', + 'container_id': self.container.id, + 'customer_id': self.customer.id, + 'user_id': self.env.uid, + }) + 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): + 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 + self.assertEqual(rec1.message_ids[0].subtype_id, self.env.ref('mail.mt_note')) + self.assertEqual(rec1.message_ids[0].notified_partner_ids, self.env['res.partner']) + # creation message + self.assertEqual(rec1.message_ids[1].subtype_id, self.env.ref('test_mail.st_mail_test_ticket_container_upd')) + self.assertEqual(rec1.message_ids[1].notified_partner_ids, self.partners) + self.assertEqual(len(rec1.message_ids), 2) + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('admin', 'employee') + @warmup + def test_complex_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): + rec = self.env['mail.test.ticket'].create({ + 'name': 'Test', + 'container_id': container_id, + 'customer_id': customer_id, + 'user_id': user_id, + }) + + 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) + # creation message + self.assertEqual(rec1.message_ids[0].subtype_id, self.env.ref('test_mail.st_mail_test_ticket_container_upd')) + self.assertEqual(rec1.message_ids[0].notified_partner_ids, self.partners | self.user_portal.partner_id) + self.assertEqual(len(rec1.message_ids), 1) + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('admin', 'employee') + @warmup + def test_complex_tracking_subscription_subtype(self): + """ Write performance test involving auto subscription, tracking with subtype """ + rec = self.env['mail.test.ticket'].create({ + 'name': 'Test', + 'container_id': False, + 'customer_id': False, + 'user_id': self.user_portal.id, + }) + 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) + self.assertEqual(len(rec1.message_ids), 1) + + with self.assertQueryCount(admin=59, employee=59): + rec.write({ + 'name': 'Test2', + 'container_id': self.container.id, + }) + + self.assertEqual(rec1.message_partner_ids, self.partners | self.env.user.partner_id | self.user_portal.partner_id) + # write tracking message + self.assertEqual(rec1.message_ids[0].subtype_id, self.env.ref('test_mail.st_mail_test_ticket_container_upd')) + self.assertEqual(rec1.message_ids[0].notified_partner_ids, self.partners | self.user_portal.partner_id) + # creation message + self.assertEqual(rec1.message_ids[1].subtype_id, self.env.ref('mail.mt_note')) + self.assertEqual(rec1.message_ids[1].notified_partner_ids, self.env['res.partner']) + self.assertEqual(len(rec1.message_ids), 2) + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('admin', 'employee') + @warmup + def test_complex_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 + container2 = self.env['mail.test.container'].with_context(mail_create_nosubscribe=True).create({ + 'name': 'Test Container 2', + 'customer_id': False, + 'alias_name': False, + }) + + rec = self.env['mail.test.ticket'].create({ + 'name': 'Test', + 'container_id': container2.id, + 'customer_id': False, + 'user_id': self.user_portal.id, + }) + 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): + rec.write({ + 'name': 'Test2', + 'container_id': container_id, + 'customer_id': customer_id, + }) + + self.assertEqual(rec1.message_partner_ids, self.partners | self.env.user.partner_id | self.user_portal.partner_id) + # write tracking message + self.assertEqual(rec1.message_ids[0].subtype_id, self.env.ref('test_mail.st_mail_test_ticket_container_upd')) + self.assertEqual(rec1.message_ids[0].notified_partner_ids, self.partners | self.user_portal.partner_id) + # creation message + self.assertEqual(rec1.message_ids[1].subtype_id, self.env.ref('test_mail.st_mail_test_ticket_container_upd')) + self.assertEqual(rec1.message_ids[1].notified_partner_ids, self.user_portal.partner_id) + self.assertEqual(len(rec1.message_ids), 2) + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('admin', 'employee') + @warmup + def test_complex_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") + rec = self.env['mail.test.ticket'].create({ + 'name': 'Test', + 'container_id': self.container.id, + 'customer_id': False, + 'user_id': self.user_portal.id, + 'mail_template': self.env.ref('test_mail.mail_test_ticket_tracking_tpl').id, + }) + 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): + rec.write({ + 'name': 'Test2', + 'customer_id': customer_id, + 'user_id': self.env.uid, + }) + + # write template message (sent to customer, mass mailing kept for history) + self.assertEqual(rec1.message_ids[0].subtype_id, self.env['mail.message.subtype']) + self.assertEqual(rec1.message_ids[0].subject, 'Test Template') + # write tracking message + self.assertEqual(rec1.message_ids[1].subtype_id, self.env.ref('mail.mt_note')) + self.assertEqual(rec1.message_ids[1].notified_partner_ids, self.env['res.partner']) + # creation message + self.assertEqual(rec1.message_ids[2].subtype_id, self.env.ref('test_mail.st_mail_test_ticket_container_upd')) + self.assertEqual(rec1.message_ids[2].notified_partner_ids, self.partners | self.user_portal.partner_id) + self.assertEqual(len(rec1.message_ids), 3) + + @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. + 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. + """ + 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 = 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=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) + + @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): + """Ensures the fetch of multiple thread names is grouped by model.""" + records = [] + for _i in range(5): + records.append(self.env['mail.test.simple'].create({'name': 'Test'})) + records.append(self.env['mail.test.track'].create({'name': 'Test'})) + + messages = self.env['mail.message'].create([{ + 'model': record._name, + 'res_id': record.id + } for record in records]) + + with self.assertQueryCount(employee=5): + res = messages.message_format() + self.assertEqual(len(res), 6) + + self.env.flush_all() + self.env.invalidate_all() + + with self.assertQueryCount(employee=14): + res = messages.message_format() + self.assertEqual(len(res), 6) + + +@tagged('mail_performance', 'post_install', '-at_install') +class TestMailHeavyPerformancePost(BaseMailPerformance): + + def setUp(self): + super(TestMailHeavyPerformancePost, self).setUp() + + # 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', + }) + + @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) + 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'}), + ] + 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, + attachments=attachments_vals, + attachment_ids=attachments.ids, + email_add_signature=True, + model_description=False, + mail_auto_delete=True + ) + 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)]) + self.assertTrue(new_message.body.startswith('

Test body |HTTP| V[Views and QWeb Templates] + V --> C[Controllers] + V --> W[Wizards – Transient Models] + C --> M[Models and ORM] + W --> M + M --> R[Reports] + DX[Data XML] --> M + S[Security – ACLs and Groups] -. enforces .-> M + + subgraph Test_mail_full Module - test_mail_full + direction LR + M:::layer + W:::layer + C:::layer + V:::layer + R:::layer + S:::layer + DX:::layer + end + + classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px +``` + +Notes +- Views include tree/form/kanban templates and report templates. +- Controllers provide website/portal routes when present. +- Wizards are UI flows implemented with `models.TransientModel`. +- Data XML loads data/demo records; Security defines groups and access. diff --git a/odoo-bringout-oca-ocb-test_mail_full/doc/CONFIGURATION.md b/odoo-bringout-oca-ocb-test_mail_full/doc/CONFIGURATION.md new file mode 100644 index 0000000..2c1f69c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/doc/CONFIGURATION.md @@ -0,0 +1,3 @@ +# Configuration + +Refer to Odoo settings for test_mail_full. Configure related models, access rights, and options as needed. diff --git a/odoo-bringout-oca-ocb-test_mail_full/doc/CONTROLLERS.md b/odoo-bringout-oca-ocb-test_mail_full/doc/CONTROLLERS.md new file mode 100644 index 0000000..ff097c0 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/doc/CONTROLLERS.md @@ -0,0 +1,17 @@ +# Controllers + +HTTP routes provided by this module. + +```mermaid +sequenceDiagram + participant U as User/Client + participant C as Module Controllers + participant O as ORM/Views + + U->>C: HTTP GET/POST (routes) + C->>O: ORM operations, render templates + O-->>U: HTML/JSON/PDF +``` + +Notes +- See files in controllers/ for route definitions. diff --git a/odoo-bringout-oca-ocb-test_mail_full/doc/DEPENDENCIES.md b/odoo-bringout-oca-ocb-test_mail_full/doc/DEPENDENCIES.md new file mode 100644 index 0000000..9e65fd1 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/doc/DEPENDENCIES.md @@ -0,0 +1,15 @@ +# Dependencies + +This addon depends on: + +- [mail](../../odoo-bringout-oca-ocb-mail) +- [mail_bot](../../odoo-bringout-oca-ocb-mail_bot) +- [portal](../../odoo-bringout-oca-ocb-portal) +- [rating](../../odoo-bringout-oca-ocb-rating) +- [mass_mailing](../../odoo-bringout-oca-ocb-mass_mailing) +- [mass_mailing_sms](../../odoo-bringout-oca-ocb-mass_mailing_sms) +- [phone_validation](../../odoo-bringout-oca-ocb-phone_validation) +- [sms](../../odoo-bringout-oca-ocb-sms) +- [test_mail](../../odoo-bringout-oca-ocb-test_mail) +- [test_mail_sms](../../odoo-bringout-oca-ocb-test_mail_sms) +- [test_mass_mailing](../../odoo-bringout-oca-ocb-test_mass_mailing) diff --git a/odoo-bringout-oca-ocb-test_mail_full/doc/FAQ.md b/odoo-bringout-oca-ocb-test_mail_full/doc/FAQ.md new file mode 100644 index 0000000..8287101 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/doc/FAQ.md @@ -0,0 +1,4 @@ +# FAQ + +- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged). +- Q: How to enable? A: Start server with --addon test_mail_full or install in UI. diff --git a/odoo-bringout-oca-ocb-test_mail_full/doc/INSTALL.md b/odoo-bringout-oca-ocb-test_mail_full/doc/INSTALL.md new file mode 100644 index 0000000..aa55570 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/doc/INSTALL.md @@ -0,0 +1,7 @@ +# Install + +```bash +pip install odoo-bringout-oca-ocb-test_mail_full" +# or +uv pip install odoo-bringout-oca-ocb-test_mail_full" +``` diff --git a/odoo-bringout-oca-ocb-test_mail_full/doc/MODELS.md b/odoo-bringout-oca-ocb-test_mail_full/doc/MODELS.md new file mode 100644 index 0000000..7d26990 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/doc/MODELS.md @@ -0,0 +1,16 @@ +# Models + +Detected core models and extensions in test_mail_full. + +```mermaid +classDiagram + class mail_test_portal + class mail_test_portal_no_partner + class mail_test_portal_public_access_action + class mail_test_rating + class mail_test_portal +``` + +Notes +- Classes show model technical names; fields omitted for brevity. +- Items listed under _inherit are extensions of existing models. diff --git a/odoo-bringout-oca-ocb-test_mail_full/doc/OVERVIEW.md b/odoo-bringout-oca-ocb-test_mail_full/doc/OVERVIEW.md new file mode 100644 index 0000000..ac061ca --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/doc/OVERVIEW.md @@ -0,0 +1,6 @@ +# Overview + +Packaged Odoo addon: test_mail_full. Provides features documented in upstream Odoo 16 under this addon. + +- Source: OCA/OCB 16.0, addon test_mail_full +- License: LGPL-3 diff --git a/odoo-bringout-oca-ocb-test_mail_full/doc/REPORTS.md b/odoo-bringout-oca-ocb-test_mail_full/doc/REPORTS.md new file mode 100644 index 0000000..e0ea35f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/doc/REPORTS.md @@ -0,0 +1,3 @@ +# Reports + +This module does not define custom reports. diff --git a/odoo-bringout-oca-ocb-test_mail_full/doc/SECURITY.md b/odoo-bringout-oca-ocb-test_mail_full/doc/SECURITY.md new file mode 100644 index 0000000..872163d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/doc/SECURITY.md @@ -0,0 +1,42 @@ +# Security + +Access control and security definitions in test_mail_full. + +## Access Control Lists (ACLs) + +Model access permissions defined in: +- **[ir.model.access.csv](../test_mail_full/security/ir.model.access.csv)** + - 9 model access rules + +## Record Rules + +Row-level security rules defined in: +- **[ir_rule_data.xml](../test_mail_full/security/ir_rule_data.xml)** + +## Security Groups & Configuration + +Security groups and permissions defined in: +- **[ir_rule_data.xml](../test_mail_full/security/ir_rule_data.xml)** + +```mermaid +graph TB + subgraph "Security Layers" + A[Users] --> B[Groups] + B --> C[Access Control Lists] + C --> D[Models] + B --> E[Record Rules] + E --> F[Individual Records] + end +``` + +Security files overview: +- **[ir.model.access.csv](../test_mail_full/security/ir.model.access.csv)** + - Model access permissions (CRUD rights) +- **[ir_rule_data.xml](../test_mail_full/security/ir_rule_data.xml)** + - Security groups, categories, and XML-based rules + +Notes +- Access Control Lists define which groups can access which models +- Record Rules provide row-level security (filter records by user/group) +- Security groups organize users and define permission sets +- All security is enforced at the ORM level by Odoo diff --git a/odoo-bringout-oca-ocb-test_mail_full/doc/TROUBLESHOOTING.md b/odoo-bringout-oca-ocb-test_mail_full/doc/TROUBLESHOOTING.md new file mode 100644 index 0000000..56853cb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/doc/TROUBLESHOOTING.md @@ -0,0 +1,5 @@ +# Troubleshooting + +- Ensure Python and Odoo environment matches repo guidance. +- Check database connectivity and logs if startup fails. +- Validate that dependent addons listed in DEPENDENCIES.md are installed. diff --git a/odoo-bringout-oca-ocb-test_mail_full/doc/USAGE.md b/odoo-bringout-oca-ocb-test_mail_full/doc/USAGE.md new file mode 100644 index 0000000..e0039b9 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/doc/USAGE.md @@ -0,0 +1,7 @@ +# Usage + +Start Odoo including this addon (from repo root): + +```bash +python3 scripts/nix_odoo_web_server.py --db-name mydb --addon test_mail_full +``` diff --git a/odoo-bringout-oca-ocb-test_mail_full/doc/WIZARDS.md b/odoo-bringout-oca-ocb-test_mail_full/doc/WIZARDS.md new file mode 100644 index 0000000..48e790d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/doc/WIZARDS.md @@ -0,0 +1,3 @@ +# Wizards + +This module does not include UI wizards. diff --git a/odoo-bringout-oca-ocb-test_mail_full/pyproject.toml b/odoo-bringout-oca-ocb-test_mail_full/pyproject.toml new file mode 100644 index 0000000..876fb0f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/pyproject.toml @@ -0,0 +1,52 @@ +[project] +name = "odoo-bringout-oca-ocb-test_mail_full" +version = "16.0.0" +description = "Mail Tests (Full) - Mail Tests: performances and tests specific to mail with all sub-modules" +authors = [ + { name = "Ernad Husremovic", email = "hernad@bring.out.ba" } +] +dependencies = [ + "odoo-bringout-oca-ocb-mail>=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", + "requests>=2.25.1" +] +readme = "README.md" +requires-python = ">= 3.11" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "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.12", + "Topic :: Office/Business", +] + +[project.urls] +homepage = "https://github.com/bringout/0" +repository = "https://github.com/bringout/0" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["test_mail_full"] + +[tool.rye] +managed = true +dev-dependencies = [ + "pytest>=8.4.1", +] 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 new file mode 100644 index 0000000..dfccc6b --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/__init__.py @@ -0,0 +1,5 @@ +# -*- 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 new file mode 100644 index 0000000..cd4c054 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/__manifest__.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +{ + 'name': 'Mail Tests (Full)', + 'version': '1.0', + 'category': 'Hidden', + 'sequence': 9876, + 'summary': 'Mail Tests: performances and tests specific to mail with all sub-modules', + 'description': """This module contains tests related to various mail features +and mail-related sub modules. Those tests are present in a separate module as it +contains models used only to perform tests independently to functional aspects of +real applications. """, + 'depends': [ + 'mail', + 'mail_bot', + 'portal', + 'rating', + # 'snailmail', + 'mass_mailing', + 'mass_mailing_sms', # adds portal + 'phone_validation', + 'sms', + 'test_mail', + 'test_mail_sms', + 'test_mass_mailing', + ], + 'data': [ + 'data/mail_message_subtype_data.xml', + 'security/ir.model.access.csv', + 'security/ir_rule_data.xml', + ], + 'assets': { + 'web.qunit_suite_tests': [ + 'test_mail_full/static/tests/qunit_suite_tests/*.js', + ], + 'web.tests_assets': [ + 'test_mail_full/static/tests/helpers/*.js', + ], + }, + 'installable': True, + '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 new file mode 100644 index 0000000..8c3feb6 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/controllers/__init__.py @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..b0c18d9 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/controllers/portal.py @@ -0,0 +1,14 @@ +from odoo import http +from odoo.http import request + + +class PortalTest(http.Controller): + """Implements some test portal routes (ex.: for viewing a record).""" + + @http.route('/my/test_portal/', type='http', auth='public', methods=['GET']) + 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('/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/data/mail_message_subtype_data.xml b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/data/mail_message_subtype_data.xml new file mode 100644 index 0000000..ad8c172 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/data/mail_message_subtype_data.xml @@ -0,0 +1,10 @@ + + + + Rating Done + Rating Done + mail.test.rating + + + + 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 new file mode 100644 index 0000000..a20be67 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/i18n/bs.po @@ -0,0 +1,291 @@ +# 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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_needaction +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_attachment_count +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__message_attachment_count +msgid "Attachment Count" +msgstr "Broj priloga" + +#. module: test_mail_full +#: model:ir.model,name:test_mail_full.model_mail_test_sms +#: model:ir.model,name:test_mail_full.model_mail_test_sms_bl +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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__create_uid +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__create_date +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__customer_id +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__partner_id +msgid "Customer" +msgstr "Kupac" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__display_name +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__display_name +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__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 "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_follower_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_follower_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_channel_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_partner_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__id +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__id +msgid "ID" +msgstr "ID" + +#. module: test_mail_full +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__message_needaction +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__message_unread +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__message_needaction +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__message_unread +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__message_needaction +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__message_unread +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 +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__message_has_sms_error +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__message_has_error +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__message_has_sms_error +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__message_has_error +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__message_has_sms_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 "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_is_follower +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_is_follower +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl____last_update +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner____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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__write_uid +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__write_date +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_main_attachment_id +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_has_error +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__name +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_needaction_counter +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_has_error_counter +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__message_needaction_counter +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__message_has_error_counter +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__message_unread_counter +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_has_sms_error +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__subject +msgid "Subject" +msgstr "Tema" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_unread +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_unread +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_unread_counter +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__website_message_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__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 +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__website_message_ids +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__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/i18n/test_mail_full.pot b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/i18n/test_mail_full.pot new file mode 100644 index 0000000..204e587 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/i18n/test_mail_full.pot @@ -0,0 +1,291 @@ +# 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 +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_needaction +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__message_needaction +msgid "Action Needed" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_attachment_count +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_attachment_count +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__message_attachment_count +msgid "Attachment Count" +msgstr "" + +#. module: test_mail_full +#: model:ir.model,name:test_mail_full.model_mail_test_sms +#: model:ir.model,name:test_mail_full.model_mail_test_sms_bl +msgid "Chatter Model for SMS Gateway" +msgstr "" + +#. 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 "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__create_uid +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__create_uid +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__create_uid +msgid "Created by" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__create_date +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__create_date +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__create_date +msgid "Created on" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__customer_id +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__customer_id +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__partner_id +msgid "Customer" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__display_name +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__display_name +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__display_name +msgid "Display Name" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__email_from +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__email_from +msgid "Email From" +msgstr "" + +#. 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 "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_follower_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_follower_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__message_follower_ids +msgid "Followers" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_channel_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_channel_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__message_channel_ids +msgid "Followers (Channels)" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_partner_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_partner_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__message_partner_ids +msgid "Followers (Partners)" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__id +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__id +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__id +msgid "ID" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__message_needaction +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__message_unread +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__message_needaction +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__message_unread +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__message_needaction +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__message_unread +msgid "If checked, new messages require your attention." +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__message_has_error +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__message_has_sms_error +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__message_has_error +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__message_has_sms_error +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__message_has_error +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "" + +#. 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 "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_is_follower +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_is_follower +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms____last_update +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl____last_update +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner____last_update +msgid "Last Modified on" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__write_uid +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__write_uid +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__write_date +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__write_date +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__write_date +msgid "Last Updated on" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_main_attachment_id +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_main_attachment_id +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__message_main_attachment_id +msgid "Main Attachment" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_has_error +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_has_error +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__message_has_error +msgid "Message Delivery error" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__message_ids +msgid "Messages" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__mobile_nbr +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__mobile_nbr +msgid "Mobile Nbr" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__name +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__name +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__name +msgid "Name" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_needaction_counter +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_needaction_counter +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__message_needaction_counter +msgid "Number of Actions" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_has_error_counter +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_has_error_counter +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__message_has_error_counter +msgid "Number of errors" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__message_needaction_counter +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__message_needaction_counter +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__message_has_error_counter +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__message_has_error_counter +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__message_unread_counter +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__message_unread_counter +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__message_unread_counter +msgid "Number of unread messages" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__phone_blacklisted +msgid "Phone Blacklisted" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__phone_nbr +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__phone_nbr +msgid "Phone Nbr" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_has_sms_error +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_has_sms_error +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__message_has_sms_error +msgid "SMS Delivery error" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__phone_sanitized +msgid "Sanitized Number" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__subject +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__subject +msgid "Subject" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_unread +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_unread +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__message_unread +msgid "Unread Messages" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__message_unread_counter +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__message_unread_counter +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__message_unread_counter +msgid "Unread Messages Counter" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms__website_message_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_bl__website_message_ids +#: model:ir.model.fields,field_description:test_mail_full.field_mail_test_sms_partner__website_message_ids +msgid "Website Messages" +msgstr "" + +#. module: test_mail_full +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms__website_message_ids +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_bl__website_message_ids +#: model:ir.model.fields,help:test_mail_full.field_mail_test_sms_partner__website_message_ids +msgid "Website communication history" +msgstr "" diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/models/__init__.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/models/__init__.py new file mode 100644 index 0000000..b3ff21e --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import test_mail_models_mail 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 new file mode 100644 index 0000000..a33736a --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/models/test_mail_models_mail.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +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' + _name = 'mail.test.portal' + _inherit = [ + 'mail.thread', + 'portal.mixin', + ] + + name = fields.Char() + partner_id = fields.Many2one('res.partner', 'Customer') + user_id = fields.Many2one(comodel_name='res.users', string="Salesperson") + + def _compute_access_url(self): + super()._compute_access_url() + for record in self.filtered('id'): + record.access_url = '/my/test_portal/%s' % self.id + + +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' + _inherit = [ + 'mail.thread', + 'portal.mixin', + ] + + name = fields.Char() + + def _compute_access_url(self): + self.access_url = False + for record in self.filtered('id'): + record.access_url = '/my/test_portal_no_partner/%s' % self.id + + +class MailTestPortalPublicAccessAction(models.Model): + """ Test 'public' target_type access action """ + _description = 'Portal Public Access Action' + _name = 'mail.test.portal.public.access.action' + _inherit = 'mail.test.portal' + + def _compute_access_url(self): + super()._compute_access_url() + for record in self.filtered('id'): + record.access_url = f'/test_portal/public_type/{record.id}' + + def _get_access_action(self, access_uid=None, force_website=False): + # Test 'public' target type for portal / public people + if self.env.user.share or force_website: + return { + 'type': 'ir.actions.act_url', + 'url': self.access_url, + 'target': 'self', + 'target_type': 'public', + 'res_id': self.id, + } + return super()._get_access_action(access_uid=access_uid, force_website=force_website) + + +class MailTestRating(models.Model): + """ A model inheriting 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' + _inherit = [ + 'mail.thread', + 'mail.activity.mixin', + 'rating.mixin', + 'portal.mixin', + ] + _mailing_enabled = True + _order = 'name asc, id asc' + + name = fields.Char() + subject = fields.Char() + 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) + user_id = fields.Many2one('res.users', 'Responsible', tracking=1) + + @api.depends('customer_id') + def _compute_email_from(self): + for rating in self: + if rating.customer_id.email_normalized: + rating.email_from = rating.customer_id.email_normalized + 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: + if rating.customer_id.phone: + rating.phone_nbr = rating.customer_id.phone + elif not rating.phone_nbr: + rating.phone_nbr = False + + def _mail_get_partner_fields(self): + return ['customer_id'] + + 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 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 new file mode 100644 index 0000000..429f70a --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/security/ir.model.access.csv @@ -0,0 +1,10 @@ +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_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 diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/security/ir_rule_data.xml b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/security/ir_rule_data.xml new file mode 100644 index 0000000..769f6f3 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/security/ir_rule_data.xml @@ -0,0 +1,17 @@ + + + + + TestRating: Multi Company + + + [('company_id', 'in', company_ids + [False])] + + + TestRating: Portal should follow + + [('message_partner_ids', 'in', [user.partner_id.id])] + + + + 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 new file mode 100644 index 0000000..dca0887 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/helpers/model_definitions_setup.js @@ -0,0 +1,5 @@ +/** @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/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 new file mode 100644 index 0000000..a3d0433 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/qunit_suite_tests/channel_preview_view_tests.js @@ -0,0 +1,55 @@ +/** @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 new file mode 100644 index 0000000..d551191 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/static/tests/qunit_suite_tests/thread_needaction_preview_tests.js @@ -0,0 +1,64 @@ +/** @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/tests/__init__.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/__init__.py new file mode 100644 index 0000000..283b523 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_odoobot +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 diff --git a/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/common.py b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/common.py new file mode 100644 index 0000000..d83e226 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/common.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.test_mass_mailing.tests.common import TestMassMailCommon + + +class TestMailFullCommon(TestMassMailCommon): + """ Keep a single entry point, notably for backward compatibility """ 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 new file mode 100644 index 0000000..0d37eb0 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mail_performance.py @@ -0,0 +1,96 @@ +# -*- 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.test_performance import BaseMailPerformance +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): + + @classmethod + def setUpClass(cls): + super(TestMailPerformance, cls).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', + }, + {'country_id': cls.env.ref('base.be').id, + 'email': 'customer.full.test.2@example.com', + 'name': 'Test Full Customer 2', + 'mobile': '0456223344', + 'phone': '0456112233', + }, + ]) + + # 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({ + 'email_from': 'email.from@test.example.com', + 'container_id': cls.record_container.id, + 'customer_id': False, + 'name': 'Test Ticket', + 'user_id': cls.user_emp_email.id, + }) + cls.record_ticket.message_subscribe(cls.customers.ids + cls.user_admin.partner_id.ids + cls.user_portal.partner_id.ids) + + def test_initial_values(self): + """ Simply ensure some values through all tests """ + record_ticket = self.env['mail.test.ticket.mc'].browse(self.record_ticket.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.assertEqual(len(record_ticket.message_ids), 1) + + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @users('employee') + @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) + attachments = self.env['ir.attachment'].create(self.test_attachments_vals) + + with self.assertQueryCount(employee=91): # tmf: 60 + new_message = record_ticket.message_post( + attachment_ids=attachments.ids, + body='

Test Content

', + message_type='comment', + subject='Test Subject', + subtype_xmlid='mail.mt_comment', + ) + + 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 + ) 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 new file mode 100644 index 0000000..d921b8d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mail_thread_internals.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from werkzeug.urls import url_parse + +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, users + + +class TestMailThreadInternalsCommon(TestMailFullCommon, TestSMSRecipients): + + @classmethod + def setUpClass(cls): + super(TestMailThreadInternalsCommon, cls).setUpClass() + + cls.test_portal_records, cls.test_portal_partners = cls._create_records_for_batch( + 'mail.test.portal', + 2, + ) + cls.test_portal_nop_records, _ = cls._create_records_for_batch( + 'mail.test.portal.no.partner', + 2, + ) + cls.test_rating_records, cls.test_rating_partners = cls._create_records_for_batch( + 'mail.test.rating', + 2, + ) + cls.test_simple_records, _ = cls._create_records_for_batch( + 'mail.test.simple', + 2, + ) + + +@tagged('mail_thread', 'portal') +class TestMailThreadInternals(TestMailThreadInternalsCommon): + + @users('employee') + def test_notify_get_recipients_groups(self): + """ Test redirection of portal-enabled records """ + test_records = [ + self.test_portal_records[0].with_env(self.env), + self.test_portal_nop_records[0].with_env(self.env), + self.test_rating_records[0].with_env(self.env), + self.test_simple_records[0].with_env(self.env), + ] + for test_record in test_records: + 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() + + if is_portal: + self.assertFalse( + test_record.access_token, + 'By default access tokens are False with portal' + ) + + groups = test_record._notify_get_recipients_groups() + portal_customer_group = next( + (group for group in groups if group[0] == 'portal_customer'), + False + ) + + if is_portal and has_customer: + # should have generated the access token, required for portal links + self.assertTrue( + test_record.access_token, + 'Portal should generate access token' + ) + # check portal_customer content and link + self.assertTrue( + portal_customer_group, + 'Portal Mixin should add portal customer notification group' + ) + portal_url = portal_customer_group[2]['button_access']['url'] + parameters = url_parse(portal_url).decode_query() + self.assertEqual(parameters['access_token'], test_record.access_token) + self.assertEqual(parameters['model'], test_record._name) + self.assertEqual(parameters['pid'], str(test_record[partner_fnames[0]].id)) + self.assertEqual(parameters['res_id'], str(test_record.id)) + else: + self.assertFalse( + portal_customer_group, + 'Portal Mixin should not add portal customer notification group' + ) 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 new file mode 100644 index 0000000..d4f580a --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_mass_mailing.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import werkzeug + +from odoo.addons.test_mail_full.tests.common import TestMailFullCommon +from odoo.tests.common import users +from odoo.tools import mute_logger +from odoo.tests import tagged + + +@tagged('mass_mailing') +class TestMassMailing(TestMailFullCommon): + + @users('user_marketing') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mailing_w_blacklist_opt_out(self): + mailing = self.env['mailing.mailing'].browse(self.mailing_bl.ids) + mailing.write({'subject': 'Subject {{ object.name }}'}) + + mailing.write({'mailing_model_id': self.env['ir.model']._get('mailing.test.optout').id}) + recipients = self._create_mailing_test_records(model='mailing.test.optout', count=10) + + # optout records 1 and 2 + (recipients[1] | recipients[2]).write({'opt_out': True}) + recipients[1].email_from = f'"Format Me" <{recipients[1].email_from}>' + # 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}>' + # have a duplicate email for 9 + recipients[9].email_from = f'"Format Me" <{recipients[9].email_from}>' + recipient_dup_1 = recipients[9].copy() + recipient_dup_1.email_from = f'"Format Me" <{recipient_dup_1.email_from}>' + # 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}>' + # 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 + recipient_falsy_1 = self.env['mailing.test.optout'].create({ + 'name': 'TestRecord_falsy_1', + 'email_from': 'falsymail' + }) + recipients_all = ( + recipients + recipient_dup_1 + recipient_dup_2 + recipient_dup_3 + recipient_dup_4 + + recipient_void_1 + recipient_falsy_1 + ) + + mailing.write({'mailing_domain': [('id', 'in', recipients_all.ids)]}) + mailing.action_put_in_queue() + with self.mock_mail_gateway(mail_unlink_sent=False): + 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 + 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']) + 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'] + ) + + self.assertMailTraces( + [recipient_info], mailing, recipient, + mail_links_info=[[ + ('url0', 'https://www.odoo.tz/my/%s' % recipient.name, True, {}), + ('url1', 'https://www.odoo.be', True, {}), + ('url2', 'https://www.odoo.com', True, {}), + ('url3', 'https://www.odoo.eu', True, {}), + ('url4', 'https://www.example.com/foo/bar?baz=qux', True, {'baz': 'qux'}), + ('url5', '%s/event/dummy-event-0' % mailing.get_base_url(), True, {}), + # view is not shortened and parsed at sending + ('url6', '%s/view' % mailing.get_base_url(), False, {}), + ('url7', 'mailto:test@odoo.com', False, {}), + # unsubscribe is not shortened and parsed at sending + ('url8', '%s/unsubscribe_from_list' % mailing.get_base_url(), False, {}), + ]], + check_mail=True, + ) + + # sent: 15, 2 bl, 3 opt-out, 3 invalid -> 7 remaining + # ignored: 2 bl + 3 optout + 2 invalid + 1 duplicate; failed: 0 + self.assertMailingStatistics(mailing, expected=16, delivered=7, sent=7, canceled=9, failed=0) 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_odoobot.py new file mode 100644 index 0000000..450d358 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_odoobot.py @@ -0,0 +1,142 @@ +# -*- 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.tests import tagged +from odoo.tools import mute_logger + + +@tagged("odoobot") +class TestOdoobot(TestMailCommon, TestRecipients): + + @classmethod + def setUpClass(cls): + super(TestOdoobot, cls).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") + cls.message_post_default_kwargs = { + 'body': '', + 'attachment_ids': [], + 'message_type': 'comment', + 'partner_ids': [], + 'subtype_xmlid': 'mail.mt_comment' + } + cls.odoobot_ping_body = '@OdooBot' % (cls.odoobot.id, cls.odoobot.id) + 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)]) + 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') + def test_odoobot_ping(self): + kwargs = self.message_post_default_kwargs.copy() + kwargs.update({'body': self.odoobot_ping_body, 'partner_ids': [self.odoobot.id, self.user_admin.partner_id.id]}) + + with patch('random.choice', lambda x: x[0]): + self.assertNextMessage( + self.test_record_employe.with_context({'mail_post_autofollow': True}).message_post(**kwargs), + sender=self.odoobot, + answer=False + ) + # Odoobot should not be a follower but user_employee and user_admin should + follower = self.test_record.message_follower_ids.mapped('partner_id') + self.assertNotIn(self.odoobot, follower) + self.assertIn(self.user_employee.partner_id, follower) + self.assertIn(self.user_admin.partner_id, follower) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_onboarding_flow(self): + kwargs = self.message_post_default_kwargs.copy() + channel = self.user_employee.with_user(self.user_employee)._init_odoobot() + + kwargs['body'] = 'tagada 😊' + last_message = self.assertNextMessage( + channel.message_post(**kwargs), + sender=self.odoobot, + answer=("help",) + ) + channel.execute_command_help() + self.assertNextMessage( + last_message, # no message will be post with command help, use last odoobot message instead + sender=self.odoobot, + answer=("@OdooBot",) + ) + kwargs['body'] = '' + kwargs['partner_ids'] = [self.env['ir.model.data']._xmlid_to_res_id("base.partner_root")] + self.assertNextMessage( + channel.message_post(**kwargs), + sender=self.odoobot, + answer=("attachment",) + ) + kwargs['body'] = '' + attachment = self.env['ir.attachment'].with_user(self.user_employee).create({ + 'datas': 'bWlncmF0aW9uIHRlc3Q=', + 'name': 'picture_of_your_dog.doc', + 'res_model': 'mail.compose.message', + }) + kwargs['attachment_ids'] = [attachment.id] + # For the end of the flow, we only test that the state changed, but not to which + # one since it depends on the intalled apps, which can add more steps (like livechat) + channel.message_post(**kwargs) + self.assertNotEqual(self.user_employee.odoobot_state, 'onboarding_attachement') + + # Test miscellaneous messages + self.user_employee.odoobot_state = "idle" + kwargs['partner_ids'] = [] + kwargs['body'] = "I love you" + self.assertNextMessage( + channel.message_post(**kwargs), + sender=self.odoobot, + answer=("too human for me",) + ) + kwargs['body'] = "Go fuck yourself" + self.assertNextMessage( + channel.message_post(**kwargs), + sender=self.odoobot, + answer=("I have feelings",) + ) + kwargs['body'] = "help me" + self.assertNextMessage( + channel.message_post(**kwargs), + sender=self.odoobot, + answer=("If you need help",) + ) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_odoobot_no_default_answer(self): + kwargs = self.message_post_default_kwargs.copy() + kwargs.update({'body': "I'm not talking to @odoobot right now", 'partner_ids': []}) + self.assertNextMessage( + self.test_record_employe.message_post(**kwargs), + answer=False + ) + + def assertNextMessage(self, message, answer=None, sender=None): + last_message = self.env['mail.message'].search([('id', '=', message.id + 1)]) + if last_message: + body = last_message.body.replace('

', '').replace('

', '') + else: + self.assertFalse(answer, "No last message found when an answer was expect") + if answer is not None: + if answer and not last_message: + self.assertTrue(False, "No last message found") + if isinstance(answer, list): + self.assertIn(body, answer) + elif isinstance(answer, tuple): + for elem in answer: + self.assertIn(elem, body) + elif not answer: + self.assertFalse(last_message, "No answer should have been post") + return + else: + self.assertEqual(body, answer) + if sender: + self.assertEqual(sender, last_message.author_id) + return last_message 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 new file mode 100644 index 0000000..70876f9 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_portal.py @@ -0,0 +1,465 @@ +# -*- coding: utf-8 -*- +# 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.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 + + +@tagged('portal') +class TestPortal(HttpCase, TestMailFullCommon, TestSMSRecipients): + + def setUp(self): + super(TestPortal, self).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() + + +@tagged('-at_install', 'post_install', 'portal', 'mail_controller') +class TestPortalControllers(TestPortal): + + def test_portal_avatar_with_access_token(self): + mail_record = self.env['mail.message'].create({ + 'author_id': self.record_portal.partner_id.id, + '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}') + 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') + + 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 + 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') + + no_token_response = self.url_open(f'/mail/avatar/mail.message/{mail_record.id}/author_avatar/50x50') + self.assertEqual(no_token_response.status_code, 200) + self.assertEqual(no_token_response.headers.get('Content-Type'), 'image/png') + self.assertRegex(no_token_response.headers.get('Content-Disposition', ''), r'placeholder\.png') + + 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( + 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, + }, + }, + ) + res.raise_for_status() + self.assertNotIn("error", res.json()) + message = self.record_portal.message_ids[0] + 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}') + 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') + + 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 + 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" + + # test as not logged + self.opener.post( + 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, + 'token': self.record_portal.access_token, + }, + }, + ) + message = self.record_portal.message_ids[0] + + self.assertIn('Test', message.body) + self.assertEqual(message.author_id, self.partner_2) + + +@tagged('-at_install', 'post_install', 'portal', 'mail_controller') +class TestPortalFlow(TestMailFullCommon, HttpCase): + """ Test shared links, mail/view links and redirection (backend, customer + portal or frontend for specific addons). """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.customer = cls.env['res.partner'].create({ + 'country_id': cls.env.ref('base.fr').id, + 'email': 'mdelvaux34@example.com', + 'lang': 'en_US', + 'mobile': '+33639982325', + 'name': 'Mathias Delvaux', + 'phone': '+33353011823', + }) + # customer portal enabled + cls.record_portal = cls.env['mail.test.portal'].create({ + 'name': 'Test Portal Record', + 'partner_id': cls.customer.id, + 'user_id': cls.user_admin.id, + }) + # internal only + cls.record_internal = cls.env['mail.test.track'].create({ + 'name': 'Test Internal Record', + }) + # readable (aka portal can read but no specific action) + cls.record_read = cls.env['mail.test.simple'].create({ + 'name': 'Test Readable Record', + }) + # 'public' target_type act_url (e.g. blog, forum, ...) -> redirection to a public page + cls.record_public_act_url = cls.env['mail.test.portal.public.access.action'].create({ + 'name': 'Public ActUrl', + }) + + cls.mail_template = cls.env['mail.template'].create({ + 'auto_delete': True, + 'body_html': '

Hello , your quotation is ready for review.

', + 'email_from': '{{ (object.user_id.email_formatted or user.email_formatted) }}', + 'model_id': cls.env.ref('test_mail_full.model_mail_test_portal').id, + 'name': 'Quotation template', + 'partner_to': '{{ object.partner_id.id }}', + 'subject': 'Your quotation "{{ object.name }}"', + }) + cls._create_portal_user() + + # prepare access URLs on self to ease tests + # ------------------------------------------------------------ + base_url = cls.record_portal.get_base_url() + cls.test_base_url = base_url + + cls.record_internal_url_base = f'{base_url}/mail/view?model={cls.record_internal._name}&res_id={cls.record_internal.id}' + cls.record_portal_url_base = f'{base_url}/mail/view?model={cls.record_portal._name}&res_id={cls.record_portal.id}' + cls.record_read_url_base = f'{base_url}/mail/view?model={cls.record_read._name}&res_id={cls.record_read.id}' + cls.record_public_act_url_base = f'{base_url}/mail/view?model={cls.record_public_act_url._name}&res_id={cls.record_public_act_url.id}' + + max_internal_id = cls.env['mail.test.track'].search([], order="id desc", limit=1).id + max_portal_id = cls.env['mail.test.portal'].search([], order="id desc", limit=1).id + max_read_id = cls.env['mail.test.simple'].search([], order="id desc", limit=1).id + max_public_act_url_id = cls.env['mail.test.portal.public.access.action'].search([], order="id desc", limit=1).id + cls.record_internal_url_no_exists = f'{base_url}/mail/view?model={cls.record_internal._name}&res_id={max_internal_id + 1}' + cls.record_portal_url_no_exists = f'{base_url}/mail/view?model={cls.record_portal._name}&res_id={max_portal_id + 1}' + cls.record_read_url_no_exists = f'{base_url}/mail/view?model={cls.record_read._name}&res_id={max_read_id + 1}' + cls.record_public_act_url_url_no_exists = f'{base_url}/mail/view?model={cls.record_public_act_url._name}&res_id={max_public_act_url_id + 1}' + + 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): + if group_name == 'portal_customer' and group_func(cls.customer): + cls.record_portal_url_auth = group_data['button_access']['url'] + break + else: + raise AssertionError('Record access URL not found') + # build altered access_token URL for testing security + parsed_url = url_parse(cls.record_portal_url_auth) + query_params = url_decode(parsed_url.query) + cls.record_portal_hash = query_params['hash'] + cls.record_portal_url_auth_wrong_token = parsed_url.replace( + query=url_encode({ + **query_params, + 'access_token': query_params['access_token'].translate( + str.maketrans('0123456789abcdef', '9876543210fedcba') + ) + }, sort=True) + ).to_url() + + # prepare result URLs on self to ease tests + # ------------------------------------------------------------ + 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' + + def test_assert_initial_data(self): + """ Test some initial values. Test that record_access_url 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') + + with self.assertRaises(AccessError): + self.record_internal.with_user(self.user_portal).check_access_rights('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.assertNotEqual(self.record_portal_url_auth, self.record_portal_url_auth_wrong_token) + url_params = [] + for url in ( + self.record_portal_url_auth, self.record_portal_url_auth_wrong_token, + ): + with self.subTest(url=url): + parsed = url_parse(url) + self.assertEqual(parsed.path, '/mail/view') + params = url_decode(parsed.query) + url_params.append(params) + # Note that pid, hash and auth_signup_token are not tested by this test but may be present in the URL (config). + self.assertEqual(params.get('model'), 'mail.test.portal') + self.assertEqual(int(params.get('res_id')), self.record_portal.id) + self.assertTrue(params.get('access_token')) + self.assertNotEqual(url_params[0]['access_token'], url_params[1]['access_token']) + self.assertEqual( + {k: v for k, v in url_params[0].items() if k != 'access_token'}, + {k: v for k, v in url_params[1].items() if k != 'access_token'}, + 'URLs should be the same, except for access token' + ) + + @users('employee') + def test_employee_access(self): + """ Check internal employee behavior when accessing mail/view """ + self.authenticate(self.env.user.login, self.env.user.login) + for url_name, url, exp_url in [ + # accessible records + ("Internal record mail/view", self.record_internal_url_base, self.internal_backend_local_url), + ("Portal record mail/view", self.record_portal_url_base, self.portal_backend_local_url), + ("Portal readable record mail/view", self.record_read_url_base, self.read_backend_local_url), + ("Public with act_url", self.record_public_act_url_base, self.public_act_url_backend_local_url), + # even with token -> backend + ("Portal record with token", self.record_portal_url_auth, self.portal_backend_local_url), + # invalid token is not an issue for employee -> backend, has access + ("Portal record with wrong token", self.record_portal_url_auth_wrong_token, self.portal_backend_local_url), + # not existing -> redirect to discuss + ("Not existing record (internal)", self.record_internal_url_no_exists, self.discuss_local_url), + ("Not existing record (portal enabled)", self.record_portal_url_no_exists, self.discuss_local_url), + ("Not existign model", self.record_url_no_model, self.discuss_local_url), + ]: + with self.subTest(name=url_name, url=url): + res = self.url_open(url) + self.assertEqual(res.status_code, 200) + self.assertURLEqual(res.url, exp_url) + + @mute_logger('werkzeug') + @users('portal_test') + def test_portal_access_logged(self): + """ Check portal behavior when accessing mail/view, notably check token + support and propagation. """ + my_url = f'{self.test_base_url}/my' + + self.authenticate(self.env.user.login, self.env.user.login) + for url_name, url, exp_url in [ + # valid token -> ok -> redirect to portal URL + ( + "No access (portal enabled), token", self.record_portal_url_auth, + self.portal_web_url_with_token, + ), + # invalid token -> ko -> redirect to my + ( + "No access (portal enabled), invalid token", self.record_portal_url_auth_wrong_token, + my_url, + ), + # 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)}', + ), + # std url, no access to record -> redirect to my + ( + 'No access record (internal)', self.record_internal_url_base, + my_url, + ), + # missing token -> redirect to my + ( + 'No access record (portal enabled)', self.record_portal_url_base, + my_url, + ), + # public_type act_url -> share users are redirected to frontend url + ( + "Public with act_url -> frontend url", self.record_public_act_url_base, + self.public_act_url_share + ), + # not existing -> redirect to my + ( + 'Not existing record (internal)', self.record_internal_url_no_exists, + my_url, + ), + ( + 'Not existing record (portal enabled)', self.record_portal_url_no_exists, + my_url, + ), + ( + 'Not existing model', self.record_url_no_model, + my_url, + ), + ]: + with self.subTest(name=url_name, url=url): + res = self.url_open(url) + self.assertEqual(res.status_code, 200) + self.assertURLEqual(res.url, exp_url) + + @mute_logger('werkzeug') + def test_portal_access_not_logged(self): + """ Check customer behavior when accessing mail/view, notably check token + support and propagation. """ + self.authenticate(None, None) + login_url = f'{self.test_base_url}/web/login' + + for url_name, url, exp_url in [ + # valid token -> ok -> redirect to portal URL + ( + "No access (portal enabled), token", self.record_portal_url_auth, + self.portal_web_url_with_token, + ), + # invalid token -> ko -> redirect to login with redirect to original link, will be rejected after login + ( + "No access (portal enabled), invalid token", self.record_portal_url_auth_wrong_token, + f'{login_url}?{url_encode({"redirect": self.record_portal_url_auth_wrong_token.replace(self.test_base_url, "")})}', + ), + # std url, no access to record -> redirect to login with redirect to original link, will be rejected after login + ( + 'No access record (internal)', self.record_internal_url_base, + f'{login_url}?{url_encode({"redirect": self.record_internal_url_base.replace(self.test_base_url, "")})}', + ), + # std url, no access to record but portal -> redirect to login, original (local) URL kept as redirection post login to try again (even if faulty) + ( + 'No access record (portal enabled)', self.record_portal_url_base, + f'{login_url}?{url_encode({"redirect": self.record_portal_url_base.replace(self.test_base_url, "")})}', + ), + ( + 'No access record (portal can read, no customer portal)', self.record_read_url_base, + f'{login_url}?{url_encode({"redirect": self.record_read_url_base.replace(self.test_base_url, "")})}', + ), + # public_type act_url -> share users are redirected to frontend url + ( + "Public with act_url -> frontend url", self.record_public_act_url_base, + self.public_act_url_share + ), + # not existing -> redirect to login, original (internal) URL kept as redirection post login to try again (even if faulty) + ( + 'Not existing record (internal)', self.record_internal_url_no_exists, + f'{login_url}?{url_encode({"redirect": self.record_internal_url_no_exists.replace(self.test_base_url, "")})}', + ), + ( + 'Not existing record (portal enabled)', self.record_portal_url_no_exists, + f'{login_url}?{url_encode({"redirect": self.record_portal_url_no_exists.replace(self.test_base_url, "")})}', + ), + ( + 'Not existing model', self.record_url_no_model, + f'{login_url}?{url_encode({"redirect": self.record_url_no_model.replace(self.test_base_url, "")})}', + ), + ]: + with self.subTest(name=url_name, url=url): + res = self.url_open(url) + self.assertEqual(res.status_code, 200) + self.assertURLEqual(res.url, exp_url) + + def test_redirect_to_records_norecord(self): + """ Check specific use case of missing model, should directly redirect + to login page. """ + for model, res_id in [ + (False, self.record_portal.id), + ('', self.record_portal.id), + (self.record_portal._name, False), + (self.record_portal._name, ''), + (False, False), + ('wrong.model', self.record_portal.id), + (self.record_portal._name, -4), + ]: + response = self.url_open( + '/mail/view?model=%s&res_id=%s' % (model, res_id), + timeout=15 + ) + path = url_parse(response.url).path + self.assertEqual( + path, '/web/login', + 'Failed with %s - %s' % (model, res_id) + ) + + +@tagged('portal') +class TestPortalMixin(TestPortal): + + @users('employee') + def test_portal_mixin(self): + """ Test internals of portal mixin """ + customer = self.partner_1.with_env(self.env) + record_portal = self.env['mail.test.portal'].create({ + 'partner_id': customer.id, + 'name': 'Test Portal Record', + }) + + self.assertFalse(record_portal.access_token) + self.assertEqual(record_portal.access_url, '/my/test_portal/%s' % record_portal.id) + + record_portal._portal_ensure_token() + self.assertTrue(record_portal.access_token) 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 new file mode 100644 index 0000000..057dc73 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_rating.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import lxml +from datetime import datetime + +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.tools import mute_logger + + +class TestRatingCommon(TestMailFullCommon, TestSMSRecipients): + @classmethod + def setUpClass(cls): + super(TestRatingCommon, cls).setUpClass() + + cls.record_rating = cls.env['mail.test.rating'].create({ + 'customer_id': cls.partner_1.id, + 'name': 'Test Rating', + '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) + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_rating_prepare(self): + record_rating = self.record_rating.with_env(self.env) + + # 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) + + @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 + + # 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 + + # 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 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) + + # 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 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) + + +@tagged('rating') +class TestRatingMixin(TestRatingCommon): + + @users('employee') + @warmup + def test_rating_values(self): + record_rating = self.record_rating.with_env(self.env) + + # prepare rating token + access_0 = record_rating._rating_get_access_token() + last_rating = record_rating.rating_apply(3, token=access_0, feedback="This record is meh but it's cheap.") + # Make sure to update the write_date which is used to retrieve the last rating + last_rating.write_date = datetime(2022, 1, 1, 14, 00) + access_1 = record_rating._rating_get_access_token() + last_rating = record_rating.rating_apply(1, token=access_1, feedback="This record sucks so much. I want to speak to the manager !") + last_rating.write_date = datetime(2022, 2, 1, 14, 00) + access_2 = record_rating._rating_get_access_token() + last_rating = record_rating.rating_apply(5, token=access_2, feedback="This is the best record ever ! I wish I read the documentation before complaining !") + last_rating.write_date = datetime(2022, 3, 1, 14, 00) + record_rating.rating_ids.flush_model(['write_date']) + + self.assertEqual(record_rating.rating_last_value, 5, "The last rating is kept.") + 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): + + 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() + + # 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') + + # 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) + + 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 = 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) 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 new file mode 100644 index 0000000..c871fd9 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_mail_full/test_mail_full/tests/test_res_users.py @@ -0,0 +1,76 @@ +# -*- 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_full.tests.common import TestMailFullCommon + + +class TestResUsers(TestMailFullCommon): + + @classmethod + def setUpClass(cls): + super(TestResUsers, cls).setUpClass() + 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', + email='portal@test.example.com', + groups='base.group_portal', + ) + + 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', + email='portal_2@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() + + def test_deactivate_portal_users_blacklist(self): + """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.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') + + blacklist = self.env['mail.blacklist'].search([ + ('email', 'in', ('portal@test.example.com', 'portal_2@test.example.com')), + ]) + self.assertEqual(len(blacklist), 2, 'Should have blacklisted the users email') + + blacklists = self.env['phone.blacklist'].search([ + ('number', 'in', ('+32494123489', '+32494123456', '+32494123422')), + ]) + self.assertEqual(len(blacklists), 3, 'Should have blacklisted the user phone and mobile') + + blacklist = self.env['phone.blacklist'].search([('number', '=', 'invalid phone')]) + self.assertFalse(blacklist, 'Should have skipped invalid phone') + + def test_deactivate_portal_users_no_blacklist(self): + """Test the case when the user do not want to blacklist his email / phone.""" + (self.portal_user | self.portal_user_2)._deactivate_portal_user(request_blacklist=False) + + 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') + + blacklists = self.env['mail.blacklist'].search([]) + self.assertFalse(blacklists, 'Should not have blacklisted the users email') + + blacklists = self.env['phone.blacklist'].search([]) + self.assertFalse(blacklists, 'Should not have blacklisted the user phone and mobile') diff --git a/odoo-bringout-oca-ocb-test_resource/README.md b/odoo-bringout-oca-ocb-test_resource/README.md new file mode 100644 index 0000000..422eef8 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/README.md @@ -0,0 +1,46 @@ +# Test - Resource + +Odoo addon: test_resource + +## Installation + +```bash +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`. + +## 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 diff --git a/odoo-bringout-oca-ocb-test_resource/doc/ARCHITECTURE.md b/odoo-bringout-oca-ocb-test_resource/doc/ARCHITECTURE.md new file mode 100644 index 0000000..c549c92 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/doc/ARCHITECTURE.md @@ -0,0 +1,32 @@ +# Architecture + +```mermaid +flowchart TD + U[Users] -->|HTTP| V[Views and QWeb Templates] + V --> C[Controllers] + V --> W[Wizards – Transient Models] + C --> M[Models and ORM] + W --> M + M --> R[Reports] + DX[Data XML] --> M + S[Security – ACLs and Groups] -. enforces .-> M + + subgraph Test_resource Module - test_resource + direction LR + M:::layer + W:::layer + C:::layer + V:::layer + R:::layer + S:::layer + DX:::layer + end + + classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px +``` + +Notes +- Views include tree/form/kanban templates and report templates. +- Controllers provide website/portal routes when present. +- Wizards are UI flows implemented with `models.TransientModel`. +- Data XML loads data/demo records; Security defines groups and access. diff --git a/odoo-bringout-oca-ocb-test_resource/doc/CONFIGURATION.md b/odoo-bringout-oca-ocb-test_resource/doc/CONFIGURATION.md new file mode 100644 index 0000000..e80a166 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/doc/CONFIGURATION.md @@ -0,0 +1,3 @@ +# Configuration + +Refer to Odoo settings for test_resource. Configure related models, access rights, and options as needed. diff --git a/odoo-bringout-oca-ocb-test_resource/doc/CONTROLLERS.md b/odoo-bringout-oca-ocb-test_resource/doc/CONTROLLERS.md new file mode 100644 index 0000000..f628e77 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/doc/CONTROLLERS.md @@ -0,0 +1,3 @@ +# Controllers + +This module does not define custom HTTP controllers. diff --git a/odoo-bringout-oca-ocb-test_resource/doc/DEPENDENCIES.md b/odoo-bringout-oca-ocb-test_resource/doc/DEPENDENCIES.md new file mode 100644 index 0000000..3498059 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/doc/DEPENDENCIES.md @@ -0,0 +1,5 @@ +# Dependencies + +This addon depends on: + +- [resource](../../odoo-bringout-oca-ocb-resource) diff --git a/odoo-bringout-oca-ocb-test_resource/doc/FAQ.md b/odoo-bringout-oca-ocb-test_resource/doc/FAQ.md new file mode 100644 index 0000000..31eabab --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/doc/FAQ.md @@ -0,0 +1,4 @@ +# FAQ + +- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged). +- Q: How to enable? A: Start server with --addon test_resource or install in UI. diff --git a/odoo-bringout-oca-ocb-test_resource/doc/INSTALL.md b/odoo-bringout-oca-ocb-test_resource/doc/INSTALL.md new file mode 100644 index 0000000..9a7298c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/doc/INSTALL.md @@ -0,0 +1,7 @@ +# Install + +```bash +pip install odoo-bringout-oca-ocb-test_resource" +# or +uv pip install odoo-bringout-oca-ocb-test_resource" +``` diff --git a/odoo-bringout-oca-ocb-test_resource/doc/MODELS.md b/odoo-bringout-oca-ocb-test_resource/doc/MODELS.md new file mode 100644 index 0000000..ca69444 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/doc/MODELS.md @@ -0,0 +1,12 @@ +# Models + +Detected core models and extensions in test_resource. + +```mermaid +classDiagram + class resource_test +``` + +Notes +- Classes show model technical names; fields omitted for brevity. +- Items listed under _inherit are extensions of existing models. diff --git a/odoo-bringout-oca-ocb-test_resource/doc/OVERVIEW.md b/odoo-bringout-oca-ocb-test_resource/doc/OVERVIEW.md new file mode 100644 index 0000000..aed6e59 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/doc/OVERVIEW.md @@ -0,0 +1,6 @@ +# Overview + +Packaged Odoo addon: test_resource. Provides features documented in upstream Odoo 16 under this addon. + +- Source: OCA/OCB 16.0, addon test_resource +- License: LGPL-3 diff --git a/odoo-bringout-oca-ocb-test_resource/doc/REPORTS.md b/odoo-bringout-oca-ocb-test_resource/doc/REPORTS.md new file mode 100644 index 0000000..e0ea35f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/doc/REPORTS.md @@ -0,0 +1,3 @@ +# Reports + +This module does not define custom reports. diff --git a/odoo-bringout-oca-ocb-test_resource/doc/SECURITY.md b/odoo-bringout-oca-ocb-test_resource/doc/SECURITY.md new file mode 100644 index 0000000..40eae60 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/doc/SECURITY.md @@ -0,0 +1,34 @@ +# Security + +Access control and security definitions in test_resource. + +## Access Control Lists (ACLs) + +Model access permissions defined in: +- **[ir.model.access.csv](../test_resource/security/ir.model.access.csv)** + - 1 model access rules + +## Record Rules + +Row-level security rules defined in: + +```mermaid +graph TB + subgraph "Security Layers" + A[Users] --> B[Groups] + B --> C[Access Control Lists] + C --> D[Models] + B --> E[Record Rules] + E --> F[Individual Records] + end +``` + +Security files overview: +- **[ir.model.access.csv](../test_resource/security/ir.model.access.csv)** + - Model access permissions (CRUD rights) + +Notes +- Access Control Lists define which groups can access which models +- Record Rules provide row-level security (filter records by user/group) +- Security groups organize users and define permission sets +- All security is enforced at the ORM level by Odoo diff --git a/odoo-bringout-oca-ocb-test_resource/doc/TROUBLESHOOTING.md b/odoo-bringout-oca-ocb-test_resource/doc/TROUBLESHOOTING.md new file mode 100644 index 0000000..56853cb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/doc/TROUBLESHOOTING.md @@ -0,0 +1,5 @@ +# Troubleshooting + +- Ensure Python and Odoo environment matches repo guidance. +- Check database connectivity and logs if startup fails. +- Validate that dependent addons listed in DEPENDENCIES.md are installed. diff --git a/odoo-bringout-oca-ocb-test_resource/doc/USAGE.md b/odoo-bringout-oca-ocb-test_resource/doc/USAGE.md new file mode 100644 index 0000000..6be3dff --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/doc/USAGE.md @@ -0,0 +1,7 @@ +# Usage + +Start Odoo including this addon (from repo root): + +```bash +python3 scripts/nix_odoo_web_server.py --db-name mydb --addon test_resource +``` diff --git a/odoo-bringout-oca-ocb-test_resource/doc/WIZARDS.md b/odoo-bringout-oca-ocb-test_resource/doc/WIZARDS.md new file mode 100644 index 0000000..48e790d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/doc/WIZARDS.md @@ -0,0 +1,3 @@ +# Wizards + +This module does not include UI wizards. diff --git a/odoo-bringout-oca-ocb-test_resource/pyproject.toml b/odoo-bringout-oca-ocb-test_resource/pyproject.toml new file mode 100644 index 0000000..06ce37f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/pyproject.toml @@ -0,0 +1,42 @@ +[project] +name = "odoo-bringout-oca-ocb-test_resource" +version = "16.0.0" +description = "Test - Resource - Odoo addon" +authors = [ + { name = "Ernad Husremovic", email = "hernad@bring.out.ba" } +] +dependencies = [ + "odoo-bringout-oca-ocb-resource>=16.0.0", + "requests>=2.25.1" +] +readme = "README.md" +requires-python = ">= 3.11" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "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.12", + "Topic :: Office/Business", +] + +[project.urls] +homepage = "https://github.com/bringout/0" +repository = "https://github.com/bringout/0" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["test_resource"] + +[tool.rye] +managed = true +dev-dependencies = [ + "pytest>=8.4.1", +] diff --git a/odoo-bringout-oca-ocb-test_resource/test_resource/__init__.py b/odoo-bringout-oca-ocb-test_resource/test_resource/__init__.py new file mode 100644 index 0000000..dc5e6b6 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/odoo-bringout-oca-ocb-test_resource/test_resource/__manifest__.py b/odoo-bringout-oca-ocb-test_resource/test_resource/__manifest__.py new file mode 100644 index 0000000..ce59dd0 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/__manifest__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Test - Resource', + 'version': '1.1', + 'category': 'Hidden', + 'depends': ['resource'], + 'data': [ + 'security/ir.model.access.csv', + ], + 'license': 'LGPL-3', +} diff --git a/odoo-bringout-oca-ocb-test_resource/test_resource/models/__init__.py b/odoo-bringout-oca-ocb-test_resource/test_resource/models/__init__.py new file mode 100644 index 0000000..9ac0305 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_resource 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 new file mode 100644 index 0000000..d137951 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/models/test_resource.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResourceTest(models.Model): + _description = 'Test Resource Model' + _name = 'resource.test' + _inherit = ['resource.mixin'] + + name = fields.Char() diff --git a/odoo-bringout-oca-ocb-test_resource/test_resource/security/ir.model.access.csv b/odoo-bringout-oca-ocb-test_resource/test_resource/security/ir.model.access.csv new file mode 100644 index 0000000..ee85fbe --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_resource_test_all,resource.test.all,model_resource_test,base.group_user,1,1,1,1 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 new file mode 100644 index 0000000..0a816e9 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_resource +from . import test_performance 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 new file mode 100644 index 0000000..8a10032 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/common.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import TransactionCase + + +class TestResourceCommon(TransactionCase): + + @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) + ], + }) + + @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) + ], + }) + + @classmethod + def setUpClass(cls): + super(TestResourceCommon, cls).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') + # 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') + # 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') + # 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_paul = cls._define_calendar('Morning and evening shifts', sum([((2, 7, i), (10, 16, i)) for i in range(5)], ()), 'America/Noronha') + + # 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.paul = cls.env['resource.test'].create({ + 'name': 'Paul', + 'resource_calendar_id': cls.calendar_paul.id, + }) + + cls.two_weeks_resource = cls._define_calendar_2_weeks( + '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) + ], + 'Europe/Brussels' + ) 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 new file mode 100644 index 0000000..bb0aedc --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_performance.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import time +import pytz + +from datetime import datetime +from dateutil.relativedelta import relativedelta + +from odoo.tests import TransactionCase, warmup + +_logger = logging.getLogger(__name__) + +class TestResourcePerformance(TransactionCase): + + @warmup + def test_performance_attendance_intervals_batch(self): + # Tests the performance of _attendance_intervals_batch with a batch of 100 resources + calendar = self.env['resource.calendar'].create({ + 'name': 'Calendar', + }) + resources = self.env['resource.test'].create([ + { + 'name': 'Resource ' + str(i), + 'resource_calendar_id': calendar.id, + } + for i in range(100) + ]) + with self.assertQueryCount(__system__=1): + # Generate attendances for a whole year + 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) + _logger.info('Attendance Intervals Batch (100): --- %s seconds ---', time.time() - start_time) + # Before + #INFO master test_performance: Attendance Intervals Batch (100): --- 2.0667169094085693 seconds --- + #INFO master test_performance: Attendance Intervals Batch (100): --- 2.0868310928344727 seconds --- + #INFO master test_performance: Attendance Intervals Batch (100): --- 1.9209258556365967 seconds --- + #INFO master test_performance: Attendance Intervals Batch (100): --- 1.9474620819091797 seconds --- + # After + #INFO master test_performance: Attendance Intervals Batch (100): --- 0.4092371463775635 seconds --- + #INFO master test_performance: Attendance Intervals Batch (100): --- 0.3598649501800537 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 new file mode 100644 index 0000000..a3fd84c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_resource/test_resource/tests/test_resource.py @@ -0,0 +1,1364 @@ +# 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 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): + calendars = self.jean.resource_id._get_calendars_validity_within_period( + utc.localize(datetime(2021, 7, 1, 8, 0, 0)), + utc.localize(datetime(2021, 7, 30, 17, 0, 0)), + ) + 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.assertEqual(1, len(calendars), "The dict returned by calendars validity should only have 1 entry") + self.assertEqual(1, len(calendars[self.jean.resource_id.id]), "Jean should only have one calendar") + jean_entry = calendars[self.jean.resource_id.id] + jean_calendar = next(iter(jean_entry)) + self.assertEqual(self.jean.resource_calendar_id, jean_calendar, "It should be Jean's Calendar") + self.assertFalse(jean_entry[jean_calendar] - interval, "Interval should cover all calendar's validity") + self.assertFalse(interval - jean_entry[jean_calendar], "Calendar validity should cover all interval") + + calendars = self.env['resource.resource']._get_calendars_validity_within_period( + utc.localize(datetime(2021, 7, 1, 8, 0, 0)), + utc.localize(datetime(2021, 7, 30, 17, 0, 0)), + ) + self.assertEqual(1, len(calendars), "The dict returned by calendars validity should only have 1 entry") + self.assertEqual(1, len(calendars[False]), "False (default) should only have one calendar") + false_entry = calendars[False] + false_calendar = next(iter(false_entry)) + self.assertEqual(self.env.company.resource_calendar_id, false_calendar, "It should be company calendar Calendar") + self.assertFalse(false_entry[false_calendar] - interval, "Interval should cover all calendar's validity") + self.assertFalse(interval - false_entry[false_calendar], "Calendar validity should cover all interval") + + def test_performance(self): + calendars = [self.calendar_jean, self.calendar_john, self.calendar_jules, self.calendar_patel] + calendars_len = len(calendars) + self.resources_test = self.env['resource.test'].create([{ + 'name': 'resource ' + str(i), + 'resource_calendar_id': calendars[i % calendars_len].id, + } for i in range(0, 50)]) + + start = utc.localize(datetime(2021, 7, 7, 12, 0, 0)) + end = utc.localize(datetime(2021, 7, 16, 23, 59, 59)) + with self.assertQueryCount(13): + work_intervals, _ = self.resources_test.resource_id._get_valid_work_intervals(start, end) + + self.assertEqual(len(work_intervals), 50) + + def test_get_valid_work_intervals(self): + start = utc.localize(datetime(2021, 7, 7, 12, 0, 0)) + end = utc.localize(datetime(2021, 7, 16, 23, 59, 59)) + work_intervals, _ = self.jean.resource_id._get_valid_work_intervals(start, end) + sum_work_intervals = sum_intervals(work_intervals[self.jean.resource_id.id]) + self.assertEqual(58, sum_work_intervals, "Sum of the work intervals for the resource jean should be 40h+18h = 58h") + + def test_get_valid_work_intervals_calendars_only(self): + calendars = [self.calendar_jean, self.calendar_john, self.calendar_jules, self.calendar_patel] + start = utc.localize(datetime(2021, 7, 7, 12, 0, 0)) + end = utc.localize(datetime(2021, 7, 16, 23, 59, 59)) + _, calendars_intervals = self.env['resource.resource']._get_valid_work_intervals(start, end, calendars) + sum_work_intervals_jean = sum_intervals(calendars_intervals[self.calendar_jean.id]) + self.assertEqual(58, sum_work_intervals_jean, "Sum of the work intervals for the calendar of jean should be 40h+18h = 58h") + sum_work_intervals_john = sum_intervals(calendars_intervals[self.calendar_john.id]) + self.assertEqual(26 - 1 / 3600, sum_work_intervals_john, "Sum of the work intervals for the calendar of john should be 20h+6h-1s = 25h59m59s") + sum_work_intervals_jules = sum_intervals(calendars_intervals[self.calendar_jules.id]) + self.assertEqual(31, sum_work_intervals_jules, "Sum of the work intervals for the calendar of jules should be Wodd:15h+Wpair:16h = 31h") + sum_work_intervals_patel = sum_intervals(calendars_intervals[self.calendar_patel.id]) + self.assertEqual(49, sum_work_intervals_patel, "Sum of the work intervals for the calendar of patel should be 14+35h = 49h") + + def test_switch_two_weeks_resource(self): + """ + Check that it is possible to switch the company's default calendar + """ + self.env.company.resource_calendar_id = self.two_weeks_resource + company_resource = self.env.company.resource_calendar_id + # Switch two times to be sure to test both cases + company_resource.switch_calendar_type() + company_resource.switch_calendar_type() + + def test_create_company_using_two_weeks_resource(self): + """ + Check that we can create a new company + if the default company calendar is two weeks + """ + self.env.company.resource_calendar_id = self.two_weeks_resource + self.env['res.company'].create({'name': 'New Company'}) diff --git a/odoo-bringout-oca-ocb-test_website/README.md b/odoo-bringout-oca-ocb-test_website/README.md new file mode 100644 index 0000000..8621ad2 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/README.md @@ -0,0 +1,52 @@ +# Website Test + +This module contains tests related to website. Those are +present in a separate module as we are testing module install/uninstall/upgrade +and we don't want to reload the website module every time, including it's possible +dependencies. Neither we want to add in website module some routes, views and +models which only purpose is to run tests. + +## Installation + +```bash +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`. + +## 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 diff --git a/odoo-bringout-oca-ocb-test_website/doc/ARCHITECTURE.md b/odoo-bringout-oca-ocb-test_website/doc/ARCHITECTURE.md new file mode 100644 index 0000000..5c011e7 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/doc/ARCHITECTURE.md @@ -0,0 +1,32 @@ +# Architecture + +```mermaid +flowchart TD + U[Users] -->|HTTP| V[Views and QWeb Templates] + V --> C[Controllers] + V --> W[Wizards – Transient Models] + C --> M[Models and ORM] + W --> M + M --> R[Reports] + DX[Data XML] --> M + S[Security – ACLs and Groups] -. enforces .-> M + + subgraph Test_website Module - test_website + direction LR + M:::layer + W:::layer + C:::layer + V:::layer + R:::layer + S:::layer + DX:::layer + end + + classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px +``` + +Notes +- Views include tree/form/kanban templates and report templates. +- Controllers provide website/portal routes when present. +- Wizards are UI flows implemented with `models.TransientModel`. +- Data XML loads data/demo records; Security defines groups and access. diff --git a/odoo-bringout-oca-ocb-test_website/doc/CONFIGURATION.md b/odoo-bringout-oca-ocb-test_website/doc/CONFIGURATION.md new file mode 100644 index 0000000..65289dd --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/doc/CONFIGURATION.md @@ -0,0 +1,3 @@ +# Configuration + +Refer to Odoo settings for test_website. Configure related models, access rights, and options as needed. diff --git a/odoo-bringout-oca-ocb-test_website/doc/CONTROLLERS.md b/odoo-bringout-oca-ocb-test_website/doc/CONTROLLERS.md new file mode 100644 index 0000000..ff097c0 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/doc/CONTROLLERS.md @@ -0,0 +1,17 @@ +# Controllers + +HTTP routes provided by this module. + +```mermaid +sequenceDiagram + participant U as User/Client + participant C as Module Controllers + participant O as ORM/Views + + U->>C: HTTP GET/POST (routes) + C->>O: ORM operations, render templates + O-->>U: HTML/JSON/PDF +``` + +Notes +- See files in controllers/ for route definitions. diff --git a/odoo-bringout-oca-ocb-test_website/doc/DEPENDENCIES.md b/odoo-bringout-oca-ocb-test_website/doc/DEPENDENCIES.md new file mode 100644 index 0000000..4d3e1ec --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/doc/DEPENDENCIES.md @@ -0,0 +1,7 @@ +# Dependencies + +This addon depends on: + +- [web_unsplash](../../odoo-bringout-oca-ocb-web_unsplash) +- [website](../../odoo-bringout-oca-ocb-website) +- [theme_default](../../odoo-bringout-oca-ocb-theme_default) diff --git a/odoo-bringout-oca-ocb-test_website/doc/FAQ.md b/odoo-bringout-oca-ocb-test_website/doc/FAQ.md new file mode 100644 index 0000000..9f11393 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/doc/FAQ.md @@ -0,0 +1,4 @@ +# FAQ + +- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged). +- Q: How to enable? A: Start server with --addon test_website or install in UI. diff --git a/odoo-bringout-oca-ocb-test_website/doc/INSTALL.md b/odoo-bringout-oca-ocb-test_website/doc/INSTALL.md new file mode 100644 index 0000000..148755e --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/doc/INSTALL.md @@ -0,0 +1,7 @@ +# Install + +```bash +pip install odoo-bringout-oca-ocb-test_website" +# or +uv pip install odoo-bringout-oca-ocb-test_website" +``` diff --git a/odoo-bringout-oca-ocb-test_website/doc/MODELS.md b/odoo-bringout-oca-ocb-test_website/doc/MODELS.md new file mode 100644 index 0000000..90e3bf0 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/doc/MODELS.md @@ -0,0 +1,15 @@ +# Models + +Detected core models and extensions in test_website. + +```mermaid +classDiagram + class test_model + class test_model_multi_website + class res_config_settings + class website +``` + +Notes +- Classes show model technical names; fields omitted for brevity. +- Items listed under _inherit are extensions of existing models. diff --git a/odoo-bringout-oca-ocb-test_website/doc/OVERVIEW.md b/odoo-bringout-oca-ocb-test_website/doc/OVERVIEW.md new file mode 100644 index 0000000..f5ecbd5 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/doc/OVERVIEW.md @@ -0,0 +1,6 @@ +# Overview + +Packaged Odoo addon: test_website. Provides features documented in upstream Odoo 16 under this addon. + +- Source: OCA/OCB 16.0, addon test_website +- License: LGPL-3 diff --git a/odoo-bringout-oca-ocb-test_website/doc/REPORTS.md b/odoo-bringout-oca-ocb-test_website/doc/REPORTS.md new file mode 100644 index 0000000..e0ea35f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/doc/REPORTS.md @@ -0,0 +1,3 @@ +# Reports + +This module does not define custom reports. diff --git a/odoo-bringout-oca-ocb-test_website/doc/SECURITY.md b/odoo-bringout-oca-ocb-test_website/doc/SECURITY.md new file mode 100644 index 0000000..53a1ba1 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/doc/SECURITY.md @@ -0,0 +1,42 @@ +# Security + +Access control and security definitions in test_website. + +## Access Control Lists (ACLs) + +Model access permissions defined in: +- **[ir.model.access.csv](../test_website/security/ir.model.access.csv)** + - 3 model access rules + +## Record Rules + +Row-level security rules defined in: + +## Security Groups & Configuration + +Security groups and permissions defined in: +- **[test_website_security.xml](../test_website/security/test_website_security.xml)** + - 1 security groups defined + +```mermaid +graph TB + subgraph "Security Layers" + A[Users] --> B[Groups] + B --> C[Access Control Lists] + C --> D[Models] + B --> E[Record Rules] + E --> F[Individual Records] + end +``` + +Security files overview: +- **[ir.model.access.csv](../test_website/security/ir.model.access.csv)** + - Model access permissions (CRUD rights) +- **[test_website_security.xml](../test_website/security/test_website_security.xml)** + - Security groups, categories, and XML-based rules + +Notes +- Access Control Lists define which groups can access which models +- Record Rules provide row-level security (filter records by user/group) +- Security groups organize users and define permission sets +- All security is enforced at the ORM level by Odoo diff --git a/odoo-bringout-oca-ocb-test_website/doc/TROUBLESHOOTING.md b/odoo-bringout-oca-ocb-test_website/doc/TROUBLESHOOTING.md new file mode 100644 index 0000000..56853cb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/doc/TROUBLESHOOTING.md @@ -0,0 +1,5 @@ +# Troubleshooting + +- Ensure Python and Odoo environment matches repo guidance. +- Check database connectivity and logs if startup fails. +- Validate that dependent addons listed in DEPENDENCIES.md are installed. diff --git a/odoo-bringout-oca-ocb-test_website/doc/USAGE.md b/odoo-bringout-oca-ocb-test_website/doc/USAGE.md new file mode 100644 index 0000000..232f965 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/doc/USAGE.md @@ -0,0 +1,7 @@ +# Usage + +Start Odoo including this addon (from repo root): + +```bash +python3 scripts/nix_odoo_web_server.py --db-name mydb --addon test_website +``` diff --git a/odoo-bringout-oca-ocb-test_website/doc/WIZARDS.md b/odoo-bringout-oca-ocb-test_website/doc/WIZARDS.md new file mode 100644 index 0000000..48e790d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/doc/WIZARDS.md @@ -0,0 +1,3 @@ +# Wizards + +This module does not include UI wizards. diff --git a/odoo-bringout-oca-ocb-test_website/pyproject.toml b/odoo-bringout-oca-ocb-test_website/pyproject.toml new file mode 100644 index 0000000..f3e9c57 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "odoo-bringout-oca-ocb-test_website" +version = "16.0.0" +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", + "requests>=2.25.1" +] +readme = "README.md" +requires-python = ">= 3.11" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "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.12", + "Topic :: Office/Business", +] + +[project.urls] +homepage = "https://github.com/bringout/0" +repository = "https://github.com/bringout/0" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["test_website"] + +[tool.rye] +managed = true +dev-dependencies = [ + "pytest>=8.4.1", +] diff --git a/odoo-bringout-oca-ocb-test_website/test_website/__init__.py b/odoo-bringout-oca-ocb-test_website/test_website/__init__.py new file mode 100644 index 0000000..c48d23b --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/__init__.py @@ -0,0 +1,5 @@ +# -*- encoding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models diff --git a/odoo-bringout-oca-ocb-test_website/test_website/__manifest__.py b/odoo-bringout-oca-ocb-test_website/test_website/__manifest__.py new file mode 100644 index 0000000..e05335a --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/__manifest__.py @@ -0,0 +1,44 @@ +# -*- encoding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Website Test', + 'version': '1.0', + 'category': 'Hidden', + 'sequence': 9876, + 'summary': 'Website Test, mainly for module install/uninstall tests', + 'description': """This module contains tests related to website. Those are +present in a separate module as we are testing module install/uninstall/upgrade +and we don't want to reload the website module every time, including it's possible +dependencies. Neither we want to add in website module some routes, views and +models which only purpose is to run tests.""", + 'depends': [ + 'web_unsplash', + 'website', + 'theme_default', + ], + 'demo': [ + 'data/test_website_demo.xml', + ], + 'data': [ + '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': { + 'web.assets_frontend': [ + 'test_website/static/src/js/test_error.js', + ], + 'web.assets_tests': [ + 'test_website/static/tests/tours/*', + ], + 'web.qunit_suite_tests': [ + 'test_website/static/tests/*.js', + ], + }, + 'license': 'LGPL-3', +} diff --git a/odoo-bringout-oca-ocb-test_website/test_website/controllers/__init__.py b/odoo-bringout-oca-ocb-test_website/test_website/controllers/__init__.py new file mode 100644 index 0000000..5d4b25d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main 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 new file mode 100644 index 0000000..6ed151e --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/controllers/main.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +import werkzeug + +from odoo import http +from odoo.http import request +from odoo.addons.portal.controllers.web import Home +from odoo.exceptions import UserError, ValidationError, AccessError, MissingError, AccessDenied + + +class WebsiteTest(Home): + + @http.route('/test_view', type='http', auth='public', website=True, sitemap=False) + def test_view(self, **kwargs): + return request.render('test_website.test_view') + + @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))) + + @http.route('/ignore_args/none', type='http', auth="public", website=True, sitemap=False) + def test_ignore_args_none(self): + return request.make_response(json.dumps(dict(a=None, kw=None))) + + @http.route('/ignore_args/a', type='http', auth="public", website=True, sitemap=False) + def test_ignore_args_a(self, a): + return request.make_response(json.dumps(dict(a=a, kw=None))) + + @http.route('/ignore_args/kw', type='http', auth="public", website=True, sitemap=False) + def test_ignore_args_kw(self, a, **kw): + return request.make_response(json.dumps(dict(a=a, kw=kw))) + + @http.route('/ignore_args/converter/', type='http', auth="public", website=True, sitemap=False) + def test_ignore_args_converter(self, a, b='youhou', **kw): + return request.make_response(json.dumps(dict(a=a, b=b, kw=kw))) + + @http.route('/ignore_args/converter//nokw', type='http', auth="public", website=True, sitemap=False) + def test_ignore_args_converter_nokw(self, a, b='youhou'): + return request.make_response(json.dumps(dict(a=a, b=b))) + + @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'))) + + @http.route('/test_lang_url/', type='http', auth='public', website=True, sitemap=False) + def test_lang_url(self, **kwargs): + return request.render('test_website.test_view') + + # Test Session + + @http.route('/test_get_dbname', type='json', auth='public', website=True, sitemap=False) + def test_get_dbname(self, **kwargs): + return request.env.cr.dbname + + # Test Error + + @http.route('/test_error_view', type='http', auth='public', website=True, sitemap=False) + def test_error_view(self, **kwargs): + return request.render('test_website.test_error_view') + + @http.route('/test_user_error_http', type='http', auth='public', website=True, sitemap=False) + 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) + def test_user_error_json(self, **kwargs): + raise UserError("This is a user rpc test") + + @http.route('/test_validation_error_http', type='http', auth='public', website=True, sitemap=False) + 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) + 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) + def test_access_error_json(self, **kwargs): + raise AccessError("This is an access rpc test") + + @http.route('/test_access_error_http', type='http', auth='public', website=True, sitemap=False) + 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) + def test_missing_error_json(self, **kwargs): + raise MissingError("This is a missing rpc test") + + @http.route('/test_missing_error_http', type='http', auth='public', website=True, sitemap=False) + 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) + def test_internal_error_json(self, **kwargs): + raise werkzeug.exceptions.InternalServerError() + + @http.route('/test_internal_error_http', type='http', auth='public', website=True, sitemap=False) + 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) + def test_denied_error_json(self, **kwargs): + raise AccessDenied("This is an access denied rpc test") + + @http.route('/test_access_denied_http', type='http', auth='public', website=True, sitemap=False) + def test_denied_error_http(self, **kwargs): + raise AccessDenied("This is an access denied http test") + + @http.route(['/get'], type='http', auth="public", methods=['GET'], website=True, sitemap=False) + def get_method(self, **kw): + return request.make_response('get') + + @http.route(['/post'], type='http', auth="public", methods=['POST'], website=True, sitemap=False) + def post_method(self, **kw): + return request.make_response('post') + + @http.route(['/get_post'], type='http', auth="public", methods=['GET', 'POST'], website=True, sitemap=False) + def get_post_method(self, **kw): + return request.make_response('get_post') + + @http.route(['/get_post_nomultilang'], type='http', auth="public", methods=['GET', 'POST'], website=True, multilang=False, sitemap=False) + def get_post_method_no_multilang(self, **kw): + return request.make_response('get_post_nomultilang') + + # Test Perfs + + @http.route(['/empty_controller_test'], type='http', auth='public', website=True, multilang=False, sitemap=False) + def empty_controller_test(self, **kw): + return 'Basic Controller Content' + + # Test Redirects + @http.route(['/test_website/country/'], type='http', auth="public", website=True, sitemap=False) + def test_model_converter_country(self, country, **kw): + return request.render('test_website.test_redirect_view', {'country': country}) + + @http.route(['/test_website/200/'], type='http', auth="public", website=True, sitemap=False) + def test_model_converter_seoname(self, rec, **kw): + return request.make_response('ok') + + @http.route(['/test_website/model_item/'], type='http', methods=['GET'], auth="public", website=True, sitemap=False) + def test_model_item(self, record_id): + values = { + 'record': request.env['test.model'].sudo().browse(record_id), + } + return request.render("test_website.model_item", values) + + @http.route(['/test_website/test_redirect_view_qs'], type='http', auth="public", website=True, sitemap=False) + def test_redirect_view_qs(self, **kw): + return request.render('test_website.test_redirect_view_qs') + + @http.route([ + '/test_countries_308', + '/test_countries_308/', + ], type='http', auth='public', website=True, sitemap=False) + def test_countries_308(self, **kwargs): + return request.make_response('ok') + + # Test Sitemap + def sitemap_test(env, rule, qs): + if not qs or qs.lower() in '/test_website_sitemap': + yield {'loc': '/test_website_sitemap'} + + @http.route([ + '/test_website_sitemap', + '/test_website_sitemap/something/', + ], type='http', auth='public', website=True, sitemap=sitemap_test) + def test_sitemap(self, rec=None, **kwargs): + return request.make_response('Sitemap Testing Page') + + @http.route('/test_model/', type='http', auth='public', website=True, sitemap=False) + def test_model(self, test_model, **kwargs): + return request.render('test_website.test_model_page_layout', {'main_object': test_model, 'test_model': test_model}) 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 new file mode 100644 index 0000000..740f8ba --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/data/test_website_data.xml @@ -0,0 +1,147 @@ + + + + + + Public user: read only website published + + + [('website_published','=', True)] + + + + + + Test Model + + + Test Multi Model Generic + + + Test Multi Model Website 1 + + + + + + Test View + qweb + test_website.test_view + + + +

Test View

+

placeholder

+
+
+
+
+ + Test Page View + qweb + test_website.test_page_view + + + +
+

Test Page View

+

placeholder

+ + + + + + Test Error View + qweb + test_website.test_error_view + + + + + + + + + + True + /test_page_view + + + + + Test View To Be t-called + qweb + test_website.test_view_to_be_t_called + + +

Test View To Be t-called

+

placeholder

+
+
+
+ + + + + + + + + + + Test Image Progress + /test_image_progress + qweb + test_website.test_image_progress + + +
+ + + + + + + + Test Model Record + + + diff --git a/odoo-bringout-oca-ocb-test_website/test_website/data/test_website_demo.xml b/odoo-bringout-oca-ocb-test_website/test_website/data/test_website_demo.xml new file mode 100644 index 0000000..70dc59e --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/data/test_website_demo.xml @@ -0,0 +1,9 @@ + + + + + Test Model Multi Website 2 + + + + diff --git a/odoo-bringout-oca-ocb-test_website/test_website/models/__init__.py b/odoo-bringout-oca-ocb-test_website/test_website/models/__init__.py new file mode 100644 index 0000000..330ccbb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/models/__init__.py @@ -0,0 +1,3 @@ +from . import model +from . import res_config_settings +from . import website diff --git a/odoo-bringout-oca-ocb-test_website/test_website/models/model.py b/odoo-bringout-oca-ocb-test_website/test_website/models/model.py new file mode 100644 index 0000000..df1b166 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/models/model.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class TestModel(models.Model): + _name = 'test.model' + _inherit = [ + 'website.seo.metadata', + 'website.published.mixin', + 'website.searchable.mixin', + ] + _description = 'Website Model Test' + + name = fields.Char(required=1) + + @api.model + def _search_get_detail(self, website, order, options): + return { + 'model': 'test.model', + 'base_domain': [], + 'search_fields': ['name'], + 'fetch_fields': ['name'], + 'mapping': { + 'name': {'name': 'name', 'type': 'text', 'match': True}, + 'website_url': {'name': 'name', 'type': 'text', 'truncate': False}, + }, + 'icon': 'fa-check-square-o', + 'order': 'name asc, id desc', + } + + def open_website_url(self): + self.ensure_one() + return self.env['website'].get_client_action(f'/test_model/{self.id}') + + +class TestModelMultiWebsite(models.Model): + _name = 'test.model.multi.website' + _inherit = [ + 'website.published.multi.mixin', + ] + _description = 'Multi Website Model Test' + + name = fields.Char(required=1) + # `cascade` is needed as there is demo data for this model which are bound + # to website 2 (demo website). But some tests are unlinking the website 2, + # which would fail if the `cascade` is not set. Note that the website 2 is + # never set on any records in all other modules. + website_id = fields.Many2one('website', string='Website', ondelete='cascade') diff --git a/odoo-bringout-oca-ocb-test_website/test_website/models/res_config_settings.py b/odoo-bringout-oca-ocb-test_website/test_website/models/res_config_settings.py new file mode 100644 index 0000000..595bdf0 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/models/res_config_settings.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + def action_website_test_setting(self): + return self.env['website'].get_client_action('/') diff --git a/odoo-bringout-oca-ocb-test_website/test_website/models/website.py b/odoo-bringout-oca-ocb-test_website/test_website/models/website.py new file mode 100644 index 0000000..a3e9f4c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/models/website.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class Website(models.Model): + _inherit = "website" + + def _search_get_details(self, search_type, order, options): + result = super()._search_get_details(search_type, order, options) + if search_type in ['test']: + result.append(self.env['test.model']._search_get_detail(self, order, options)) + return result diff --git a/odoo-bringout-oca-ocb-test_website/test_website/security/ir.model.access.csv b/odoo-bringout-oca-ocb-test_website/test_website/security/ir.model.access.csv new file mode 100644 index 0000000..a68b918 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_test_model,access_test_model,model_test_model,,1,0,0,0 +access_test_model_multi_website,access_test_model_multi_website,model_test_model_multi_website,,1,0,0,0 +access_test_model_tester,access_test_model,model_test_model,test_website.group_test_website_tester,1,1,1,1 diff --git a/odoo-bringout-oca-ocb-test_website/test_website/security/test_website_security.xml b/odoo-bringout-oca-ocb-test_website/test_website/security/test_website_security.xml new file mode 100644 index 0000000..b79fba2 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/security/test_website_security.xml @@ -0,0 +1,16 @@ + + + + Tests about Website with additional model + 24 + + + + Tester + + + + + + + diff --git a/odoo-bringout-oca-ocb-test_website/test_website/static/src/js/test_error.js b/odoo-bringout-oca-ocb-test_website/test_website/static/src/js/test_error.js new file mode 100644 index 0000000..8a0f909 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/static/src/js/test_error.js @@ -0,0 +1,30 @@ +odoo.define('website_forum.test_error', function (require) { +'use strict'; + +var publicWidget = require('web.public.widget'); + +publicWidget.registry.testError = publicWidget.Widget.extend({ + selector: '.rpc_error', + events: { + 'click a': '_onRpcErrorClick', + }, + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * make a rpc call with the href of the DOM element clicked + * @private + * @param {Event} ev + * @returns {Promise} + */ + _onRpcErrorClick: function (ev) { + ev.preventDefault(); + var $link = $(ev.currentTarget); + return this._rpc({ + route: $link.attr('href'), + }); + } +}); +}); diff --git a/odoo-bringout-oca-ocb-test_website/test_website/static/tests/field_html_file_upload.js b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/field_html_file_upload.js new file mode 100644 index 0000000..a0bb612 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/field_html_file_upload.js @@ -0,0 +1,137 @@ +/** @odoo-module **/ + +import { FileSelectorControlPanel } from '@web_editor/components/media_dialog/file_selector'; +import { getFixture, patchWithCleanup } from "@web/../tests/helpers/utils"; +import { HtmlField } from '@web_editor/js/backend/html_field'; +import {registry} from '@web/core/registry'; +import testUtils from 'web.test_utils'; +import { uploadService } from '@web_editor/components/upload_progress_toast/upload_service'; +import { unsplashService } from '@web_unsplash/services/unsplash_service'; +import { createWebClient, doAction } from "@web/../tests/webclient/helpers"; +import weTestUtils from 'web_editor.test_utils'; +import Wysiwyg from 'web_editor.wysiwyg'; + +const { useEffect } = owl; + +QUnit.module('field html file upload', { + beforeEach: function () { + this.data = weTestUtils.wysiwygData({ + 'mail.compose.message': { + fields: { + display_name: { + string: "Displayed name", + type: "char" + }, + body: { + string: "Message Body inline (to send)", + type: "html" + }, + attachment_ids: { + string: "Attachments", + type: "many2many", + relation: "ir.attachment", + } + }, + records: [{ + id: 1, + display_name: "Some Composer", + body: "Hello", + attachment_ids: [], + }], + }, + }); + }, +}, function () { + QUnit.test('media dialog: upload', async function (assert) { + assert.expect(4); + const onAttachmentChangeTriggered = testUtils.makeTestPromise(); + patchWithCleanup(HtmlField.prototype, { + '_onAttachmentChange': function (event) { + this._super(event); + onAttachmentChangeTriggered.resolve(true); + } + }); + const defFileSelector = testUtils.makeTestPromise(); + const onChangeTriggered = testUtils.makeTestPromise(); + patchWithCleanup(FileSelectorControlPanel.prototype, { + setup() { + this._super(); + useEffect(() => { + defFileSelector.resolve(true); + }, () => []); + }, + async onChangeFileInput() { + this._super(); + onChangeTriggered.resolve(true); + } + }); + // create and load form view + const serviceRegistry = registry.category("services"); + serviceRegistry.add("upload", uploadService); + serviceRegistry.add("unsplash", unsplashService); + const serverData = { + models: this.data, + }; + serverData.actions = { + 1: { + id: 1, + name: "test", + res_model: "mail.compose.message", + type: "ir.actions.act_window", + views: [[false, "form"]], + }, + }; + serverData.views = { + "mail.compose.message,false,search": "", + "mail.compose.message,false,form": ` +
+ + + `, + }; + const mockRPC = (route, args) => { + if (route === "/web_editor/attachment/add_data") { + return Promise.resolve({"id": 5, "name": "test.jpg", "description": false, "mimetype": "image/jpeg", "checksum": "7951a43bbfb08fd742224ada280913d1897b89ab", + "url": false, "type": "binary", "res_id": 1, "res_model": "note.note", "public": false, "access_token": false, + "image_src": "/web/image/1-a0e63e61/test.jpg", "image_width": 1, "image_height": 1, "original_id": false + }); + } + else if (route === "/web/dataset/call_kw/ir.attachment/generate_access_token") { + return Promise.resolve(["129a52e1-6bf2-470a-830e-8e368b022e13"]); + } + }; + const webClient = await createWebClient({ serverData, mockRPC }); + await doAction(webClient, 1); + //trigger wysiwyg mediadialog + const fixture = getFixture(); + const formField = fixture.querySelector('.o_field_html[name="body"]'); + const textInput = formField.querySelector('.note-editable p'); + textInput.innerText = "test"; + const pText = $(textInput).contents()[0]; + Wysiwyg.setRange(pText, 1, pText, 2); + await new Promise((resolve) => setTimeout(resolve)); //ensure fully set up + const wysiwyg = $(textInput.parentElement).data('wysiwyg'); + wysiwyg.openMediaDialog(); + assert.ok(await Promise.race([defFileSelector, new Promise((res, _) => setTimeout(() => res(false), 400))]), "File Selector did not mount"); + // upload test + const fileInputs = document.querySelectorAll(".o_select_media_dialog input.d-none.o_file_input"); + const fileB64 = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3+iiigD//2Q=='; + const fileBytes = new Uint8Array(atob(fileB64).split('').map(char => char.charCodeAt(0))); + // redefine 'files' so we can put mock data in through js + fileInputs.forEach((input) => Object.defineProperty(input, 'files', { + value: [new File(fileBytes, "test.jpg", { type: 'image/jpeg' })], + })); + fileInputs.forEach(input => { + input.dispatchEvent(new Event('change', {})); + }); + + assert.ok(await Promise.race([onChangeTriggered, new Promise((res, _) => setTimeout(() => res(false), 400))]), + "File change event was not triggered"); + assert.ok(await Promise.race([onAttachmentChangeTriggered, new Promise((res, _) => setTimeout(() => res(false), 400))]), + "_onAttachmentChange was not called with the new attachment, necessary for unsused upload cleanup on backend"); + + // wait to check that dom is properly updated + await new Promise((res, _) => setTimeout(() => res(false), 400)); + assert.ok(fixture.querySelector('.o_attachment[title="test.jpg"]')); + }); +}); diff --git a/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/custom_snippets.js b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/custom_snippets.js new file mode 100644 index 0000000..5fdacd0 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/custom_snippets.js @@ -0,0 +1,108 @@ +odoo.define('test_website.custom_snippets', function (require) { +'use strict'; + +const wTourUtils = require('website.tour_utils'); + +/** + * The purpose of this tour is to check the custom snippets flow: + * + * -> go to edit mode + * -> drag a banner into page content + * -> customize banner (set text) + * -> save banner as custom snippet + * -> confirm save + * -> ensure custom snippet is available + * -> drag custom snippet + * -> ensure block appears as banner + * -> ensure block appears as custom banner + * -> rename custom banner + * -> verify rename took effect + * -> delete custom snippet + * -> confirm delete + * -> ensure it was deleted + */ + +wTourUtils.registerWebsitePreviewTour('test_custom_snippet', { + url: '/', + edition: true, + test: true, +}, [ + { + content: "drop a snippet", + trigger: ".oe_snippet[name='Banner'] .oe_snippet_thumbnail:not(.o_we_already_dragging)", + moveTrigger: ".oe_drop_zone", + run: "drag_and_drop iframe #wrap", + }, + { + content: "customize snippet", + trigger: "iframe #wrapwrap .s_banner h1", + run: "text", + consumeEvent: "input", + }, + { + content: "save custom snippet", + trigger: ".snippet-option-SnippetSave we-button", + }, + { + content: "confirm reload", + trigger: ".modal-dialog button span:contains('Save and Reload')", + }, + { + content: "ensure custom snippet appeared", + trigger: "#oe_snippets.o_loaded .oe_snippet[name='Custom Banner']", + run: function () { + $("#oe_snippets .oe_snippet[name='Custom Banner'] .o_rename_btn").attr("style", "display: block;"); + // hover is needed for rename button to appear + }, + }, + { + content: "rename custom snippet", + trigger: ".oe_snippet[name='Custom Banner'] we-button.o_rename_btn", + extra_trigger: ".oe_snippet[name='Custom Banner'] .oe_snippet_thumbnail:not(.o_we_already_dragging)", + }, + { + content: "set name", + trigger: ".oe_snippet[name='Custom Banner'] input", + run: "text Bruce Banner", + }, + { + content: "confirm rename", + trigger: ".oe_snippet[name='Custom Banner'] we-button.o_we_confirm_btn", + }, + { + content: "drop custom snippet", + trigger: ".oe_snippet[name='Bruce Banner'] .oe_snippet_thumbnail:not(.o_we_already_dragging)", + extra_trigger: "iframe body.editor_enable", + moveTrigger: ".oe_drop_zone", + run: "drag_and_drop iframe #wrap", + }, + { + content: "ensure banner section exists", + trigger: "iframe #wrap section[data-name='Banner']", + run: function () {}, // check + }, + { + content: "ensure custom banner section exists", + trigger: "iframe #wrap section[data-name='Bruce Banner']", + run: function () { + $("#oe_snippets .oe_snippet[name='Bruce Banner'] .o_delete_btn").attr("style", "display: block;"); + // hover is needed for delete button to appear + }, + }, + { + content: "delete custom snippet", + trigger: ".oe_snippet[name='Bruce Banner'] we-button.o_delete_btn", + extra_trigger: ".oe_snippet[name='Bruce Banner'] .oe_snippet_thumbnail:not(.o_we_already_dragging)", + }, + { + content: "confirm delete", + trigger: ".modal-dialog button:has(span:contains('Yes'))", + }, + { + content: "ensure custom snippet disappeared", + trigger: "#oe_snippets:not(:has(.oe_snippet[name='Bruce Banner']))", + run: function () {}, // check + }, +]); + +}); diff --git a/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/error_views.js b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/error_views.js new file mode 100644 index 0000000..b0a7b14 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/error_views.js @@ -0,0 +1,152 @@ +odoo.define('test_website.error_views', function (require) { +'use strict'; + +var tour = require('web_tour.tour'); + +tour.register('test_error_website', { + test: true, + url: '/test_error_view', +}, +[ + // RPC ERROR + { + content: "trigger rpc user error", + trigger: 'a[href="/test_user_error_json"]', + }, { + content: "rpc user error modal has message", + extra_trigger: 'div.o_notification_content:contains("This is a user rpc test")', + trigger: 'button.o_notification_close', + }, { + content: "trigger rpc access error", + trigger: 'a[href="/test_access_error_json"]', + }, { + content: "rpc access error modal has message", + extra_trigger: 'div.o_notification_content:contains("This is an access rpc test")', + trigger: 'button.o_notification_close', + }, { + content: "trigger validation rpc error", + trigger: 'a[href="/test_validation_error_json"]', + }, { + content: "rpc validation error modal has message", + extra_trigger: 'div.o_notification_content:contains("This is a validation rpc test")', + trigger: 'button.o_notification_close', + }, { + content: "trigger rpc missing error", + trigger: 'a[href="/test_missing_error_json"]', + }, { + content: "rpc missing error modal has message", + extra_trigger: 'div.o_notification_content:contains("This is a missing rpc test")', + trigger: 'button.o_notification_close', + }, { + content: "trigger rpc error 403", + trigger: 'a[href="/test_access_denied_json"]', + }, { + content: "rpc error 403 modal has message", + extra_trigger: 'div.o_notification_content:contains("This is an access denied rpc test")', + trigger: 'button.o_notification_close', + }, { + content: "trigger rpc error 500", + trigger: 'a[href="/test_internal_error_json"]', + }, { + content: "rpc error 500 modal is an ErrorDialog", + extra_trigger: 'div.o_dialog_error.modal-content div.alert.alert-warning', + trigger: '.modal-footer button.btn.btn-primary', + }, + // HTTP ERROR + { + content: "trigger http user error", + trigger: 'body', + run: function () { + window.location.href = window.location.origin + '/test_user_error_http?debug=0'; + }, + }, { + content: "http user error page has title and message", + extra_trigger: 'h1:contains("Something went wrong.")', + trigger: 'div.container pre:contains("This is a user http test")', + run: function () { + window.location.href = window.location.origin + '/test_user_error_http?debug=1'; + }, + }, { + content: "http user error page debug has title and message open", + extra_trigger: 'h1:contains("Something went wrong.")', + trigger: 'div#error_main.collapse.show pre:contains("This is a user http test")', + run: function () {}, + }, { + content: "http user error page debug has traceback closed", + trigger: 'body:has(div#error_traceback.collapse:not(.show) pre#exception_traceback)', + run: function () { + window.location.href = window.location.origin + '/test_validation_error_http?debug=0'; + }, + }, { + content: "http validation error page has title and message", + extra_trigger: 'h1:contains("Something went wrong.")', + trigger: 'div.container pre:contains("This is a validation http test")', + run: function () { + window.location.href = window.location.origin + '/test_validation_error_http?debug=1'; + }, + }, { + content: "http validation error page debug has title and message open", + extra_trigger: 'h1:contains("Something went wrong.")', + trigger: 'div#error_main.collapse.show pre:contains("This is a validation http test")', + run: function () {}, + }, { + content: "http validation error page debug has traceback closed", + trigger: 'body:has(div#error_traceback.collapse:not(.show) pre#exception_traceback)', + run: function () { + window.location.href = window.location.origin + '/test_access_error_http?debug=0'; + }, + }, { + content: "http access error page has title and message", + extra_trigger: 'h1:contains("403: Forbidden")', + trigger: 'div.container pre:contains("This is an access http test")', + run: function () { + window.location.href = window.location.origin + '/test_access_error_http?debug=1'; + }, + }, { + content: "http access error page debug has title and message open", + extra_trigger: 'h1:contains("403: Forbidden")', + trigger: 'div#error_main.collapse.show pre:contains("This is an access http test")', + run: function () {}, + }, { + content: "http access error page debug has traceback closed", + trigger: 'body:has(div#error_traceback.collapse:not(.show) pre#exception_traceback)', + run: function () { + window.location.href = window.location.origin + '/test_missing_error_http?debug=0'; + }, + }, { + content: "http missing error page has title and message", + extra_trigger: 'h1:contains("Something went wrong.")', + trigger: 'div.container pre:contains("This is a missing http test")', + run: function () { + window.location.href = window.location.origin + '/test_missing_error_http?debug=1'; + }, + }, { + content: "http missing error page debug has title and message open", + extra_trigger: 'h1:contains("Something went wrong.")', + trigger: 'div#error_main.collapse.show pre:contains("This is a missing http test")', + run: function () {}, + }, { + content: "http missing error page debug has traceback closed", + trigger: 'body:has(div#error_traceback.collapse:not(.show) pre#exception_traceback)', + run: function () { + window.location.href = window.location.origin + '/test_access_denied_http?debug=0'; + }, + }, { + content: "http error 403 page has title but no message", + extra_trigger: 'h1:contains("403: Forbidden")', + trigger: 'div#wrap:not(:has(pre:contains("This is an access denied http test"))', //See ir_http.py handle_exception, the exception is replaced so there is no message ! + run: function () { + window.location.href = window.location.origin + '/test_access_denied_http?debug=1'; + }, + }, { + content: "http 403 error page debug has title but no message", + extra_trigger: 'h1:contains("403: Forbidden")', + trigger: 'div#debug_infos:not(:has(#error_main))', + run: function () {}, + }, { + content: "http 403 error page debug has traceback open", + trigger: 'body:has(div#error_traceback.collapse.show pre#exception_traceback)', + run: function () {}, + }, +]); +}); diff --git a/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/image_link.js b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/image_link.js new file mode 100644 index 0000000..114d677 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/image_link.js @@ -0,0 +1,85 @@ +/** @odoo-module **/ + +import wTourUtils from 'website.tour_utils'; + +/** + * The purpose of this tour is to check the link on image flow. + */ + +const selectImageSteps = [{ + content: "select block", + trigger: "iframe #wrapwrap .s_text_image", +}, { + content: "check link popover disappeared", + trigger: "iframe body:not(:has(.o_edit_menu_popover))", + run: () => {}, // check +}, { + content: "select image", + trigger: "iframe #wrapwrap .s_text_image img", +}]; + +wTourUtils.registerWebsitePreviewTour('test_image_link', { + test: true, + url: '/', + edition: true, +}, [ + wTourUtils.dragNDrop({ + id: 's_text_image', + name: 'Text - Image', + }), + ...selectImageSteps, + { + content: "enable link", + trigger: "#oe_snippets we-customizeblock-options:has(we-title:contains('Image')) we-customizeblock-option:has(we-title:contains(Media)) we-button.fa-link", + }, { + content: "enter site URL", + trigger: "#oe_snippets we-customizeblock-options:has(we-title:contains('Image')) we-input:contains(Your URL) input", + run: "text odoo.com", + }, + ...selectImageSteps, + { + content: "check popover content has site URL", + trigger: "iframe .o_edit_menu_popover a.o_we_url_link[href='http://odoo.com/']:contains(http://odoo.com/)", + run: () => {}, // check + }, { + content: "remove URL", + trigger: "#oe_snippets we-customizeblock-options:has(we-title:contains('Image')) we-input:contains(Your URL) input", + run: "remove_text", + }, + ...selectImageSteps, + { + content: "check popover content has no URL", + trigger: "iframe .o_edit_menu_popover a.o_we_url_link:not([href]):contains(No URL specified)", + run: () => {}, // check + }, { + content: "enter email URL", + trigger: "#oe_snippets we-customizeblock-options:has(we-title:contains('Image')) we-input:contains(Your URL) input", + run: "text mailto:test@test.com", + }, + ...selectImageSteps, + { + content: "check popover content has mail URL", + trigger: "iframe .o_edit_menu_popover:has(.fa-envelope-o) a.o_we_url_link[href='mailto:test@test.com']:contains(mailto:test@test.com)", + run: () => {}, // check + }, { + content: "enter phone URL", + trigger: "#oe_snippets we-customizeblock-options:has(we-title:contains('Image')) we-input:contains(Your URL) input", + run: "text tel:555-2368", + }, + ...selectImageSteps, + { + content: "check popover content has phone URL", + trigger: "iframe .o_edit_menu_popover:has(.fa-phone) a.o_we_url_link[href='tel:555-2368']:contains(tel:555-2368)", + run: () => {}, // check + }, { + content: "remove URL", + trigger: "#oe_snippets we-customizeblock-options:has(we-title:contains('Image')) we-input:contains(Your URL) input", + run: "remove_text", + }, + ...selectImageSteps, + { + content: "check popover content has no URL", + trigger: "iframe .o_edit_menu_popover a.o_we_url_link:not([href]):contains(No URL specified)", + run: () => {}, // check + }, +]); diff --git a/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/image_upload_progress.js b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/image_upload_progress.js new file mode 100644 index 0000000..d1f41d6 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/image_upload_progress.js @@ -0,0 +1,245 @@ +odoo.define('test_website.image_upload_progress', function (require) { +'use strict'; + +const wTourUtils = require('website.tour_utils'); + +const { FileSelectorControlPanel } = require('@web_editor/components/media_dialog/file_selector'); +const { patch, unpatch } = require('web.utils'); + +let patchWithError = false; +const patchMediaDialog = () => patch(FileSelectorControlPanel.prototype, 'test_website.mock_image_widgets', { + async onChangeFileInput() { + const getFileFromB64 = (fileData) => { + const binary = atob(fileData[2]); + let len = binary.length; + const arr = new Uint8Array(len); + while (len--) { + arr[len] = binary.charCodeAt(len); + } + return new File([arr], fileData[1], {type: fileData[0]}); + }; + + let files = [ + getFileFromB64(['image/vnd.microsoft.icon', 'icon.ico', "AAABAAEAAQEAAAEAIAAwAAAAFgAAACgAAAABAAAAAgAAAAEAIAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAA=="]), + getFileFromB64(['image/webp', 'image.webp', "UklGRhwAAABXRUJQVlA4TBAAAAAvE8AEAAfQhuh//wMR0f8A"]), + getFileFromB64(['image/png', 'image.png', "iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAApElEQVR42u3RAQ0AAAjDMO5fNCCDkC5z0HTVrisFCBABASIgQAQEiIAAAQJEQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAREQIAICBABASIgQAQECBAgAgJEQIAIyPcGFY7HnV2aPXoAAAAASUVORK5CYII="]), + getFileFromB64(['image/jpeg', 'image.jpeg', "/9j/4AAQSkZJRgABAQAAAQABAAD//gAfQ29tcHJlc3NlZCBieSBqcGVnLXJlY29tcHJlc3P/2wCEAA0NDQ0ODQ4QEA4UFhMWFB4bGRkbHi0gIiAiIC1EKjIqKjIqRDxJOzc7STxsVUtLVWx9aWNpfZeHh5e+tb75+f8BDQ0NDQ4NDhAQDhQWExYUHhsZGRseLSAiICIgLUQqMioqMipEPEk7NztJPGxVS0tVbH1pY2l9l4eHl761vvn5///CABEIAEsASwMBIgACEQEDEQH/xAAVAAEBAAAAAAAAAAAAAAAAAAAABv/aAAgBAQAAAACHAAAAAAAAAAAAAAAAH//EABUBAQEAAAAAAAAAAAAAAAAAAAAH/9oACAECEAAAAKYAAAB//8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/2gAIAQMQAAAAngAAAf/EABQQAQAAAAAAAAAAAAAAAAAAAGD/2gAIAQEAAT8ASf/EABQRAQAAAAAAAAAAAAAAAAAAAED/2gAIAQIBAT8AT//EABQRAQAAAAAAAAAAAAAAAAAAAED/2gAIAQMBAT8AT//Z"]), + ]; + + if (!this.props.multiSelect) { + if (patchWithError) { + files = [files[0]]; + } else { + files = [files[2]]; + } + } + await this.props.uploadFiles(files); + } +}); + +const unpatchMediaDialog = () => unpatch(FileSelectorControlPanel.prototype, 'test_website.mock_image_widgets'); + +const setupSteps = [{ + content: "reload to load patch", + trigger: ".o_website_preview", + run: () => { + patchMediaDialog(); + }, +}, { + content: "drop a snippet", + trigger: "#oe_snippets .oe_snippet[name='Text - Image'] .oe_snippet_thumbnail:not(.o_we_already_dragging)", + moveTrigger: "iframe .oe_drop_zone", + run: "drag_and_drop iframe #wrap", +}, { + content: "drop a snippet", + trigger: "#oe_snippets .oe_snippet[name='Image Gallery'] .oe_snippet_thumbnail:not(.o_we_already_dragging)", + extra_trigger: "body.editor_has_snippets", + moveTrigger: ".oe_drop_zone", + run: "drag_and_drop iframe #wrap", +}]; + +const formatErrorMsg = "format is not supported. Try with: .gif, .jpe, .jpeg, .jpg, .png, .svg"; + +wTourUtils.registerWebsitePreviewTour('test_image_upload_progress', { + url: '/test_image_progress', + test: true, + edition: true, +}, [ + ...setupSteps, + // 1. Check multi image upload + { + content: "click on dropped snippet", + trigger: "iframe #wrap .s_image_gallery .img", + }, { + content: "click on add images to open image dialog (in multi mode)", + trigger: 'we-customizeblock-option [data-add-images]', + }, { + content: "manually trigger input change", + trigger: ".o_select_media_dialog .o_upload_media_button", + run: () => { + // This will trigger upload of dummy files for test purpose, as a + // test can't select local files to upload into the input. + document.body.querySelector('.o_select_media_dialog .o_file_input').dispatchEvent(new Event('change')); + }, + }, { + content: "check upload progress bar is correctly shown (1)", + trigger: `.o_we_progressbar:contains('icon.ico'):contains('${formatErrorMsg}')`, + in_modal: false, + run: function () {}, // it's a check + }, { + content: "check upload progress bar is correctly shown (2)", + trigger: `.o_we_progressbar:contains('image.webp'):contains('${formatErrorMsg}')`, + in_modal: false, + run: function () {}, // it's a check + }, { + content: "check upload progress bar is correctly shown (3)", + trigger: ".o_we_progressbar:contains('image.png'):contains('File has been uploaded')", + in_modal: false, + run: function () {}, // it's a check + }, { + content: "check upload progress bar is correctly shown (4)", + trigger: ".o_we_progressbar:contains('image.jpeg'):contains('File has been uploaded')", + in_modal: false, + run: function () {}, // it's a check + }, { + content: "there should only have one notification toaster", + trigger: ".o_notification", + in_modal: false, + run: () => { + const notificationCount = $('.o_notification').length; + if (notificationCount !== 1) { + console.error("There should be one noficiation toaster opened, and only one."); + } + } + }, { + content: "close notification", + trigger: '.o_notification_close', + in_modal: false, + }, { + content: "close media dialog", + trigger: '.modal-footer .btn-secondary', + }, + // 2. Check success single image upload + { + content: "click on dropped snippet", + trigger: "iframe #wrap .s_text_image .img", + }, { + content: "click on replace media to open image dialog", + trigger: 'we-customizeblock-option [data-replace-media]', + }, { + content: "manually trigger input change", + trigger: ".o_select_media_dialog .o_upload_media_button", + run: () => { + // This will trigger upload of dummy files for test purpose, as a + // test can't select local files to upload into the input. + document.body.querySelector('.o_select_media_dialog .o_file_input').dispatchEvent(new Event('change')); + }, + }, { + content: "check upload progress bar is correctly shown", + trigger: ".o_we_progressbar:contains('image.png')", + in_modal: false, + run: function () {}, // it's a check + }, { + content: "there should only have one notification toaster", + trigger: ".o_notification", + in_modal: false, + run: () => { + const notificationCount = $('.o_notification').length; + if (notificationCount !== 1) { + console.error("There should be one noficiation toaster opened, and only one."); + } + } + }, { + content: "media dialog has closed after the upload", + trigger: 'body:not(:has(.o_select_media_dialog))', + run: () => {}, // It's a check. + }, { + content: "the upload progress toast was updated", + trigger: ".o_we_progressbar:contains('image.png'):contains('File has been uploaded')", + run: () => {}, // It's a check. + }, { + content: "toaster should disappear after a few seconds if the uploaded image is successful", + trigger: "body:not(:has(.o_we_progressbar))", + run: function () {}, // it's a check + }, + // 3. Check error single image upload + { + content: "click on dropped snippet", + trigger: "iframe #wrap .s_text_image .img", + }, { + content: "click on replace media to open image dialog", + trigger: 'we-customizeblock-option [data-replace-media]', + }, { + content: "manually trigger input change", + trigger: ".o_select_media_dialog .o_upload_media_button", + in_modal: false, + run: () => { + patchWithError = true; + // This will trigger upload of dummy files for test purpose, as a + // test can't select local files to upload into the input. + document.body.querySelector('.o_select_media_dialog .o_file_input').dispatchEvent(new Event('change')); + + }, + }, { + content: "check upload progress bar is correctly shown", + trigger: `.o_we_progressbar:contains('icon.ico'):contains('${formatErrorMsg}')`, + in_modal: false, + run: function () { + patchWithError = false; + }, + }, { + content: "there should only have one notification toaster", + trigger: ".o_notification", + in_modal: false, + run: () => { + const notificationCount = $('.o_notification').length; + if (notificationCount !== 1) { + console.error("There should be one noficiation toaster opened, and only one."); + } + unpatchMediaDialog(); + } + }, +]); + + +wTourUtils.registerWebsitePreviewTour('test_image_upload_progress_unsplash', { + url: '/test_image_progress', + test: true, + edition: true, +}, [ + ...setupSteps, + // 1. Check multi image upload + { + content: "click on dropped snippet", + trigger: "iframe #wrap .s_image_gallery .img", + }, { + content: "click on replace media to open image dialog", + trigger: 'we-customizeblock-option [data-replace-media]', + }, { + content: "search 'fox' images", + trigger: ".o_we_search", + run: "text fox", + }, { + content: "click on unsplash result", // note that unsplash is mocked + trigger: "img[alt~=fox]" + }, { + content: "check that the upload progress bar is correctly shown", + // ensure it is there so we are sure next step actually test something + extra_trigger: '.o_notification_close', + trigger: ".o_we_progressbar:contains('fox'):contains('File has been uploaded')", + in_modal: false, + run: function () {}, // it's a check + }, { + content: "notification should close after 3 seconds", + trigger: 'body:not(:has(.o_notification_close))', + in_modal: false, + }, { + content: "unsplash image (mocked to logo) should have been used", + trigger: "iframe #wrap .s_image_gallery .img[data-original-src^='/unsplash/HQqIOc8oYro/fox']", + run: () => { + unpatchMediaDialog(); + }, + }, +]); + +}); diff --git a/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/json_auth.js b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/json_auth.js new file mode 100644 index 0000000..ba89bed --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/json_auth.js @@ -0,0 +1,26 @@ +odoo.define('test_website.json_auth', function (require) { +'use strict'; + +var tour = require('web_tour.tour'); +var session = require('web.session') + +tour.register('test_json_auth', { + test: true, +}, [{ + trigger: 'body', + run: async function () { + await session.rpc('/test_get_dbname').then( function (result){ + return session.rpc("/web/session/authenticate", { + db: result, + login: 'admin', + password: 'admin' + }); + }); + window.location.href = window.location.origin; + }, +}, { + trigger: 'span:contains(Mitchell Admin), span:contains(Administrator)', + run: function () {}, +} +]); +}); diff --git a/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/page_manager.js b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/page_manager.js new file mode 100644 index 0000000..91a4d24 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/page_manager.js @@ -0,0 +1,77 @@ +/** @odoo-module **/ + +import tour from 'web_tour.tour'; + +tour.register('test_website_page_manager', { + test: true, + url: '/web#action=test_website.action_test_model_multi_website', +}, [ +// Part 1: check that the website filter is working +{ + content: "Check that we see records from My Website", + trigger: ".o_list_table .o_data_row .o_data_cell[name=name]:contains('Test Multi Model Website 1') " + + "~ .o_data_cell[name=website_id]:contains('My Website')", + run: () => null, // it's a check +}, { + content: "Check that there is only 2 records in the pager", + trigger: ".o_pager .o_pager_value:contains('1-2')", + run: () => null, // it's a check +}, { + content: "Click on the 'Select all records' checkbox", + trigger: "thead .o_list_record_selector", +}, { + content: "Check that there is only 2 records selected", + trigger: ".o_list_selection_box:contains('2 selected')", + run: () => null, // it's a check +}, { + content: "Click on My Website search filter", + trigger: "button.dropdown-toggle:contains('My Website')", +}, { + content: "Select My Website 2", + trigger: ".dropdown-menu.show > .dropdown-item:contains('My Website 2')", +}, { + // This step is just here to ensure there is more records than the 2 + // available on website 1, to ensure the test is actually testing something. + content: "Check that we see records from My Website 2", + trigger: ".o_list_table .o_data_row .o_data_cell[name=name]:contains('Test Model Multi Website 2') " + + "~ .o_data_cell[name=website_id]:contains('My Website 2')", + run: () => null, // it's a check +}, +// Part 2: ensure Kanban View is working / not crashing +{ + content: "Click on Kanban View", + trigger: '.o_cp_switch_buttons .o_kanban', +}, { + content: "Click on List View", + extra_trigger: '.o_kanban_renderer', + trigger: '.o_cp_switch_buttons .o_list', +}, { + content: "Wait for List View to be loaded", + trigger: '.o_list_renderer', + run: () => null, // it's a check +}]); + +tour.register('test_website_page_manager_js_class_bug', { + test: true, + url: '/web#action=test_website.action_test_model_multi_website_js_class_bug', +}, [{ + content: "Click on Kanban View", + trigger: '.o_cp_switch_buttons .o_kanban', +}, { + content: "Wait for Kanban View to be loaded", + trigger: '.o_kanban_renderer', + run: () => null, // it's a check +}]); + +tour.register('test_website_page_manager_no_website_id', { + test: true, + url: '/web#action=test_website.action_test_model', +}, [{ + content: "Click on Kanban View", + trigger: '.o_cp_switch_buttons .o_kanban', +}, { + content: "Wait for Kanban View to be loaded", + trigger: '.o_kanban_renderer', + run: () => null, // it's a check +}]); + diff --git a/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/replace_media.js b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/replace_media.js new file mode 100644 index 0000000..e1e66f8 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/replace_media.js @@ -0,0 +1,160 @@ +/** @odoo-module **/ + +import { patch } from '@web/core/utils/patch'; +import { VideoSelector } from '@web_editor/components/media_dialog/video_selector'; +import wTourUtils from 'website.tour_utils'; + +const VIDEO_URL = 'https://www.youtube.com/watch?v=Dpq87YCHmJc'; + +/** + * The purpose of this tour is to check the media replacement flow. + */ +wTourUtils.registerWebsitePreviewTour('test_replace_media', { + url: '/', + test: true, + edition: true, +}, [ + { + trigger: "body", + run: function () { + // Patch the VideoDialog so that it does not do external calls + // during the test (note that we don't unpatch but as the patch + // is only done after the execution of a test_website test and + // specific to an URL only, it is acceptable). + // TODO if we ever give the possibility to upload its own videos, + // this won't be necessary anymore. + patch(VideoSelector.prototype, "Video selector patch", { + async _getVideoURLData(src, options) { + if (src === VIDEO_URL || src === 'about:blank') { + return {platform: 'youtube', embed_url: 'about:blank'}; + } + return this._super(...arguments); + }, + }); + }, + }, + { + content: "drop picture snippet", + trigger: "#oe_snippets .oe_snippet[name='Picture'] .oe_snippet_thumbnail:not(.o_we_already_dragging)", + moveTrigger: "iframe .oe_drop_zone", + run: "drag_and_drop iframe #wrap", + }, + { + content: "select image", + trigger: "iframe .s_picture figure img", + }, + { + content: "ensure image size is displayed", + trigger: "#oe_snippets we-title:contains('Image') .o_we_image_weight:contains('kb')", + run: function () {}, // check + }, + wTourUtils.changeOption("ImageTools", 'we-select[data-name="shape_img_opt"] we-toggler'), + wTourUtils.changeOption("ImageTools", "we-button[data-set-img-shape]"), + { + content: "replace image", + trigger: "#oe_snippets we-button[data-replace-media]", + }, + { + content: "select svg", + trigger: ".o_select_media_dialog img[title='sample.svg']", + }, + { + content: "ensure the svg doesn't have a shape", + trigger: "iframe .s_picture figure img:not([data-shape])", + run: function () {}, // check + }, + { + content: "ensure image size is not displayed", + trigger: "#oe_snippets we-title:contains('Image'):not(:has(.o_we_image_weight:visible))", + run: function () {}, // check + }, + { + content: "replace image", + trigger: "#oe_snippets we-button[data-replace-media]", + }, + { + content: "go to pictogram tab", + trigger: ".o_select_media_dialog .nav-link:contains('Icons')", + }, + { + content: "select an icon", + trigger: ".o_select_media_dialog:has(.nav-link.active:contains('Icons')) .tab-content span.fa-lemon-o", + }, + { + content: "ensure icon block is displayed", + trigger: "#oe_snippets we-customizeblock-options we-title:contains('Icon')", + run: function () {}, // check + }, + { + content: "select footer", + trigger: "iframe footer", + }, + { + content: "select icon", + trigger: "iframe .s_picture figure span.fa-lemon-o", + }, + { + content: "ensure icon block is still displayed", + trigger: "#oe_snippets we-customizeblock-options we-title:contains('Icon')", + run: function () {}, // check + }, + { + content: "replace icon", + trigger: "#oe_snippets we-button[data-replace-media]", + }, + { + content: "go to video tab", + trigger: ".o_select_media_dialog .nav-link:contains('Video')", + }, + { + content: "enter a video URL", + trigger: ".o_select_media_dialog #o_video_text", + // Design your first web page. + run: `text ${VIDEO_URL}`, + }, + { + content: "wait for preview to appear", + // "about:blank" because the VideoWidget was patched at the start of this tour + trigger: ".o_select_media_dialog div.media_iframe_video iframe[src='about:blank']", + run: function () {}, // check + }, + { + content: "confirm selection", + trigger: ".o_select_media_dialog .modal-footer .btn-primary", + }, + { + content: "ensure video option block is displayed", + trigger: "#oe_snippets we-customizeblock-options we-title:contains('Video')", + run: function () {}, // check + }, + { + content: "replace image", + trigger: "#oe_snippets we-button[data-replace-media]", + }, + { + content: "go to pictogram tab", + trigger: ".o_select_media_dialog .nav-link:contains('Icons')", + }, + { + content: "select an icon", + trigger: ".o_select_media_dialog:has(.nav-link.active:contains('Icons')) .tab-content span.fa-lemon-o", + }, + { + content: "ensure icon block is displayed", + trigger: "#oe_snippets we-customizeblock-options we-title:contains('Icon')", + run: function () {}, // check + }, + { + content: "select footer", + trigger: "iframe footer", + }, + { + content: "select icon", + trigger: "iframe .s_picture figure span.fa-lemon-o", + }, + { + content: "ensure icon block is still displayed", + trigger: "#oe_snippets we-customizeblock-options we-title:contains('Icon')", + run: function () {}, // check + }, +]); diff --git a/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/reset_views.js b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/reset_views.js new file mode 100644 index 0000000..2e04a00 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/reset_views.js @@ -0,0 +1,107 @@ +/* global ace */ +odoo.define('test_website.reset_views', function (require) { +'use strict'; + +const wTourUtils = require('website.tour_utils'); + +var BROKEN_STEP = { + // because saving a broken template opens a recovery page with no assets + // there's no way for the tour to resume on the new page, and thus no way + // to properly wait for the page to be saved & reloaded in order to fix the + // race condition of a tour ending on a side-effect (with the possible + // exception of somehow telling the harness / browser to do it) + trigger: 'body', + run: function () {} +}; +wTourUtils.registerWebsitePreviewTour('test_reset_page_view_complete_flow_part1', { + test: true, + url: '/test_page_view', + // 1. Edit the page through Edit Mode, it will COW the view + edition: true, +}, + [ + { + content: "drop a snippet", + trigger: ".oe_snippet:has(.s_cover) .oe_snippet_thumbnail", + // id starting by 'oe_structure..' will actually create an inherited view + run: "drag_and_drop iframe #oe_structure_test_website_page", + }, + { + content: "save the page", + extra_trigger: 'iframe #oe_structure_test_website_page.o_dirty', + trigger: "button[data-action=save]", + }, + // 2. Edit that COW'd view in the HTML editor to break it. + { + content: "open site menu", + extra_trigger: "iframe body:not(.editor_enable)", + trigger: 'button[data-menu-xmlid="website.menu_site"]', + }, + { + content: "open html editor", + trigger: 'a[data-menu-xmlid="website.menu_ace_editor"]', + }, + { + content: "add a broken t-field in page DOM", + trigger: 'div.ace_line .ace_xml:contains("placeholder")', + run: function () { + ace.edit('ace-view-editor').getSession().insert({row: 4, column: 1}, '\n'); + }, + }, + { + content: "save the html editor", + extra_trigger: '.ace_content:contains("not.exist")', + trigger: ".o_ace_view_editor button[data-action=save]", + }, + BROKEN_STEP + ] +); + +wTourUtils.registerWebsitePreviewTour('test_reset_page_view_complete_flow_part2', { + test: true, + url: '/test_page_view', +}, + [ + { + content: "check that the view got fixed", + trigger: 'iframe p:containsExact("Test Page View")', + run: function () {}, // it's a check + }, + { + content: "check that the inherited COW view is still there (created during edit mode)", + trigger: 'iframe #oe_structure_test_website_page .s_cover', + run: function () {}, // it's a check + }, + //4. Now break the inherited view created when dropping a snippet + { + content: "open site menu", + trigger: 'button[data-menu-xmlid="website.menu_site"]', + }, + { + content: "open html editor", + trigger: 'a[data-menu-xmlid="website.menu_ace_editor"]', + }, + { + content: "select oe_structure view", + trigger: '#s2id_ace-view-list', // use select2 version + run: function () { + var viewId = $('#ace-view-list option:contains("oe_structure_test_website_page")').val(); + $('#ace-view-list').val(viewId).trigger('change'); + }, + }, + { + content: "add a broken t-field in page DOM", + trigger: 'div.ace_line .ace_xml:contains("oe_structure_test_website_page")', + run: function () { + ace.edit('ace-view-editor').getSession().insert({row: 4, column: 1}, '\n'); + }, + }, + { + content: "save the html editor", + trigger: ".o_ace_view_editor button[data-action=save]", + }, + BROKEN_STEP + ] +); + +}); diff --git a/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/systray.js b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/systray.js new file mode 100644 index 0000000..e555c86 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/systray.js @@ -0,0 +1,200 @@ +/** @odoo-module **/ + +import wTourUtils from 'website.tour_utils'; + +/** + * The purpose of these tours is to check the systray visibility: + * + * - as an administrator + * - as a restricted editor with "tester" right + * - as a restricted editor without "tester" right + * - as a "tester" who is not a restricted editor + * - as an unrelated user (neither "tester" nor restricted editor) + */ + +const canPublish = [{ + content: 'Publish', + trigger: '.o_menu_systray .o_menu_systray_item:contains("Unpublished")', +}, { + content: 'Wait for Publish', + trigger: '.o_menu_systray .o_menu_systray_item:contains("Published"):not([data-processing])', + run: () => {}, // This is a check. +}, { + content: 'Unpublish', + trigger: '.o_menu_systray .o_menu_systray_item:contains("Published")', +}, { + content: 'Wait for Unpublish', + trigger: '.o_menu_systray .o_menu_systray_item:contains("Unpublished"):not([data-processing])', + run: () => {}, // This is a check. +}]; + +const cannotPublish = [{ + content: 'Check has no Publish/Unpublish', + trigger: '.o_menu_systray:not(:has(.o_menu_systray_item:contains("ublished")))', + run: () => {}, // This is a check. +}]; + +const canToggleMobilePreview = [{ + content: 'Enable mobile preview', + trigger: '.o_menu_systray .o_menu_systray_item.o_mobile_preview:not(.o_mobile_preview_active)', +}, { + content: 'Disable mobile preview', + trigger: '.o_menu_systray .o_menu_systray_item.o_mobile_preview.o_mobile_preview_active', +}]; + +const cannotToggleMobilePreview = [{ + content: 'Enable mobile preview', + trigger: '.o_menu_systray:not(:has(.o_menu_systray_item.o_mobile_preview))', + run: () => {}, // This is a check. +}]; + +// For non-website users, switching across website only works if the domains are +// specified. Within the scope of test tours, this cannot be achieved. +const canSwitchWebsiteNoCheck = [{ + content: 'Open website switcher', + trigger: '.o_menu_systray .o_menu_systray_item.o_website_switcher_container .dropdown-toggle:contains("My Website"):not(:contains("My Website 2"))', +}, { + content: 'Switch to other website', + trigger: '.o_menu_systray .o_menu_systray_item.o_website_switcher_container .dropdown-item:contains("Other")', + run: () => {}, // This is a check. +}]; + + +const canSwitchWebsite = [{ + content: 'Open website switcher', + trigger: '.o_menu_systray .o_menu_systray_item.o_website_switcher_container .dropdown-toggle:contains("My Website"):not(:contains("My Website 2"))', +}, { + content: 'Switch to other website', + trigger: '.o_menu_systray .o_menu_systray_item.o_website_switcher_container .dropdown-item:contains("Other")', +}, { + content: 'Wait for other website', + trigger: 'iframe body:contains("Test Model") div:contains("Other")', + run: () => {}, // This is a check. +}]; + +const canAddNewContent = [{ + content: 'Open +New content', + trigger: '.o_menu_systray .o_menu_systray_item.o_new_content_container', +}, { + content: 'Close +New content', + trigger: '#o_new_content_menu_choices', +}]; + +const cannotAddNewContent = [{ + content: 'No +New content', + trigger: '.o_menu_systray:not(:has(.o_menu_systray_item.o_new_content_container))', + run: () => {}, // This is a check. +}]; + +const canEditInBackEnd = [{ + content: 'Edit in backend', + trigger: '.o_menu_systray .o_website_edit_in_backend a', +}, { + content: 'Check that the form is editable', + trigger: '.o_form_view_container .o_form_editable', + run: () => {}, // This is a check. +}, { + content: 'Return to website', + trigger: '.oe_button_box .fa-globe', +}]; + +const canViewInBackEnd = [{ + content: 'Go to backend', + trigger: '.o_menu_systray .o_website_edit_in_backend a', +}, { + content: 'Check that the form is read-only', + trigger: '.o_form_view_container .o_form_readonly', + run: () => {}, // This is a check. +}, { + content: 'Return to website', + trigger: '.oe_button_box .fa-globe', +}]; + +const canEdit = [ + ...wTourUtils.clickOnEditAndWaitEditMode(), + { + content: 'Click on name', + trigger: 'iframe span[data-oe-expression="test_model.name"][contenteditable="true"]', + }, { + content: 'Change name', + trigger: 'iframe span[data-oe-expression="test_model.name"][contenteditable="true"]', + run: 'text Better name', + }, { + content: 'Check that field becomes dirty', + trigger: 'iframe span[data-oe-expression="test_model.name"].o_dirty', + run: () => {}, // This is a check. + }, + ...wTourUtils.clickOnSave(), + { + content: 'Check whether name is saved', + trigger: 'iframe span[data-oe-expression="test_model.name"]:contains("Better name")', + run: () => {}, // This is a check. + }, +]; + +const cannotEdit = [{ + content: 'Check Edit is not available', + trigger: '.o_menu_systray:not(:has(.o_edit_website_container))', + run: () => {}, // This is a check. +}]; + +const canEditButCannotChange = [ + ...wTourUtils.clickOnEditAndWaitEditMode(), + { + content: 'Cannot change name', + trigger: 'iframe main:not(:has([data-oe-expression])):contains("Test Model")', + run: () => {}, // This is a check. + }, +]; + +const register = (title, steps) => { + wTourUtils.registerWebsitePreviewTour(title, { + url: '/test_model/1', + test: true, + }, steps); +}; + +register('test_systray_admin', [ + ...canPublish, + ...canToggleMobilePreview, + ...canSwitchWebsite, + ...canAddNewContent, + ...canEditInBackEnd, + ...canEdit, +]); + +register('test_systray_reditor_tester', [ + ...canPublish, + ...canToggleMobilePreview, + ...canSwitchWebsite, + ...canAddNewContent, + ...canEditInBackEnd, + ...canEdit, +]); + +register('test_systray_reditor_not_tester', [ + ...cannotPublish, + ...canToggleMobilePreview, + ...canSwitchWebsite, + ...canAddNewContent, + ...canViewInBackEnd, + ...canEditButCannotChange, +]); + +register('test_systray_not_reditor_tester', [ + ...canPublish, + ...cannotToggleMobilePreview, + ...canSwitchWebsiteNoCheck, + ...cannotAddNewContent, + ...canEditInBackEnd, + ...cannotEdit, +]); + +register('test_systray_not_reditor_not_tester', [ + ...cannotPublish, + ...cannotToggleMobilePreview, + ...canSwitchWebsiteNoCheck, + ...cannotAddNewContent, + ...canViewInBackEnd, + ...cannotEdit, +]); diff --git a/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/website_settings.js b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/website_settings.js new file mode 100644 index 0000000..0f2ffda --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/static/tests/tours/website_settings.js @@ -0,0 +1,49 @@ +/** @odoo-module */ + +import tour from "web_tour.tour"; + +const websiteName = "Website Test Settings"; + +tour.register("website_settings_m2o_dirty", { + test: true, + url: "/web", +}, +[ + tour.stepUtils.showAppsMenuItem(), + { + content: "open settings", + trigger: ".o_app[data-menu-xmlid='base.menu_administration'", + }, { + content: "open website settings", + trigger: ".settings_tab .tab[data-key='website']", + }, { + content: "check that the 'Shared Customers Accounts' setting is checked", + trigger: "input#shared_user_account:checked", + run: function () {}, // it's a check + }, { + content: "open website switcher", + trigger: "input#website_id", + }, { + content: `select ${websiteName} in the website switcher`, + trigger: `li:has(.dropdown-item:contains('${websiteName}'))`, + }, { + content: `check that the settings of ${websiteName} are loaded (Shared Customers Accounts)`, + trigger: "input#shared_user_account:not(:checked)", + run: function () {}, // it's a check + }, { + content: "click on the fake website setting after checking the edited website", + trigger: "button[name='action_website_test_setting']", + }, { + content: "check that we are on '/'", + trigger: "iframe body div#wrap", + run: function () { + if (window.location.pathname !== "/") { + // If this fails, it's probably because the change of website + // in the settings dirty the record and so there is a dialog + // save/discard displayed. This test ensure that does not happen + // because it makes actions unreachable in multi website. + console.error("We should be on '/' the settings didn't work"); + } + } + }, +]); diff --git a/odoo-bringout-oca-ocb-test_website/test_website/tests/__init__.py b/odoo-bringout-oca-ocb-test_website/test_website/tests/__init__.py new file mode 100644 index 0000000..03ff45a --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/tests/__init__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_controller_args +from . import test_custom_snippet +from . import test_error +from . import test_fuzzy +from . import test_image_upload_progress +from . import test_is_multilang +from . import test_media +from . import test_menu +from . import test_multi_company +from . import test_page_manager +from . import test_page +from . import test_performance +from . import test_redirect +from . import test_reset_views +from . import test_session +from . import test_settings +from . import test_systray +from . import test_views_during_module_operation diff --git a/odoo-bringout-oca-ocb-test_website/test_website/tests/test_controller_args.py b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_controller_args.py new file mode 100644 index 0000000..28420b8 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_controller_args.py @@ -0,0 +1,46 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import odoo.tests +from odoo.tools import mute_logger + + +@odoo.tests.common.tagged('post_install', '-at_install') +class TestWebsiteControllerArgs(odoo.tests.HttpCase): + + @mute_logger('odoo.http') + def test_crawl_args(self): + req = self.url_open('/ignore_args/converter/valueA/?b=valueB&c=valueC') + self.assertEqual(req.status_code, 200) + self.assertEqual(req.json(), {'a': 'valueA', 'b': 'valueB', 'kw': {'c': 'valueC'}}) + + req = self.url_open('/ignore_args/converter/valueA/nokw?b=valueB&c=valueC') + self.assertEqual(req.status_code, 200) + self.assertEqual(req.json(), {'a': 'valueA', 'b': 'valueB'}) + + req = self.url_open('/ignore_args/converteronly/valueA/?b=valueB&c=valueC') + self.assertEqual(req.status_code, 200) + self.assertEqual(req.json(), {'a': 'valueA', 'kw': None}) + + req = self.url_open('/ignore_args/none?a=valueA&b=valueB') + self.assertEqual(req.status_code, 200) + self.assertEqual(req.json(), {'a': None, 'kw': None}) + + req = self.url_open('/ignore_args/a?a=valueA&b=valueB') + self.assertEqual(req.status_code, 200) + self.assertEqual(req.json(), {'a': 'valueA', 'kw': None}) + + req = self.url_open('/ignore_args/kw?a=valueA&b=valueB') + self.assertEqual(req.status_code, 200) + self.assertEqual(req.json(), {'a': 'valueA', 'kw': {'b': 'valueB'}}) + + req = self.url_open('/test_website/country/whatever-999999') + self.assertEqual(req.status_code, 404, + "Model converter record does not exist, return a 404.") + + +@odoo.tests.common.tagged('post_install', '-at_install') +class TestWebsiteControllers(odoo.tests.TransactionCase): + + def test_01_sitemap(self): + website = self.env['website'].browse(1) + locs = website.with_user(website.user_id)._enumerate_pages(query_string='test_website_sitemap') + self.assertEqual(len(list(locs)), 1, "The same URL should only be shown once") diff --git a/odoo-bringout-oca-ocb-test_website/test_website/tests/test_custom_snippet.py b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_custom_snippet.py new file mode 100644 index 0000000..d5adc0c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_custom_snippet.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import odoo.tests +from odoo.tools import mute_logger + + +@odoo.tests.common.tagged('post_install', '-at_install') +class TestCustomSnippet(odoo.tests.HttpCase): + + @mute_logger('odoo.addons.http_routing.models.ir_http', 'odoo.http') + def test_01_run_tour(self): + self.start_tour(self.env['website'].get_client_action_url('/'), 'test_custom_snippet', login="admin") diff --git a/odoo-bringout-oca-ocb-test_website/test_website/tests/test_error.py b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_error.py new file mode 100644 index 0000000..f4c9334 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_error.py @@ -0,0 +1,10 @@ +import odoo.tests +from odoo.tools import mute_logger + + +@odoo.tests.common.tagged('post_install', '-at_install') +class TestWebsiteError(odoo.tests.HttpCase): + + @mute_logger('odoo.addons.http_routing.models.ir_http', 'odoo.http') + def test_01_run_test(self): + self.start_tour("/test_error_view", 'test_error_website') diff --git a/odoo-bringout-oca-ocb-test_website/test_website/tests/test_fuzzy.py b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_fuzzy.py new file mode 100644 index 0000000..ac76608 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_fuzzy.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import psycopg2 + +from odoo.addons.website.controllers.main import Website +from odoo.addons.website.tools import MockRequest +import odoo.tests +from odoo.tests.common import TransactionCase + +_logger = logging.getLogger(__name__) + +@odoo.tests.tagged('-at_install', 'post_install') +class TestAutoComplete(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.website = cls.env['website'].browse(1) + cls.WebsiteController = Website() + + def _autocomplete(self, term, expected_count, expected_fuzzy_term, search_type="test", options=None): + """ Calls the autocomplete for a given term and performs general checks """ + with MockRequest(self.env, website=self.website): + suggestions = self.WebsiteController.autocomplete( + search_type=search_type, term=term, max_nb_chars=50, options=options or {}, + ) + self.assertEqual(expected_count, suggestions['results_count'], "Wrong number of suggestions") + self.assertEqual(expected_fuzzy_term, suggestions.get('fuzzy_search', 'Not found'), "Wrong fuzzy match") + + def _autocomplete_page(self, term, expected_count, expected_fuzzy_term): + self._autocomplete(term, expected_count, expected_fuzzy_term, search_type="pages", options={ + 'displayDescription': False, 'displayDetail': False, + 'displayExtraDetail': False, 'displayExtraLink': False, + 'displayImage': False, 'allowFuzzy': True + }) + + def test_01_many_records(self): + # REF1000~REF3999 + data = [{ + 'name': 'REF%s' % count, + 'is_published': True, + } for count in range(1000, 4000)] + self.env['test.model'].create(data) + # NUM1000~NUM1998 + data = [{ + 'name': 'NUM%s' % count, + 'is_published': True, + } for count in range(1000, 1999)] + self.env['test.model'].create(data) + # There are more than 1000 "R*" records + # => Find exact match through the fallback + self._autocomplete('REF3000', 1, False) + # => No exact match => Find fuzzy within first 1000 (distance=3: replace D by F, move 3, add 1) + self._autocomplete('RED3000', 1, 'ref3000' if self.env.registry.has_trigram else 'ref1003') + # => Find exact match through the fallback + self._autocomplete('REF300', 10, False) + # => Find exact match through the fallback + self._autocomplete('REF1', 1000, False) + # => No exact match => Nothing close enough (min distance=5) + self._autocomplete('REFX', 0, "Not found") + # => Find exact match through the fallback - unfortunate because already in the first 1000 records + self._autocomplete('REF1230', 1, False) + # => Find exact match through the fallback + self._autocomplete('REF2230', 1, False) + + # There are less than 1000 "N*" records + # => Fuzzy within N* (distance=1: add 1) + self._autocomplete('NUM000', 1, "num1000") + # => Exact match (distance=0 shortcut logic) + self._autocomplete('NUM100', 10, False) + # => Exact match (distance=0 shortcut logic) + self._autocomplete('NUM199', 9, False) + # => Exact match (distance=0 shortcut logic) + self._autocomplete('NUM1998', 1, False) + # => Fuzzy within N* (distance=1: replace 1 by 9) + self._autocomplete('NUM1999', 1, 'num1199') + # => Fuzzy within N* (distance=1: add 1) + self._autocomplete('NUM200', 1, 'num1200') + + # There are no "X*" records + self._autocomplete('XEF1000', 0, "Not found") + + def test_02_pages_search(self): + if not self.env.registry.has_trigram: + try: + self.env.cr.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") + self.env.registry.has_trigram = True + except psycopg2.Error: + _logger.warning("pg_trgm extension can't be installed, which is required to run this test") + return + + with MockRequest(self.env, website=self.env['website'].browse(1)): + # This should not crash. This ensures that when searching on `name` + # field of `website.page` model, it works properly when `pg_trgm` is + # activated. + # Indeed, `name` is a field of `website.page` record but only at the + # ORM level, not in SQL, due to how `inherits` works. + self.env['website'].browse(1)._search_with_fuzzy( + 'pages', 'test', limit=5, order='name asc, website_id desc, id', options={ + 'displayDescription': False, 'displayDetail': False, + 'displayExtraDetail': False, 'displayExtraLink': False, + 'displayImage': False, 'allowFuzzy': True + } + ) + + test_page = self.env.ref('test_website.test_page') + test_page.name = 'testTotallyUnique' + + # Editor and Designer see pages in result + self._autocomplete_page('testTotallyUnique', 1, False) + + test_page.visibility = 'connected' + self._autocomplete_page('testTotallyUnique', 1, False) + test_page.visibility = False + + test_page.groups_id = self.env.ref('base.group_public') + self._autocomplete_page('testTotallyUnique', 1, False) + test_page.groups_id = False + + # Public user don't see restricted page + saved_env = self.env + self.website.env = self.env = self.env(user=self.website.user_id) + self._autocomplete_page('testTotallyUnique', 0, "Not found") + + test_page.website_indexed = True + self._autocomplete_page('testTotallyUnique', 1, False) + + test_page.groups_id = self.env.ref('base.group_system') + self._autocomplete_page('testTotallyUnique', 0, "Not found") + + test_page.groups_id = self.env.ref('base.group_public') + self._autocomplete_page('testTotallyUnique', 1, False) + test_page.groups_id = False + + test_page.visibility = 'password' + self._autocomplete_page('testTotallyUnique', 0, "Not found") + + test_page.visibility = 'connected' + self._autocomplete_page('testTotallyUnique', 0, "Not found") + + # restore website env for next tests + self.website.env = self.env = saved_env diff --git a/odoo-bringout-oca-ocb-test_website/test_website/tests/test_image_upload_progress.py b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_image_upload_progress.py new file mode 100644 index 0000000..b737dcc --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_image_upload_progress.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.web_editor.controllers.main import Web_Editor +from odoo.addons.web_unsplash.controllers.main import Web_Unsplash + +import odoo.tests + +from odoo import http + + +@odoo.tests.common.tagged('post_install', '-at_install') +class TestImageUploadProgress(odoo.tests.HttpCase): + + def test_01_image_upload_progress(self): + self.start_tour(self.env['website'].get_client_action_url('/test_image_progress'), 'test_image_upload_progress', login="admin") + + def test_02_image_upload_progress_unsplash(self): + BASE_URL = self.base_url() + + @http.route('/web_editor/media_library_search', type='json', auth="user", website=True) + def media_library_search(self, **params): + return {"results": 0, "media": []} + # because not preprocessed by ControllerType metaclass + media_library_search.original_endpoint.routing_type = 'json' + # disable undraw, no third party should be called in tests + self.patch(Web_Editor, 'media_library_search', media_library_search) + + @http.route("/web_unsplash/fetch_images", type='json', auth="user") + def fetch_unsplash_images(self, **post): + return { + 'total': 1434, + 'total_pages': 48, + 'results': [{ + 'id': 'HQqIOc8oYro', + 'alt_description': 'brown fox sitting on green grass field during daytime', + 'urls': { + # 'regular': 'https://images.unsplash.com/photo-1462953491269-9aff00919695?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwzMDUwOHwwfDF8c2VhcmNofDF8fGZveHxlbnwwfHx8fDE2MzEwMzIzNDE&ixlib=rb-1.2.1&q=80&w=1080', + 'regular': BASE_URL + '/website/static/src/img/phone.png', + }, + 'links': { + # 'download_location': 'https://api.unsplash.com/photos/HQqIOc8oYro/download?ixid=MnwzMDUwOHwwfDF8c2VhcmNofDF8fGZveHxlbnwwfHx8fDE2MzEwMzIzNDE' + 'download_location': BASE_URL + '/website/static/src/img/phone.png', + }, + 'user': { + 'name': 'Mitchell Admin', + 'links': { + 'html': BASE_URL, + }, + }, + }] + } + # because not preprocessed by ControllerType metaclass + fetch_unsplash_images.original_endpoint.routing_type = 'json' + # disable undraw, no third party should be called in tests + self.patch(Web_Unsplash, 'fetch_unsplash_images', fetch_unsplash_images) + + self.start_tour(self.env['website'].get_client_action_url('/test_image_progress'), 'test_image_upload_progress_unsplash', login="admin") diff --git a/odoo-bringout-oca-ocb-test_website/test_website/tests/test_is_multilang.py b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_is_multilang.py new file mode 100644 index 0000000..38bf27c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_is_multilang.py @@ -0,0 +1,97 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from urllib.parse import urlparse +import odoo.tests +import lxml + + +@odoo.tests.common.tagged('post_install', '-at_install') +class TestIsMultiLang(odoo.tests.HttpCase): + + def test_01_is_multilang_url(self): + website = self.env['website'].search([], limit=1) + fr = self.env.ref('base.lang_fr').sudo() + en = self.env.ref('base.lang_en').sudo() + + fr.active = True + fr_prefix = "/" + fr.iso_code + + website.default_lang_id = en + website.language_ids = en + fr + + for data in [None, {'post': True}]: # GET / POST + body = lxml.html.fromstring(self.url_open('/fr/multi_url', data=data).content) + + self.assertEqual(fr_prefix + '/get', body.find('./a[@id="get"]').get('href')) + self.assertEqual(fr_prefix + '/post', body.find('./form[@id="post"]').get('action')) + self.assertEqual(fr_prefix + '/get_post', body.find('./a[@id="get_post"]').get('href')) + self.assertEqual('/get_post_nomultilang', body.find('./a[@id="get_post_nomultilang"]').get('href')) + + def test_02_url_lang_code_underscore(self): + website = self.env['website'].browse(1) + it = self.env.ref('base.lang_it').sudo() + en = self.env.ref('base.lang_en').sudo() + be = self.env.ref('base.lang_fr_BE').sudo() + country1 = self.env['res.country'].create({'name': "My Super Country"}) + + it.active = True + be.active = True + website.domain = self.base_url() # for _is_canonical_url + website.default_lang_id = en + website.language_ids = en + it + be + country1.update_field_translations('name', { + it.code: country1.name + ' Italia', + be.code: country1.name + ' Belgium' + }) + + r = self.url_open(f'/test_lang_url/{country1.id}') + self.assertEqual(r.status_code, 200) + self.assertEqual(urlparse(r.url).path, f'/test_lang_url/my-super-country-{country1.id}') + + r = self.url_open(f'/{it.url_code}/test_lang_url/{country1.id}') + self.assertEqual(r.status_code, 200) + self.assertEqual(urlparse(r.url).path, f'/{it.url_code}/test_lang_url/my-super-country-italia-{country1.id}') + + body = lxml.html.fromstring(r.content) + # Note: this test is indirectly testing the `ref=canonical` tag is correctly set, + # as it is required in order for `rel=alternate` tags to be inserted in the DOM + it_href = body.find('./head/link[@rel="alternate"][@hreflang="it"]').get('href') + fr_href = body.find('./head/link[@rel="alternate"][@hreflang="fr"]').get('href') + en_href = body.find('./head/link[@rel="alternate"][@hreflang="en"]').get('href') + + self.assertEqual(urlparse(it_href).path, f'/{it.url_code}/test_lang_url/my-super-country-italia-{country1.id}') + self.assertEqual(urlparse(fr_href).path, f'/{be.url_code}/test_lang_url/my-super-country-belgium-{country1.id}') + self.assertEqual(urlparse(en_href).path, f'/test_lang_url/my-super-country-{country1.id}') + + def test_03_head_alternate_href(self): + website = self.env['website'].search([], limit=1) + be = self.env.ref('base.lang_fr_BE').sudo() + en = self.env.ref('base.lang_en').sudo() + + be.active = True + be_prefix = "/" + be.iso_code + + website.default_lang_id = en + website.language_ids = en + be + + # alternate href should be use the current url. + self.url_open(be_prefix) + self.url_open(be_prefix + '/contactus') + r = self.url_open(be_prefix) + self.assertRegex(r.text, r'') + r = self.url_open(be_prefix + '/contactus') + self.assertRegex(r.text, r'') + + def test_04_multilang_false(self): + website = self.env['website'].search([], limit=1) + fr = self.env.ref('base.lang_fr').sudo() + en = self.env.ref('base.lang_en').sudo() + fr.active = True + + website.default_lang_id = en + website.language_ids = en + fr + self.opener.cookies['frontend_lang'] = fr.iso_code + + res = self.url_open('/get_post_nomultilang', allow_redirects=False) + res.raise_for_status() + + self.assertEqual(res.status_code, 200, "Should not be redirected") diff --git a/odoo-bringout-oca-ocb-test_website/test_website/tests/test_media.py b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_media.py new file mode 100644 index 0000000..e03890b --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_media.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 + +import odoo.tests +from odoo.tools import mute_logger + + +@odoo.tests.common.tagged('post_install', '-at_install') +class TestMedia(odoo.tests.HttpCase): + + @mute_logger('odoo.addons.http_routing.models.ir_http', 'odoo.http') + def test_01_replace_media(self): + SVG = base64.b64encode(b'') + self.env['ir.attachment'].create({ + 'name': 'sample.svg', + 'public': True, + 'mimetype': 'image/svg+xml', + 'datas': SVG, + }) + self.start_tour("/", 'test_replace_media', login="admin") + + def test_02_image_link(self): + self.start_tour("/", 'test_image_link', login="admin") diff --git a/odoo-bringout-oca-ocb-test_website/test_website/tests/test_menu.py b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_menu.py new file mode 100644 index 0000000..bba560d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_menu.py @@ -0,0 +1,45 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from lxml import html + +from odoo.addons.website.tools import MockRequest +from odoo.tests import tagged, HttpCase + + +@tagged('post_install', '-at_install') +class TestWebsiteMenu(HttpCase): + + def test_menu_active_element(self): + records = self.env['test.model'].create([{ + 'name': "Record 1", + 'is_published': True, + }, { + 'name': "Record 2", + 'is_published': True, + }]) + + controller_url = '/test_website/model_item/' + website = self.env['website'].browse(1) + + self.env['website.menu'].create([{ + 'name': records[0].name, + 'url': f"{controller_url}{records[0].id}", + 'parent_id': website.menu_id.id, + 'website_id': website.id, + 'sequence': 10, + }, { + 'name': records[1].name, + 'url': f"{controller_url}{records[1].id}", + 'parent_id': website.menu_id.id, + 'website_id': website.id, + 'sequence': 20, + }]) + for record in records: + record_url = f"{controller_url}{record.id}" + with MockRequest(self.env, website=website, url_root='', path=record_url): + tree = html.fromstring(self.env['ir.qweb']._render('test_website.model_item', { + 'record': record, + 'main_object': record, + })) + menu_link_el = tree.xpath(".//*[@id='top_menu']//a[@href='%s' and contains(@class, 'active')]" % record_url) + self.assertEqual(len(menu_link_el), 1, "The menu link related to the current record should be active") diff --git a/odoo-bringout-oca-ocb-test_website/test_website/tests/test_multi_company.py b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_multi_company.py new file mode 100644 index 0000000..1395466 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_multi_company.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import HttpCase, tagged + + +@tagged('post_install', '-at_install') +class TestMultiCompany(HttpCase): + + def test_company_in_context(self): + """ Test website company is set in context """ + website = self.env.ref('website.default_website') + company = self.env['res.company'].create({'name': "Adaa"}) + website.company_id = company + response = self.url_open('/multi_company_website') + self.assertEqual(response.json()[0], company.id) diff --git a/odoo-bringout-oca-ocb-test_website/test_website/tests/test_page.py b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_page.py new file mode 100644 index 0000000..c47a8ab --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_page.py @@ -0,0 +1,59 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import HttpCase, tagged +from odoo.tests.common import HOST +from odoo.tools import config, mute_logger + + +@tagged('-at_install', 'post_install') +class WithContext(HttpCase): + def test_01_homepage_url(self): + # Setup + website = self.env['website'].browse([1]) + website.write({ + 'name': 'Test Website', + 'domain': f'http://{HOST}:{config["http_port"]}', + 'homepage_url': '/unexisting', + }) + home_url = '/' + contactus_url = '/contactus' + contactus_url_full = website.domain + contactus_url + contactus_content = b'content="Contact Us | Test Website"' + self.env['website.menu'].search([ + ('website_id', '=', website.id), + ('url', '=', contactus_url), + ]).sequence = 1 + + # 404 shouldn't be served but fallback on first menu + # ------------------------------------------- + # / page exists | first menu | homepage_url + # ------------------------------------------- + # yes | /contactus | /unexisting + # ------------------------------------------- + r = self.url_open(website.homepage_url) + self.assertEqual(r.status_code, 404, "The website homepage_url should be a 404") + r = self.url_open(home_url) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.history[0].status_code, 303) + self.assertURLEqual(r.url, contactus_url_full) + self.assertIn(contactus_content, r.content) + + # same with 403 + # ------------------------------------------- + # / page exists | first menu | homepage_url + # ------------------------------------------- + # yes | /contactus | /test_website/200/name-1 + # ------------------------------------------- + rec_unpublished = self.env['test.model'].create({ + 'name': 'name', + 'is_published': False, + }) + website.homepage_url = f"/test_website/200/name-{rec_unpublished.id}" + with mute_logger('odoo.http'): # mute 403 warning + r = self.url_open(website.homepage_url) + self.assertEqual(r.status_code, 404, "The website homepage_url should be a 404") + r = self.url_open(home_url) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.history[0].status_code, 303) + self.assertURLEqual(r.url, contactus_url_full) + self.assertIn(contactus_content, r.content) diff --git a/odoo-bringout-oca-ocb-test_website/test_website/tests/test_page_manager.py b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_page_manager.py new file mode 100644 index 0000000..35cb86e --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_page_manager.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import odoo.tests + + +@odoo.tests.common.tagged('post_install', '-at_install') +class TestWebsitePageManager(odoo.tests.HttpCase): + def test_page_manager_test_model(self): + if self.env['website'].search_count([]) == 1: + website2 = self.env['website'].create({ + 'name': 'My Website 2', + 'domain': '', + 'sequence': 20, + }) + else: + website2 = self.env['website'].search([], order='id desc', limit=1) + self.env['test.model.multi.website'].create({'name': 'Test Model Multi Website 2', 'website_id': website2.id}) + self.assertTrue( + len(set([t.website_id.id for t in self.env['test.model.multi.website'].search([])])) >= 3, + "There should at least be one record without website_id and one for 2 different websites", + ) + self.assertNotIn('website_id', self.env['test.model']._fields) + self.start_tour('/web#action=test_website.action_test_model_multi_website', 'test_website_page_manager', login="admin") + # This second test is about ensuring that you can switch from a list + # view which has no `website_pages_list` js_class to its kanban view + self.start_tour('/web#action=test_website.action_test_model_multi_website_js_class_bug', 'test_website_page_manager_js_class_bug', login="admin") + self.start_tour('/web#action=test_website.action_test_model', 'test_website_page_manager_no_website_id', login="admin") diff --git a/odoo-bringout-oca-ocb-test_website/test_website/tests/test_performance.py b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_performance.py new file mode 100644 index 0000000..414449d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_performance.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.website.tests.test_performance import UtilPerf + + +class TestPerformance(UtilPerf): + def test_10_perf_sql_website_controller_minimalist(self): + url = '/empty_controller_test' + select_tables_perf = { + 'base_registry_signaling': 1, + } + self._check_url_hot_query(url, 1, select_tables_perf) + self.assertEqual(self._get_url_hot_query(url, cache=False), 1) diff --git a/odoo-bringout-oca-ocb-test_website/test_website/tests/test_redirect.py b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_redirect.py new file mode 100644 index 0000000..3eca6f9 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_redirect.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import odoo +from odoo.tests import HttpCase, tagged +from odoo.tools import mute_logger +from odoo.addons.http_routing.models.ir_http import slug + +from unittest.mock import patch + + +@tagged('-at_install', 'post_install') +class TestRedirect(HttpCase): + + def setUp(self): + super(TestRedirect, self).setUp() + + self.user_portal = self.env['res.users'].with_context({'no_reset_password': True}).create({ + 'name': 'Test Website Portal User', + 'login': 'portal_user', + 'password': 'portal_user', + 'email': 'portal_user@mail.com', + 'groups_id': [(6, 0, [self.env.ref('base.group_portal').id])] + }) + + def test_01_redirect_308_model_converter(self): + + self.env['website.rewrite'].create({ + 'name': 'Test Website Redirect', + 'redirect_type': '308', + 'url_from': '/test_website/country/', + 'url_to': '/redirected/country/', + }) + country_ad = self.env.ref('base.ad') + + """ Ensure 308 redirect with model converter works fine, including: + - Correct & working redirect as public user + - Correct & working redirect as logged in user + - Correct replace of url_for() URLs in DOM + """ + url = '/test_website/country/' + slug(country_ad) + redirect_url = url.replace('test_website', 'redirected') + + # [Public User] Open the original url and check redirect OK + r = self.url_open(url) + self.assertEqual(r.status_code, 200) + self.assertTrue(r.url.endswith(redirect_url), "Ensure URL got redirected") + self.assertTrue(country_ad.name in r.text, "Ensure the controller returned the expected value") + self.assertTrue(redirect_url in r.text, "Ensure the url_for has replaced the href URL in the DOM") + + # [Logged In User] Open the original url and check redirect OK + self.authenticate("portal_user", "portal_user") + r = self.url_open(url) + self.assertEqual(r.status_code, 200) + self.assertTrue(r.url.endswith(redirect_url), "Ensure URL got redirected (2)") + self.assertTrue('Logged In' in r.text, "Ensure logged in") + self.assertTrue(country_ad.name in r.text, "Ensure the controller returned the expected value (2)") + self.assertTrue(redirect_url in r.text, "Ensure the url_for has replaced the href URL in the DOM") + + def test_redirect_308_by_method_url_rewrite(self): + self.env['website.rewrite'].create({ + 'name': 'Test Website Redirect', + 'redirect_type': '308', + 'url_from': url_from, + 'url_to': f'{url_from}_new', + } for url_from in ('/get', '/post', '/get_post')) + + self.env.ref('test_website.test_view').arch = ''' + + + + ''' + + # [Public User] Open the /test_view url and ensure urls are rewritten + r = self.url_open('/test_view') + self.assertEqual(r.status_code, 200) + self.assertEqual( + r.content.strip(), + b'' + ) + + @mute_logger('odoo.http') # mute 403 warning + def test_02_redirect_308_RequestUID(self): + self.env['website.rewrite'].create({ + 'name': 'Test Website Redirect', + 'redirect_type': '308', + 'url_from': '/test_website/200/', + 'url_to': '/test_website/308/', + }) + + rec_published = self.env['test.model'].create({'name': 'name', 'website_published': True}) + rec_unpublished = self.env['test.model'].create({'name': 'name', 'website_published': False}) + + WebsiteHttp = odoo.addons.website.models.ir_http.Http + + def _get_error_html(env, code, value): + return str(code).split('_')[-1], f"CUSTOM {code}" + + with patch.object(WebsiteHttp, '_get_error_html', _get_error_html): + # Patch will avoid to display real 404 page and regenerate assets each time and unlink old one. + # And it allow to be sur that exception id handled by handle_exception and return a "managed error" page. + + # published + resp = self.url_open(f"/test_website/200/name-{rec_published.id}", allow_redirects=False) + self.assertEqual(resp.status_code, 308) + self.assertURLEqual(resp.headers.get('Location'), f"/test_website/308/name-{rec_published.id}") + + resp = self.url_open(f"/test_website/308/name-{rec_published.id}", allow_redirects=False) + self.assertEqual(resp.status_code, 200) + + resp = self.url_open(f"/test_website/200/xx-{rec_published.id}", allow_redirects=False) + self.assertEqual(resp.status_code, 308) + self.assertURLEqual(resp.headers.get('Location'), f"/test_website/308/xx-{rec_published.id}") + + resp = self.url_open(f"/test_website/308/xx-{rec_published.id}", allow_redirects=False) + self.assertEqual(resp.status_code, 301) + self.assertURLEqual(resp.headers.get('Location'), f"/test_website/308/name-{rec_published.id}") + + resp = self.url_open(f"/test_website/200/xx-{rec_published.id}", allow_redirects=True) + self.assertEqual(resp.status_code, 200) + self.assertURLEqual(resp.url, f"/test_website/308/name-{rec_published.id}") + + # unexisting + resp = self.url_open("/test_website/200/name-100", allow_redirects=False) + self.assertEqual(resp.status_code, 308) + self.assertURLEqual(resp.headers.get('Location'), "/test_website/308/name-100") + + resp = self.url_open("/test_website/308/name-100", allow_redirects=False) + self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.text, "CUSTOM 404") + + resp = self.url_open("/test_website/200/xx-100", allow_redirects=False) + self.assertEqual(resp.status_code, 308) + self.assertURLEqual(resp.headers.get('Location'), "/test_website/308/xx-100") + + resp = self.url_open("/test_website/308/xx-100", allow_redirects=False) + self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.text, "CUSTOM 404") + + # unpublish + resp = self.url_open(f"/test_website/200/name-{rec_unpublished.id}", allow_redirects=False) + self.assertEqual(resp.status_code, 308) + self.assertURLEqual(resp.headers.get('Location'), f"/test_website/308/name-{rec_unpublished.id}") + + resp = self.url_open(f"/test_website/308/name-{rec_unpublished.id}", allow_redirects=False) + self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.text, "CUSTOM 404") + + resp = self.url_open(f"/test_website/200/xx-{rec_unpublished.id}", allow_redirects=False) + self.assertEqual(resp.status_code, 308) + self.assertURLEqual(resp.headers.get('Location'), f"/test_website/308/xx-{rec_unpublished.id}") + + resp = self.url_open(f"/test_website/308/xx-{rec_unpublished.id}", allow_redirects=False) + self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.text, "CUSTOM 404") + + # with seo_name as slug + rec_published.seo_name = "seo_name" + rec_unpublished.seo_name = "seo_name" + + resp = self.url_open(f"/test_website/200/seo-name-{rec_published.id}", allow_redirects=False) + self.assertEqual(resp.status_code, 308) + self.assertURLEqual(resp.headers.get('Location'), f"/test_website/308/seo-name-{rec_published.id}") + + resp = self.url_open(f"/test_website/308/seo-name-{rec_published.id}", allow_redirects=False) + self.assertEqual(resp.status_code, 200) + + resp = self.url_open(f"/test_website/200/xx-{rec_unpublished.id}", allow_redirects=False) + self.assertEqual(resp.status_code, 308) + self.assertURLEqual(resp.headers.get('Location'), f"/test_website/308/xx-{rec_unpublished.id}") + + resp = self.url_open(f"/test_website/308/xx-{rec_unpublished.id}", allow_redirects=False) + self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.text, "CUSTOM 404") + + resp = self.url_open("/test_website/200/xx-100", allow_redirects=False) + self.assertEqual(resp.status_code, 308) + self.assertURLEqual(resp.headers.get('Location'), "/test_website/308/xx-100") + + resp = self.url_open("/test_website/308/xx-100", allow_redirects=False) + self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.text, "CUSTOM 404") + + def test_03_redirect_308_qs(self): + self.env['website.rewrite'].create({ + 'name': 'Test QS Redirect', + 'redirect_type': '308', + 'url_from': '/empty_controller_test', + 'url_to': '/empty_controller_test_redirected', + }) + r = self.url_open('/test_website/test_redirect_view_qs?a=a') + self.assertEqual(r.status_code, 200) + self.assertIn( + 'href="/empty_controller_test_redirected?a=a"', r.text, + "Redirection should have been applied, and query string should not have been duplicated.", + ) + + @mute_logger('odoo.http') # mute 403 warning + def test_04_redirect_301_route_unpublished_record(self): + # 1. Accessing published record: Normal case, expecting 200 + rec1 = self.env['test.model'].create({ + 'name': '301 test record', + 'is_published': True, + }) + url_rec1 = '/test_website/200/' + slug(rec1) + r = self.url_open(url_rec1) + self.assertEqual(r.status_code, 200) + + # 2. Accessing unpublished record: expecting 404 for public users + rec1.is_published = False + r = self.url_open(url_rec1) + self.assertEqual(r.status_code, 404) + + # 3. Accessing unpublished record with redirect to a 404: expecting 404 + redirect = self.env['website.rewrite'].create({ + 'name': 'Test 301 Redirect route unpublished record', + 'redirect_type': '301', + 'url_from': url_rec1, + 'url_to': '/404', + }) + r = self.url_open(url_rec1) + self.assertEqual(r.status_code, 404) + + # 4. Accessing unpublished record with redirect to another published + # record: expecting redirect to that record + rec2 = rec1.copy({'is_published': True}) + url_rec2 = '/test_website/200/' + slug(rec2) + redirect.url_to = url_rec2 + r = self.url_open(url_rec1) + self.assertEqual(r.status_code, 200) + self.assertTrue( + r.url.endswith(url_rec2), + "Unpublished record should redirect to published record set in redirect") + + @mute_logger('odoo.http') + def test_05_redirect_404_notfound_record(self): + # 1. Accessing unexisting record: raise 404 + url_rec1 = '/test_website/200/unexisting-100000' + r = self.url_open(url_rec1) + self.assertEqual(r.status_code, 404) + + # 2. Accessing unpublished record with redirect to a 404: expecting 404 + redirect = self.env['website.rewrite'].create({ + 'name': 'Test 301 Redirect route unexisting record', + 'redirect_type': '301', + 'url_from': url_rec1, + 'url_to': '/get', + }) + r = self.url_open(url_rec1, allow_redirects=False) + self.assertEqual(r.status_code, 301) + self.assertURLEqual(r.headers.get('Location'), redirect.url_to) + + r = self.url_open(url_rec1, allow_redirects=True) + self.assertEqual(r.status_code, 200) + self.assertURLEqual(r.url, redirect.url_to) + + def test_redirect_308_multiple_url_endpoint(self): + self.env['website.rewrite'].create({ + 'name': 'Test Multi URL 308', + 'redirect_type': '308', + 'url_from': '/test_countries_308', + 'url_to': '/test_countries_308_redirected', + }) + rec1 = self.env['test.model'].create({ + 'name': '301 test record', + 'is_published': True, + }) + url_rec1 = f"/test_countries_308/{slug(rec1)}" + + resp = self.url_open("/test_countries_308", allow_redirects=False) + self.assertEqual(resp.status_code, 308) + self.assertURLEqual(resp.headers.get('Location'), "/test_countries_308_redirected") + + resp = self.url_open(url_rec1) + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.url.endswith(url_rec1)) + + def test_redirect_with_qs(self): + self.env['website.rewrite'].create({ + 'name': 'Test 301 Redirect with qs', + 'redirect_type': '301', + 'url_from': '/foo?bar=1', + 'url_to': '/new-page-01', + }) + self.env['website.rewrite'].create({ + 'name': 'Test 301 Redirect with qs', + 'redirect_type': '301', + 'url_from': '/foo?bar=2', + 'url_to': '/new-page-10?qux=2', + }) + self.env['website.rewrite'].create({ + 'name': 'Test 301 Redirect without qs', + 'redirect_type': '301', + 'url_from': '/foo', + 'url_to': '/new-page-11', + }) + + # should match qs first + resp = self.url_open("/foo?bar=1", allow_redirects=False) + self.assertEqual(resp.status_code, 301) + self.assertURLEqual(resp.headers.get('Location'), "/new-page-01?bar=1") + + # should match qs first + resp = self.url_open("/foo?bar=2", allow_redirects=False) + self.assertEqual(resp.status_code, 301) + self.assertURLEqual(resp.headers.get('Location'), "/new-page-10?qux=2&bar=2") + + # should match no qs + resp = self.url_open("/foo?bar=3", allow_redirects=False) + self.assertEqual(resp.status_code, 301) + self.assertURLEqual(resp.headers.get('Location'), "/new-page-11?bar=3") + + resp = self.url_open("/foo", allow_redirects=False) + self.assertEqual(resp.status_code, 301) + self.assertURLEqual(resp.headers.get('Location'), "/new-page-11") + + # we dont support wrong get order + # purpose is to support simple case like content.asp?id=xx + resp = self.url_open("/foo?oups=1&bar=2", allow_redirects=False) + self.assertEqual(resp.status_code, 301) + self.assertURLEqual(resp.headers.get('Location'), "/new-page-11?oups=1&bar=2") diff --git a/odoo-bringout-oca-ocb-test_website/test_website/tests/test_reset_views.py b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_reset_views.py new file mode 100644 index 0000000..96fbb9d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/tests/test_reset_views.py @@ -0,0 +1,113 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import re + +import odoo.tests +from odoo.tools import mute_logger + + +def break_view(view, fr='

placeholder

', to='

'): + view.arch = view.arch.replace(fr, to) + + +@odoo.tests.common.tagged('post_install', '-at_install') +class TestWebsiteResetViews(odoo.tests.HttpCase): + + def fix_it(self, page, mode='soft'): + self.authenticate("admin", "admin") + resp = self.url_open(page) + self.assertEqual(resp.status_code, 500, "Waiting 500") + self.assertTrue('

+ +
+
+ + + + + diff --git a/odoo-bringout-oca-ocb-test_website/test_website/views/test_model_multi_website_views.xml b/odoo-bringout-oca-ocb-test_website/test_website/views/test_model_multi_website_views.xml new file mode 100644 index 0000000..9bfccb3 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/views/test_model_multi_website_views.xml @@ -0,0 +1,81 @@ + + + + + + test.model.multi.website.kanban + test.model.multi.website + + + + + + +
+
+ + +
+ + +
+
+
+
+ + Published + Not Published +
+
+
+
+
+
+
+ + Test Multi Model Pages Tree + test.model.multi.website + 99 + + + + + + + + + + Test Multi Model Pages + test.model.multi.website + tree,kanban,form + + + + + + Test Multi Model Pages Tree js_class bug + test.model.multi.website + 99 + + + + + + + + + + + Test Multi Model Pages js_class bug + test.model.multi.website + tree,kanban,form + + + +
diff --git a/odoo-bringout-oca-ocb-test_website/test_website/views/test_model_views.xml b/odoo-bringout-oca-ocb-test_website/test_website/views/test_model_views.xml new file mode 100644 index 0000000..767114e --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website/test_website/views/test_model_views.xml @@ -0,0 +1,87 @@ + + + + + + test.model.kanban + test.model + + + + + + +
+
+ + + +
+
+ + Published + Not Published +
+
+
+
+
+
+
+ + Test Model Pages Tree + test.model + 99 + + + + + + + + + Test Model Pages + test.model + tree,kanban,form + + + + + + Test Model + ir.actions.act_window + test.model + + + + + + + test.model.form + test.model + +
+ +
+ +
+
+
+
+
+
+
+ +
diff --git a/odoo-bringout-oca-ocb-test_website_modules/README.md b/odoo-bringout-oca-ocb-test_website_modules/README.md new file mode 100644 index 0000000..eb3181c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/README.md @@ -0,0 +1,52 @@ +# Website Modules Test + +This module contains tests related to website modules. +It allows to test website business code when another website module is +installed. + +## Installation + +```bash +pip install odoo-bringout-oca-ocb-test_website_modules +``` + +## Dependencies + +This addon depends on: +- theme_default +- website +- website_blog +- website_event_sale +- website_slides + +## Manifest Information + +- **Name**: Website Modules 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_modules`. + +## 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 diff --git a/odoo-bringout-oca-ocb-test_website_modules/doc/ARCHITECTURE.md b/odoo-bringout-oca-ocb-test_website_modules/doc/ARCHITECTURE.md new file mode 100644 index 0000000..40c6856 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/doc/ARCHITECTURE.md @@ -0,0 +1,32 @@ +# Architecture + +```mermaid +flowchart TD + U[Users] -->|HTTP| V[Views and QWeb Templates] + V --> C[Controllers] + V --> W[Wizards – Transient Models] + C --> M[Models and ORM] + W --> M + M --> R[Reports] + DX[Data XML] --> M + S[Security – ACLs and Groups] -. enforces .-> M + + subgraph Test_website_modules Module - test_website_modules + direction LR + M:::layer + W:::layer + C:::layer + V:::layer + R:::layer + S:::layer + DX:::layer + end + + classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px +``` + +Notes +- Views include tree/form/kanban templates and report templates. +- Controllers provide website/portal routes when present. +- Wizards are UI flows implemented with `models.TransientModel`. +- Data XML loads data/demo records; Security defines groups and access. diff --git a/odoo-bringout-oca-ocb-test_website_modules/doc/CONFIGURATION.md b/odoo-bringout-oca-ocb-test_website_modules/doc/CONFIGURATION.md new file mode 100644 index 0000000..94ca42f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/doc/CONFIGURATION.md @@ -0,0 +1,3 @@ +# Configuration + +Refer to Odoo settings for test_website_modules. Configure related models, access rights, and options as needed. diff --git a/odoo-bringout-oca-ocb-test_website_modules/doc/CONTROLLERS.md b/odoo-bringout-oca-ocb-test_website_modules/doc/CONTROLLERS.md new file mode 100644 index 0000000..f628e77 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/doc/CONTROLLERS.md @@ -0,0 +1,3 @@ +# Controllers + +This module does not define custom HTTP controllers. diff --git a/odoo-bringout-oca-ocb-test_website_modules/doc/DEPENDENCIES.md b/odoo-bringout-oca-ocb-test_website_modules/doc/DEPENDENCIES.md new file mode 100644 index 0000000..380850a --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/doc/DEPENDENCIES.md @@ -0,0 +1,9 @@ +# Dependencies + +This addon depends on: + +- [theme_default](../../odoo-bringout-oca-ocb-theme_default) +- [website](../../odoo-bringout-oca-ocb-website) +- [website_blog](../../odoo-bringout-oca-ocb-website_blog) +- [website_event_sale](../../odoo-bringout-oca-ocb-website_event_sale) +- [website_slides](../../odoo-bringout-oca-ocb-website_slides) diff --git a/odoo-bringout-oca-ocb-test_website_modules/doc/FAQ.md b/odoo-bringout-oca-ocb-test_website_modules/doc/FAQ.md new file mode 100644 index 0000000..d22e41c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/doc/FAQ.md @@ -0,0 +1,4 @@ +# FAQ + +- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged). +- Q: How to enable? A: Start server with --addon test_website_modules or install in UI. diff --git a/odoo-bringout-oca-ocb-test_website_modules/doc/INSTALL.md b/odoo-bringout-oca-ocb-test_website_modules/doc/INSTALL.md new file mode 100644 index 0000000..bcd0d5b --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/doc/INSTALL.md @@ -0,0 +1,7 @@ +# Install + +```bash +pip install odoo-bringout-oca-ocb-test_website_modules" +# or +uv pip install odoo-bringout-oca-ocb-test_website_modules" +``` diff --git a/odoo-bringout-oca-ocb-test_website_modules/doc/MODELS.md b/odoo-bringout-oca-ocb-test_website_modules/doc/MODELS.md new file mode 100644 index 0000000..277949c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/doc/MODELS.md @@ -0,0 +1,11 @@ +# Models + +Detected core models and extensions in test_website_modules. + +```mermaid +classDiagram +``` + +Notes +- Classes show model technical names; fields omitted for brevity. +- Items listed under _inherit are extensions of existing models. diff --git a/odoo-bringout-oca-ocb-test_website_modules/doc/OVERVIEW.md b/odoo-bringout-oca-ocb-test_website_modules/doc/OVERVIEW.md new file mode 100644 index 0000000..c119a0a --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/doc/OVERVIEW.md @@ -0,0 +1,6 @@ +# Overview + +Packaged Odoo addon: test_website_modules. Provides features documented in upstream Odoo 16 under this addon. + +- Source: OCA/OCB 16.0, addon test_website_modules +- License: LGPL-3 diff --git a/odoo-bringout-oca-ocb-test_website_modules/doc/REPORTS.md b/odoo-bringout-oca-ocb-test_website_modules/doc/REPORTS.md new file mode 100644 index 0000000..e0ea35f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/doc/REPORTS.md @@ -0,0 +1,3 @@ +# Reports + +This module does not define custom reports. diff --git a/odoo-bringout-oca-ocb-test_website_modules/doc/SECURITY.md b/odoo-bringout-oca-ocb-test_website_modules/doc/SECURITY.md new file mode 100644 index 0000000..e07da9d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/doc/SECURITY.md @@ -0,0 +1,8 @@ +# Security + +This module does not define custom security rules or access controls beyond Odoo defaults. + +Default Odoo security applies: +- Base user access through standard groups +- Model access inherited from dependencies +- No custom row-level security rules diff --git a/odoo-bringout-oca-ocb-test_website_modules/doc/TROUBLESHOOTING.md b/odoo-bringout-oca-ocb-test_website_modules/doc/TROUBLESHOOTING.md new file mode 100644 index 0000000..56853cb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/doc/TROUBLESHOOTING.md @@ -0,0 +1,5 @@ +# Troubleshooting + +- Ensure Python and Odoo environment matches repo guidance. +- Check database connectivity and logs if startup fails. +- Validate that dependent addons listed in DEPENDENCIES.md are installed. diff --git a/odoo-bringout-oca-ocb-test_website_modules/doc/USAGE.md b/odoo-bringout-oca-ocb-test_website_modules/doc/USAGE.md new file mode 100644 index 0000000..4da1c40 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/doc/USAGE.md @@ -0,0 +1,7 @@ +# Usage + +Start Odoo including this addon (from repo root): + +```bash +python3 scripts/nix_odoo_web_server.py --db-name mydb --addon test_website_modules +``` diff --git a/odoo-bringout-oca-ocb-test_website_modules/doc/WIZARDS.md b/odoo-bringout-oca-ocb-test_website_modules/doc/WIZARDS.md new file mode 100644 index 0000000..48e790d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/doc/WIZARDS.md @@ -0,0 +1,3 @@ +# Wizards + +This module does not include UI wizards. diff --git a/odoo-bringout-oca-ocb-test_website_modules/pyproject.toml b/odoo-bringout-oca-ocb-test_website_modules/pyproject.toml new file mode 100644 index 0000000..31aa0a9 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "odoo-bringout-oca-ocb-test_website_modules" +version = "16.0.0" +description = "Website Modules Test - Odoo addon" +authors = [ + { name = "Ernad Husremovic", email = "hernad@bring.out.ba" } +] +dependencies = [ + "odoo-bringout-oca-ocb-theme_default>=16.0.0", + "odoo-bringout-oca-ocb-website>=16.0.0", + "odoo-bringout-oca-ocb-website_blog>=16.0.0", + "odoo-bringout-oca-ocb-website_event_sale>=16.0.0", + "odoo-bringout-oca-ocb-website_slides>=16.0.0", + "requests>=2.25.1" +] +readme = "README.md" +requires-python = ">= 3.11" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "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.12", + "Topic :: Office/Business", +] + +[project.urls] +homepage = "https://github.com/bringout/0" +repository = "https://github.com/bringout/0" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["test_website_modules"] + +[tool.rye] +managed = true +dev-dependencies = [ + "pytest>=8.4.1", +] diff --git a/odoo-bringout-oca-ocb-test_website_modules/test_website_modules/__init__.py b/odoo-bringout-oca-ocb-test_website_modules/test_website_modules/__init__.py new file mode 100644 index 0000000..67dee8c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/test_website_modules/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. diff --git a/odoo-bringout-oca-ocb-test_website_modules/test_website_modules/__manifest__.py b/odoo-bringout-oca-ocb-test_website_modules/test_website_modules/__manifest__.py new file mode 100644 index 0000000..d37d00f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/test_website_modules/__manifest__.py @@ -0,0 +1,26 @@ +# -*- encoding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Website Modules Test', + 'version': '1.0', + 'category': 'Hidden', + 'sequence': 9876, + 'description': """This module contains tests related to website modules. +It allows to test website business code when another website module is +installed.""", + 'depends': [ + 'theme_default', + 'website', + 'website_blog', + 'website_event_sale', + 'website_slides', + ], + 'installable': True, + 'assets': { + 'web.assets_tests': [ + 'test_website_modules/static/tests/**/*', + ], + }, + 'license': 'LGPL-3', +} diff --git a/odoo-bringout-oca-ocb-test_website_modules/test_website_modules/static/tests/tours/configurator_flow.js b/odoo-bringout-oca-ocb-test_website_modules/test_website_modules/static/tests/tours/configurator_flow.js new file mode 100644 index 0000000..7b9685f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/test_website_modules/static/tests/tours/configurator_flow.js @@ -0,0 +1,106 @@ +odoo.define('test_website_modules.tour.configurator_flow', function (require) { +'use strict'; + +const tour = require('web_tour.tour'); +const wTourUtils = require('website.tour_utils'); + +tour.register('configurator_flow', { + test: true, + url: '/web#action=website.action_website_configuration', +}, +[ + { + content: "click on create new website", + trigger: 'button[name="action_website_create_new"]', + }, { + content: "insert website name", + trigger: '[name="name"] input', + run: 'text Website Test', + }, { + content: "validate the website creation modal", + trigger: 'button.btn-primary', + }, + // Configurator first screen + { + content: "click next", + trigger: 'button.o_configurator_show', + timeout: 20000, /* previous step create a new website, this could take a long time */ + }, + // Description screen + { + content: "select a website type", + trigger: 'a.o_change_website_type', + }, { + content: "insert a website industry", + trigger: '.o_configurator_industry input', + run: 'text ab', + }, { + content: "select a website industry from the autocomplete", + trigger: '.o_configurator_industry ul li a', + }, { + content: "select an objective", + trigger: '.o_configurator_purpose_dd a', + }, { + content: "choose from the objective list", + trigger: 'a.o_change_website_purpose', + }, + // Palette screen + { + content: "chose a palette card", + trigger: '.palette_card', + }, + // Features screen + { + content: "select Pricing", + trigger: '.card:contains("Pricing")', + }, { + content: "Events should be selected (module already installed)", + extra_trigger: '.card.border-success:contains("Pricing")', + trigger: '.card.card_installed:contains("Events")', + run: function () {}, // it's a check + }, { + content: "Slides should be selected (module already installed)", + trigger: '.card.card_installed:contains("eLearning")', + run: function () {}, // it's a check + }, { + content: "Success Stories (Blog) and News (Blog) should be selected (module already installed)", + extra_trigger: '.card.card_installed:contains("Success Stories")', + trigger: '.card.card_installed:contains("News")', + run: function () {}, // it's a check + }, { + content: "Click on build my website", + trigger: 'button.btn-primary', + }, { + content: "Loader should be shown", + trigger: '.o_website_loader_container', + run: function () {}, // it's a check + }, { + content: "Wait untill the configurator is finished", + trigger: '#oe_snippets.o_loaded', + timeout: 30000, + }, + ...wTourUtils.clickOnSave(), + { + content: "check menu and footer links are correct", + trigger: 'body:not(.editor_enable)', // edit mode left + run: function () { + const $iframe = this.$anchor.find('iframe.o_iframe:not(.o_ignore_in_tour)'); + for (const menu of ['Home', 'Events', 'Courses', 'Pricing', 'News', 'Success Stories', 'Contact us']) { + if (!$iframe.contents().find(`#top_menu a:contains(${menu})`).length) { + console.error(`Missing ${menu} menu. It should have been created by the configurator.`); + } + } + for (const url of ['/', '/event', '/slides', '/pricing', '/blog/', '/blog/', '/contactus']) { + if (!$iframe.contents().find(`#top_menu a[href^='${url}']`).length) { + console.error(`Missing ${url} menu URL. It should have been created by the configurator.`); + } + } + for (const link of ['Privacy Policy', 'Contact us']) { + if (!$iframe.contents().find(`#footer ul a:contains(${link})`).length) { + console.error(`Missing ${link} footer link. It should have been created by the configurator.`); + } + } + }, + }, +]); +}); diff --git a/odoo-bringout-oca-ocb-test_website_modules/test_website_modules/tests/__init__.py b/odoo-bringout-oca-ocb-test_website_modules/test_website_modules/tests/__init__.py new file mode 100644 index 0000000..458e74d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/test_website_modules/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_configurator diff --git a/odoo-bringout-oca-ocb-test_website_modules/test_website_modules/tests/test_configurator.py b/odoo-bringout-oca-ocb-test_website_modules/test_website_modules/tests/test_configurator.py new file mode 100644 index 0000000..cf67a6b --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_modules/test_website_modules/tests/test_configurator.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import odoo.tests +from odoo.addons.website.tests.test_configurator import TestConfiguratorCommon + + +@odoo.tests.common.tagged('post_install', '-at_install') +class TestConfigurator(TestConfiguratorCommon): + + def test_01_configurator_flow(self): + # If not enabled (like in demo data), landing on res.config will try + # to disable module_sale_quotation_builder and raise an issue + group_order_template = self.env.ref('sale_management.group_sale_order_template', raise_if_not_found=False) + if group_order_template: + self.env.ref('base.group_user').write({"implied_ids": [(4, group_order_template.id)]}) + self.start_tour('/web#action=website.action_website_configuration', 'configurator_flow', login="admin") diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/README.md b/odoo-bringout-oca-ocb-test_website_slides_full/README.md new file mode 100644 index 0000000..e6033e6 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/README.md @@ -0,0 +1,54 @@ +# Test Full eLearning Flow + + +This module will test the main certification flow of Odoo. +It will install the e-learning, survey and e-commerce apps and make a complete +certification flow including purchase, certification, failure and success. + + +## Installation + +```bash +pip install odoo-bringout-oca-ocb-test_website_slides_full +``` + +## Dependencies + +This addon depends on: +- website_sale_product_configurator +- website_sale_slides +- website_slides_forum +- website_slides_survey +- payment_demo + +## Manifest Information + +- **Name**: Test Full eLearning Flow +- **Version**: 1.0 +- **Category**: Hidden/Tests +- **License**: LGPL-3 +- **Installable**: True + +## Source + +Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `test_website_slides_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 diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/doc/ARCHITECTURE.md b/odoo-bringout-oca-ocb-test_website_slides_full/doc/ARCHITECTURE.md new file mode 100644 index 0000000..0d5f185 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/doc/ARCHITECTURE.md @@ -0,0 +1,32 @@ +# Architecture + +```mermaid +flowchart TD + U[Users] -->|HTTP| V[Views and QWeb Templates] + V --> C[Controllers] + V --> W[Wizards – Transient Models] + C --> M[Models and ORM] + W --> M + M --> R[Reports] + DX[Data XML] --> M + S[Security – ACLs and Groups] -. enforces .-> M + + subgraph Test_website_slides_full Module - test_website_slides_full + direction LR + M:::layer + W:::layer + C:::layer + V:::layer + R:::layer + S:::layer + DX:::layer + end + + classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px +``` + +Notes +- Views include tree/form/kanban templates and report templates. +- Controllers provide website/portal routes when present. +- Wizards are UI flows implemented with `models.TransientModel`. +- Data XML loads data/demo records; Security defines groups and access. diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/doc/CONFIGURATION.md b/odoo-bringout-oca-ocb-test_website_slides_full/doc/CONFIGURATION.md new file mode 100644 index 0000000..feec678 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/doc/CONFIGURATION.md @@ -0,0 +1,3 @@ +# Configuration + +Refer to Odoo settings for test_website_slides_full. Configure related models, access rights, and options as needed. diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/doc/CONTROLLERS.md b/odoo-bringout-oca-ocb-test_website_slides_full/doc/CONTROLLERS.md new file mode 100644 index 0000000..f628e77 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/doc/CONTROLLERS.md @@ -0,0 +1,3 @@ +# Controllers + +This module does not define custom HTTP controllers. diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/doc/DEPENDENCIES.md b/odoo-bringout-oca-ocb-test_website_slides_full/doc/DEPENDENCIES.md new file mode 100644 index 0000000..c387f97 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/doc/DEPENDENCIES.md @@ -0,0 +1,9 @@ +# Dependencies + +This addon depends on: + +- [website_sale_product_configurator](../../odoo-bringout-oca-ocb-website_sale_product_configurator) +- [website_sale_slides](../../odoo-bringout-oca-ocb-website_sale_slides) +- [website_slides_forum](../../odoo-bringout-oca-ocb-website_slides_forum) +- [website_slides_survey](../../odoo-bringout-oca-ocb-website_slides_survey) +- [payment_demo](../../odoo-bringout-oca-ocb-payment_demo) diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/doc/FAQ.md b/odoo-bringout-oca-ocb-test_website_slides_full/doc/FAQ.md new file mode 100644 index 0000000..7a37aca --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/doc/FAQ.md @@ -0,0 +1,4 @@ +# FAQ + +- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged). +- Q: How to enable? A: Start server with --addon test_website_slides_full or install in UI. diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/doc/INSTALL.md b/odoo-bringout-oca-ocb-test_website_slides_full/doc/INSTALL.md new file mode 100644 index 0000000..0f41665 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/doc/INSTALL.md @@ -0,0 +1,7 @@ +# Install + +```bash +pip install odoo-bringout-oca-ocb-test_website_slides_full" +# or +uv pip install odoo-bringout-oca-ocb-test_website_slides_full" +``` diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/doc/MODELS.md b/odoo-bringout-oca-ocb-test_website_slides_full/doc/MODELS.md new file mode 100644 index 0000000..e826c9c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/doc/MODELS.md @@ -0,0 +1,11 @@ +# Models + +Detected core models and extensions in test_website_slides_full. + +```mermaid +classDiagram +``` + +Notes +- Classes show model technical names; fields omitted for brevity. +- Items listed under _inherit are extensions of existing models. diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/doc/OVERVIEW.md b/odoo-bringout-oca-ocb-test_website_slides_full/doc/OVERVIEW.md new file mode 100644 index 0000000..d8acb61 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/doc/OVERVIEW.md @@ -0,0 +1,6 @@ +# Overview + +Packaged Odoo addon: test_website_slides_full. Provides features documented in upstream Odoo 16 under this addon. + +- Source: OCA/OCB 16.0, addon test_website_slides_full +- License: LGPL-3 diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/doc/REPORTS.md b/odoo-bringout-oca-ocb-test_website_slides_full/doc/REPORTS.md new file mode 100644 index 0000000..e0ea35f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/doc/REPORTS.md @@ -0,0 +1,3 @@ +# Reports + +This module does not define custom reports. diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/doc/SECURITY.md b/odoo-bringout-oca-ocb-test_website_slides_full/doc/SECURITY.md new file mode 100644 index 0000000..e07da9d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/doc/SECURITY.md @@ -0,0 +1,8 @@ +# Security + +This module does not define custom security rules or access controls beyond Odoo defaults. + +Default Odoo security applies: +- Base user access through standard groups +- Model access inherited from dependencies +- No custom row-level security rules diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/doc/TROUBLESHOOTING.md b/odoo-bringout-oca-ocb-test_website_slides_full/doc/TROUBLESHOOTING.md new file mode 100644 index 0000000..56853cb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/doc/TROUBLESHOOTING.md @@ -0,0 +1,5 @@ +# Troubleshooting + +- Ensure Python and Odoo environment matches repo guidance. +- Check database connectivity and logs if startup fails. +- Validate that dependent addons listed in DEPENDENCIES.md are installed. diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/doc/USAGE.md b/odoo-bringout-oca-ocb-test_website_slides_full/doc/USAGE.md new file mode 100644 index 0000000..3fd25d9 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/doc/USAGE.md @@ -0,0 +1,7 @@ +# Usage + +Start Odoo including this addon (from repo root): + +```bash +python3 scripts/nix_odoo_web_server.py --db-name mydb --addon test_website_slides_full +``` diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/doc/WIZARDS.md b/odoo-bringout-oca-ocb-test_website_slides_full/doc/WIZARDS.md new file mode 100644 index 0000000..48e790d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/doc/WIZARDS.md @@ -0,0 +1,3 @@ +# Wizards + +This module does not include UI wizards. diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/pyproject.toml b/odoo-bringout-oca-ocb-test_website_slides_full/pyproject.toml new file mode 100644 index 0000000..de07467 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "odoo-bringout-oca-ocb-test_website_slides_full" +version = "16.0.0" +description = "Test Full eLearning Flow - Odoo addon" +authors = [ + { name = "Ernad Husremovic", email = "hernad@bring.out.ba" } +] +dependencies = [ + "odoo-bringout-oca-ocb-website_sale_product_configurator>=16.0.0", + "odoo-bringout-oca-ocb-website_sale_slides>=16.0.0", + "odoo-bringout-oca-ocb-website_slides_forum>=16.0.0", + "odoo-bringout-oca-ocb-website_slides_survey>=16.0.0", + "odoo-bringout-oca-ocb-payment_demo>=16.0.0", + "requests>=2.25.1" +] +readme = "README.md" +requires-python = ">= 3.11" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "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.12", + "Topic :: Office/Business", +] + +[project.urls] +homepage = "https://github.com/bringout/0" +repository = "https://github.com/bringout/0" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["test_website_slides_full"] + +[tool.rye] +managed = true +dev-dependencies = [ + "pytest>=8.4.1", +] diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/__init__.py b/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/__init__.py new file mode 100644 index 0000000..67dee8c --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/__manifest__.py b/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/__manifest__.py new file mode 100644 index 0000000..26acca5 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/__manifest__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': 'Test Full eLearning Flow', + 'version': '1.0', + 'category': 'Hidden/Tests', + 'description': """ +This module will test the main certification flow of Odoo. +It will install the e-learning, survey and e-commerce apps and make a complete +certification flow including purchase, certification, failure and success. +""", + 'depends': [ + 'website_sale_product_configurator', + 'website_sale_slides', + 'website_slides_forum', + 'website_slides_survey', + 'payment_demo' + ], + 'data': [ + 'data/res_groups_data.xml', + ], + 'demo': [ + 'data/product_demo.xml', + ], + 'installable': True, + 'assets': { + 'web.assets_tests': [ + 'test_website_slides_full/tests/tours/**/*', + ], + }, + 'license': 'LGPL-3', +} diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/data/product_demo.xml b/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/data/product_demo.xml new file mode 100644 index 0000000..e978872 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/data/product_demo.xml @@ -0,0 +1,27 @@ + + + + Water can + 12.0 + 12.0 + consu + + + + + + Flower pot + 4.5 + 4.5 + consu + + + + + + + + diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/data/res_groups_data.xml b/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/data/res_groups_data.xml new file mode 100644 index 0000000..75be4f7 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/data/res_groups_data.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/tests/__init__.py b/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/tests/__init__.py new file mode 100644 index 0000000..5e6934d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_ui_wslides diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/tests/test_ui_wslides.py b/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/tests/test_ui_wslides.py new file mode 100644 index 0000000..95422d5 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/tests/test_ui_wslides.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from dateutil.relativedelta import relativedelta +from odoo.fields import Datetime +from odoo import tests +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.addons.website_slides.tests.test_ui_wslides import TestUICommon + + +@tests.common.tagged('post_install', '-at_install') +class TestUi(AccountTestInvoicingCommon, TestUICommon): + + def test_course_certification_employee(self): + user_demo = self.user_demo + self.user_demo.write({ + 'company_id': self.env.company.id, + 'company_ids': [(6, 0, self.env.company.ids)], + }) + self.user_demo.sudo().partner_id.company_id = self.env.company + # Avoid Billing/Shipping address page + user_demo.write({ + 'groups_id': [(5, 0), (4, self.env.ref('base.group_user').id)], + 'street': '215 Vine St', + 'city': 'Scranton', + 'zip': '18503', + 'country_id': self.env.ref('base.us').id, + 'state_id': self.env.ref('base.state_us_39').id, + 'phone': '+1 555-555-5555', + 'email': 'admin@yourcompany.example.com', + }) + + # Specify Accounting Data + cash_journal = self.env['account.journal'].create({'name': 'Cash - Test', 'type': 'cash', 'code': 'CASH - Test'}) + self.env['payment.provider'].sudo().search([('code', '=', 'demo')]).write({ + 'journal_id': cash_journal.id, + 'state': 'test', + 'website_id': self.env.ref('website.default_website').id, + 'company_id': self.env.company.id, + }) + self.env.ref('website.default_website').company_id = self.env.company + a_recv = self.env['account.account'].create({ + 'code': 'X1012', + 'name': 'Debtors - (test)', + 'reconcile': True, + 'account_type': 'asset_receivable', + }) + a_pay = self.env['account.account'].create({ + 'code': 'X1111', + 'name': 'Creditors - (test)', + 'account_type': 'liability_payable', + 'reconcile': True, + }) + + Property = self.env['ir.property'] + Property._set_default('property_account_receivable_id', 'res.partner', a_recv, self.env.company) + Property._set_default('property_account_payable_id', 'res.partner', a_pay, self.env.company) + + product_course_channel_6 = self.env['product.product'].create({ + 'name': 'DIY Furniture Course', + 'list_price': 100.0, + 'type': 'service', + 'is_published': True, + }) + + furniture_survey = self.env['survey.survey'].create({ + 'title': 'Furniture Creation Certification', + 'access_token': '5632a4d7-48cf-aaaa-8c52-2174d58cf50b', + 'access_mode': 'public', + 'questions_layout': 'one_page', + 'users_can_go_back': True, + 'users_login_required': True, + 'scoring_type': 'scoring_with_answers', + 'certification': True, + 'certification_mail_template_id': self.env.ref('survey.mail_template_certification').id, + 'is_attempts_limited': True, + 'attempts_limit': 3, + 'description': "

Test your furniture knowledge!

", + 'question_and_page_ids': [ + (0, 0, { + 'title': 'Furniture', + 'sequence': 1, + 'is_page': True, + 'question_type': False, + 'description': "<p>Test your furniture knowledge!</p>", + }), (0, 0, { + 'title': 'What type of wood is the best for furniture?', + 'sequence': 2, + 'question_type': 'simple_choice', + 'constr_mandatory': True, + 'suggested_answer_ids': [ + (0, 0, { + 'value': 'Fir', + 'sequence': 1, + }), (0, 0, { + 'value': 'Oak', + 'sequence': 2, + 'is_correct': True, + 'answer_score': 2.0, + }), (0, 0, { + 'value': 'Ash', + 'sequence': 3, + }), (0, 0, { + 'value': 'Beech', + 'sequence': 4, + }) + ] + }), (0, 0, { + 'title': 'Select all the furniture shown in the video', + 'sequence': 3, + 'question_type': 'multiple_choice', + 'suggested_answer_ids': [ + (0, 0, { + 'value': 'Chair', + 'sequence': 1, + 'is_correct': True, + 'answer_score': 1.0, + }), (0, 0, { + 'value': 'Table', + 'sequence': 2, + 'answer_score': -1.0, + }), (0, 0, { + 'value': 'Desk', + 'sequence': 3, + 'is_correct': True, + 'answer_score': 1.0, + }), (0, 0, { + 'value': 'Shelve', + 'sequence': 4, + 'is_correct': True, + 'answer_score': 1.0, + }), (0, 0, { + 'value': 'Bed', + 'sequence': 5, + 'answer_score': -1.0, + }) + ] + }), (0, 0, { + 'title': 'What do you think about the content of the course? (not rated)', + 'sequence': 4, + 'question_type': 'text_box', + }) + ] + }) + + slide_channel_demo_6_furn3 = self.env['slide.channel'].create({ + 'name': 'DIY Furniture - TEST', + 'user_id': self.env.ref('base.user_admin').id, + 'enroll': 'payment', + 'product_id': product_course_channel_6.id, + 'channel_type': 'training', + 'allow_comment': True, + 'promote_strategy': 'most_voted', + 'is_published': True, + 'description': 'So much amazing certification.', + 'create_date': Datetime.now() - relativedelta(days=2), + 'slide_ids': [ + (0, 0, { + 'name': 'DIY Furniture Certification', + 'sequence': 1, + 'slide_category': 'certification', + 'category_id': False, + 'is_published': True, + 'is_preview': False, + 'description': "It's time to test your knowledge!", + 'survey_id': furniture_survey.id, + }) + ] + }) + + self.browser_js( + '/slides', + 'odoo.__DEBUG__.services["web_tour.tour"].run("certification_member")', + 'odoo.__DEBUG__.services["web_tour.tour"].tours.certification_member.ready', + login=user_demo.login) diff --git a/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/tests/tours/slides_certification_member.js b/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/tests/tours/slides_certification_member.js new file mode 100644 index 0000000..5c7de4a --- /dev/null +++ b/odoo-bringout-oca-ocb-test_website_slides_full/test_website_slides_full/tests/tours/slides_certification_member.js @@ -0,0 +1,176 @@ +odoo.define('test_website_slides_full.tour.slide.certification.member', function (require) { +"use strict"; + +var tour = require('web_tour.tour'); +const tourUtils = require('website_sale.tour_utils'); + +/** + * The purpose of this tour is to check the whole certification flow: + * + * -> student (= demo user) checks 'on payment' course content + * -> clicks on "buy course" + * -> is redirected to webshop on the product page + * -> buys the course + * -> fails 3 times, exhausting their attempts + * -> is removed to the members of the course + * -> buys the course again + * -> succeeds the certification + * -> has the course marked as completed + * -> has the certification in their user profile + * + */ + +var initTourSteps = [{ + content: 'eLearning: go to certification course', + trigger: 'a:contains("DIY Furniture - TEST")' +}, { + content: 'eLearning: does not have access to certification', + trigger: '.o_wslides_course_main', + run: function () { + // check that user doesn't have access to course content + if ($('.o_wslides_slides_list_slide .o_wslides_js_slides_list_slide_link').length === 0) { + $('.o_wslides_course_main').addClass('empty-content-success'); + } + } +}, { + content: 'eLearning: previous step check', + trigger: '.o_wslides_course_main.empty-content-success', + run: function () {} // check that previous step succeeded +}]; + +var buyCertificationSteps = [{ + content: 'eLearning: try to buy course', + trigger: 'a:contains("Add to Cart")' +}, + tourUtils.goToCart(), +{ + content: 'eCommerce: Process Checkout', + trigger: 'a:contains("Process Checkout")' +}, { + content: 'eCommerce: select Test payment provider', + trigger: '.o_payment_option_card:contains("Demo")' +}, { + content: 'eCommerce: add card number', + trigger: 'input[name="customer_input"]', + run: 'text 4242424242424242' +}, { + content: 'eCommerce: pay', + trigger: 'button[name="o_payment_submit_button"]' +}, { + content: 'eCommerce: check that the payment is successful', + trigger: '.oe_website_sale_tx_status:contains("Your payment has been successfully processed.")', + run: function () {} +}, { + content: 'eCommerce: go back to e-learning home page', + trigger: '.nav-link:contains("Courses")' +}, { + content: 'eLearning: go into bought course', + trigger: 'a:contains("DIY Furniture")' +}, { + content: 'eLearning: user should be enrolled', + trigger: '.o_wslides_js_course_join:contains("You\'re enrolled")', + run: function () {} +}, { + content: 'eLearning: start course', + trigger: '.o_wslides_js_slides_list_slide_link' +}]; + +var failCertificationSteps = [{ + content: 'eLearning: start certification', + trigger: 'button:contains("Start Certification")' +}, { // Question: What type of wood is the best for furniture? + content: 'Survey: selecting answer "Fir"', + trigger: 'div.js_question-wrapper:contains("What type of wood is the best for furniture") label:contains("Fir")' +}, { // Question: Select all the furniture shown in the video + content: 'Survey: ticking answer "Table"', + trigger: 'div.js_question-wrapper:contains("Select all the furniture shown in the video") label:contains("Table")' +}, { + content: 'Survey: ticking answer "Bed"', + trigger: 'div.js_question-wrapper:contains("Select all the furniture shown in the video") label:contains("Bed")' +}, { + content: 'Survey: submitting the certification with wrong answers', + trigger: 'button:contains("Submit")' +}]; + +var retrySteps = [{ + content: 'Survey: retry certification', + trigger: 'a:contains("Retry")' +}]; + +var succeedCertificationSteps = [{ + content: 'eLearning: start certification', + trigger: 'button:contains("Start Certification")' +}, { // Question: What type of wood is the best for furniture? + content: 'Survey: selecting answer "Oak"', + trigger: 'div.js_question-wrapper:contains("What type of wood is the best for furniture") label:contains("Oak")', +}, { // Question: Select all the furniture shown in the video + content: 'Survey: ticking answer "Chair"', + trigger: 'div.js_question-wrapper:contains("Select all the furniture shown in the video") label:contains("Chair")' +}, { + content: 'Survey: ticking answer "Shelve"', + trigger: 'div.js_question-wrapper:contains("Select all the furniture shown in the video") label:contains("Shelve")' +}, { + content: 'Survey: ticking answer "Desk"', + trigger: 'div.js_question-wrapper:contains("Select all the furniture shown in the video") label:contains("Desk")' +}, { + content: 'Survey: submitting the certification with correct answers', + trigger: 'button:contains("Submit")' +}]; + +var certificationCompletionSteps = [{ + content: 'Survey: check certification successful', + trigger: 'div:contains("Congratulations, you have passed the test")', + run: function () {} +}, { // Sharing the certification + trigger: 'a:contains("Share your certification")' +}, { + trigger: '.oe_slide_js_share_email input', + run: 'text friend@example.com' +}, { + trigger: '.oe_slide_js_share_email button', +}, { + trigger: '.oe_slide_js_share_email .alert:not(.d-none):contains("Sharing is caring")', + run: function () {} // check email has been sent +}, { + trigger: 'button.btn-close', // close sharing modal +}, { + content: 'Survey: back to course home page', + trigger: 'a:contains("Go back to course")' +}, { + content: 'eLearning: back to e-learning home page', + trigger: '.nav-link:contains("Courses")' +}, { + content: 'eLearning: course should be completed', + trigger: '.o_wslides_course_card:contains("DIY Furniture") .rounded-pill:contains("Completed")', + run: function () {} +}]; + +var profileSteps = [{ + content: 'eLearning: access user profile', + trigger: '.o_wslides_home_aside_loggedin a:contains("View")' +}, { + content: 'eLearning: check that the user profile certifications include the new certification', + trigger: '.o_wprofile_slides_course_card_body:contains("Furniture Creation Certification")', + run: function () {} +}]; + +tour.register('certification_member', { + url: '/slides', + test: true +}, [].concat( + initTourSteps, + buyCertificationSteps, + failCertificationSteps, + retrySteps, + failCertificationSteps, + retrySteps, + failCertificationSteps, + [{trigger: 'a:contains("Go back to course")'}], + buyCertificationSteps, + succeedCertificationSteps, + certificationCompletionSteps, + profileSteps + ) +); + +}); diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/README.md b/odoo-bringout-oca-ocb-test_xlsx_export/README.md new file mode 100644 index 0000000..8e5e9cf --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/README.md @@ -0,0 +1,47 @@ +# test xlsx export + +A module to test xlsx export. + +## Installation + +```bash +pip install odoo-bringout-oca-ocb-test_xlsx_export +``` + +## Dependencies + +This addon depends on: +- web +- test_mail + +## Manifest Information + +- **Name**: test xlsx export +- **Version**: 0.1 +- **Category**: Hidden/Tests +- **License**: LGPL-3 +- **Installable**: True + +## Source + +Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `test_xlsx_export`. + +## 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 diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/doc/ARCHITECTURE.md b/odoo-bringout-oca-ocb-test_xlsx_export/doc/ARCHITECTURE.md new file mode 100644 index 0000000..114a41f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/doc/ARCHITECTURE.md @@ -0,0 +1,32 @@ +# Architecture + +```mermaid +flowchart TD + U[Users] -->|HTTP| V[Views and QWeb Templates] + V --> C[Controllers] + V --> W[Wizards – Transient Models] + C --> M[Models and ORM] + W --> M + M --> R[Reports] + DX[Data XML] --> M + S[Security – ACLs and Groups] -. enforces .-> M + + subgraph Test_xlsx_export Module - test_xlsx_export + direction LR + M:::layer + W:::layer + C:::layer + V:::layer + R:::layer + S:::layer + DX:::layer + end + + classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px +``` + +Notes +- Views include tree/form/kanban templates and report templates. +- Controllers provide website/portal routes when present. +- Wizards are UI flows implemented with `models.TransientModel`. +- Data XML loads data/demo records; Security defines groups and access. diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/doc/CONFIGURATION.md b/odoo-bringout-oca-ocb-test_xlsx_export/doc/CONFIGURATION.md new file mode 100644 index 0000000..623d608 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/doc/CONFIGURATION.md @@ -0,0 +1,3 @@ +# Configuration + +Refer to Odoo settings for test_xlsx_export. Configure related models, access rights, and options as needed. diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/doc/CONTROLLERS.md b/odoo-bringout-oca-ocb-test_xlsx_export/doc/CONTROLLERS.md new file mode 100644 index 0000000..f628e77 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/doc/CONTROLLERS.md @@ -0,0 +1,3 @@ +# Controllers + +This module does not define custom HTTP controllers. diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/doc/DEPENDENCIES.md b/odoo-bringout-oca-ocb-test_xlsx_export/doc/DEPENDENCIES.md new file mode 100644 index 0000000..5367e91 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/doc/DEPENDENCIES.md @@ -0,0 +1,6 @@ +# Dependencies + +This addon depends on: + +- [web](../../odoo-bringout-oca-ocb-web) +- [test_mail](../../odoo-bringout-oca-ocb-test_mail) diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/doc/FAQ.md b/odoo-bringout-oca-ocb-test_xlsx_export/doc/FAQ.md new file mode 100644 index 0000000..539b712 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/doc/FAQ.md @@ -0,0 +1,4 @@ +# FAQ + +- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged). +- Q: How to enable? A: Start server with --addon test_xlsx_export or install in UI. diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/doc/INSTALL.md b/odoo-bringout-oca-ocb-test_xlsx_export/doc/INSTALL.md new file mode 100644 index 0000000..ec49608 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/doc/INSTALL.md @@ -0,0 +1,7 @@ +# Install + +```bash +pip install odoo-bringout-oca-ocb-test_xlsx_export" +# or +uv pip install odoo-bringout-oca-ocb-test_xlsx_export" +``` diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/doc/MODELS.md b/odoo-bringout-oca-ocb-test_xlsx_export/doc/MODELS.md new file mode 100644 index 0000000..c4f5534 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/doc/MODELS.md @@ -0,0 +1,11 @@ +# Models + +Detected core models and extensions in test_xlsx_export. + +```mermaid +classDiagram +``` + +Notes +- Classes show model technical names; fields omitted for brevity. +- Items listed under _inherit are extensions of existing models. diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/doc/OVERVIEW.md b/odoo-bringout-oca-ocb-test_xlsx_export/doc/OVERVIEW.md new file mode 100644 index 0000000..aec7fa9 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/doc/OVERVIEW.md @@ -0,0 +1,6 @@ +# Overview + +Packaged Odoo addon: test_xlsx_export. Provides features documented in upstream Odoo 16 under this addon. + +- Source: OCA/OCB 16.0, addon test_xlsx_export +- License: LGPL-3 diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/doc/REPORTS.md b/odoo-bringout-oca-ocb-test_xlsx_export/doc/REPORTS.md new file mode 100644 index 0000000..e0ea35f --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/doc/REPORTS.md @@ -0,0 +1,3 @@ +# Reports + +This module does not define custom reports. diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/doc/SECURITY.md b/odoo-bringout-oca-ocb-test_xlsx_export/doc/SECURITY.md new file mode 100644 index 0000000..e07da9d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/doc/SECURITY.md @@ -0,0 +1,8 @@ +# Security + +This module does not define custom security rules or access controls beyond Odoo defaults. + +Default Odoo security applies: +- Base user access through standard groups +- Model access inherited from dependencies +- No custom row-level security rules diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/doc/TROUBLESHOOTING.md b/odoo-bringout-oca-ocb-test_xlsx_export/doc/TROUBLESHOOTING.md new file mode 100644 index 0000000..56853cb --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/doc/TROUBLESHOOTING.md @@ -0,0 +1,5 @@ +# Troubleshooting + +- Ensure Python and Odoo environment matches repo guidance. +- Check database connectivity and logs if startup fails. +- Validate that dependent addons listed in DEPENDENCIES.md are installed. diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/doc/USAGE.md b/odoo-bringout-oca-ocb-test_xlsx_export/doc/USAGE.md new file mode 100644 index 0000000..8043ce6 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/doc/USAGE.md @@ -0,0 +1,7 @@ +# Usage + +Start Odoo including this addon (from repo root): + +```bash +python3 scripts/nix_odoo_web_server.py --db-name mydb --addon test_xlsx_export +``` diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/doc/WIZARDS.md b/odoo-bringout-oca-ocb-test_xlsx_export/doc/WIZARDS.md new file mode 100644 index 0000000..48e790d --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/doc/WIZARDS.md @@ -0,0 +1,3 @@ +# Wizards + +This module does not include UI wizards. diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/pyproject.toml b/odoo-bringout-oca-ocb-test_xlsx_export/pyproject.toml new file mode 100644 index 0000000..23cb055 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "odoo-bringout-oca-ocb-test_xlsx_export" +version = "16.0.0" +description = "test xlsx export - Odoo addon" +authors = [ + { name = "Ernad Husremovic", email = "hernad@bring.out.ba" } +] +dependencies = [ + "odoo-bringout-oca-ocb-web>=16.0.0", + "odoo-bringout-oca-ocb-test_mail>=16.0.0", + "requests>=2.25.1" +] +readme = "README.md" +requires-python = ">= 3.11" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "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.12", + "Topic :: Office/Business", +] + +[project.urls] +homepage = "https://github.com/bringout/0" +repository = "https://github.com/bringout/0" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["test_xlsx_export"] + +[tool.rye] +managed = true +dev-dependencies = [ + "pytest>=8.4.1", +] diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/__init__.py b/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/__manifest__.py b/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/__manifest__.py new file mode 100644 index 0000000..323d099 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/__manifest__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': 'test xlsx export', + 'version': '0.1', + 'category': 'Hidden/Tests', + 'description': """A module to test xlsx export.""", + 'depends': ['web', 'test_mail'], + 'data': ['ir.model.access.csv'], + 'installable': True, + 'license': 'LGPL-3', +} diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/ir.model.access.csv b/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/ir.model.access.csv new file mode 100644 index 0000000..cb43b22 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/ir.model.access.csv @@ -0,0 +1,4 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_export_group_operator,access_export_group_operator,model_export_group_operator,,1,1,1,1 +access_export_group_operator_one2many,access_export_group_operator_one2many,model_export_group_operator_one2many,,1,1,1,1 +access_export_integer,access_export_integer,model_export_integer,,1,1,1,1 diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/models.py b/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/models.py new file mode 100644 index 0000000..2cc3879 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/models.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + +class NewModel(models.Model): + _name = 'export.integer' + _description = 'Export: Integer' + + value = fields.Integer(default=4) + + def name_get(self): + return [(record.id, "%s:%s" % (self._name, record.value)) for record in self] + +class GroupOperator(models.Model): + _name = 'export.group_operator' + _description = 'Export Group Operator' + + int_sum = fields.Integer(group_operator='sum') + int_max = fields.Integer(group_operator='max') + float_min = fields.Float(group_operator='min') + float_avg = fields.Float(group_operator='avg') + float_monetary = fields.Monetary(currency_field='currency_id', group_operator='sum') + currency_id = fields.Many2one('res.currency') + date_max = fields.Date(group_operator='max') + bool_and = fields.Boolean(group_operator='bool_and') + bool_or = fields.Boolean(group_operator='bool_or') + many2one = fields.Many2one('export.integer') + one2many = fields.One2many('export.group_operator.one2many', 'parent_id') + active = fields.Boolean(default=True) + +class GroupOperatorO2M(models.Model): + _name = 'export.group_operator.one2many' + _description = 'Export Group Operator One2Many' + + parent_id = fields.Many2one('export.group_operator') + value = fields.Integer() diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/tests/__init__.py b/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/tests/__init__.py new file mode 100644 index 0000000..0621413 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import test_export diff --git a/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/tests/test_export.py b/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/tests/test_export.py new file mode 100644 index 0000000..a709743 --- /dev/null +++ b/odoo-bringout-oca-ocb-test_xlsx_export/test_xlsx_export/tests/test_export.py @@ -0,0 +1,423 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +from datetime import date +from unittest.mock import patch + +from odoo import http +from odoo.tests import common, tagged +from odoo.tools.misc import get_lang +from odoo.addons.web.controllers.export import ExportXlsxWriter, Export +from odoo.addons.mail.tests.common import mail_new_test_user + + +class XlsxCreatorCase(common.HttpCase): + model_name = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.model = None + + def setUp(self): + super().setUp() + self.model = self.env[self.model_name] + + mail_new_test_user(self.env, login='fof', password='123456789', groups='base.group_user,base.group_allow_export') + self.session = self.authenticate('fof', '123456789') + + self.worksheet = {} # mock worksheet + + self.default_params = { + 'domain': [], + 'fields': [{'name': field.name, 'label': field.string} for field in self.model._fields.values()], + 'groupby': [], + 'ids': False, + 'import_compat': False, + 'model': self.model._name, + } + + def _mock_write(self, row, column, value, style=None): + if isinstance(value, float): + decimal_places = style.num_format[::-1].find('.') + style_format = "{:." + str(decimal_places) + "f}" + self.worksheet[row, column] = style_format.format(value) + else: + self.worksheet[row, column] = str(value) + + def make(self, values, context=None): + return self.model.with_context(**(context or {})).create(values) + + def export(self, values, fields=[], params={}, context=None): + self.worksheet = {} + self.make(values, context=context) + + if fields and 'fields' not in params: + params['fields'] = [{ + 'name': self.model._fields[f].name, + 'label': self.model._fields[f].string, + 'type': self.model._fields[f].type, + } for f in fields] + + with patch.object(ExportXlsxWriter, 'write', self._mock_write): + self.url_open('/web/export/xlsx', data={ + 'data': json.dumps(dict(self.default_params, **params)), + 'csrf_token': http.Request.csrf_token(self), + }) + return self.worksheet + + def assertExportEqual(self, value, expected): + for row in range(len(expected)): + for column in range(len(expected[row])): + cell_value = value.pop((row, column), '') + expected_value = expected[row][column] + self.assertEqual(cell_value, expected_value, "Cell %s, %s have a wrong value" % (row, column)) + self.assertFalse(value, "There are unexpected cells in the export") + + +@tagged('-at_install', 'post_install') +class TestGroupedExport(XlsxCreatorCase): + model_name = 'export.group_operator' + # pylint: disable=bad-whitespace + + def test_archived_groupped(self): + """ The decimal separator of the language used shouldn't impact the float representation in the exported xlsx """ + get_lang(self.env).decimal_point = ',' + get_lang(self.env).thousands_sep = '.' + + values = [ + {'int_sum': 1, 'active': False}, + ] + export = self.export(values, fields=['int_sum', 'active'], params={'groupby': ['int_sum']}) + + self.assertExportEqual(export, [ + ['Int Sum', 'Active'], + ['1 (1)', ''], + ]) + + def test_int_sum_max(self): + values = [ + {'int_sum': 10, 'int_max': 20}, + {'int_sum': 10, 'int_max': 50}, + {'int_sum': 20,'int_max': 30}, + ] + export = self.export(values, fields=['int_sum', 'int_max'], params={'groupby': ['int_sum', 'int_max']}) + self.assertExportEqual(export, [ + ['Int Sum' ,'Int Max'], + ['10 (2)' ,'50'], + [' 20 (1)' ,'20'], + ['10' ,'20'], + [' 50 (1)' ,'50'], + ['10' ,'50'], + ['20 (1)' ,'30'], + [' 30 (1)' ,'30'], + ['20' ,'30'], + ]) + + export = self.export([], fields=['int_max', 'int_sum'], params={'groupby': ['int_sum', 'int_max']}) + + self.assertExportEqual(export, [ + ['Int Max' ,'Int Sum'], + ['10 (2)' ,'20'], + [' 20 (1)' ,'10'], + ['20' ,'10'], + [' 50 (1)' ,'10'], + ['50' ,'10'], + ['20 (1)' ,'20'], + [' 30 (1)' ,'20'], + ['30' ,'20'], + ]) + + def test_float_min(self): + values = [ + {'int_sum': 10, 'float_min': 111.0}, + {'int_sum': 10, 'float_min': 222.0}, + {'int_sum': 20, 'float_min': 333.0}, + ] + export = self.export(values, fields=['int_sum', 'float_min'], params={'groupby': ['int_sum', 'float_min']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Float Min'], + ['10 (2)' ,'111.00'], + [' 111.0 (1)','111.00'], + ['10' ,'111.00'], + [' 222.0 (1)','222.00'], + ['10' ,'222.00'], + ['20 (1)' ,'333.00'], + [' 333.0 (1)','333.00'], + ['20' ,'333.00'], + ]) + + def test_float_avg(self): + values = [ + {'int_sum': 10, 'float_avg': 100.0}, + {'int_sum': 10, 'float_avg': 200.0}, + {'int_sum': 20, 'float_avg': 300.0}, + ] + export = self.export(values, fields=['int_sum', 'float_avg'], params={'groupby': ['int_sum', 'float_avg']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Float Avg'], + ['10 (2)' ,'150.00'], + [' 100.0 (1)','100.00'], + ['10' ,'100.00'], + [' 200.0 (1)','200.00'], + ['10' ,'200.00'], + ['20 (1)' ,'300.00'], + [' 300.0 (1)','300.00'], + ['20' ,'300.00'], + ]) + + def test_float_avg_nested(self): + """ With more than one nested level (avg aggregation) """ + values = [ + {'int_sum': 10, 'int_max': 30, 'float_avg': 100.0}, + {'int_sum': 10, 'int_max': 30, 'float_avg': 200.0}, + {'int_sum': 10, 'int_max': 20, 'float_avg': 600.0}, + ] + export = self.export(values, fields=['int_sum', 'float_avg'], params={'groupby': ['int_sum', 'int_max', 'float_avg']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Float Avg'], + ['10 (3)' ,'300.00'], + [' 20 (1)' ,'600.00'], + [' 600.0 (1)','600.00'], + ['10' ,'600.00'], + [' 30 (2)' ,'150.00'], + [' 100.0 (1)','100.00'], + ['10' ,'100.00'], + [' 200.0 (1)','200.00'], + ['10' ,'200.00'], + ]) + + def test_float_avg_nested_no_value(self): + """ With more than one nested level (avg aggregation is done on 0, not False) """ + values = [ + {'int_sum': 10, 'int_max': 20, 'float_avg': False}, + {'int_sum': 10, 'int_max': 30, 'float_avg': False}, + {'int_sum': 10, 'int_max': 30, 'float_avg': False}, + ] + export = self.export(values, fields=['int_sum', 'float_avg'], params={'groupby': ['int_sum', 'int_max', 'float_avg']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Float Avg'], + ['10 (3)' ,'0.00'], + [' 20 (1)' ,'0.00'], + [' Undefined (1)','0.00'], + ['10' ,'0.00'], + [' 30 (2)' ,'0.00'], + [' Undefined (2)','0.00'], + ['10' ,'0.00'], + ['10' ,'0.00'], + ]) + + def test_date_max(self): + values = [ + {'int_sum': 10, 'date_max': date(2019, 1, 1)}, + {'int_sum': 10, 'date_max': date(2000, 1, 1)}, + {'int_sum': 20, 'date_max': date(1980, 1, 1)}, + ] + export = self.export(values, fields=['int_sum', 'date_max'], params={'groupby': ['int_sum', 'date_max:month']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Date Max'], + ['10 (2)' ,'2019-01-01'], + [' January 2000 (1)' ,'2000-01-01'], + ['10' ,'2000-01-01'], + [' January 2019 (1)' ,'2019-01-01'], + ['10' ,'2019-01-01'], + ['20 (1)' ,'1980-01-01'], + [' January 1980 (1)' ,'1980-01-01'], + ['20' ,'1980-01-01'], + ]) + + def test_bool_and(self): + values = [ + {'int_sum': 10, 'bool_and': True}, + {'int_sum': 10, 'bool_and': True}, + {'int_sum': 20, 'bool_and': True}, + {'int_sum': 20, 'bool_and': False}, + ] + export = self.export(values, fields=['int_sum', 'bool_and'], params={'groupby': ['int_sum', 'bool_and']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Bool And'], + ['10 (2)' ,'True'], + [' True (2)' ,'True'], + ['10' ,'True'], + ['10' ,'True'], + ['20 (2)' ,'False'], + [' False (1)' ,'False'], + ['20' ,'False'], + [' True (1)' ,'True'], + ['20' ,'True'], + ]) + + def test_bool_or(self): + values = [ + {'int_sum': 10, 'bool_or': True}, + {'int_sum': 10, 'bool_or': False}, + {'int_sum': 20, 'bool_or': False}, + {'int_sum': 20, 'bool_or': False}, + ] + export = self.export(values, fields=['int_sum', 'bool_or'], params={'groupby': ['int_sum', 'bool_or']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Bool Or'], + ['10 (2)' ,'True'], + [' False (1)' ,'False'], + ['10' ,'False'], + [' True (1)' ,'True'], + ['10' ,'True'], + ['20 (2)' ,'False'], + [' False (2)' ,'False'], + ['20' ,'False'], + ['20' ,'False'], + ]) + + def test_many2one(self): + values = [ + {'int_sum': 10, 'many2one': self.env['export.integer'].create({}).id}, + {'int_sum': 10}, + ] + export = self.export(values, fields=['int_sum', 'many2one'], params={'groupby': ['int_sum', 'many2one']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Many2One'], + ['10 (2)' ,''], + [' export.integer:4 (1)' ,''], + ['10' ,'export.integer:4'], + [' Undefined (1)' ,''], + ['10' ,''], + ]) + + def test_nested_records(self): + """ + aggregated values currently not supported for nested record export, but it should not crash + e.g. export 'many2one/const' + """ + values = [{'int_sum': 10, + 'date_max': date(2019, 1, 1), + 'many2one': self.env['export.integer'].create({}).id, + }, { + 'int_sum': 10, + 'date_max': date(2000, 1, 1), + 'many2one': self.env['export.integer'].create({}).id, + },] + export = self.export(values, + params={ + 'groupby': ['int_sum', 'date_max:month'], + 'fields': [ + {'name': 'int_sum', 'label': 'Int Sum'}, + {'name': 'date_max', 'label': 'Date Max'}, + {'name': 'many2one/value', 'label': 'Many2One/Value'}, + ] + }) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Date Max' ,'Many2One/Value'], + ['10 (2)' ,'2019-01-01' ,''], + [' January 2000 (1)' ,'2000-01-01' ,''], + ['10' ,'2000-01-01' ,'4'], + [' January 2019 (1)' ,'2019-01-01' ,''], + ['10' ,'2019-01-01' ,'4'], + ]) + + def test_one2many(self): + values = [{ + 'int_sum': 10, + 'one2many': [ + (0, 0, {'value': 8}), + (0, 0, {'value': 9}), + ], + }] + export = self.export(values, + params={ + 'groupby': ['int_sum',], + 'fields': [ + {'name': 'int_sum', 'label': 'Int Sum'}, + {'name': 'one2many/value', 'label': 'One2many/Value'}, + ] + }) + self.assertExportEqual(export, [ + ['Int Sum' ,'One2many/Value'], + ['10 (1)' ,''], + ['10' ,'8'], + ['' ,'9'], + ]) + + def test_unset_date_values(self): + values = [ + {'int_sum': 10, 'date_max': date(2019, 1, 1)}, + {'int_sum': 10, 'date_max': False}, + ] + # Group and aggregate by date, but date fields are not set for all records + export = self.export(values, fields=['int_sum', 'date_max'], params={'groupby': ['int_sum', 'date_max:month']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Date Max'], + ['10 (2)' ,'2019-01-01'], + [' January 2019 (1)' ,'2019-01-01'], + ['10' ,'2019-01-01'], + [' Undefined (1)' ,''], + ['10' ,''], + ]) + + def test_float_representation(self): + currency = self.env['res.currency'].create({ + 'name': "bottlecap", + 'symbol': "b", + 'rounding': 0.001, + 'decimal_places': 3, + }) + + values = [ + {'int_sum': 1, 'currency_id': currency.id, 'float_monetary': 60739.2000000004}, + {'int_sum': 2, 'currency_id': currency.id, 'float_monetary': 2.0}, + {'int_sum': 3, 'currency_id': currency.id, 'float_monetary': 999.9995999}, + ] + export = self.export(values, fields=['int_sum', 'float_monetary'], params={'groupby': ['int_sum', 'float_monetary']}) + + self.assertExportEqual(export, [ + ['Int Sum', 'Float Monetary'], + ['1 (1)', '60739.200'], + [' 60739.2 (1)', '60739.200'], + ['1', '60739.20'], + ['2 (1)', '2.000'], + [' 2.0 (1)', '2.000'], + ['2', '2.00'], + ['3 (1)', '1000.000'], + [' 1000.0 (1)', '1000.000'], + ['3', '1000.00'], + ]) + + def test_decimal_separator(self): + """ The decimal separator of the language used shouldn't impact the float representation in the exported xlsx """ + get_lang(self.env).decimal_point = ',' + get_lang(self.env).thousands_sep = '.' + + values = [ + {'int_sum': 1, 'float_min': 86420.864}, + ] + export = self.export(values, fields=['int_sum', 'float_min'], params={'groupby': ['int_sum', 'float_min']}) + + self.assertExportEqual(export, [ + ['Int Sum' ,'Float Min'], + ['1 (1)' ,'86420.86'], + [' 86420.864 (1)','86420.86'], + ['1' ,'86420.86'], + ]) + +@tagged('-at_install', 'post_install') +class TestExport(common.HttpCase): + + def test_properties_type_fields_not_selectable_with_import_compat(self): + with patch.object(Export, 'fields_get', return_value={ + 'id': {'string': 'ID', 'type': 'integer'}, + 'name': {'string': 'Name', 'type': 'char'}, + 'properties': {'string': 'Properties', 'type': 'properties'}, + 'properties_definition': {'string': 'Properties Definition', 'type': 'properties_definition'} + }): + fields = Export().get_fields("mock_model", import_compat=True) + field_names = [field['id'] for field in fields] + self.assertNotIn('properties', field_names) + self.assertNotIn('properties_definition', field_names)