[MERGE] timer: Introduce a new mixin to record time
1/ timer: New mixin to record time on models
Purpose
The timer mechanism is already existing on timesheet.
We would like to introduce it on helpdesk.ticket too, and
probably on other models afterward.
On the other hand, only one timer can be launched on a single
timesheet currently, shared amongst the different users.
This commit introduced a new mixin on which
- Each user can launch a timer on each record of the models
inheriting the mixin.
- Launching a new timer on a record pauses the timer of the
user that could be already running.
- The mixin brings methods to start/stop/pause/resume the
timer, that could be overridden to implement a specific
behavior for the related model, for example create a
timesheet entry.
2/ test_timer: Add test module for the timer mixin
Purpose
As testing the mixin requires to create a new model,
we prefered to create another module.
3/ hr_timesheet: Adapt timer with new timer mixin
Purpose
Now that we have a new timer mixin that offers more possibilities
than what was currently implemented, we can reuse it in the timesheet
management module.
4/ timesheet_grid: Adapt timer buttons to new mixin
Purpose
The timesheets now uses an improved version of the timer,
using a new mixin.
Adapt the code accordingly.
5/ helpdesk_timesheet: Use timer mixin on helpdesk tickets
Purpose
Now that we have a new timer mixin that offers more possibilities
than what was currently implemented, we can reuse it in the helpdesk
management module.
closes odoo/odoo#43778
Taskid: 2149450
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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: []
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
<xpath expr="." position="inside">
|
||||
<link type="text/scss" href="/hr_timesheet/static/src/scss/timer_button.scss" rel="stylesheet"/>
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/task_with_hours.js"></script>
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/timer.js"/>
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/timer_mixin.js"/>
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/timesheet_uom.js"/>
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/timer_toggle_button.js"/>
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/timesheet_list_view.js"/>
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/timesheet_kanban_view.js"/>
|
||||
</xpath>
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
<attribute name="class">ml-2</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='stage_id']" position="before">
|
||||
<field name="timer_start" widget="timesheet_timer" class="text-danger ml-auto h2 ml-4 font-weight-bold" groups="hr_timesheet.group_hr_timesheet_user"/>
|
||||
<field name="timer_start" widget="timer_timer" class="text-danger ml-auto h2 ml-4 font-weight-bold" groups="hr_timesheet.group_hr_timesheet_user"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import timer_test
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_timer
|
||||
@@ -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()
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
|
||||
<record id="timer_user_rule_create-write-delete" model="ir.rule">
|
||||
<field name="name">timer.user.rule.create-write-delete</field>
|
||||
<field name="model_id" ref="timer.model_timer_timer"/>
|
||||
<field name="domain_force">[
|
||||
('user_id', '=', user.id)
|
||||
]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="perm_create" eval="1"/>
|
||||
<field name="perm_write" eval="1"/>
|
||||
<field name="perm_unlink" eval="1"/>
|
||||
<field name="perm_read" eval="1"/>
|
||||
</record>
|
||||
|
||||
<record id="timer_not_user_rule_create-write-delete" model="ir.rule">
|
||||
<field name="name">timer.not.user.rule.create-write-delete</field>
|
||||
<field name="model_id" ref="timer.model_timer_timer"/>
|
||||
<field name="domain_force">[
|
||||
('user_id', '!=', user.id)
|
||||
]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="perm_create" eval="0"/>
|
||||
<field name="perm_write" eval="0"/>
|
||||
<field name="perm_unlink" eval="0"/>
|
||||
<field name="perm_read" eval="1"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -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);
|
||||
|
||||
});
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
odoo.define('hr_timesheet.Timer', function (require) {
|
||||
odoo.define('timer.Timer', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
+2
-3
@@ -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]
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<template id="assets_backend" name="timer assets" inherit_id="web.assets_backend">
|
||||
<xpath expr="." position="inside">
|
||||
<link type="text/scss" href="/timer/static/src/scss/timer_button.scss" rel="stylesheet"/>
|
||||
<script type="text/javascript" src="/timer/static/src/js/timer_mixin.js"/>
|
||||
<script type="text/javascript" src="/timer/static/src/js/timer.js"/>
|
||||
<script type="text/javascript" src="/timer/static/src/js/timer_toggle_button.js"/>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user