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 @@ + + +