diff --git a/mail_reply_stage/README.rst b/mail_reply_stage/README.rst new file mode 100644 index 0000000..248e813 --- /dev/null +++ b/mail_reply_stage/README.rst @@ -0,0 +1,167 @@ +================ +Mail Reply Stage +================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:3e0a708a7c69ddc0f3b1222b5c268d8d6acc6f32c6de186e7c9a5df995a98bb3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github + :target: https://github.com/OCA/social/tree/15.0/mail_reply_stage + :alt: OCA/social +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/social-15-0/social-15-0-mail_reply_stage + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/social&target_branch=15.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides a feature that automatically updates the stage of a record when a +non-internal user sends a mail message to that record. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Go to **Settings > Technical > Email > Mail Reply Configurations** and create records +according to your needs. + +For each record: + +- **Model**: Choose a model (required). +- **Parent Field**: Select the many2one field that links to the parent model. + For example, if you select the model `project.task`, choose `project_id` as the parent + field. +- **Parent Stage Field**: Choose the many2many field from the model of Parent Field that defines + the allowed stages.The system will check whether the selected Reply Stage is + included in the value of this field. + If the Reply Stage is not present in the Parent Stage Field, it will not be assigned to + the record. +- **domain**: Set a domain to filter which records this config applies to. + Example: ``[('project_id.name', '=', 'My Project')]`` +- **Reply Stage Field**: Choose the field (e.g., `stage_id`) to be updated when a + non-internal user replies. (required) +- **Reply Stage**: Set the name of the stage to apply on reply. (required) + +Examples +~~~~~~~~ + +Example 1 – For "Office Design" Project +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This rule applies to tasks under the **"Office Design"** project. + ++------------------------+-------------------------------------------------------------+ +| **Field** | **Value** | ++========================+=============================================================+ +| Model | Task (``project.task``) | ++------------------------+-------------------------------------------------------------+ +| Parent Field | Project (``project_id``) | ++------------------------+-------------------------------------------------------------+ +| Parent Stage Field | Task Stages (``project.task.type_ids``) | ++------------------------+-------------------------------------------------------------+ +| Domain | ``[('project_id.name', '=', 'Office Design')]`` | ++------------------------+-------------------------------------------------------------+ +| Reply Stage Field | Stage (``stage_id``) | ++------------------------+-------------------------------------------------------------+ +| Reply Stage | Reply to Customer | ++------------------------+-------------------------------------------------------------+ + +Example 2 – Fallback for All Other Projects +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This rule applies to all tasks that do **not** belong to the "Office Design" project. + ++------------------------+-------------------------------------------------------------+ +| **Field** | **Value** | ++========================+=============================================================+ +| Model | Task (``project.task``) | ++------------------------+-------------------------------------------------------------+ +| Parent Field | Project (``project_id``) | ++------------------------+-------------------------------------------------------------+ +| Parent Stage Field | Task Stages (``project.task.type_ids``) | ++------------------------+-------------------------------------------------------------+ +| Domain | | ++------------------------+-------------------------------------------------------------+ +| Reply Stage Field | Stage (``stage_id``) | ++------------------------+-------------------------------------------------------------+ +| Reply Stage | Need Discussion | ++------------------------+-------------------------------------------------------------+ + +Use the up/down arrows to prioritize the rules. +The system evaluates rules from top to bottom and applies only the first matching one. +Place more specific rules (with a domain) above general ones (e.g., fallback rules with an empty domain). + +Based on the two example configurations: +For a task under the "Office Design" project, both rules match. +However, the first rule at the top will be used. + +Note: Make sure the selected reply stage exists in the parent record’s allowed stages, +as defined by the **Parent Stage Field**. + +Known issues / Roadmap +====================== + +Due to a technical limitation, if you create a new stage after the reply stage +configuration record has already been created and want to use this new stage as +the Reply Stage, you must clear and reselect the Reply Stage Field to trigger the +onchange. This will allow the newly created stage to appear in the Reply Stage +selection. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Quartile + +Contributors +~~~~~~~~~~~~ + +* `Quartile `_ + + * Aung Ko Ko Lin + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/social `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/mail_reply_stage/__init__.py b/mail_reply_stage/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/mail_reply_stage/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/mail_reply_stage/__manifest__.py b/mail_reply_stage/__manifest__.py new file mode 100644 index 0000000..84dc140 --- /dev/null +++ b/mail_reply_stage/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Mail Reply Stage", + "category": "Mail", + "version": "15.0.1.0.0", + "author": "Quartile, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/social", + "license": "AGPL-3", + "depends": ["mail"], + "data": [ + "security/ir.model.access.csv", + "views/mail_reply_config_views.xml", + ], + "installable": True, +} diff --git a/mail_reply_stage/models/__init__.py b/mail_reply_stage/models/__init__.py new file mode 100644 index 0000000..f660b2d --- /dev/null +++ b/mail_reply_stage/models/__init__.py @@ -0,0 +1,3 @@ +from . import ir_model_data +from . import mail_message +from . import mail_reply_config diff --git a/mail_reply_stage/models/ir_model_data.py b/mail_reply_stage/models/ir_model_data.py new file mode 100644 index 0000000..eddab18 --- /dev/null +++ b/mail_reply_stage/models/ir_model_data.py @@ -0,0 +1,28 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class IrModelData(models.Model): + _inherit = "ir.model.data" + + @api.model + def _name_search( + self, name, args=None, operator="ilike", limit=100, name_get_uid=None + ): + stage_model = self.env.context.get("mail_reply_stage_model") + if name and stage_model: + stage_ids = self.env[stage_model]._search( + [("name", operator, name)], limit=limit, access_rights_uid=name_get_uid + ) + domain = [("model", "=", stage_model), ("res_id", "in", stage_ids)] + xml_ids = self._search(domain, limit=limit, access_rights_uid=name_get_uid) + return xml_ids + return super()._name_search( + name=name, + args=args, + operator=operator, + limit=limit, + name_get_uid=name_get_uid, + ) diff --git a/mail_reply_stage/models/mail_message.py b/mail_reply_stage/models/mail_message.py new file mode 100644 index 0000000..fd0efa6 --- /dev/null +++ b/mail_reply_stage/models/mail_message.py @@ -0,0 +1,72 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, models +from odoo.tools import safe_eval + +_logger = logging.getLogger(__name__) + + +class MailMessage(models.Model): + _inherit = "mail.message" + + def _get_reply_stage(self, res, config): + self.ensure_one() + reply_stage = self.env[config.reply_stage_model_name].search( + [("id", "=", config.reply_stage_id)] + ) + if config.parent_stage_field_id: + parent_field_rec = getattr(res, config.parent_field_id.name, None) + allowed_stages = getattr( + parent_field_rec, + config.parent_stage_field_id.name, + self.env[config.parent_stage_field_id.relation], + ) + reply_stage = reply_stage.filtered(lambda stage: stage in allowed_stages) + return reply_stage + + def _get_mail_reply_config(self, res, res_model): + self.ensure_one() + configs = self.env["mail.reply.config"].search( + [("model_id", "=", res_model.id)], order="sequence ASC" + ) + for config in configs: + reply_stage = self._get_reply_stage(res, config) + if not reply_stage: + continue + domain = [] + if config.domain: + try: + domain = safe_eval.safe_eval(config.domain) + except Exception as e: + _logger.warning("Invalid domain: %s (%s)", config.domain, e) + continue + if not domain or res.filtered_domain(domain): + return config, reply_stage + return None, None + + @api.model_create_multi + def create(self, values_list): + messages = super().create(values_list) + for message in messages: + user = message.author_id.user_ids[:1] + if user and user.has_group("base.group_user"): + continue + if message.subtype_id and message.subtype_id.internal: + continue + res_model = ( + self.env["ir.model"] + .sudo() + .search([("model", "=", message.model)], limit=1) + ) + if not res_model: + continue + res = self.env[message.model].browse(message.res_id) + config, reply_stage = message._get_mail_reply_config(res, res_model) + if not config: + continue + if reply_stage: + res.sudo().write({config.reply_stage_field_id.name: reply_stage.id}) + return messages diff --git a/mail_reply_stage/models/mail_reply_config.py b/mail_reply_stage/models/mail_reply_config.py new file mode 100644 index 0000000..6879c26 --- /dev/null +++ b/mail_reply_stage/models/mail_reply_config.py @@ -0,0 +1,97 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class MailReplyConfig(models.Model): + _name = "mail.reply.config" + _description = "Mail Reply Configuration" + + sequence = fields.Integer(default=10) + model_id = fields.Many2one( + "ir.model", string="Model", required=True, ondelete="cascade" + ) + parent_field_id = fields.Many2one( + "ir.model.fields", + string="Parent Field", + domain="[('model_id', '=', model_id), ('ttype', '=', 'many2one')]", + ondelete="cascade", + ) + parent_model_name = fields.Char( + related="parent_field_id.relation", + string="Parent Model", + help="Automatically stores the model name of the related parent entity.", + ) + parent_stage_field_id = fields.Many2one( + "ir.model.fields", + string="Parent Stage Field", + domain="[('model_id.model', '=', parent_model_name), ('ttype', '=', 'many2many')]", + ondelete="cascade", + help="A Many2Many field within the parent model that defines " + "valid stages for this configuration.", + ) + domain = fields.Char( + help="Domain used to find matching config dynamically," + "e.g., [('project_id.name', '=', 'My Project')]", + ) + reply_stage_field_id = fields.Many2one( + "ir.model.fields", + domain="[('model_id', '=', model_id), ('ttype', '=', 'many2one')]", + required=True, + ondelete="cascade", + ) + reply_stage_model_name = fields.Char(related="reply_stage_field_id.relation") + reply_stage_xml_id = fields.Many2one( + "ir.model.data", + string="Reply Stage", + help="Select the reply stage from the related model.", + ) + reply_stage_xml_id_domain = fields.Binary( + compute="_compute_reply_stage_xml_id_domain" + ) + reply_stage_id = fields.Many2oneReference( + related="reply_stage_xml_id.res_id", + help="Technical field to store the id of reply stage.", + ) + + @api.depends("reply_stage_field_id") + def _compute_reply_stage_xml_id_domain(self): + for rec in self: + if not rec.reply_stage_field_id: + rec.reply_stage_xml_id_domain = [] + continue + Model = self.env[rec.reply_stage_model_name] + records = Model.search([]) + xml_ids = self.env["ir.model.data"].search( + [ + ("model", "=", rec.reply_stage_model_name), + ("res_id", "in", records.ids), + ] + ) + rec.reply_stage_xml_id_domain = [("id", "in", xml_ids.ids)] + + @api.onchange("model_id") + def _onchange_model_id(self): + self.parent_field_id = False + self.parent_stage_field_id = False + self.reply_stage_field_id = False + self.reply_stage_xml_id = False + + @api.onchange("parent_field_id") + def _onchange_parent_field_id(self): + self.parent_stage_field_id = False + + @api.onchange("reply_stage_field_id") + def _onchange_reply_stage_field_id(self): + self.reply_stage_xml_id = False + if self.reply_stage_field_id: + model_name = self.reply_stage_model_name + xmlid_ids = ( + self.env["ir.model.data"] + .search([("model", "=", model_name)]) + .mapped("res_id") + ) + recs_to_export = self.env[model_name].search([("id", "not in", xmlid_ids)]) + if recs_to_export: + recs_to_export._export_rows([["id"]]) diff --git a/mail_reply_stage/readme/CONFIGURE.rst b/mail_reply_stage/readme/CONFIGURE.rst new file mode 100644 index 0000000..ed91f2c --- /dev/null +++ b/mail_reply_stage/readme/CONFIGURE.rst @@ -0,0 +1,75 @@ +Go to **Settings > Technical > Email > Mail Reply Configurations** and create records +according to your needs. + +For each record: + +- **Model**: Choose a model (required). +- **Parent Field**: Select the many2one field that links to the parent model. + For example, if you select the model `project.task`, choose `project_id` as the parent + field. +- **Parent Stage Field**: Choose the many2many field from the model of Parent Field that defines + the allowed stages.The system will check whether the selected Reply Stage is + included in the value of this field. + If the Reply Stage is not present in the Parent Stage Field, it will not be assigned to + the record. +- **domain**: Set a domain to filter which records this config applies to. + Example: ``[('project_id.name', '=', 'My Project')]`` +- **Reply Stage Field**: Choose the field (e.g., `stage_id`) to be updated when a + non-internal user replies. (required) +- **Reply Stage**: Set the name of the stage to apply on reply. (required) + +Examples +~~~~~~~~ + +Example 1 – For "Office Design" Project +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This rule applies to tasks under the **"Office Design"** project. + ++------------------------+-------------------------------------------------------------+ +| **Field** | **Value** | ++========================+=============================================================+ +| Model | Task (``project.task``) | ++------------------------+-------------------------------------------------------------+ +| Parent Field | Project (``project_id``) | ++------------------------+-------------------------------------------------------------+ +| Parent Stage Field | Task Stages (``project.task.type_ids``) | ++------------------------+-------------------------------------------------------------+ +| Domain | ``[('project_id.name', '=', 'Office Design')]`` | ++------------------------+-------------------------------------------------------------+ +| Reply Stage Field | Stage (``stage_id``) | ++------------------------+-------------------------------------------------------------+ +| Reply Stage | Reply to Customer | ++------------------------+-------------------------------------------------------------+ + +Example 2 – Fallback for All Other Projects +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This rule applies to all tasks that do **not** belong to the "Office Design" project. + ++------------------------+-------------------------------------------------------------+ +| **Field** | **Value** | ++========================+=============================================================+ +| Model | Task (``project.task``) | ++------------------------+-------------------------------------------------------------+ +| Parent Field | Project (``project_id``) | ++------------------------+-------------------------------------------------------------+ +| Parent Stage Field | Task Stages (``project.task.type_ids``) | ++------------------------+-------------------------------------------------------------+ +| Domain | | ++------------------------+-------------------------------------------------------------+ +| Reply Stage Field | Stage (``stage_id``) | ++------------------------+-------------------------------------------------------------+ +| Reply Stage | Need Discussion | ++------------------------+-------------------------------------------------------------+ + +Use the up/down arrows to prioritize the rules. +The system evaluates rules from top to bottom and applies only the first matching one. +Place more specific rules (with a domain) above general ones (e.g., fallback rules with an empty domain). + +Based on the two example configurations: +For a task under the "Office Design" project, both rules match. +However, the first rule at the top will be used. + +Note: Make sure the selected reply stage exists in the parent record’s allowed stages, +as defined by the **Parent Stage Field**. diff --git a/mail_reply_stage/readme/CONTRIBUTORS.rst b/mail_reply_stage/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..3356ed4 --- /dev/null +++ b/mail_reply_stage/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Quartile `_ + + * Aung Ko Ko Lin diff --git a/mail_reply_stage/readme/DESCRIPTION.rst b/mail_reply_stage/readme/DESCRIPTION.rst new file mode 100644 index 0000000..336b1e1 --- /dev/null +++ b/mail_reply_stage/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This module provides a feature that automatically updates the stage of a record when a +non-internal user sends a mail message to that record. diff --git a/mail_reply_stage/readme/ROADMAP.rst b/mail_reply_stage/readme/ROADMAP.rst new file mode 100644 index 0000000..877bfa2 --- /dev/null +++ b/mail_reply_stage/readme/ROADMAP.rst @@ -0,0 +1,5 @@ +Due to a technical limitation, if you create a new stage after the reply stage +configuration record has already been created and want to use this new stage as +the Reply Stage, you must clear and reselect the Reply Stage Field to trigger the +onchange. This will allow the newly created stage to appear in the Reply Stage +selection. diff --git a/mail_reply_stage/security/ir.model.access.csv b/mail_reply_stage/security/ir.model.access.csv new file mode 100644 index 0000000..7eb425b --- /dev/null +++ b/mail_reply_stage/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mail_reply_config_all,mail.reply.config.all,model_mail_reply_config,,1,0,0,0 +access_mail_reply_config_admin,mail.reply.config.admin,model_mail_reply_config,base.group_system,1,1,1,1 diff --git a/mail_reply_stage/static/description/index.html b/mail_reply_stage/static/description/index.html new file mode 100644 index 0000000..255b169 --- /dev/null +++ b/mail_reply_stage/static/description/index.html @@ -0,0 +1,542 @@ + + + + + +Mail Reply Stage + + + +
+

