diff --git a/addons/hr_timesheet/__manifest__.py b/addons/hr_timesheet/__manifest__.py
index c9e8db62f0f..01e53c8457d 100644
--- a/addons/hr_timesheet/__manifest__.py
+++ b/addons/hr_timesheet/__manifest__.py
@@ -20,7 +20,7 @@ It is completely integrated with the cost accounting module. It allows you to se
up a management by affair.
""",
'website': 'https://www.odoo.com/page/timesheet-mobile-app',
- 'depends': ['hr', 'analytic', 'project', 'uom'],
+ 'depends': ['hr', 'analytic', 'project', 'uom', 'timer'],
'data': [
'security/hr_timesheet_security.xml',
'security/ir.model.access.csv',
diff --git a/addons/hr_timesheet/models/__init__.py b/addons/hr_timesheet/models/__init__.py
index 343cff74edd..ee021539a0f 100644
--- a/addons/hr_timesheet/models/__init__.py
+++ b/addons/hr_timesheet/models/__init__.py
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from . import timer_mixin
from . import hr_employee
from . import hr_timesheet
from . import ir_http
diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py
index e58daf247d6..324ece59f25 100644
--- a/addons/hr_timesheet/models/hr_timesheet.py
+++ b/addons/hr_timesheet/models/hr_timesheet.py
@@ -199,42 +199,33 @@ class AccountAnalyticLine(models.Model):
def _compute_display_timer(self):
uom_hour = self.env.ref('uom.product_uom_hour')
for analytic_line in self:
- analytic_line.display_timer = analytic_line.encoding_uom_id == uom_hour
+ analytic_line.display_timer = analytic_line.encoding_uom_id == uom_hour and analytic_line.project_id.allow_timesheet_timer
def action_timer_start(self):
- """ Start timer and search if another timer hasn't been launched.
- If yes, then stop the timer before launch this timer.
+ """ Start a timer if it isn't already started and the
+ timesheets allow to track time
"""
- if not self.timer_start and self.display_timer:
- self._stop_running_timers()
+ if not self.user_timer_id.timer_start and self.display_timer:
super().action_timer_start()
- def _stop_running_timers(self):
- """ Search if a timesheet has a timer activated and stop the timer.
- Check if a timer is activated for another timesheet
- if yes, then update unit_amount field and stop timer,
- otherwise, do nothing.
- """
- analytic_line = self.search([('timer_start', '!=', False), ('user_id', '=', self.env.uid)])
- if analytic_line:
- analytic_line.action_timer_stop()
-
def action_timer_stop(self):
- """ Action stop the timer of the current timesheet.
- When the timer must be stopped, we must calculate the new
- unit_amount based on the timer and the previous value of
- unit_amount for the current timesheet.
+ """ Stop the current timer
"""
- if self.timer_start and self.display_timer:
- minutes_spent = self._get_minutes_spent()
- if self.unit_amount == 0 and minutes_spent < 1:
- # Check if unit_amount equals 0 and minutes_spent is less than 1 minute,
- # if yes, then remove the timesheet
- self.unlink()
+ if self.user_timer_id.timer_start and self.display_timer:
+ minutes_spent = super().action_timer_stop()
+ self._add_timesheet_time(minutes_spent)
+
+ def _add_timesheet_time(self, minutes_spent):
+ if self.unit_amount == 0 and minutes_spent < 1:
+ # Check if unit_amount equals 0 and minutes_spent is less than 1 minute,
+ # if yes, then remove the timesheet
+ self.unlink()
+ else:
+ if minutes_spent < 1:
+ amount = self.unit_amount
else:
- if minutes_spent < 1:
- amount = self.unit_amount
- else:
- amount = self.unit_amount + minutes_spent * 60 / 3600
- self.write({'unit_amount': amount})
- super().action_timer_stop()
+ amount = self.unit_amount + minutes_spent * 60 / 3600
+ self.write({'unit_amount': amount})
+
+ def _action_interrupt_user_timers(self):
+ self.action_timer_stop()
\ No newline at end of file
diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py
index c44381ef79a..bc82a9866a3 100644
--- a/addons/hr_timesheet/models/project.py
+++ b/addons/hr_timesheet/models/project.py
@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from math import ceil
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
@@ -79,7 +78,6 @@ class Project(models.Model):
def _init_data_analytic_account(self):
self.search([('analytic_account_id', '=', False), ('allow_timesheets', '=', True)])._create_analytic_account()
-
class Task(models.Model):
_name = "project.task"
_inherit = ["project.task", "timer.mixin"]
@@ -94,32 +92,22 @@ class Task(models.Model):
subtask_effective_hours = fields.Float("Sub-tasks Hours Spent", compute='_compute_subtask_effective_hours', store=True, help="Sum of actually spent hours on the subtask(s)")
timesheet_ids = fields.One2many('account.analytic.line', 'task_id', 'Timesheets')
- timer_start = fields.Datetime("Timesheet Timer Start")
- timer_pause = fields.Datetime("Timesheet Timer Last Pause")
# YTI FIXME: Those field seems quite useless
timesheet_timer_first_start = fields.Datetime("Timesheet Timer First Use", readonly=True)
timesheet_timer_last_stop = fields.Datetime("Timesheet Timer Last Use", readonly=True)
display_timesheet_timer = fields.Boolean("Display Timesheet Time", compute='_compute_display_timesheet_timer')
- display_timer_start_primary = fields.Boolean(compute='_compute_display_timer_buttons')
+
display_timer_start_secondary = fields.Boolean(compute='_compute_display_timer_buttons')
- display_timer_stop = fields.Boolean(compute='_compute_display_timer_buttons')
- display_timer_pause = fields.Boolean(compute='_compute_display_timer_buttons')
- display_timer_resume = fields.Boolean(compute='_compute_display_timer_buttons')
@api.depends('display_timesheet_timer', 'timer_start', 'timer_pause', 'total_hours_spent')
def _compute_display_timer_buttons(self):
for task in self:
- start_p, start_s, stop, pause, resume = True, True, True, True, True
+ displays = super()._compute_display_timer_buttons()
+ start_p, start_s, stop, pause, resume = displays['start_p'], displays['start_p'], displays['stop'], displays['pause'], displays['resume']
if not task.display_timesheet_timer:
start_p, start_s, stop, pause, resume = False, False, False, False, False
else:
- if task.timer_start:
- start_p, start_s = False, False
- if task.timer_pause:
- pause = False
- else:
- resume = False
- else:
+ if not task.timer_start:
stop, pause, resume = False, False, False
if not task.total_hours_spent:
start_s = False
@@ -213,27 +201,19 @@ class Task(models.Model):
return result
def action_timer_start(self):
- self.ensure_one()
- if not self.timesheet_timer_first_start:
- self.write({'timesheet_timer_first_start': fields.Datetime.now()})
- super(Task, self).action_timer_start()
+ if not self.user_timer_id.timer_start and self.display_timesheet_timer:
+ super(Task, self).action_timer_start()
def action_timer_stop(self):
- self.ensure_one()
- if self.timer_start: # timer was either running or paused
- minutes_spent = self._get_minutes_spent()
- minutes_spent = self._timer_rounding(minutes_spent)
+ # timer was either running or paused
+ if self.user_timer_id.timer_start and self.display_timesheet_timer:
+ minutes_spent = super().action_timer_stop()
+ minimum_duration = int(self.env['ir.config_parameter'].sudo().get_param('hr_timesheet.timesheet_min_duration', 0))
+ rounding = int(self.env['ir.config_parameter'].sudo().get_param('hr_timesheet.timesheet_rounding', 0))
+ minutes_spent = self._timer_rounding(minutes_spent, minimum_duration, rounding)
return self._action_create_timesheet(minutes_spent * 60 / 3600)
return False
- def _timer_rounding(self, minutes_spent):
- minimum_duration = int(self.env['ir.config_parameter'].sudo().get_param('hr_timesheet.timesheet_min_duration', 0))
- rounding = int(self.env['ir.config_parameter'].sudo().get_param('hr_timesheet.timesheet_rounding', 0))
- minutes_spent = max(minimum_duration, minutes_spent)
- if rounding and ceil(minutes_spent % rounding) != 0:
- minutes_spent = ceil(minutes_spent / rounding) * rounding
- return minutes_spent
-
def _action_create_timesheet(self, time_spent):
return {
"name": _("Validate Spent Time"),
@@ -244,7 +224,7 @@ class Task(models.Model):
"context": {
**self.env.context,
'active_id': self.id,
- 'active_model': 'project.task',
+ 'active_model': self._name,
'default_time_spent': time_spent,
},
}
diff --git a/addons/hr_timesheet/static/src/js/timesheet_uom.js b/addons/hr_timesheet/static/src/js/timesheet_uom.js
index a462b1d463b..9c7f9a869f7 100644
--- a/addons/hr_timesheet/static/src/js/timesheet_uom.js
+++ b/addons/hr_timesheet/static/src/js/timesheet_uom.js
@@ -6,7 +6,7 @@ const fieldUtils = require('web.field_utils');
const fieldRegistry = require('web.field_registry');
-const Timer = require('hr_timesheet.Timer');
+const Timer = require('timer.Timer');
// We need the field registry to be populated, as we bind the
// timesheet_uom widget on existing field widgets.
@@ -92,9 +92,9 @@ const FieldTimesheetTime = basicFields.FieldFloatTime.extend({
// Check if the timer_start exists and it's not false
// In other word, when user clicks on play button, this button
// launches the "action_timer_start".
- if (this.recordData.timer_start) {
+ if (this.recordData.timer_start && !this.recordData.timer_pause) {
const time = await this._rpc({
- model: this.model,
+ model: 'timer.timer',
method: 'get_server_time',
args: []
});
diff --git a/addons/hr_timesheet/tests/test_timesheet.py b/addons/hr_timesheet/tests/test_timesheet.py
index eb7e71fddd7..ff786bc268b 100644
--- a/addons/hr_timesheet/tests/test_timesheet.py
+++ b/addons/hr_timesheet/tests/test_timesheet.py
@@ -4,6 +4,8 @@
from odoo.tests.common import TransactionCase
from odoo.exceptions import AccessError, UserError
+from datetime import datetime, timedelta
+
class TestCommonTimesheet(TransactionCase):
@@ -35,6 +37,7 @@ class TestCommonTimesheet(TransactionCase):
'allow_timesheets': True,
'partner_id': self.partner.id,
'analytic_account_id': self.analytic_account.id,
+ 'allow_timesheet_timer': True
})
self.task1 = self.env['project.task'].create({
'name': 'Task One',
@@ -82,7 +85,6 @@ class TestCommonTimesheet(TransactionCase):
'user_id': self.user_manager.id,
})
-
class TestTimesheet(TestCommonTimesheet):
def setUp(self):
@@ -287,3 +289,29 @@ class TestTimesheet(TestCommonTimesheet):
}, {
'amount': -12.0,
}])
+
+ def test_minutes_computing_after_timer_stop(self):
+ """ Test if unit_amount is updated after stoping a timer """
+ Timesheet = self.env['account.analytic.line']
+ timesheet_1 = Timesheet.with_user(self.user_employee).create({
+ 'project_id': self.project_customer.id,
+ 'task_id': self.task1.id,
+ 'name': '/',
+ 'unit_amount': 1,
+ })
+
+ # When the timer is less than 1 minute
+ now = datetime.now()
+ timesheet_1.with_user(self.user_employee).action_timer_start()
+ timesheet_1.with_user(self.user_employee).user_timer_id.timer_start = now - timedelta(seconds=28)
+ timesheet_1.with_user(self.user_employee).action_timer_stop()
+
+ self.assertEqual(timesheet_1.unit_amount, 1, 'unit_amount still should have the same value')
+
+ # When the timer is greater than 1 minute
+ now = datetime.now()
+ timesheet_1.with_user(self.user_employee).action_timer_start()
+ timesheet_1.with_user(self.user_employee).user_timer_id.timer_start = now - timedelta(minutes=1, seconds=28)
+ timesheet_1.with_user(self.user_employee).action_timer_stop()
+
+ self.assertGreater(timesheet_1.unit_amount, 1, 'unit_amount should be greated than his last value')
diff --git a/addons/hr_timesheet/views/assets.xml b/addons/hr_timesheet/views/assets.xml
index cf8f06e6802..34f25b9ab14 100644
--- a/addons/hr_timesheet/views/assets.xml
+++ b/addons/hr_timesheet/views/assets.xml
@@ -4,10 +4,7 @@
-
-
-
diff --git a/addons/hr_timesheet/views/project_views.xml b/addons/hr_timesheet/views/project_views.xml
index 3d1530d7378..ee1dc7527de 100644
--- a/addons/hr_timesheet/views/project_views.xml
+++ b/addons/hr_timesheet/views/project_views.xml
@@ -282,7 +282,7 @@
ml-2
-
+
diff --git a/addons/hr_timesheet/wizard/project_task_create_timesheet.py b/addons/hr_timesheet/wizard/project_task_create_timesheet.py
index dd461c29ea9..a73eb9326f9 100644
--- a/addons/hr_timesheet/wizard/project_task_create_timesheet.py
+++ b/addons/hr_timesheet/wizard/project_task_create_timesheet.py
@@ -15,9 +15,6 @@ class ProjectTaskCreateTimesheet(models.TransientModel):
@api.model
def default_get(self, fields):
result = super(ProjectTaskCreateTimesheet, self).default_get(fields)
- active_model = self._context.get('active_model')
- if active_model != 'project.task':
- raise UserError(_("You can only apply this action from a task."))
active_id = self._context.get('active_id')
if 'task_id' in fields and active_id:
diff --git a/addons/test_timer/__init__.py b/addons/test_timer/__init__.py
new file mode 100644
index 00000000000..dc5e6b693d1
--- /dev/null
+++ b/addons/test_timer/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
diff --git a/addons/test_timer/__manifest__.py b/addons/test_timer/__manifest__.py
new file mode 100644
index 00000000000..542b88755d4
--- /dev/null
+++ b/addons/test_timer/__manifest__.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+
+{
+ 'name': 'Timer Tests',
+ 'version': '1.0',
+ 'category': 'Hidden',
+ 'sequence': 8765,
+ 'summary': 'Timer Tests: feature and performance tests for timer',
+ 'description': """This module contains tests related to timer. Those are
+contained in a separate module as it contains models used only to perform
+tests independently to functional aspects of other models. """,
+ 'depends': ['timer'],
+ 'data': [
+ 'security/ir.model.access.csv',
+ ],
+ 'demo': [
+ ],
+ 'installable': True,
+ 'application': False,
+}
diff --git a/addons/test_timer/models/__init__.py b/addons/test_timer/models/__init__.py
new file mode 100644
index 00000000000..e2a77737638
--- /dev/null
+++ b/addons/test_timer/models/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import timer_test
diff --git a/addons/test_timer/models/timer_test.py b/addons/test_timer/models/timer_test.py
new file mode 100644
index 00000000000..5eb26b7618d
--- /dev/null
+++ b/addons/test_timer/models/timer_test.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class TimerTest(models.Model):
+ """ A very simple model only inheriting from timer.mixin to test
+ timer features """
+ _description = 'Timer Model'
+ _name = 'timer.test'
+ _inherit = ['timer.mixin']
+
+ name = fields.Char(required=1)
+
+
+class OverrideInterruptionTimerTest(models.Model):
+ """ A very simple model inheriting from timer.mixin and
+ overriding _action_interrupt_user_timers() """
+ _description = 'Interruption Timer Model'
+ _name = 'interruption.timer.test'
+ _inherit = ['timer.mixin']
+
+ def _action_interrupt_user_timers(self):
+ self.action_timer_stop()
diff --git a/addons/test_timer/security/ir.model.access.csv b/addons/test_timer/security/ir.model.access.csv
new file mode 100644
index 00000000000..d3c447a94e4
--- /dev/null
+++ b/addons/test_timer/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_timer_test_user,timer.test.user,model_timer_test,base.group_user,1,1,1,1
+access_interruption_timer_test_user,timer.interruption.test.user,model_interruption_timer_test,base.group_user,1,1,1,1
diff --git a/addons/test_timer/tests/__init__.py b/addons/test_timer/tests/__init__.py
new file mode 100644
index 00000000000..a52db405139
--- /dev/null
+++ b/addons/test_timer/tests/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import test_timer
\ No newline at end of file
diff --git a/addons/test_timer/tests/test_timer.py b/addons/test_timer/tests/test_timer.py
new file mode 100644
index 00000000000..427210524da
--- /dev/null
+++ b/addons/test_timer/tests/test_timer.py
@@ -0,0 +1,141 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.exceptions import AccessError
+
+from odoo.tests.common import SavepointCase
+
+
+class TestTimer(SavepointCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestTimer, cls).setUpClass()
+
+ # Setup mixin
+ cls.test_timer = cls.env['timer.test'].create({'name': 'Timer 1'})
+ cls.test_timer_bis = cls.env['timer.test'].create({'name': 'Timer 2'})
+
+ # Setup users
+ cls.usr1 = cls.env['res.users'].create({
+ 'name': 'Usr1',
+ 'login': 'Usr1',
+ 'email': 'usr1@test.com',
+ 'groups_id': [(6, 0, [cls.env.ref('base.group_user').id])],
+ })
+
+ cls.usr2 = cls.env['res.users'].create({
+ 'name': 'Usr2',
+ 'login': 'Usr2',
+ 'email': 'usr2@test.com',
+ 'groups_id': [(6, 0, [cls.env.ref('base.group_user').id])],
+ })
+
+
+ def test_timer_from_self_user(self):
+
+ # Start and stop
+ self.test_timer.action_timer_start()
+ self.assertEqual(len(self.test_timer.user_timer_id), 1, 'It should have created one timer')
+
+ self.test_timer.action_timer_stop()
+ self.assertEqual(len(self.test_timer.user_timer_id), 0, 'It should have delete the timer')
+
+ # Start the first timer then the second one
+ self.test_timer.action_timer_start()
+ self.test_timer_bis.action_timer_start()
+
+ self.assertEqual(len(self.env['timer.timer'].search([])), 2, 'It should have created 2 timers for the same user')
+ self.assertFalse(self.test_timer.is_timer_running, "The first timer should be in pause because the second one interrupt it")
+ self.assertTrue(self.test_timer_bis.is_timer_running, 'The second timer should be running')
+
+ # Resume the first one
+ self.test_timer.action_timer_resume()
+
+ self.assertTrue(self.test_timer.is_timer_running, 'The first timer should be running after being resumed')
+ self.assertFalse(self.test_timer_bis.is_timer_running, 'The second timer should be in pause when another timer has been started')
+
+ # Start a new test timer with interruption override
+ override_test_timer = self.env['interruption.timer.test'].create({})
+ override_test_timer.action_timer_start()
+
+ self.assertEqual(len(self.env['timer.timer'].search([])), 3, 'A third timer should be created')
+ self.assertFalse(self.test_timer.is_timer_running, 'The first timer has been interrupted and should be in pause')
+ self.assertFalse(self.test_timer_bis.is_timer_running, 'The second timer has been interrupted and should be in pause')
+ self.assertTrue(override_test_timer.is_timer_running, 'The third timer should be running')
+
+ # Resume another timer to interrupt the new one
+ self.test_timer_bis.action_timer_resume()
+
+ self.assertEqual(len(self.env['timer.timer'].search([])), 2, 'It should remains only 2 timers')
+ self.assertEqual(len(override_test_timer.user_timer_id), 0, 'The third timer should be deleted because of his override method')
+
+ def test_timer_with_many_users(self):
+
+ # 2 users, 1 record = 2 timers
+ self.test_timer.with_user(self.usr1).action_timer_start()
+ self.test_timer.with_user(self.usr2).action_timer_start()
+
+ self.assertEqual(len(self.env['timer.timer'].search([])), 2, 'It should have created two timers')
+ self.assertEqual(len(self.test_timer.with_user(self.usr1).user_timer_id), 1, 'It should exist only one timer for this user, model and record')
+ self.assertEqual(len(self.test_timer.with_user(self.usr2).user_timer_id), 1, 'It should exist only one timer for this user, model and record')
+
+ # Stop one of them
+ self.test_timer.with_user(self.usr2).action_timer_stop()
+
+ self.assertEqual(len(self.env['timer.timer'].search([])), 1, 'It should have deleted one timer')
+ self.assertEqual(len(self.test_timer.with_user(self.usr1).user_timer_id), 1, 'It should exist only one timer for this user, model and record')
+ self.assertEqual(len(self.test_timer.with_user(self.usr2).user_timer_id), 0, 'It shouldn\'t exit one timer for this user, model and record')
+
+ def test_timer_rounding(self):
+
+ minutes_spent, minimum, rounding = 4.5,10,5
+ result = self.test_timer._timer_rounding(minutes_spent, minimum, rounding)
+ self.assertEqual(result, 10, 'It should have been round to the minimum amount')
+
+ minutes_spent = 12.4
+ result = self.test_timer._timer_rounding(minutes_spent, minimum, rounding)
+ self.assertEqual(result, 15, 'It should have been round to the next multiple of 15')
+
+ def test_timer_access_security(self):
+
+ # Create usr1's timer1
+ timer1 = self.env['timer.timer'].with_user(self.usr1).create({
+ 'timer_start' : False,
+ 'timer_pause' : False,
+ 'is_timer_running' : False,
+ 'res_model' : self.test_timer._name,
+ 'res_id' : self.test_timer.id,
+ 'user_id' : self.usr1.id,
+ })
+
+ # Create usr1's timer2
+ timer2 = self.env['timer.timer'].with_user(self.usr1).create({
+ 'timer_start' : False,
+ 'timer_pause' : False,
+ 'is_timer_running' : False,
+ 'res_model' : self.test_timer_bis._name,
+ 'res_id' : self.test_timer_bis.id,
+ 'user_id' : self.usr1.id,
+ })
+
+ # Start timer2
+ timer2.action_timer_start()
+
+ with self.assertRaises(AccessError):
+
+ # Try to create a timer with usr1 for usr2 (Create)
+ self.env['timer.timer'].with_user(self.usr1).create({
+ 'timer_start' : False,
+ 'timer_pause' : False,
+ 'is_timer_running' : False,
+ 'res_model' : self.test_timer._name,
+ 'res_id' : self.test_timer.id,
+ 'user_id' : self.usr2.id,
+ })
+
+ # Try to start the timer1 with another usr2 (Write)
+ timer1.with_user(self.usr2).action_timer_start()
+
+ # Try to stop the timer2 with usr2 (Unlink)
+ timer2.with_user(self.usr2).action_timer_stop()
diff --git a/addons/timer/__init__.py b/addons/timer/__init__.py
new file mode 100644
index 00000000000..dc5e6b693d1
--- /dev/null
+++ b/addons/timer/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
diff --git a/addons/timer/__manifest__.py b/addons/timer/__manifest__.py
new file mode 100644
index 00000000000..c0aa17e115d
--- /dev/null
+++ b/addons/timer/__manifest__.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+
+{
+ 'name': 'Timer',
+ 'version': '1.0',
+ 'sequence': 24,
+ 'summary': 'Record time',
+ 'description': """
+This module implements a timer.
+==========================================
+
+It adds a timer to a view for time recording purpose
+ """,
+ 'depends': ['web', 'mail'],
+ 'data': [
+ 'security/timer_security.xml',
+ 'security/ir.model.access.csv',
+ 'views/assets.xml',
+ ],
+ 'demo': [],
+ 'installable': True,
+ 'application': False,
+ 'auto_install': False,
+}
diff --git a/addons/timer/models/__init__.py b/addons/timer/models/__init__.py
new file mode 100644
index 00000000000..66c7f179a40
--- /dev/null
+++ b/addons/timer/models/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import timer
+from . import timer_mixin
diff --git a/addons/hr_timesheet/models/timer_mixin.py b/addons/timer/models/timer.py
similarity index 65%
rename from addons/hr_timesheet/models/timer_mixin.py
rename to addons/timer/models/timer.py
index e4070d9b0a3..d8e87533703 100644
--- a/addons/hr_timesheet/models/timer_mixin.py
+++ b/addons/timer/models/timer.py
@@ -2,29 +2,35 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, api
+from odoo.exceptions import ValidationError
-
-class TimerMixin(models.AbstractModel):
- _name = 'timer.mixin'
- _description = 'Timer Mixin'
- # YTI Note: This mixin is supposed to be extended to
- # any models
+class TimerTimer(models.Model):
+ _name = 'timer.timer'
+ _description = 'Timer Module'
timer_start = fields.Datetime("Timer Start")
timer_pause = fields.Datetime("Timer Last Pause")
- is_timer_running = fields.Boolean(compute="_compute_timer")
+ is_timer_running = fields.Boolean(compute="_compute_is_timer_running")
+ res_model = fields.Char(required=True)
+ res_id = fields.Char(required=True)
+ user_id = fields.Many2one('res.users')
- @api.depends('timer_start')
- def _compute_timer(self):
+ _sql_constraints = [(
+ 'unique_timer', 'UNIQUE(res_model, res_id, user_id)',
+ 'Only one timer occurrence by model, record and user')]
+
+ @api.depends('timer_start', 'timer_pause')
+ def _compute_is_timer_running(self):
for record in self:
- record.is_timer_running = bool(record.timer_start)
+ record.is_timer_running = record.timer_start and not record.timer_pause
+
+ @api.model
+ def create(self, vals):
+ # Reset the user_timer_id to force the recomputation
+ self.env[vals['res_model']].invalidate_cache(fnames=['user_timer_id'], ids=[vals['res_id']])
+ return super().create(vals)
def action_timer_start(self):
- """ Action start the timer.
- Start timer and search if another timer hasn't been launched.
- If yes, then stop the timer before launch this timer.
- """
- self.ensure_one()
if not self.timer_start:
self.write({'timer_start': fields.Datetime.now()})
@@ -33,7 +39,6 @@ class TimerMixin(models.AbstractModel):
:return minutes_spent if the timer is started,
otherwise return False
"""
- self.ensure_one()
if not self.timer_start:
return False
minutes_spent = self._get_minutes_spent()
diff --git a/addons/timer/models/timer_mixin.py b/addons/timer/models/timer_mixin.py
new file mode 100644
index 00000000000..201628a16af
--- /dev/null
+++ b/addons/timer/models/timer_mixin.py
@@ -0,0 +1,135 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, fields, api
+from math import ceil
+
+class TimerMixin(models.AbstractModel):
+ _name = 'timer.mixin'
+ _description = 'Timer Mixin'
+
+ timer_start = fields.Datetime(related='user_timer_id.timer_start')
+ timer_pause = fields.Datetime(related='user_timer_id.timer_pause')
+ is_timer_running = fields.Boolean(related='user_timer_id.is_timer_running')
+ user_timer_id = fields.One2many('timer.timer', compute='_compute_user_timer_id', search='_search_user_timer_id')
+
+ display_timer_start_primary = fields.Boolean(compute='_compute_display_timer_buttons')
+ display_timer_stop = fields.Boolean(compute='_compute_display_timer_buttons')
+ display_timer_pause = fields.Boolean(compute='_compute_display_timer_buttons')
+ display_timer_resume = fields.Boolean(compute='_compute_display_timer_buttons')
+
+ def _search_user_timer_id(self, operator, value):
+ timers = self.env['timer.timer'].search([
+ ('id', operator, value),
+ ('user_id', '=', self.env.user.id),
+ ])
+ return [('id', 'in', timers.mapped('res_id'))]
+
+ @api.depends_context('uid')
+ def _compute_user_timer_id(self):
+ """ Get the timers according these conditions
+ :user_id is is the current user
+ :res_id is the current record
+ :res_model is the current model
+ limit=1 by security but the search should never have more than one record
+ """
+ for record in self:
+ record.user_timer_id = self.env['timer.timer'].search([
+ ('user_id', '=', record.env.user.id),
+ ('res_id', '=', record.id),
+ ('res_model', '=', record._name)
+ ], limit=1)
+
+ @api.model
+ def _get_user_timers(self):
+ # Return user's timers. Can have multiple timers if some are in pause
+ return self.env['timer.timer'].search([('user_id', '=', self.env.user.id)])
+
+ def action_timer_start(self):
+ """ Start the timer of the current record
+ First, if a timer is running, stop or pause it
+ If there isn't a timer for the current record, create one then start it
+ Otherwise, resume or start it
+ """
+ self.ensure_one()
+ self._stop_timer_in_progress()
+ timer = self.user_timer_id
+ if not timer:
+ timer = self.env['timer.timer'].create({
+ 'timer_start' : False,
+ 'timer_pause' : False,
+ 'is_timer_running' : False,
+ 'res_model' : self._name,
+ 'res_id' : self.id,
+ 'user_id' : self.env.user.id,
+ })
+ timer.action_timer_start()
+ else:
+ # Check if it is in pause then resume it or start it
+ if timer.timer_pause:
+ timer.action_timer_resume()
+ else:
+ timer.action_timer_start()
+
+ def action_timer_stop(self):
+ """ Stop the timer of the current record
+ Unlink the timer, it's useless to keep the stopped timer.
+ A new timer can be create if needed
+ Return the amount of minutes spent
+ """
+ self.ensure_one()
+ timer = self.user_timer_id
+ minutes_spent = timer.action_timer_stop()
+ timer.unlink()
+ return minutes_spent
+
+ def action_timer_pause(self):
+ self.ensure_one()
+ timer = self.user_timer_id
+ timer.action_timer_pause()
+
+ def action_timer_resume(self):
+ self.ensure_one()
+ self._stop_timer_in_progress()
+ timer = self.user_timer_id
+ timer.action_timer_resume()
+
+ def _action_interrupt_user_timers(self):
+ # Interruption is the action called when the timer is stoped by the start of another one
+ self.action_timer_pause()
+
+ def _stop_timer_in_progress(self):
+ """
+ Cancel the timer in progress if there is one
+ Each model can interrupt the running timer in a specific way
+ By setting it in pause or stop by example
+ """
+ timer = self._get_user_timers().filtered(lambda t: t.is_timer_running)
+ if timer:
+ model = self.env[timer.res_model].browse(timer.res_id)
+ model._action_interrupt_user_timers()
+
+ @api.depends('timer_start', 'timer_pause')
+ def _compute_display_timer_buttons(self):
+ for record in self:
+ start_p, stop, pause, resume = True, True, True, True
+ if record.timer_start:
+ start_p = False
+ if record.timer_pause:
+ pause = False
+ else:
+ resume = False
+ record.write({
+ 'display_timer_start_primary': start_p,
+ 'display_timer_stop': stop,
+ 'display_timer_pause': pause,
+ 'display_timer_resume': resume,
+ })
+ return {'start_p': start_p, 'stop': stop, 'pause': pause, 'resume': resume}
+
+ @api.model
+ def _timer_rounding(self, minutes_spent, minimum, rounding):
+ minutes_spent = max(minimum, minutes_spent)
+ if rounding and ceil(minutes_spent % rounding) != 0:
+ minutes_spent = ceil(minutes_spent / rounding) * rounding
+ return minutes_spent
diff --git a/addons/timer/security/ir.model.access.csv b/addons/timer/security/ir.model.access.csv
new file mode 100644
index 00000000000..852a5f208df
--- /dev/null
+++ b/addons/timer/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_timer_user,timer.timer.user,timer.model_timer_timer,base.group_user,1,1,1,1
diff --git a/addons/timer/security/timer_security.xml b/addons/timer/security/timer_security.xml
new file mode 100644
index 00000000000..662a2aac0d7
--- /dev/null
+++ b/addons/timer/security/timer_security.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+ timer.user.rule.create-write-delete
+
+ [
+ ('user_id', '=', user.id)
+ ]
+
+
+
+
+
+
+
+
+ timer.not.user.rule.create-write-delete
+
+ [
+ ('user_id', '!=', user.id)
+ ]
+
+
+
+
+
+
+
+
+
diff --git a/addons/hr_timesheet/static/src/js/timer.js b/addons/timer/static/src/js/timer.js
similarity index 88%
rename from addons/hr_timesheet/static/src/js/timer.js
rename to addons/timer/static/src/js/timer.js
index 5e7e4a1b2fb..0042ebfaead 100644
--- a/addons/hr_timesheet/static/src/js/timer.js
+++ b/addons/timer/static/src/js/timer.js
@@ -1,9 +1,9 @@
-odoo.define('hr_timesheet.timer', function (require) {
+odoo.define('timer.timer', function (require) {
"use strict";
var fieldRegistry = require('web.field_registry');
var AbstractField = require('web.AbstractField');
-var Timer = require('hr_timesheet.Timer');
+var Timer = require('timer.Timer');
var TimerFieldWidget = AbstractField.extend({
@@ -51,13 +51,13 @@ var TimerFieldWidget = AbstractField.extend({
},
_getServerTime: function () {
return this._rpc({
- model: 'account.analytic.line',
+ model: 'timer.timer',
method: 'get_server_time',
args: []
});
}
});
-fieldRegistry.add('timesheet_timer', TimerFieldWidget);
+fieldRegistry.add('timer_timer', TimerFieldWidget);
});
diff --git a/addons/hr_timesheet/static/src/js/timer_mixin.js b/addons/timer/static/src/js/timer_mixin.js
similarity index 98%
rename from addons/hr_timesheet/static/src/js/timer_mixin.js
rename to addons/timer/static/src/js/timer_mixin.js
index 2a2c993dd91..779d06dbe4a 100644
--- a/addons/hr_timesheet/static/src/js/timer_mixin.js
+++ b/addons/timer/static/src/js/timer_mixin.js
@@ -1,4 +1,4 @@
-odoo.define('hr_timesheet.Timer', function (require) {
+odoo.define('timer.Timer', function (require) {
"use strict";
/**
diff --git a/addons/hr_timesheet/static/src/js/timer_toggle_button.js b/addons/timer/static/src/js/timer_toggle_button.js
similarity index 95%
rename from addons/hr_timesheet/static/src/js/timer_toggle_button.js
rename to addons/timer/static/src/js/timer_toggle_button.js
index 3a0fabd28c9..544c6219940 100644
--- a/addons/hr_timesheet/static/src/js/timer_toggle_button.js
+++ b/addons/timer/static/src/js/timer_toggle_button.js
@@ -1,4 +1,4 @@
-odoo.define('hr_timesheet.timer_toggle_button', function (require) {
+odoo.define('timer.timer_toggle_button', function (require) {
"use strict";
const fieldRegistry = require('web.field_registry');
@@ -51,9 +51,8 @@ const TimerToggleButton = FieldToggleBoolean.extend({
*/
_onToggleButton: async function (event) {
event.stopPropagation();
-
const result = await this._rpc({
- model: 'account.analytic.line',
+ model: this.model,
method: this._getActionButton(),
args: [this.res_id]
});
diff --git a/addons/timer/static/src/scss/timer_button.scss b/addons/timer/static/src/scss/timer_button.scss
new file mode 100644
index 00000000000..8b269fa8b20
--- /dev/null
+++ b/addons/timer/static/src/scss/timer_button.scss
@@ -0,0 +1,26 @@
+.o-timer-button {
+ &:focus {
+ border: none !important;
+ outline: 0 !important;
+ }
+
+ & > i {
+ transition: 0.3s;
+
+ &.o-timer-play-button {
+ color: $link-color !important;
+ }
+
+ &.o-timer-stop-button {
+ color: $red !important;
+ }
+ }
+
+ &:hover > i.o-timer-play-button {
+ color: $link-hover-color !important;
+ }
+
+ &:hover > i.o-timer-stop-button {
+ color: darken($red, 15%) !important;
+ }
+}
diff --git a/addons/timer/views/assets.xml b/addons/timer/views/assets.xml
new file mode 100644
index 00000000000..a8565a53515
--- /dev/null
+++ b/addons/timer/views/assets.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+