[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
This commit is contained in:
Okan SUMER (osu)
2020-02-05 16:44:37 +01:00
committed by Yannick Tivisse
parent a6d7c7b04a
commit e2b749fb7a
12 changed files with 565 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
+26
View File
@@ -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,
}
+5
View File
@@ -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
+70
View File
@@ -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()
+135
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_timer_user timer.timer.user timer.model_timer_timer base.group_user 1 1 1 1
+32
View File
@@ -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>
+63
View File
@@ -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);
});
+115
View File
@@ -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;
});
@@ -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;
});
@@ -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;
}
}
+11
View File
@@ -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>