diff --git a/mail_reply_stage/README.rst b/mail_reply_stage/README.rst new file mode 100644 index 0000000000..370910acc8 --- /dev/null +++ b/mail_reply_stage/README.rst @@ -0,0 +1,175 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================ +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/license-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/19.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-19-0/social-19-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=19.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. + +.. |maintainer-yostashiro| image:: https://github.com/yostashiro.png?size=40px + :target: https://github.com/yostashiro + :alt: yostashiro +.. |maintainer-aungkokolin1997| image:: https://github.com/aungkokolin1997.png?size=40px + :target: https://github.com/aungkokolin1997 + :alt: aungkokolin1997 + +Current `maintainers `__: + +|maintainer-yostashiro| |maintainer-aungkokolin1997| + +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 0000000000..0650744f6b --- /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 0000000000..0b89dcdc9e --- /dev/null +++ b/mail_reply_stage/__manifest__.py @@ -0,0 +1,17 @@ +# 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": "19.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", + ], + "maintainers": ["yostashiro", "aungkokolin1997"], + "installable": True, +} diff --git a/mail_reply_stage/models/__init__.py b/mail_reply_stage/models/__init__.py new file mode 100644 index 0000000000..f660b2d8a0 --- /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 0000000000..ba246247f1 --- /dev/null +++ b/mail_reply_stage/models/ir_model_data.py @@ -0,0 +1,33 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models +from odoo.fields import Domain + + +class IrModelData(models.Model): + _inherit = "ir.model.data" + + @api.model + @api.readonly + def name_search( + self, + name: str = "", + domain=None, + operator: str = "ilike", + limit: int = 100, + ): + 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 + ) + domain = [ + ("model", "=", stage_model), + ("res_id", "in", stage_ids), + ] + records = self.search_fetch(Domain(domain), ["display_name"], limit=limit) + return [(rec.id, rec.display_name) for rec in records] + return super().name_search( + name=name, domain=domain, operator=operator, limit=limit + ) diff --git a/mail_reply_stage/models/mail_message.py b/mail_reply_stage/models/mail_message.py new file mode 100644 index 0000000000..fd0efa6015 --- /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 0000000000..933ca75653 --- /dev/null +++ b/mail_reply_stage/models/mail_reply_config.py @@ -0,0 +1,99 @@ +# 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([], limit=1000) + 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/pyproject.toml b/mail_reply_stage/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/mail_reply_stage/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/mail_reply_stage/readme/CONFIGURE.md b/mail_reply_stage/readme/CONFIGURE.md new file mode 100644 index 0000000000..340bbeba90 --- /dev/null +++ b/mail_reply_stage/readme/CONFIGURE.md @@ -0,0 +1,61 @@ +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.md b/mail_reply_stage/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..e49c3c4ef2 --- /dev/null +++ b/mail_reply_stage/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Quartile](https://www.quartile.co) + - Aung Ko Ko Lin diff --git a/mail_reply_stage/readme/DESCRIPTION.md b/mail_reply_stage/readme/DESCRIPTION.md new file mode 100644 index 0000000000..4ebfd2f820 --- /dev/null +++ b/mail_reply_stage/readme/DESCRIPTION.md @@ -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.md b/mail_reply_stage/readme/ROADMAP.md new file mode 100644 index 0000000000..113bd8fdaf --- /dev/null +++ b/mail_reply_stage/readme/ROADMAP.md @@ -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 0000000000..56da2fc629 --- /dev/null +++ b/mail_reply_stage/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mail_reply_config_public,mail.reply.config.public,model_mail_reply_config,base.group_public,1,0,0,0 +access_mail_reply_config_portal,mail.reply.config.portal,model_mail_reply_config,base.group_portal,1,0,0,0 +access_mail_reply_config_user,mail.reply.config.user,model_mail_reply_config,base.group_user,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 0000000000..b338a8c1c3 --- /dev/null +++ b/mail_reply_stage/static/description/index.html @@ -0,0 +1,556 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

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.

+

Current maintainers:

+

yostashiro aungkokolin1997

+

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 0000000000..88f5bc00a3 --- /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 0000000000..80ea3762bb --- /dev/null +++ b/mail_reply_stage/tests/test_mail_reply_stage.py @@ -0,0 +1,154 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.orm.model_classes import add_to_registry +from odoo.tests.common import TransactionCase + + +class TestMailReplyStage(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + from .test_models import TestMailReply, TestMailReplyParent, TestMailReplyStage + + add_to_registry(cls.registry, TestMailReply) + add_to_registry(cls.registry, TestMailReplyParent) + add_to_registry(cls.registry, TestMailReplyStage) + cls.registry._setup_models__( + cls.env.cr, + ["test.mail.reply", "test.mail.reply.stage", "test.mail.reply.parent"], + ) + cls.registry.init_models( + cls.env.cr, + ["test.mail.reply", "test.mail.reply.stage", "test.mail.reply.parent"], + {"models_to_check": True}, + ) + cls.addClassCleanup(cls.registry.__delitem__, "test.mail.reply") + cls.addClassCleanup(cls.registry.__delitem__, "test.mail.reply.stage") + cls.addClassCleanup(cls.registry.__delitem__, "test.mail.reply.parent") + cls.test_model = cls.env["ir.model"]._get("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", + "group_ids": [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 + + 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 0000000000..c091a245f6 --- /dev/null +++ b/mail_reply_stage/tests/test_models.py @@ -0,0 +1,29 @@ +# 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" + _description = "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"] + _description = "Test Mail Reply" + + 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" + _description = "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 0000000000..c4cb3d59be --- /dev/null +++ b/mail_reply_stage/views/mail_reply_config_views.xml @@ -0,0 +1,60 @@ + + + + mail.reply.config.search + mail.reply.config + + + + + + + + + + + mail.reply.config.tree + mail.reply.config + + + + + + + + + + + + + + + + + Mail Reply Configurations + mail.reply.config + list + + + +