diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py
index 958377398ee..fd4e0aa5796 100644
--- a/addons/crm/models/crm_lead.py
+++ b/addons/crm/models/crm_lead.py
@@ -98,9 +98,11 @@ class Lead(models.Model):
'mail.activity.mixin',
'utm.mixin',
'format.address.mixin',
+ 'mail.tracking.duration.mixin',
]
_primary_email = 'email_from'
_check_company_auto = True
+ _track_duration_field = 'stage_id'
# Description
name = fields.Char(
diff --git a/addons/crm/tests/__init__.py b/addons/crm/tests/__init__.py
index f6a9231caa2..3b4a892c0bb 100644
--- a/addons/crm/tests/__init__.py
+++ b/addons/crm/tests/__init__.py
@@ -18,3 +18,4 @@ from . import test_digest
from . import test_performances
from . import test_res_partner
from . import test_sales_team_ui
+from . import test_crm_lead_mail_tracking_duration
diff --git a/addons/crm/tests/test_crm_lead_mail_tracking_duration.py b/addons/crm/tests/test_crm_lead_mail_tracking_duration.py
new file mode 100644
index 00000000000..16be8f3f217
--- /dev/null
+++ b/addons/crm/tests/test_crm_lead_mail_tracking_duration.py
@@ -0,0 +1,17 @@
+from odoo.addons.mail.tests.mail_tracking_duration_testing import TestMailTrackingDurationMixin
+
+
+class TestCrmLeadMailTrackingDuration(TestMailTrackingDurationMixin):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass('crm.lead')
+
+ def test_crm_lead_mail_tracking_duration(self):
+ self._test_record_duration_tracking()
+
+ def test_crm_lead_mail_tracking_duration_batch(self):
+ self._test_record_duration_tracking_batch()
+
+ def test_crm_lead_queries_batch_mail_tracking_duration(self):
+ self._test_queries_batch_duration_tracking()
diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml
index bc3a92e7629..5aaff56ffda 100644
--- a/addons/crm/views/crm_lead_views.xml
+++ b/addons/crm/views/crm_lead_views.xml
@@ -17,7 +17,7 @@
attrs="{'invisible': ['|', ('probability', '>', 0), ('active', '=', True)]}"/>
-
diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py
index fc26aee58c3..90420e4e42d 100644
--- a/addons/mail/__manifest__.py
+++ b/addons/mail/__manifest__.py
@@ -139,6 +139,7 @@ For more specific needs, you may also assign custom-defined actions
'mail/static/src/discuss/**/common/**/*',
'mail/static/src/discuss/**/web/**/*',
('remove', 'mail/static/src/discuss/**/*.dark.scss'),
+ 'mail/static/src/views/fields/**/*',
],
"web.dark_mode_assets_backend": [
'mail/static/src/**/*.dark.scss',
diff --git a/addons/mail/models/__init__.py b/addons/mail/models/__init__.py
index b520bdcd34e..6dc4d57c8a2 100644
--- a/addons/mail/models/__init__.py
+++ b/addons/mail/models/__init__.py
@@ -14,6 +14,7 @@ from . import mail_thread
from . import mail_thread_blacklist
from . import mail_thread_cc
from . import mail_thread_main_attachment
+from . import mail_tracking_duration_mixin
from . import template_reset_mixin
# mail models
diff --git a/addons/mail/models/mail_tracking_duration_mixin.py b/addons/mail/models/mail_tracking_duration_mixin.py
new file mode 100644
index 00000000000..c97385538aa
--- /dev/null
+++ b/addons/mail/models/mail_tracking_duration_mixin.py
@@ -0,0 +1,95 @@
+from collections import defaultdict
+
+from odoo import _, fields, models
+
+
+class MailTrackingDurationMixin(models.AbstractModel):
+ _name = "mail.tracking.duration.mixin"
+ _description = "Mixin to compute the time a record has spent in each value a many2one field can take"
+
+ duration_tracking = fields.Json(
+ string="Status time", compute="_compute_duration_tracking",
+ help="JSON that maps ids from a many2one field to seconds spent")
+
+ def _compute_duration_tracking(self):
+ """
+ Computes duration_tracking, a Json field stored as { : }
+
+ e.g. {"1": 1230, "2": 2220, "5": 14}
+
+ `_track_duration_field` must be present in the model that uses the mixin to specify on what
+ field to compute time spent. Besides, tracking must be activated for that field.
+
+ e.g.
+ class MyModel(models.Model):
+ _track_duration_field = "tracked_field"
+
+ tracked_field = fields.Many2one('tracked.model', tracking=True)
+ """
+
+ field = self.env['ir.model.fields'].sudo().search_fetch([
+ ('model', '=', self._name),
+ ('name', '=', self._track_duration_field),
+ ], ['id'], limit=1)
+
+ if (
+ self._track_duration_field not in self._track_get_fields()
+ or self._fields[self._track_duration_field].type != 'many2one'
+ ):
+ self.duration_tracking = False
+ raise ValueError(
+ _('Field "%(field)s" on model "%(model)s" must be of type Many2one '
+ 'and have tracking=True for the computation of duration.') % {
+ 'field': self._track_duration_field, 'model': self._name
+ }
+ )
+
+ self.env['mail.tracking.value'].flush_model()
+ self.env['mail.message'].flush_model()
+ query = """
+ SELECT m.res_id,
+ v.create_date,
+ v.old_value_integer
+ FROM mail_tracking_value v
+ LEFT JOIN mail_message m
+ ON m.id = v.mail_message_id
+ AND v.field = %(field_id)s
+ WHERE m.model = %(model_name)s
+ AND m.res_id IN %(record_ids)s
+ ORDER BY v.id
+ """
+ self.env.cr.execute(query, {"field_id": field.id, "model_name": self._name, "record_ids": tuple(self.ids)})
+ trackings = self.env.cr.dictfetchall()
+
+ for record in self:
+ record_trackings = [tracking for tracking in trackings if tracking['res_id'] == record._origin.id]
+ record.duration_tracking = record._get_duration_from_tracking(record_trackings)
+
+ def _get_duration_from_tracking(self, trackings):
+ """
+ Calculates the duration spent in each value based on the provided list of trackings.
+ It adds a "fake" tracking at the end of the trackings list to account for the time spent in the current value.
+
+ Args:
+ trackings (list): A list of dictionaries representing the trackings with:
+ - 'create_date': The date and time of the tracking.
+ - 'old_value_integer': The ID of the previous value.
+
+ Returns:
+ dict: A dictionary where the keys are the IDs of the values, and the values are the durations in seconds
+ """
+ self.ensure_one()
+ json = defaultdict(lambda: 0)
+ previous_date = self.create_date
+
+ # add "fake" tracking for time spent in the current value
+ trackings.append({
+ 'create_date': self.env.cr.now(),
+ 'old_value_integer': self[self._track_duration_field].id,
+ })
+
+ for tracking in trackings:
+ json[tracking['old_value_integer']] += int((tracking['create_date'] - previous_date).total_seconds())
+ previous_date = tracking['create_date']
+
+ return json
diff --git a/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.js b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.js
new file mode 100644
index 00000000000..c7e1c6fb1cf
--- /dev/null
+++ b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.js
@@ -0,0 +1,60 @@
+/** @odoo-module **/
+
+import { _lt } from "@web/core/l10n/translation";
+import { registry } from "@web/core/registry";
+import { formatDuration } from "@web/views/fields/formatters";
+import {
+ preloadStatusBar,
+ statusBarField,
+ StatusBarField,
+} from "@web/views/fields/statusbar/statusbar_field";
+
+export class StatusBarDurationField extends StatusBarField {
+ static template = "mail.StatusBarDurationField";
+
+ computeItems(grouped = true) {
+ const items = super.computeItems(grouped);
+ if (!grouped) {
+ return items;
+ }
+ if (this.props.record.data.duration_tracking) {
+ for (const property in items) {
+ this.setStatusbarDuration(items[property]);
+ }
+ }
+ return items;
+ }
+
+ setStatusbarDuration(stages) {
+ const durationTracking = this.props.record.data.duration_tracking;
+ if (!Object.keys(durationTracking).length) {
+ return;
+ }
+ for (const stage of stages) {
+ if (stage.id in durationTracking && durationTracking[stage.id] > 0) {
+ stage.shortTimeInStage = formatDuration(durationTracking[stage.id], false);
+ stage.fullTimeInStage = formatDuration(durationTracking[stage.id], true);
+ } else {
+ stage.shortTimeInStage = false;
+ }
+ }
+ }
+}
+
+export const statusBarDurationField = {
+ ...statusBarField,
+ component: StatusBarDurationField,
+ displayName: _lt("Status with time"),
+ supportedTypes: ["many2one"],
+ fieldDependencies: [{ name: "duration_tracking", type: "JSON" }],
+};
+
+registry.category("fields").add("statusbar_duration", statusBarDurationField);
+
+registry.category("preloadedData").add("statusbar_duration", {
+ loadOnTypes: ["many2one"],
+ extraMemoizationKey: (record, fieldName) => {
+ return record.data[fieldName];
+ },
+ preload: preloadStatusBar,
+});
diff --git a/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.scss b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.scss
new file mode 100644
index 00000000000..fb1a9a1ac4d
--- /dev/null
+++ b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.scss
@@ -0,0 +1,3 @@
+.o_field_statusbar_duration {
+ @extend .o_field_statusbar;
+}
diff --git a/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.xml b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.xml
new file mode 100644
index 00000000000..22e4d984fb6
--- /dev/null
+++ b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/mail/static/tests/views/fields/statusbar_duration_field_tests.js b/addons/mail/static/tests/views/fields/statusbar_duration_field_tests.js
new file mode 100644
index 00000000000..e0876d4a418
--- /dev/null
+++ b/addons/mail/static/tests/views/fields/statusbar_duration_field_tests.js
@@ -0,0 +1,100 @@
+/** @odoo-module **/
+
+import { getFixture } from "@web/../tests/helpers/utils";
+import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
+
+let serverData;
+let target;
+
+QUnit.module("Fields", (hooks) => {
+ hooks.beforeEach(() => {
+ target = getFixture();
+ serverData = {
+ models: {
+ partner: {
+ fields: {
+ display_name: { string: "Displayed name", type: "char" },
+ stage_id: { string: "Stage", type: "many2one", relation: "stage_model" },
+ duration_tracking: {
+ string: "Time per stage",
+ type: "char",
+ default: "{}",
+ },
+ },
+ records: [
+ {
+ id: 1,
+ display_name: "first record",
+ stage_id: 30,
+ // 7 days, 30 minutes - 3 hours - 2 days, 5 hours
+ duration_tracking: {
+ 10: 7 * 24 * 60 * 60 + 30 * 60,
+ 20: 3 * 60 * 60,
+ 40: 24 * 2 * 60 * 60 + 5 * 60 * 60,
+ },
+ },
+ ],
+ },
+ stage_model: {
+ fields: {
+ name: { string: "Stage Name", type: "char" },
+ },
+ records: [
+ {
+ id: 10,
+ display_name: "New",
+ },
+ {
+ id: 20,
+ display_name: "Qualified",
+ },
+ {
+ id: 30,
+ display_name: "Proposition",
+ },
+ {
+ id: 40,
+ display_name: "Won",
+ },
+ ],
+ },
+ },
+ };
+
+ setupViewRegistries();
+ });
+
+ QUnit.module("StatusBarDurationField");
+
+ QUnit.test("StatusBarDurationField in a form view", async function (assert) {
+ await makeView({
+ serverData,
+ type: "form",
+ resModel: "partner",
+ arch: `
+ `,
+ resId: 1,
+ });
+
+ // Check that time is put in the stages
+ const firstButton = target.querySelector("button[data-value='10'");
+ assert.strictEqual(firstButton.innerText, "New7d");
+ assert.strictEqual(firstButton.querySelectorAll("span")[1].title, "7 days, 30 minutes");
+
+ const secondButton = target.querySelector("button[data-value='20']");
+ assert.strictEqual(secondButton.innerText, "Qualified3h");
+ assert.strictEqual(secondButton.querySelectorAll("span")[1].title, "3 hours");
+
+ const thirdButton = target.querySelector("button[data-value='30']");
+ assert.strictEqual(thirdButton.innerText, "Proposition");
+ assert.strictEqual(thirdButton.title, "");
+
+ const fourthButton = target.querySelector("button[data-value='40']");
+ assert.strictEqual(fourthButton.innerText, "Won2d");
+ assert.strictEqual(fourthButton.querySelectorAll("span")[1].title, "2 days, 5 hours");
+ });
+});
diff --git a/addons/mail/tests/mail_tracking_duration_testing.py b/addons/mail/tests/mail_tracking_duration_testing.py
new file mode 100644
index 00000000000..70a20027454
--- /dev/null
+++ b/addons/mail/tests/mail_tracking_duration_testing.py
@@ -0,0 +1,196 @@
+from collections import defaultdict
+from datetime import datetime, timedelta
+from unittest.mock import patch
+
+from odoo.addons.mail.tests.common import MailCommon
+
+
+class TestMailTrackingDurationMixin(MailCommon):
+
+ @classmethod
+ def setUpClass(cls, tested_model, model_fields=None):
+
+ super().setUpClass()
+ if model_fields:
+ for field in model_fields:
+ if model_fields[field] == 'create':
+ model_fields[field] = cls.env[tested_model][field].create({'name': 'test'}).id
+
+ stage_1_values = {'name': 'Stage 1'}
+ stage_2_values = {'name': 'Stage 2'}
+ stage_3_values = {'name': 'Stage 3'}
+ stage_4_values = {'name': 'Stage 4'}
+ cls.track_duration_field = cls.env[tested_model]._track_duration_field
+
+ stage_model = cls.env[tested_model][cls.track_duration_field]
+ cls.stage_1 = stage_model.create(stage_1_values)
+ cls.stage_2 = stage_model.create(stage_2_values)
+ cls.stage_3 = stage_model.create(stage_3_values)
+ cls.stage_4 = stage_model.create(stage_4_values)
+
+ record_values = {'name': 'test record', cls.track_duration_field: cls.stage_1.id}
+ if model_fields:
+ record_values.update(model_fields)
+
+ cls.mock_start_time = datetime(2023, 2, 15, 12, 0, 0)
+
+ with patch.object(cls.env.cr, 'now', return_value=cls.mock_start_time):
+ cls.rec_1, cls.rec_2, cls.rec_3, cls.rec_4 = cls.env[tested_model].create(
+ [record_values for i in range(4)])
+ cls.flush_tracking(cls)
+
+ def _update_duration_tracking(self, record_to_tracking_dic, minutes, new_stage=False):
+ """
+ Updates the mock duration_tracking field for multiple records based on the provided minutes.
+ If new_stage is defined, the stage of the records is updated as well.
+
+ Args:
+ record_to_tracking_dic (list): A list of tuples mapping records to their respective tracking dictionaries.
+ minutes (int): The number of minutes to be added to the duration tracking, which will be converted to seconds.
+ new_stage (int, optional): Indicated the new stage to be set for the records. Defaults to False.
+ """
+ for record, tracking_dic in record_to_tracking_dic:
+ tracking_dic[str(record[self.track_duration_field].id)] += minutes * 60
+ if new_stage:
+ tracking_dic[str(new_stage.id)] += 0
+ record[self.track_duration_field] = new_stage
+ self.flush_tracking()
+
+ def assertTrackingDuration(self, records, record_to_tracking_dic):
+ """
+ Asserts whether for multiple records their duration_tracking is equal to a dictionary
+
+ Args:
+ records (recordset): all the records that need to be asserted
+ record_to_tracking_dic (list): A list of tuples mapping records to their respective tracking dictionaries.
+ """
+ records._compute_duration_tracking()
+ for record, tracking_dic in record_to_tracking_dic:
+ self.assertDictEqual(dict(tracking_dic), record.duration_tracking)
+
+ def _test_record_duration_tracking(self):
+ """
+ Moves a record's many2one field through several values and asserts the duration spent in that value each time
+ """
+
+ with patch.object(self.env.cr, 'now', return_value=self.mock_start_time) as now:
+
+ track_duration_tracking = defaultdict(lambda: 0)
+ record = self.rec_1
+
+ minutes = 5
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking([(record, track_duration_tracking)], minutes, self.stage_2)
+ self.assertTrackingDuration(record, [(record, track_duration_tracking)])
+
+ minutes = 100
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking([(record, track_duration_tracking)], minutes)
+ self.assertTrackingDuration(record, [(record, track_duration_tracking)])
+
+ minutes = 5000
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking([(record, track_duration_tracking)], minutes, self.stage_3)
+ self.assertTrackingDuration(record, [(record, track_duration_tracking)])
+
+ minutes = 5000
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking([(record, track_duration_tracking)], minutes, self.stage_4)
+ self.assertTrackingDuration(record, [(record, track_duration_tracking)])
+
+ minutes = 20
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking([(record, track_duration_tracking)], minutes, self.stage_2)
+ self.assertTrackingDuration(record, [(record, track_duration_tracking)])
+
+ minutes = 55
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking([(record, track_duration_tracking)], minutes, self.stage_4)
+ self.assertTrackingDuration(record, [(record, track_duration_tracking)])
+
+ minutes = 200
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking([(record, track_duration_tracking)], minutes)
+ self.assertTrackingDuration(record, [(record, track_duration_tracking)])
+
+ minutes = 300
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking([(record, track_duration_tracking)], minutes, self.stage_3)
+ self.assertTrackingDuration(record, [(record, track_duration_tracking)])
+
+ def _test_record_duration_tracking_batch(self):
+ """
+ Moves for a batch of records many2one field through several values and asserts the duration
+ spent in that value each time.
+ """
+
+ with patch.object(self.env.cr, 'now', return_value=self.mock_start_time) as now:
+
+ track_duration_tracking1 = defaultdict(lambda: 0)
+ track_duration_tracking2 = defaultdict(lambda: 0)
+ track_duration_tracking3 = defaultdict(lambda: 0)
+ batch = self.rec_1 | self.rec_2 | self.rec_3
+ record_to_tracking_dic = [
+ (self.rec_1, track_duration_tracking1),
+ (self.rec_2, track_duration_tracking2),
+ (self.rec_3, track_duration_tracking3)
+ ]
+
+ minutes = 5
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking(record_to_tracking_dic, minutes, self.stage_2)
+ self.assertTrackingDuration(batch, record_to_tracking_dic)
+
+ minutes = 100
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking(record_to_tracking_dic, minutes)
+ self.assertTrackingDuration(batch, record_to_tracking_dic)
+
+ minutes = 5000
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking(record_to_tracking_dic, minutes, self.stage_3)
+ self.assertTrackingDuration(batch, record_to_tracking_dic)
+
+ minutes = 5000
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking(record_to_tracking_dic, minutes, self.stage_4)
+ self.assertTrackingDuration(batch, record_to_tracking_dic)
+
+ minutes = 20
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking(record_to_tracking_dic, minutes, self.stage_2)
+ self.assertTrackingDuration(batch, record_to_tracking_dic)
+
+ minutes = 55
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking(record_to_tracking_dic, minutes, self.stage_4)
+ self.assertTrackingDuration(batch, record_to_tracking_dic)
+
+ minutes = 200
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking(record_to_tracking_dic, minutes)
+ self.assertTrackingDuration(batch, record_to_tracking_dic)
+
+ minutes = 300
+ now.return_value += timedelta(minutes=minutes)
+ self._update_duration_tracking(record_to_tracking_dic, minutes, self.stage_3)
+ self.assertTrackingDuration(batch, record_to_tracking_dic)
+
+ def _test_queries_batch_duration_tracking(self):
+ """
+ The MailTrackingDuration mixin is only supposed to add 3 queries
+ """
+
+ batch = self.rec_1 | self.rec_2 | self.rec_3 | self.rec_4
+ batch[self.track_duration_field] = self.stage_2.id
+ self.flush_tracking()
+ batch[self.track_duration_field] = self.stage_4.id
+ self.flush_tracking()
+ batch[self.track_duration_field] = self.stage_1.id
+ self.flush_tracking()
+ batch[self.track_duration_field] = self.stage_3.id
+ self.flush_tracking()
+ batch[self.track_duration_field] = self.stage_2.id
+
+ with self.assertQueryCount(3):
+ batch._compute_duration_tracking()
diff --git a/addons/project/models/project_task.py b/addons/project/models/project_task.py
index 571786088ae..157c56d3475 100644
--- a/addons/project/models/project_task.py
+++ b/addons/project/models/project_task.py
@@ -67,11 +67,18 @@ class Task(models.Model):
_name = "project.task"
_description = "Task"
_date_name = "date_assign"
- _inherit = ['portal.mixin', 'mail.thread.cc', 'mail.activity.mixin', 'rating.mixin']
+ _inherit = [
+ 'portal.mixin',
+ 'mail.thread.cc',
+ 'mail.activity.mixin',
+ 'rating.mixin',
+ 'mail.tracking.duration.mixin'
+ ]
_mail_post_access = 'read'
_order = "priority desc, sequence, date_deadline asc, id desc"
_primary_email = 'email_from'
_check_company_auto = True
+ _track_duration_field = 'stage_id'
@api.model
def _get_default_partner_id(self, project=None, parent=None):
diff --git a/addons/project/tests/__init__.py b/addons/project/tests/__init__.py
index 3cdb6128956..9b0e2edaa05 100644
--- a/addons/project/tests/__init__.py
+++ b/addons/project/tests/__init__.py
@@ -27,3 +27,4 @@ from . import test_task_tracking
from . import test_project_report
from . import test_project_task_quick_create
from . import test_task_state
+from . import test_project_task_mail_tracking_duration
diff --git a/addons/project/tests/test_project_task_mail_tracking_duration.py b/addons/project/tests/test_project_task_mail_tracking_duration.py
new file mode 100644
index 00000000000..d06cb17ba2d
--- /dev/null
+++ b/addons/project/tests/test_project_task_mail_tracking_duration.py
@@ -0,0 +1,17 @@
+from odoo.addons.mail.tests.mail_tracking_duration_testing import TestMailTrackingDurationMixin
+
+
+class TestProjectTaskMailTrackingDuration(TestMailTrackingDurationMixin):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass('project.task', {'project_id': 'create'})
+
+ def test_project_task_mail_tracking_duration(self):
+ self._test_record_duration_tracking()
+
+ def test_project_task_mail_tracking_duration_batch(self):
+ self._test_record_duration_tracking_batch()
+
+ def test_project_task_queries_batch_mail_tracking_duration(self):
+ self._test_queries_batch_duration_tracking()
diff --git a/addons/project/views/project_task_views.xml b/addons/project/views/project_task_views.xml
index 9da21cae3c7..5a7a6aadc7a 100644
--- a/addons/project/views/project_task_views.xml
+++ b/addons/project/views/project_task_views.xml
@@ -253,7 +253,7 @@
diff --git a/addons/test_mail/models/test_mail_models.py b/addons/test_mail/models/test_mail_models.py
index 37e77df62eb..4bcfc74d502 100644
--- a/addons/test_mail/models/test_mail_models.py
+++ b/addons/test_mail/models/test_mail_models.py
@@ -397,3 +397,13 @@ class MailTestComposerSource(models.Model):
def _mail_get_partner_fields(self):
return ['customer_id']
+
+
+class MailTestMailTrackingDuration(models.Model):
+ _description = 'Fake model to test the mixin mail.tracking.duration.mixin'
+ _name = 'mail.test.mail.tracking.duration'
+ _track_duration_field = 'customer_id'
+ _inherit = ['mail.thread', 'mail.tracking.duration.mixin']
+
+ name = fields.Char()
+ customer_id = fields.Many2one('res.partner', 'Customer', tracking=True)
diff --git a/addons/test_mail/security/ir.model.access.csv b/addons/test_mail/security/ir.model.access.csv
index d9905108bd3..77ce6e4b639 100644
--- a/addons/test_mail/security/ir.model.access.csv
+++ b/addons/test_mail/security/ir.model.access.csv
@@ -49,3 +49,4 @@ access_mail_test_track_monetary,mail.test.track.monetary,model_mail_test_track_m
access_mail_test_track_selection_portal,mail.test.track.selection.portal,model_mail_test_track_selection,base.group_portal,0,0,0,0
access_mail_test_track_selection_user,mail.test.track.selection.user,model_mail_test_track_selection,base.group_user,1,1,1,1
access_mail_test_properties_user,mail.test.properties.user,model_mail_test_properties,base.group_user,1,1,1,1
+access_mail_test_mail_tracking_duration,mail.test.mail.tracking.duration,model_mail_test_mail_tracking_duration,base.group_user,1,1,1,1
diff --git a/addons/test_mail/tests/__init__.py b/addons/test_mail/tests/__init__.py
index bd325b1003e..80cd71ffd88 100644
--- a/addons/test_mail/tests/__init__.py
+++ b/addons/test_mail/tests/__init__.py
@@ -6,17 +6,18 @@ 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_mail
from . import test_mail_gateway
+from . import test_mail_mail
+from . import test_mail_management
+from . import test_mail_message
from . import test_mail_multicompany
-from . import test_mail_thread_internals
+from . import test_mail_security
from . import test_mail_template
from . import test_mail_template_preview
+from . import test_mail_thread_internals
+from . import test_mail_tracking_duration_mixin
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/addons/test_mail/tests/test_mail_tracking_duration_mixin.py b/addons/test_mail/tests/test_mail_tracking_duration_mixin.py
new file mode 100644
index 00000000000..e5042106adc
--- /dev/null
+++ b/addons/test_mail/tests/test_mail_tracking_duration_mixin.py
@@ -0,0 +1,17 @@
+from odoo.addons.mail.tests.mail_tracking_duration_testing import TestMailTrackingDurationMixin
+
+
+class TestMailTrackingDuration(TestMailTrackingDurationMixin):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass('mail.test.mail.tracking.duration')
+
+ def test_mail_tracking_duration(self):
+ self._test_record_duration_tracking()
+
+ def test_mail_tracking_duration_batch(self):
+ self._test_record_duration_tracking_batch()
+
+ def test_queries_batch_mail_tracking_duration(self):
+ self._test_queries_batch_duration_tracking()
diff --git a/addons/web/static/src/views/fields/formatters.js b/addons/web/static/src/views/fields/formatters.js
index b16e1e3b33a..197fe8fb485 100644
--- a/addons/web/static/src/views/fields/formatters.js
+++ b/addons/web/static/src/views/fields/formatters.js
@@ -154,6 +154,51 @@ export function formatChar(value, options) {
return value;
}
+/**
+ * Converts a given duration in seconds into a human-readable format.
+ *
+ * The function takes a duration in seconds and converts it into a human-readable form,
+ * such as "1h" or "1 hour, 30 minutes", depending on the value of the `showFullDuration` parameter.
+ * If the `showFullDuration` is set to true, the function will display up to two non-zero duration
+ * components in long form (e.g: hours, minutes).
+ * Otherwise, it will show just the largest non-zero duration component in narrow form (e.g: y or h).
+ * Luxon takes care of translations given the current locale.
+ *
+ * @param {number} seconds - The duration in seconds to be converted.
+ * @param {boolean} showFullDuration - If true, the output will have two components in long form.
+ * Otherwise, just one component will be displayed in narrow form.
+ *
+ * @returns {string} A human-readable string representation of the duration.
+ *
+ * @example
+ * // Sample usage
+ * const durationInSeconds = 7320; // 2 hours and 2 minutes (2 * 3600 + 2 * 60)
+ * const fullDuration = humanizeDuration(durationInSeconds, true);
+ * console.log(fullDuration); // Output: "2 hours, 2 minutes"
+ *
+ * const shortDuration = humanizeDuration(durationInSeconds, false);
+ * console.log(shortDuration); // Output: "2h"
+ */
+export function formatDuration(seconds, showFullDuration) {
+ const displayStyle = showFullDuration ? "long" : "narrow";
+ const numberOfValuesToDisplay = showFullDuration ? 2 : 1;
+ const durationKeys = ["years", "months", "days", "hours", "minutes"];
+
+ if (seconds < 60) {
+ seconds = 60;
+ }
+ seconds -= seconds % 60;
+
+ let duration = luxon.Duration.fromObject({ seconds: seconds }).shiftTo(...durationKeys);
+ duration = duration.shiftTo(...durationKeys.filter((key) => duration.get(key)));
+ const durationSplit = duration.toHuman({ unitDisplay: displayStyle }).split(",");
+
+ if (!showFullDuration && duration.loc.locale.includes("en") && duration.months > 0) {
+ durationSplit[0] = durationSplit[0].replace("m", "M");
+ }
+ return durationSplit.slice(0, numberOfValuesToDisplay).join(",");
+}
+
/**
* Returns a string representing a float. The result takes into account the
* user settings (to display the correct decimal separator).
diff --git a/addons/web/static/src/views/fields/statusbar/statusbar_field.scss b/addons/web/static/src/views/fields/statusbar/statusbar_field.scss
index 75816704612..dab8fd862c6 100644
--- a/addons/web/static/src/views/fields/statusbar/statusbar_field.scss
+++ b/addons/web/static/src/views/fields/statusbar/statusbar_field.scss
@@ -50,7 +50,8 @@
&.disabled {
opacity: 1;
- pointer-events: none;
+ pointer-events: auto;
+ cursor: default;
&:not(.o_arrow_button_current) {
&, &:hover, &:focus {
@@ -76,4 +77,4 @@
}
}
}
-}
\ No newline at end of file
+}