[MERGE] hr_timesheet: log timesheet in any time UoM
Since uom management has been extract in a separated module, we can now add the possibility to use it without product. This commit brings the ability to record the timesheets in an different UoM than 'Hours'. The user can now set an encoding UoM for its timesheet on the company. This will display the timesheet field `unit_amount` with a different widget. For instance, timesheeting in hours will still use the traditionnal float_time widget, when timesheeting in 'Days' will displayed a toggle button with a range of defined possible values. The widget used by UoM is customizable on the UoM. The `unit_amount` field should now be decorated with the 'timesheet_uom' widget. This one will bind the correct widget (float_time, float_toggle, ...). If the encoding UoM has no widget associated, the fallback will be 'float_factor', using the uoM converstion factor. Everything will still be stored in the database in 'Hours' (the UoM set on the `project_mode_time_id` field on the company). For more details, please have a look at the subcommits. Task #39079 Closes #24893
This commit is contained in:
@@ -20,10 +20,11 @@ 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'],
|
||||
'depends': ['hr', 'analytic', 'project', 'uom'],
|
||||
'data': [
|
||||
'security/hr_timesheet_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/assets.xml',
|
||||
'views/hr_timesheet_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/project_views.xml',
|
||||
|
||||
@@ -8,9 +8,19 @@
|
||||
<field name="user_id" ref='base.user_root'/>
|
||||
</record>
|
||||
|
||||
<!-- Set the JS widget -->
|
||||
<record id="uom.product_uom_day" model="uom.uom">
|
||||
<field name="timesheet_widget">float_toggle</field>
|
||||
</record>
|
||||
|
||||
<record id="uom.product_uom_hour" model="uom.uom">
|
||||
<field name="timesheet_widget">float_time</field>
|
||||
</record>
|
||||
|
||||
<!-- Force Analytic account creation for projects allowing timesheet (default is True) -->
|
||||
<function id="_init_data_analytic_account"
|
||||
model="project.project"
|
||||
name="_init_data_analytic_account"
|
||||
eval="[]"/>
|
||||
|
||||
</odoo>
|
||||
</odoo>
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
from . import analytic_account
|
||||
from . import hr
|
||||
from . import hr_timesheet
|
||||
from . import ir_http
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
from . import project
|
||||
from . import res_users
|
||||
from . import uom
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from lxml import etree
|
||||
import json
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
@@ -46,6 +49,10 @@ class AccountAnalyticLine(models.Model):
|
||||
for line in self:
|
||||
line.department_id = line.employee_id.department_id
|
||||
|
||||
# ----------------------------------------------------
|
||||
# ORM overrides
|
||||
# ----------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def create(self, values):
|
||||
# compute employee only for timesheet lines, makes no sense for other lines
|
||||
@@ -70,6 +77,28 @@ class AccountAnalyticLine(models.Model):
|
||||
self.filtered(lambda t: t.project_id)._timesheet_postprocess(values)
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
|
||||
""" Set the correct label for `unit_amount`, depending on company UoM """
|
||||
result = super(AccountAnalyticLine, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
|
||||
result['arch'] = self._apply_timesheet_label(result['arch'])
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _apply_timesheet_label(self, view_arch):
|
||||
doc = etree.XML(view_arch)
|
||||
encoding_uom = self.env.user.company_id.timesheet_encode_uom_id
|
||||
# Here, we select only the unit_amount field having no string set to give priority to
|
||||
# custom inheretied view stored in database. Even if normally, no xpath can be done on
|
||||
# 'string' attribute.
|
||||
for node in doc.xpath("//field[@name='unit_amount'][@widget='timesheet_uom'][not(@string)]"):
|
||||
node.set('string', _('Duration (%s)') % (encoding_uom.name))
|
||||
return etree.tostring(doc, encoding='unicode')
|
||||
|
||||
# ----------------------------------------------------
|
||||
# Business Methods
|
||||
# ----------------------------------------------------
|
||||
|
||||
def _timesheet_preprocess(self, vals):
|
||||
""" Deduce other field values from the one given.
|
||||
Overrride this to compute on the fly some field that can not be computed fields.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class Http(models.AbstractModel):
|
||||
_inherit = 'ir.http'
|
||||
|
||||
def session_info(self):
|
||||
""" The widget 'timesheet_uom' needs to know which UoM conversion factor and which javascript
|
||||
widget to apply, depending on th ecurrent company.
|
||||
"""
|
||||
result = super(Http, self).session_info()
|
||||
|
||||
company = self.env.user.company_id
|
||||
encoding_uom = company.timesheet_encode_uom_id
|
||||
|
||||
result['timesheet_uom'] = encoding_uom.read(['name', 'rounding', 'timesheet_widget'])[0]
|
||||
result['timesheet_uom_factor'] = company.project_time_mode_id._compute_quantity(1.0, encoding_uom, round=False) # convert encoding uom into stored uom to get conversion factor
|
||||
return result
|
||||
@@ -151,3 +151,10 @@ class Task(models.Model):
|
||||
'company_id': account_id.company_id.id,
|
||||
})
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
|
||||
""" Set the correct label for `unit_amount`, depending on company UoM """
|
||||
result = super(Task, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
|
||||
result['arch'] = self.env['account.analytic.line']._apply_timesheet_label(result['arch'])
|
||||
return result
|
||||
|
||||
@@ -16,8 +16,21 @@ class ResCompany(models.Model):
|
||||
uom = self.env['uom.uom'].search([('measure_type', '=', 'time')], limit=1)
|
||||
return uom
|
||||
|
||||
@api.model
|
||||
def _default_timesheet_encode_uom_id(self):
|
||||
uom = self.env.ref('uom.product_uom_hour', raise_if_not_found=False)
|
||||
if not uom:
|
||||
uom = self.env['uom.uom'].search([('measure_type', '=', 'time'), ('uom_type', '=', 'reference')], limit=1)
|
||||
if not uom:
|
||||
uom = self.env['uom.uom'].search([('measure_type', '=', 'time')], limit=1)
|
||||
return uom
|
||||
|
||||
project_time_mode_id = fields.Many2one('uom.uom', string='Project Time Unit',
|
||||
default=_default_project_time_mode_id, domain=[('measure_type', '=', 'time')],
|
||||
help="This will set the unit of measure used in projects and tasks.\n"
|
||||
"If you use the timesheet linked to projects, don't "
|
||||
"forget to setup the right unit of measure in your employees.")
|
||||
timesheet_encode_uom_id = fields.Many2one('uom.uom', string="Timesheet Encoding Unit",
|
||||
default=_default_timesheet_encode_uom_id, domain=[('measure_type', '=', 'time')], required=True,
|
||||
help="""This will set the unit of measure used to encode timesheet. This will simply provide tools
|
||||
and widgets to help the encoding. All reporting will still be expressed in hours (default value).""")
|
||||
@@ -9,3 +9,12 @@ class ResConfigSettings(models.TransientModel):
|
||||
|
||||
module_project_timesheet_synchro = fields.Boolean("Awesome Timesheet")
|
||||
module_project_timesheet_holidays = fields.Boolean("Leaves")
|
||||
project_time_mode_id = fields.Many2one(
|
||||
'uom.uom', related='company_id.project_time_mode_id', string='Project Time Unit',
|
||||
help="This will set the unit of measure used in projects and tasks.\n"
|
||||
"If you use the timesheet linked to projects, don't "
|
||||
"forget to setup the right unit of measure in your employees.")
|
||||
timesheet_encode_uom_id = fields.Many2one('uom.uom', string="Encoding Unit",
|
||||
related='company_id.timesheet_encode_uom_id',
|
||||
help="""This will set the unit of measure used to encode timesheet. This will simply provide tools
|
||||
and widgets to help the encoding. All reporting will still be expressed in hours (default value).""")
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class Uom(models.Model):
|
||||
_inherit = 'uom.uom'
|
||||
|
||||
timesheet_widget = fields.Char("Widget", help="Widget used in the webclient when this unit is the one used to encode timesheets.")
|
||||
@@ -6,8 +6,6 @@ access_account_analytic_account_user,account.analytic.account,analytic.model_acc
|
||||
access_account_analytic_account_manager,account.analytic.account,analytic.model_account_analytic_account,project.group_project_manager,1,1,1,1
|
||||
access_account_analytic_account_portal,account_analytic_account,analytic.model_account_analytic_account,base.group_portal,1,0,0,0
|
||||
access_account_analytic_line_project,account.analytic.line project,analytic.model_account_analytic_line,project.group_project_manager,1,1,1,1
|
||||
access_product_product_user,product.product.timesheet.user,product.model_product_product,hr_timesheet.group_hr_timesheet_user,1,1,1,0
|
||||
access_product_template_timesheet_user,product.template.timesheet.user,product.model_product_template,hr_timesheet.group_hr_timesheet_user,1,1,1,0
|
||||
access_uom_uom_hr_timesheet,uom.uom.timesheet.user,uom.model_uom_uom,hr_timesheet.group_hr_timesheet_user,1,0,0,0
|
||||
access_project_project,project.project.timesheet.user,model_project_project,hr_timesheet.group_hr_timesheet_user,1,0,0,0
|
||||
access_project_task,project.task.timesheet.user,model_project_task,hr_timesheet.group_hr_timesheet_user,1,1,0,0
|
||||
|
||||
|
@@ -0,0 +1,86 @@
|
||||
odoo.define('hr_timesheet.timesheet_uom', function (require) {
|
||||
'use strict';
|
||||
|
||||
var AbstractField = require('web.AbstractField');
|
||||
var basicFields = require('web.basic_fields');
|
||||
var fieldRegistry = require('web.field_registry');
|
||||
var session = require('web.session');
|
||||
|
||||
/**
|
||||
* Extend the float factor widget to set default value for timesheet
|
||||
* use case. The 'factor' is forced to be the UoM timesheet
|
||||
* conversion from the session info.
|
||||
**/
|
||||
var FieldTimesheetFactor = basicFields.FieldFloatFactor.extend({
|
||||
formatType: 'float_factor',
|
||||
/**
|
||||
* Override init to tweak options depending on the session_info
|
||||
*
|
||||
* @constructor
|
||||
* @override
|
||||
*/
|
||||
init: function(parent, name, record, options) {
|
||||
this._super(parent, name, record, options);
|
||||
|
||||
// force factor in format and parse options
|
||||
if (session.timesheet_uom_factor) {
|
||||
this.nodeOptions.factor = session.timesheet_uom_factor;
|
||||
this.parseOptions.factor = session.timesheet_uom_factor;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Extend the float toggle widget to set default value for timesheet
|
||||
* use case. The 'range' is different from the default one of the
|
||||
* native widget, and the 'factor' is forced to be the UoM timesheet
|
||||
* conversion.
|
||||
**/
|
||||
var FieldTimesheetToggle = basicFields.FieldFloatToggle.extend({
|
||||
formatType: 'float_factor',
|
||||
/**
|
||||
* Override init to tweak options depending on the session_info
|
||||
*
|
||||
* @constructor
|
||||
* @override
|
||||
*/
|
||||
init: function(parent, name, record, options) {
|
||||
options = options || {};
|
||||
var fieldsInfo = record.fieldsInfo[options.viewType || 'default'];
|
||||
var attrs = options.attrs || (fieldsInfo && fieldsInfo[name]) || {};
|
||||
|
||||
var hasRange = _.contains(_.keys(attrs.options || {}), 'range');
|
||||
|
||||
this._super(parent, name, record, options);
|
||||
|
||||
// Set the timesheet widget options: the range can be customized
|
||||
// by setting the option on the field in the view. The factor
|
||||
// is forced to be the UoM conversion factor.
|
||||
if (!hasRange) {
|
||||
this.nodeOptions.range = [0.00, 1.00, 0.50];
|
||||
}
|
||||
this.nodeOptions.factor = session.timesheet_uom_factor;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Binding depending on Company Preference
|
||||
*
|
||||
* determine wich widget will be the timesheet one.
|
||||
* Simply match the 'timesheet_uom' widget key with the correct
|
||||
* implementation (float_time, float_toggle, ...). The default
|
||||
* value will be 'float_factor'.
|
||||
**/
|
||||
var FieldTimesheetUom = FieldTimesheetFactor;
|
||||
var widgetName = 'timesheet_uom' in session ?
|
||||
session.timesheet_uom.timesheet_widget : 'float_factor';
|
||||
var FieldTimesheetUom = widgetName === 'float_toggle' ?
|
||||
FieldTimesheetToggle : (fieldRegistry.get(widgetName) || FieldTimesheetFactor);
|
||||
|
||||
fieldRegistry.add('timesheet_uom', FieldTimesheetUom);
|
||||
|
||||
return FieldTimesheetUom;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<template id="assets_backend" name="timesheet assets" inherit_id="web.assets_backend">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/hr_timesheet/static/src/js/timesheet_uom.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -43,8 +43,8 @@
|
||||
<field name="date"/>
|
||||
<field name="name"/>
|
||||
<field name="project_id" required="1"/>
|
||||
<field name="task_id" context="{'default_project_id': project_id}"/>
|
||||
<field name="unit_amount" string="Time (Hours)" sum="Total time" widget="float_time"/>
|
||||
<field name="task_id" context="{'default_project_id': project_id}" domain="[('project_id', '=', project_id)]"/>
|
||||
<field name="unit_amount" widget="timesheet_uom"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
@@ -70,7 +70,7 @@
|
||||
<pivot string="Timesheet">
|
||||
<field name="employee_id" type="row"/>
|
||||
<field name="date" interval="month" type="col"/>
|
||||
<field name="unit_amount" type="measure"/>
|
||||
<field name="unit_amount" type="measure" widget="timesheet_uom"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
@@ -93,7 +93,7 @@
|
||||
</group>
|
||||
<group>
|
||||
<field name="amount"/>
|
||||
<field name="unit_amount"/>
|
||||
<field name="unit_amount" widget="timesheet_uom"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
@@ -184,7 +184,7 @@
|
||||
<t t-esc="record.date.value"/>
|
||||
</span>
|
||||
<span class="float-right">
|
||||
<strong>Duration: </strong><field name="unit_amount" widget="float_time"/>
|
||||
<strong>Duration: </strong><field name="unit_amount" widget="timesheet_uom"/>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
@@ -201,7 +201,9 @@
|
||||
<field name="res_model">account.analytic.line</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="domain">[('project_id', '!=', False), ('user_id', '=', uid)]</field>
|
||||
<field name="context">{"search_default_week":1}</field>
|
||||
<field name="context">{
|
||||
"search_default_week":1,
|
||||
}</field>
|
||||
<field name="search_view_id" ref="hr_timesheet_line_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
@@ -256,7 +258,7 @@
|
||||
'search_default_week':1,
|
||||
'search_default_groupby_employee':1,
|
||||
'search_default_groupby_project':1,
|
||||
'search_default_groupby_task':1
|
||||
'search_default_groupby_task':1,
|
||||
}</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -13,6 +13,20 @@
|
||||
}</field>
|
||||
</record>
|
||||
|
||||
<record id="timesheet_action_view_from_employee_list" model="ir.actions.act_window.view">
|
||||
<field name="sequence" eval="5"/>
|
||||
<field name="view_mode">tree</field>
|
||||
<field name="view_id" ref="hr_timesheet_line_tree"/>
|
||||
<field name="act_window_id" ref="timesheet_action_from_employee"/>
|
||||
</record>
|
||||
|
||||
<record id="timesheet_action_view_from_employee_form" model="ir.actions.act_window.view">
|
||||
<field name="sequence" eval="10"/>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="hr_timesheet_line_form"/>
|
||||
<field name="act_window_id" ref="timesheet_action_from_employee"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_employee_view_form_inherit_timesheet" model="ir.ui.view">
|
||||
<field name="name">hr.employee.form.timesheet</field>
|
||||
<field name="model">hr.employee</field>
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
<field name="user_id" invisible="1"/>
|
||||
<field name="employee_id" required="1"/>
|
||||
<field name="name"/>
|
||||
<field name="unit_amount" string="Duration" widget="float_time"/>
|
||||
<field name="unit_amount" widget="timesheet_uom"/>
|
||||
<field name="project_id" invisible="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
|
||||
@@ -11,8 +11,35 @@
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[hasclass('settings')]" position="inside">
|
||||
<div class="app_settings_block" data-string="Timesheets" string="Timesheets" data-key="hr_timesheet" groups="hr_timesheet.group_timesheet_manager">
|
||||
<h2>Timesheet Apps</h2>
|
||||
<h2>Time Encoding</h2>
|
||||
<div class="row mt16 o_settings_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box" attrs="{'invisible':[('project_time_mode_id', '!=', False)]}">
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="project_time_mode_id"/>
|
||||
<span class="fa fa-lg fa-building-o" title="Values set here are company-specific." groups="base.group_multi_company"/>
|
||||
<div class="content-group">
|
||||
<div class="mt16">
|
||||
<field name="project_time_mode_id" options="{'no_create': True, 'no_open': True}"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="timesheet_encode_uom_id"/>
|
||||
<span class="fa fa-lg fa-building-o" title="Values set here are company-specific." groups="base.group_multi_company"/>
|
||||
<div class="row">
|
||||
<div class="text-muted col-md-12">
|
||||
Set time unit used to record your timesheets
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="mt16">
|
||||
<field name="timesheet_encode_uom_id" options="{'no_create': True}"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="module_project_timesheet_synchro" widget="upgrade_boolean"/>
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
'summary': 'Organize and schedule your projects ',
|
||||
'depends': [
|
||||
'base_setup',
|
||||
'product',
|
||||
'mail',
|
||||
'portal',
|
||||
'rating',
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
from . import project
|
||||
from . import res_config_settings
|
||||
from . import res_company
|
||||
from . import res_partner
|
||||
from . import res_users
|
||||
from . import digest
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import timedelta
|
||||
from lxml import etree
|
||||
|
||||
from odoo import api, fields, models, tools, SUPERUSER_ID, _
|
||||
from odoo.exceptions import UserError, AccessError, ValidationError
|
||||
@@ -620,47 +619,6 @@ class Task(models.Model):
|
||||
if not task._check_recursion():
|
||||
raise ValidationError(_('Error! You cannot create recursive hierarchy of task(s).'))
|
||||
|
||||
# Override view according to the company definition
|
||||
@api.model
|
||||
def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
|
||||
# read uom as admin to avoid access rights issues, e.g. for portal/share users,
|
||||
# this should be safe (no context passed to avoid side-effects)
|
||||
obj_tm = self.env.user.company_id.project_time_mode_id
|
||||
tm = obj_tm and obj_tm.name or 'Hours'
|
||||
|
||||
res = super(Task, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
|
||||
|
||||
# read uom as admin to avoid access rights issues, e.g. for portal/share users,
|
||||
# this should be safe (no context passed to avoid side-effects)
|
||||
obj_tm = self.env.user.company_id.project_time_mode_id
|
||||
# using get_object to get translation value
|
||||
uom_hour = self.env.ref('uom.product_uom_hour', False)
|
||||
if not obj_tm or not uom_hour or obj_tm.id == uom_hour.id:
|
||||
return res
|
||||
|
||||
eview = etree.fromstring(res['arch'])
|
||||
|
||||
# if the project_time_mode_id is not in hours (so in days), display it as a float field
|
||||
def _check_rec(eview):
|
||||
if eview.attrib.get('widget', '') == 'float_time':
|
||||
eview.set('widget', 'float')
|
||||
for child in eview:
|
||||
_check_rec(child)
|
||||
return True
|
||||
|
||||
_check_rec(eview)
|
||||
|
||||
res['arch'] = etree.tostring(eview, encoding='unicode')
|
||||
|
||||
# replace reference of 'Hours' to 'Day(s)'
|
||||
for f in res['fields']:
|
||||
# TODO this NOT work in different language than english
|
||||
# the field 'Initially Planned Hours' should be replaced by 'Initially Planned Days'
|
||||
# but string 'Initially Planned Days' is not available in translation
|
||||
if 'Hours' in res['fields'][f]['string']:
|
||||
res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours', obj_tm.name)
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def get_empty_list_help(self, help):
|
||||
tname = _("task")
|
||||
|
||||
@@ -10,8 +10,3 @@ class ResConfigSettings(models.TransientModel):
|
||||
module_project_forecast = fields.Boolean(string="Forecasts")
|
||||
group_subtask_project = fields.Boolean("Sub-tasks", implied_group="project.group_subtask_project")
|
||||
group_project_rating = fields.Boolean("Use Rating on Project", implied_group='project.group_project_rating')
|
||||
project_time_mode_id = fields.Many2one(
|
||||
'uom.uom', related='company_id.project_time_mode_id', string='Project Time Unit',
|
||||
help="This will set the unit of measure used in projects and tasks.\n"
|
||||
"If you use the timesheet linked to projects, don't "
|
||||
"forget to setup the right unit of measure in your employees.")
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<h2>Time Management</h2>
|
||||
<div class="row mt16 o_settings_container">
|
||||
<div class="row mt16 o_settings_container" name="project_time">
|
||||
<div class="col-12 col-lg-6 o_setting_box" name="project_time_management">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="module_project_forecast" widget="upgrade_boolean"/>
|
||||
|
||||
@@ -22,6 +22,12 @@ class UoMCategory(models.Model):
|
||||
('uom_category_unique_type', 'UNIQUE(measure_type)', 'You can have only one category per measurement type.'),
|
||||
]
|
||||
|
||||
@api.multi
|
||||
def unlink(self):
|
||||
if self.filtered(lambda categ: categ.measure_type == 'time'):
|
||||
raise UserError(_("You cannot delete this UoM Category as it is used by the system."))
|
||||
return super(UoMCategory, self).unlink()
|
||||
|
||||
|
||||
class UoM(models.Model):
|
||||
_name = 'uom.uom'
|
||||
@@ -102,6 +108,12 @@ class UoM(models.Model):
|
||||
values['factor'] = factor_inv and (1.0 / factor_inv) or 0.0
|
||||
return super(UoM, self).write(values)
|
||||
|
||||
@api.multi
|
||||
def unlink(self):
|
||||
if self.filtered(lambda uom: uom.measure_type == 'time'):
|
||||
raise UserError(_("You cannot delete this UoM as it is used by the system. You should rather archive it."))
|
||||
return super(UoM, self).unlink()
|
||||
|
||||
@api.model
|
||||
def name_create(self, name):
|
||||
""" The UoM category and factor are required, so we'll have to add temporary values
|
||||
|
||||
@@ -511,6 +511,26 @@ var utils = {
|
||||
return Object.freeze(obj);
|
||||
},
|
||||
|
||||
/**
|
||||
* Find the closest value of the given one in the provided array
|
||||
*
|
||||
* @param {Number} num
|
||||
* @param {Array} arr
|
||||
* @returns {Number|undefined}
|
||||
*/
|
||||
closestNumber: function (num, arr) {
|
||||
var curr = arr[0];
|
||||
var diff = Math.abs (num - curr);
|
||||
for (var val = 0; val < arr.length; val++) {
|
||||
var newdiff = Math.abs (num - arr[val]);
|
||||
if (newdiff < diff) {
|
||||
diff = newdiff;
|
||||
curr = arr[val];
|
||||
}
|
||||
}
|
||||
return curr;
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
return utils;
|
||||
|
||||
@@ -898,6 +898,142 @@ var FieldFloatTime = FieldFloat.extend({
|
||||
formatType: 'float_time',
|
||||
});
|
||||
|
||||
var FieldFloatFactor = FieldFloat.extend({
|
||||
supportedFieldTypes: ['float'],
|
||||
className: 'o_field_float_factor',
|
||||
formatType: 'float_factor',
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
// default values
|
||||
if (!this.nodeOptions.factor){
|
||||
this.nodeOptions.factor = 1;
|
||||
}
|
||||
// use as format and parse options
|
||||
this.parseOptions = this.nodeOptions;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The goal of this widget is to replace the input field by a button containing a
|
||||
* range of possible values (given in the options). Each click allows the user to loop
|
||||
* in the range. The purpose here is to restrict the field value to a predefined selection.
|
||||
* Also, the widget support the factor conversion as the *float_factor* widget (Range values
|
||||
* should be the result of the conversion).
|
||||
**/
|
||||
var FieldFloatToggle = AbstractField.extend({
|
||||
supportedFieldTypes: ['float'],
|
||||
formatType: 'float_factor',
|
||||
className: 'o_field_float_toggle',
|
||||
tagName: 'span',
|
||||
events: {
|
||||
click: '_onClick'
|
||||
},
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this.formatType = 'float_factor';
|
||||
|
||||
if (this.mode === 'edit') {
|
||||
this.tagName = 'button';
|
||||
}
|
||||
|
||||
// we don't inherit Float Field
|
||||
if (this.attrs.digits) {
|
||||
this.nodeOptions.digits = JSON.parse(this.attrs.digits);
|
||||
}
|
||||
// default values
|
||||
if (!this.nodeOptions.factor){
|
||||
this.nodeOptions.factor = 1;
|
||||
}
|
||||
if (!this.nodeOptions.range){
|
||||
this.nodeOptions.range = [0.0, 0.5, 1.0];
|
||||
}
|
||||
|
||||
// use as format and parse options
|
||||
this.parseOptions = this.nodeOptions;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the display value but in real type to use it in calculations
|
||||
*
|
||||
* @private
|
||||
* @returns {float} The current formatted value
|
||||
*/
|
||||
_getDisplayedValue: function () {
|
||||
return parseFloat(this._formatValue(this.value));
|
||||
},
|
||||
/**
|
||||
* Formats the HTML input tag for edit mode and stores selection status.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_renderEdit: function () {
|
||||
// Keep a reference to the input so $el can become something else
|
||||
// without losing track of the actual input.
|
||||
this.$el.text(this._formatValue(this.value));
|
||||
},
|
||||
/**
|
||||
* Resets the content to the formated value in readonly mode.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_renderReadonly: function () {
|
||||
this.$el.text(this._formatValue(this.value));
|
||||
},
|
||||
/**
|
||||
* Get the next value in the range, from the current one. If the current
|
||||
* one is not in the range, the next value of the closest one will be chosen.
|
||||
*
|
||||
* @private
|
||||
* @returns {number} The next formatted value in the range
|
||||
*/
|
||||
_nextValue: function () {
|
||||
var range = this.nodeOptions.range;
|
||||
var val = utils.closestNumber(this._getDisplayedValue(), range);
|
||||
var index = _.indexOf(range, val);
|
||||
if (index !== -1) {
|
||||
if (index + 1 < range.length) {
|
||||
return range[index + 1];
|
||||
}
|
||||
}
|
||||
return range[0];
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Clicking on the button triggers the change of value; the next one of
|
||||
* the range will be displayed.
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onClick: function(ev) {
|
||||
if (this.mode === 'edit') {
|
||||
ev.stopPropagation(); // only stop propagation in edit mode
|
||||
var next_val = this._nextValue();
|
||||
this._setValue(next_val.toString()); // will be parsed in _setValue
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
var FieldPercentage = FieldFloat.extend({
|
||||
formatType:'percentage',
|
||||
});
|
||||
@@ -1102,7 +1238,7 @@ var CopyClipboard = {
|
||||
$clipboardBtn.tooltip({title: _t('Copied !'), trigger: 'manual', placement: 'right'});
|
||||
this.clipboard = new ClipboardJS($clipboardBtn[0], {
|
||||
text: function (_) {
|
||||
return self.value.trim();
|
||||
return self.value.trim();
|
||||
},
|
||||
// Container added because of Bootstrap modal that give the focus to another element.
|
||||
// We need to give to correct focus to ClipboardJS (see in ClipboardJS doc)
|
||||
@@ -2757,6 +2893,8 @@ return {
|
||||
FieldDomain: FieldDomain,
|
||||
FieldFloat: FieldFloat,
|
||||
FieldFloatTime: FieldFloatTime,
|
||||
FieldFloatFactor: FieldFloatFactor,
|
||||
FieldFloatToggle: FieldFloatToggle,
|
||||
FieldPercentage : FieldPercentage,
|
||||
FieldInteger: FieldInteger,
|
||||
FieldMonetary: FieldMonetary,
|
||||
|
||||
@@ -53,6 +53,8 @@ registry
|
||||
.add('statinfo', basic_fields.StatInfo)
|
||||
.add('percentpie', basic_fields.FieldPercentPie)
|
||||
.add('float_time', basic_fields.FieldFloatTime)
|
||||
.add('float_factor', basic_fields.FieldFloatFactor)
|
||||
.add('float_toggle', basic_fields.FieldFloatToggle)
|
||||
.add('progressbar', basic_fields.FieldProgressBar)
|
||||
.add('toggle_button', basic_fields.FieldToggleBoolean)
|
||||
.add('dashboard_graph', basic_fields.JournalDashboardGraph)
|
||||
|
||||
@@ -173,6 +173,21 @@ function formatFloat(value, field, options) {
|
||||
return formatted.join(l10n.decimal_point);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a string representing a float value, from a float converted with a
|
||||
* factor.
|
||||
*
|
||||
* @param {number} value
|
||||
* @param {number} [options.factor]
|
||||
* Conversion factor, default value is 1.0
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatFloatFactor(value, field, options) {
|
||||
var factor = options.factor || 1;
|
||||
return formatFloat(value * factor, field, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string representing a time value, from a float. The idea is that
|
||||
* we sometimes want to display something like 1:45 instead of 1.75, or 0:15
|
||||
@@ -528,6 +543,18 @@ function parseMonetary(value, field, options) {
|
||||
return parseFloat(values[0] === currency.symbol ? values[1] : values[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a String containing float and unconvert it with a conversion factor
|
||||
*
|
||||
* @param {number} [options.factor]
|
||||
* Conversion factor, default value is 1.0
|
||||
*/
|
||||
function parseFloatFactor(value, field, options) {
|
||||
var parsed = parseFloat(value);
|
||||
var factor = options.factor || 1.0;
|
||||
return parsed / factor;
|
||||
}
|
||||
|
||||
function parseFloatTime(value) {
|
||||
var factor = 1;
|
||||
if (value[0] === '-') {
|
||||
@@ -596,6 +623,7 @@ return {
|
||||
date: formatDate,
|
||||
datetime: formatDateTime,
|
||||
float: formatFloat,
|
||||
float_factor: formatFloatFactor,
|
||||
float_time: formatFloatTime,
|
||||
html: _.identity, // todo
|
||||
integer: formatInteger,
|
||||
@@ -615,6 +643,7 @@ return {
|
||||
date: parseDate, // todo
|
||||
datetime: parseDateTime, // todo
|
||||
float: parseFloat,
|
||||
float_factor: parseFloatFactor,
|
||||
float_time: parseFloatTime,
|
||||
html: _.identity, // todo
|
||||
integer: parseInteger,
|
||||
|
||||
@@ -429,3 +429,8 @@
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
button.o_field_float_toggle {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -3264,6 +3264,90 @@ QUnit.module('basic_fields', {
|
||||
});
|
||||
|
||||
|
||||
QUnit.module('FieldFloatFactor');
|
||||
|
||||
QUnit.test('float_factor field in form view', function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
var form = createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch:'<form string="Partners">' +
|
||||
'<sheet>' +
|
||||
'<field name="qux" widget="float_factor" options="{\'factor\': 0.5}" digits="[16,2]"/>' +
|
||||
'</sheet>' +
|
||||
'</form>',
|
||||
mockRPC: function (route, args) {
|
||||
if (route === '/web/dataset/call_kw/partner/write') {
|
||||
// 16.4 / 2 = 8.2
|
||||
assert.strictEqual(args.args[1].qux, 4.6, 'the correct float value should be saved');
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
res_id: 5,
|
||||
});
|
||||
assert.strictEqual(form.$('.o_field_widget').first().text(), '4.55', // 9.1 / 0.5
|
||||
'The formatted value should be displayed properly.');
|
||||
|
||||
form.$buttons.find('.o_form_button_edit').click();
|
||||
assert.strictEqual(form.$('input').val(), '4.55',
|
||||
'The value should be rendered correctly in the input.');
|
||||
|
||||
form.$('input').val('2.3').trigger('input');
|
||||
|
||||
form.$buttons.find('.o_form_button_save').click();
|
||||
assert.strictEqual(form.$('.o_field_widget').first().text(), '2.30',
|
||||
'The new value should be saved and displayed properly.');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.module('FieldFloatToggle');
|
||||
|
||||
QUnit.test('float_toggle field in form view', function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
var form = createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch:'<form string="Partners">' +
|
||||
'<sheet>' +
|
||||
'<field name="qux" widget="float_toggle" options="{\'factor\': 0.125, \'range\': [0, 1, 0.75, 0.5, 0.25]}" digits="[5,3]"/>' +
|
||||
'</sheet>' +
|
||||
'</form>',
|
||||
mockRPC: function (route, args) {
|
||||
if (route === '/web/dataset/call_kw/partner/write') {
|
||||
// 1.000 / 0.125 = 8
|
||||
assert.strictEqual(args.args[1].qux, 8, 'the correct float value should be saved');
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
res_id: 1,
|
||||
});
|
||||
assert.strictEqual(form.$('.o_field_widget').first().text(), '0.056',
|
||||
'The formatted time value should be displayed properly.');
|
||||
|
||||
form.$buttons.find('.o_form_button_edit').click();
|
||||
|
||||
assert.strictEqual(form.$('button.o_field_float_toggle').text(), '0.056',
|
||||
'The value should be rendered correctly on the button.');
|
||||
|
||||
form.$('button.o_field_float_toggle').click(); // clicking will make the next value 1, since 0 was the closest of 0.056
|
||||
|
||||
assert.strictEqual(form.$('button.o_field_float_toggle').text(), '1.000',
|
||||
'The value should be rendered correctly on the button.');
|
||||
|
||||
form.$buttons.find('.o_form_button_save').click();
|
||||
|
||||
assert.strictEqual(form.$('.o_field_widget').first().text(), '1.000',
|
||||
'The new value should be saved and displayed properly.');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
|
||||
QUnit.module('PhoneWidget');
|
||||
|
||||
QUnit.test('phone field in form view on extra small screens', function (assert) {
|
||||
|
||||
@@ -1418,6 +1418,26 @@ order.
|
||||
|
||||
- Supported field types: *float*
|
||||
|
||||
- float_factor (FieldFloatFactor)
|
||||
This widget aims to display properly a float value that converted using a factor
|
||||
given in its options. So, for example, the value saved in database is 0.5 and the
|
||||
factor is 3, the widget value should be formatted as 1.5.
|
||||
|
||||
- Supported field types: *float*
|
||||
|
||||
- float_toggle (FieldFloatToggle)
|
||||
The goal of this widget is to replace the input field by a button containing a
|
||||
range of possible values (given in the options). Each click allows the user to loop
|
||||
in the range. The purpose here is to restrict the field value to a predefined selection.
|
||||
Also, the widget support the factor conversion as the *float_factor* widget (Range values
|
||||
should be the result of the conversion).
|
||||
|
||||
- Supported field types: *float*
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<field name="days_to_close" widget="float_toggle" options='{"factor": 2, "range": [0, 4, 8]}'/>
|
||||
|
||||
- boolean (FieldBoolean)
|
||||
This is the default field type for fields of type *boolean*.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user