Mail Reply Stage

+ + +

Beta License: AGPL-3 OCA/social Translate me on Weblate Try me on Runboat

+

This module provides a feature that automatically updates the stage of a record when a +non-internal user sends a mail message to that record.

+

Table of contents

+ +
+

Configuration

+

Go to Settings > Technical > Email > Mail Reply Configurations and create records +according to your needs.

+

For each record:

+
    +
  • Model: Choose a model (required).
  • +
  • Parent Field: Select the many2one field that links to the parent model. +For example, if you select the model project.task, choose project_id as the parent +field.
  • +
  • Parent Stage Field: Choose the many2many field from the model of Parent Field that defines +the allowed stages.The system will check whether the selected Reply Stage is +included in the value of this field. +If the Reply Stage is not present in the Parent Stage Field, it will not be assigned to +the record.
  • +
  • domain: Set a domain to filter which records this config applies to. +Example: [('project_id.name', '=', 'My Project')]
  • +
  • Reply Stage Field: Choose the field (e.g., stage_id) to be updated when a +non-internal user replies. (required)
  • +
  • Reply Stage: Set the name of the stage to apply on reply. (required)
  • +
+ +
+

Example 1 – For “Office Design” Project

+

This rule applies to tasks under the “Office Design” project.

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldValue
ModelTask (project.task)
Parent FieldProject (project_id)
Parent Stage FieldTask Stages (project.task.type_ids)
Domain[('project_id.name', '=', 'Office Design')]
Reply Stage FieldStage (stage_id)
Reply StageReply to Customer
+
+
+

