Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions mail_reply_stage/README.rst
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/OCA/social/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 <https://github.com/OCA/social/issues/new?body=module:%20mail_reply_stage%0Aversion:%2015.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

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

Credits
=======

Authors
~~~~~~~

* Quartile

Contributors
~~~~~~~~~~~~

* `Quartile <https://www.quartile.co>`_

* 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 <https://github.com/OCA/social/tree/15.0/mail_reply_stage>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions mail_reply_stage/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
16 changes: 16 additions & 0 deletions mail_reply_stage/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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,
}
3 changes: 3 additions & 0 deletions mail_reply_stage/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import ir_model_data
from . import mail_message
from . import mail_reply_config
28 changes: 28 additions & 0 deletions mail_reply_stage/models/ir_model_data.py
Original file line number Diff line number Diff line change
@@ -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,
)
72 changes: 72 additions & 0 deletions mail_reply_stage/models/mail_message.py
Original file line number Diff line number Diff line change
@@ -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
97 changes: 97 additions & 0 deletions mail_reply_stage/models/mail_reply_config.py
Original file line number Diff line number Diff line change
@@ -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"]])
Loading