From e2b749fb7a441b4337c40cc856459e2e5f95b597 Mon Sep 17 00:00:00 2001 From: "Okan SUMER (osu)" Date: Wed, 5 Feb 2020 16:15:29 +0100 Subject: [PATCH] [ADD] 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. TaskID: 2149450 --- addons/timer/__init__.py | 4 + addons/timer/__manifest__.py | 26 ++++ addons/timer/models/__init__.py | 5 + addons/timer/models/timer.py | 70 +++++++++ addons/timer/models/timer_mixin.py | 135 ++++++++++++++++++ addons/timer/security/ir.model.access.csv | 2 + addons/timer/security/timer_security.xml | 32 +++++ addons/timer/static/src/js/timer.js | 63 ++++++++ addons/timer/static/src/js/timer_mixin.js | 115 +++++++++++++++ .../static/src/js/timer_toggle_button.js | 76 ++++++++++ .../timer/static/src/scss/timer_button.scss | 26 ++++ addons/timer/views/assets.xml | 11 ++ 12 files changed, 565 insertions(+) create mode 100644 addons/timer/__init__.py create mode 100644 addons/timer/__manifest__.py create mode 100644 addons/timer/models/__init__.py create mode 100644 addons/timer/models/timer.py create mode 100644 addons/timer/models/timer_mixin.py create mode 100644 addons/timer/security/ir.model.access.csv create mode 100644 addons/timer/security/timer_security.xml create mode 100644 addons/timer/static/src/js/timer.js create mode 100644 addons/timer/static/src/js/timer_mixin.js create mode 100644 addons/timer/static/src/js/timer_toggle_button.js create mode 100644 addons/timer/static/src/scss/timer_button.scss create mode 100644 addons/timer/views/assets.xml 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/timer/models/timer.py b/addons/timer/models/timer.py new file mode 100644 index 00000000000..d8e87533703 --- /dev/null +++ b/addons/timer/models/timer.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api +from odoo.exceptions import ValidationError + +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_is_timer_running") + res_model = fields.Char(required=True) + res_id = fields.Char(required=True) + user_id = fields.Many2one('res.users') + + _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 = 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): + if not self.timer_start: + self.write({'timer_start': fields.Datetime.now()}) + + def action_timer_stop(self): + """ Stop the timer and return the spent minutes since it started + :return minutes_spent if the timer is started, + otherwise return False + """ + if not self.timer_start: + return False + minutes_spent = self._get_minutes_spent() + self.write({'timer_start': False, 'timer_pause': False}) + return minutes_spent + + def _get_minutes_spent(self): + start_time = self.timer_start + stop_time = fields.Datetime.now() + # timer was either running or paused + if self.timer_pause: + start_time += (stop_time - self.timer_pause) + return (stop_time - start_time).total_seconds() / 60 + + def action_timer_pause(self): + self.write({'timer_pause': fields.Datetime.now()}) + + def action_timer_resume(self): + new_start = self.timer_start + (fields.Datetime.now() - self.timer_pause) + self.write({'timer_start': new_start, 'timer_pause': False}) + + @api.model + def get_server_time(self): + """ Returns the server time. + The timer widget needs the server time instead of the client time + to avoid time desynchronization issues like the timer beginning at 0:00 + and not 23:59 and so on. + """ + return fields.Datetime.now() 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/timer/static/src/js/timer.js b/addons/timer/static/src/js/timer.js new file mode 100644 index 00000000000..0042ebfaead --- /dev/null +++ b/addons/timer/static/src/js/timer.js @@ -0,0 +1,63 @@ +odoo.define('timer.timer', function (require) { +"use strict"; + +var fieldRegistry = require('web.field_registry'); +var AbstractField = require('web.AbstractField'); +var Timer = require('timer.Timer'); + +var TimerFieldWidget = AbstractField.extend({ + + /** + * @override + * @private + */ + isSet: function () { + return true; + }, + /** + * @override + * @private + */ + _render: function () { + this._super.apply(this, arguments); + this._startTimeCounter(); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + clearInterval(this.timer); + }, + /** + * @private + */ + _startTimeCounter: async function () { + if (this.record.data.timer_start) { + const serverTime = this.record.data.timer_pause || await this._getServerTime(); + this.time = Timer.createTimer(0, this.record.data.timer_start, serverTime); + this.$el.text(this.time.toString()); + this.timer = setInterval(() => { + if (this.record.data.timer_pause) { + clearInterval(this.timer); + } else { + this.time.addSecond(); + this.$el.text(this.time.toString()); + } + }, 1000); + } else if (!this.record.data.timer_pause){ + clearInterval(this.timer); + } + }, + _getServerTime: function () { + return this._rpc({ + model: 'timer.timer', + method: 'get_server_time', + args: [] + }); + } +}); + +fieldRegistry.add('timer_timer', TimerFieldWidget); + +}); diff --git a/addons/timer/static/src/js/timer_mixin.js b/addons/timer/static/src/js/timer_mixin.js new file mode 100644 index 00000000000..779d06dbe4a --- /dev/null +++ b/addons/timer/static/src/js/timer_mixin.js @@ -0,0 +1,115 @@ +odoo.define('timer.Timer', function (require) { +"use strict"; + + /** + * This class improves the timer display using the + * unit_amount field on account.analytic.line. + */ + class Timer { + constructor(hours, minutes, seconds) { + this.hours = hours; + this.minutes = minutes; + this.seconds = seconds; + } + + /** + * Convert float to time + * @param {number} float + */ + static convertFloatToTime(float) { + if (float === 0) { + return new Timer(0, 0, 0); + } + + let minutes = float % 1; + const hours = float - minutes; + minutes *= 60; + + return new Timer(hours, Math.round(minutes), 0); + } + + /** + * Create timer + * @param {number} unit_amount + * @param {String} timer_start + * @param {String} serverTime + */ + static createTimer(unit_amount, timer_start, serverTime) { + const timer = this.convertFloatToTime(unit_amount); + + timer.addTime( + moment.utc( + moment.utc(serverTime) + .diff(moment.utc(timer_start)) + ).format("HH:mm:ss") + ); + + return timer; + } + + addHours(hours) { + this.hours += hours; + } + + addMinutes(minutes) { + minutes += this.minutes; + + this.minutes = minutes % 60; + this.addHours(Math.floor(minutes / 60)); + } + + addSeconds(seconds) { + seconds += this.seconds; + this.seconds = seconds % 60; + this.addMinutes(Math.floor(seconds / 60)); + } + + display2digits(number) { + return number > 9 ? "" + number : "0" + number; + } + + addSecond() { + this.seconds += 1; + if (this.seconds === 60) { + this.minutes += 1; + this.seconds = 0; + + if (this.minutes === 60) { + this.hours += 1; + this.minutes = 0; + } + } + } + + addTime(time) { + if (typeof time == 'string' && time.indexOf(':') !== -1) { + let [hour, minute, second] = time.split(':'); + + hour = parseInt(hour); + minute = parseInt(minute); + second = parseInt(second); + + this.addSeconds(second); + this.addMinutes(minute); + this.addHours(hour); + } + } + + convertToFloat() { + return (this.hours * 60 + this.minutes) * 60 / 3600; + } + + toString() { + const time = { + hours: this.display2digits(this.hours), + minutes: this.display2digits(this.minutes), + seconds: this.display2digits(this.seconds) + }; + + return `${time.hours}:${time.minutes}:${time.seconds}`; + } + } + + return Timer; + +}); diff --git a/addons/timer/static/src/js/timer_toggle_button.js b/addons/timer/static/src/js/timer_toggle_button.js new file mode 100644 index 00000000000..544c6219940 --- /dev/null +++ b/addons/timer/static/src/js/timer_toggle_button.js @@ -0,0 +1,76 @@ +odoo.define('timer.timer_toggle_button', function (require) { +"use strict"; + +const fieldRegistry = require('web.field_registry'); +const { FieldToggleBoolean } = require("web.basic_fields"); + +/** + * The TimerToggleButton is used to display correctly the button + * to start or stop a timer for a timesheet in kanban, list and grid + * views. + */ +const TimerToggleButton = FieldToggleBoolean.extend({ + /** + * @override + * @private + */ + _render: function () { + // When the is_timer_running field is false, then the button is used to start the timer + const title = this.value ? 'stop' : 'play'; + const name = this.value ? 'action_timer_stop' : 'action_timer_start'; + const label = this.value ? 'stop' : 'start'; + + this.$('i') + .addClass('fa') + .toggleClass('fa-stop-circle o-timer-stop-button', this.value) + .toggleClass('fa-play-circle o-timer-play-button', !this.value) + .attr('title', title); + + this.$el.addClass('o-timer-button'); + this.$el.attr('title', title); + this.$el.attr('name', name); + this.$el.attr('aria-label', label); + this.$el.attr('aria-pressed', this.value); + this.$el.attr('type', 'button'); + this.$el.attr('role', 'button'); + }, + /** + * Toggle the button + * + * When the user click on this button, + * - the action "action_timer_start" is called + * into the account.analytic.line model, + * if the value of is_timer_running field is set on false. + * - the action "action_timer_stop" is called + * into the account.analytic.line model, + * if the value of is_timer_running field is set on true. + * Then we change the value of the is_timer_running. + * @override + * @private + * @param {MouseEvent} event + */ + _onToggleButton: async function (event) { + event.stopPropagation(); + const result = await this._rpc({ + model: this.model, + method: this._getActionButton(), + args: [this.res_id] + }); + + this.trigger_up('timer_changed', { + id: this.res_id, + is_timer_running: !this.value + }); + + this._setValue(!this.value); + }, + _getActionButton: function () { + return this.value ? 'action_timer_stop' : 'action_timer_start'; + } +}); + +fieldRegistry.add('timer_toggle_button', TimerToggleButton); + +return TimerToggleButton; + +}); 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 @@ + + +