Example 2 – Fallback for All Other Projects

+

This rule applies to all tasks that do not belong to the “Office Design” project.

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldValue
ModelTask (project.task)
Parent FieldProject (project_id)
Parent Stage FieldTask Stages (project.task.type_ids)
Domain 
Reply Stage FieldStage (stage_id)
Reply StageNeed Discussion
+

Use the up/down arrows to prioritize the rules. +The system evaluates rules from top to bottom and applies only the first matching one. +Place more specific rules (with a domain) above general ones (e.g., fallback rules with an empty domain).

+

Based on the two example configurations: +For a task under the “Office Design” project, both rules match. +However, the first rule at the top will be used.

+

Note: Make sure the selected reply stage exists in the parent record’s allowed stages, +as defined by the Parent Stage Field.

+
+
+
+

Known issues / Roadmap

+

Due to a technical limitation, if you create a new stage after the reply stage +configuration record has already been created and want to use this new stage as +the Reply Stage, you must clear and reselect the Reply Stage Field to trigger the +onchange. This will allow the newly created stage to appear in the Reply Stage +selection.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Quartile
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/social project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/mail_reply_stage/tests/__init__.py b/mail_reply_stage/tests/__init__.py new file mode 100644 index 0000000..88f5bc0 --- /dev/null +++ b/mail_reply_stage/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mail_reply_stage diff --git a/mail_reply_stage/tests/test_mail_reply_stage.py b/mail_reply_stage/tests/test_mail_reply_stage.py new file mode 100644 index 0000000..5c812a8 --- /dev/null +++ b/mail_reply_stage/tests/test_mail_reply_stage.py @@ -0,0 +1,150 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo_test_helper import FakeModelLoader + +from odoo import Command +from odoo.tests.common import TransactionCase + + +class TestMailReplyStage(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .test_models import TestMailReply, TestMailReplyParent, TestMailReplyStage + + cls.loader.update_registry( + (TestMailReplyParent, TestMailReply, TestMailReplyStage) + ) + cls.test_model = cls.env.ref("mail_reply_stage.model_test_mail_reply") + cls.parent_stage_ids_field = cls.env["ir.model.fields"]._get( + "test.mail.reply.parent", "stage_ids" + ) + cls.parent_id_field = cls.env["ir.model.fields"]._get( + "test.mail.reply", "parent_id" + ) + cls.reply_stage_id_field = cls.env["ir.model.fields"]._get( + "test.mail.reply", "stage_id" + ) + cls.stage_a, cls.stage_a_xmlid = cls.create_stage("Stage A") + cls.stage_b, cls.stage_b_xmlid = cls.create_stage("Stage B") + cls.stage_c, cls.stage_c_xmlid = cls.create_stage("Stage C") + cls.stage_d, cls.stage_d_xmlid = cls.create_stage("Stage D") + cls.parent_1 = cls.env["test.mail.reply.parent"].create( + { + "name": "Test Parent 1", + "stage_ids": [ + Command.set([cls.stage_a.id, cls.stage_b.id, cls.stage_c.id]) + ], + } + ) + cls.parent_2 = cls.env["test.mail.reply.parent"].create( + { + "name": "Test Parent 2", + "stage_ids": [ + Command.set([cls.stage_a.id, cls.stage_b.id, cls.stage_c.id]) + ], + } + ) + cls.record_1 = cls.env["test.mail.reply"].create( + { + "name": "Test 1", + "parent_id": cls.parent_1.id, + "stage_id": cls.stage_a.id, + } + ) + cls.record_2 = cls.env["test.mail.reply"].create( + { + "name": "Test 2", + "parent_id": cls.parent_2.id, + "stage_id": cls.stage_a.id, + } + ) + cls.mail_reply_config_1 = cls.env["mail.reply.config"].create( + { + "model_id": cls.test_model.id, + "parent_field_id": cls.parent_id_field.id, + "parent_stage_field_id": cls.parent_stage_ids_field.id, + "domain": "[('parent_id.name', '=', 'Test Parent 1')]", + "reply_stage_field_id": cls.reply_stage_id_field.id, + "reply_stage_xml_id": cls.stage_b_xmlid.id, + } + ) + cls.mail_reply_config_2 = cls.env["mail.reply.config"].create( + { + "sequence": 20, + "model_id": cls.test_model.id, + "parent_field_id": cls.parent_id_field.id, + "parent_stage_field_id": cls.parent_stage_ids_field.id, + "reply_stage_field_id": cls.reply_stage_id_field.id, + "reply_stage_xml_id": cls.stage_c_xmlid.id, + } + ) + cls.user = ( + cls.env["res.users"] + .with_context(no_reset_password=True) + .create( + { + "name": "Non-Internal User", + "login": "test@example.com", + "email": "test@example.com", + "groups_id": [Command.set([cls.env.ref("base.group_portal").id])], + } + ) + ) + + @classmethod + def create_stage(cls, name): + stage = cls.env["test.mail.reply.stage"].create({"name": name}) + stage._export_rows([["id"]]) + xmlid = cls.env["ir.model.data"].search( + [("model", "=", "test.mail.reply.stage"), ("res_id", "=", stage.id)] + ) + return stage, xmlid + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super().tearDownClass() + + def test_mail_reply_stage_assigned(self): + self.assertEqual(self.record_1.stage_id, self.stage_a) + self.record_1.message_post( + author_id=self.user.partner_id.id, + subtype_id=self.env.ref("mail.mt_comment").id, + body="Test mail reply stage.", + ) + self.assertEqual(self.record_1.stage_id, self.stage_b) + self.assertEqual(self.record_2.stage_id, self.stage_a) + self.record_2.message_post( + author_id=self.user.partner_id.id, + subtype_id=self.env.ref("mail.mt_comment").id, + body="Test mail reply stage.", + ) + self.assertEqual(self.record_2.stage_id, self.stage_c) + + def test_mail_reply_stage_sequence(self): + self.assertEqual(self.record_1.stage_id, self.stage_a) + self.mail_reply_config_1.sequence = 30 + self.record_1.message_post( + author_id=self.user.partner_id.id, + subtype_id=self.env.ref("mail.mt_comment").id, + body="Test mail reply stage.", + ) + self.assertEqual(self.record_1.stage_id, self.stage_c) + + def test_mail_reply_stage_not_assigned(self): + self.assertEqual(self.record_1.stage_id, self.stage_a) + # Send as an internal user + self.record_1.message_post( + author_id=self.env.user.partner_id.id, body="Test mail reply stage." + ) + self.assertEqual(self.record_1.stage_id, self.stage_a) + self.mail_reply_config_1.reply_stage_xml_id = self.stage_d_xmlid.id + self.mail_reply_config_2.reply_stage_xml_id = self.stage_d_xmlid.id + self.record_1.message_post( + author_id=self.user.partner_id.id, body="Test mail reply stage." + ) + self.assertEqual(self.record_1.stage_id, self.stage_a) diff --git a/mail_reply_stage/tests/test_models.py b/mail_reply_stage/tests/test_models.py new file mode 100644 index 0000000..9717200 --- /dev/null +++ b/mail_reply_stage/tests/test_models.py @@ -0,0 +1,26 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class TestMailReplyParent(models.Model): + _name = "test.mail.reply.parent" + + name = fields.Char() + stage_ids = fields.Many2many("test.mail.reply.stage") + + +class TestMailReply(models.Model): + _name = "test.mail.reply" + _inherit = ["mail.thread"] + + name = fields.Char() + parent_id = fields.Many2one("test.mail.reply.parent") + stage_id = fields.Many2one("test.mail.reply.stage") + + +class TestMailReplyStage(models.Model): + _name = "test.mail.reply.stage" + + name = fields.Char() diff --git a/mail_reply_stage/views/mail_reply_config_views.xml b/mail_reply_stage/views/mail_reply_config_views.xml new file mode 100644 index 0000000..966050d --- /dev/null +++ b/mail_reply_stage/views/mail_reply_config_views.xml @@ -0,0 +1,59 @@ + + + + mail.reply.config.search + mail.reply.config + + + + + + + + + + + mail.reply.config.tree + mail.reply.config + + + + + + + + + + + + + + + + + Mail Reply Configurations + mail.reply.config + tree + + + + diff --git a/setup/mail_reply_stage/odoo/addons/mail_reply_stage b/setup/mail_reply_stage/odoo/addons/mail_reply_stage new file mode 120000 index 0000000..dbc0c8b --- /dev/null +++ b/setup/mail_reply_stage/odoo/addons/mail_reply_stage @@ -0,0 +1 @@ +../../../../mail_reply_stage \ No newline at end of file diff --git a/setup/mail_reply_stage/setup.py b/setup/mail_reply_stage/setup.py new file mode 100644 index 0000000..28c57bb --- /dev/null +++ b/setup/mail_reply_stage/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)