summaryrefslogtreecommitdiff
path: root/addons/hr_timesheet/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/hr_timesheet/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/hr_timesheet/models')
-rw-r--r--addons/hr_timesheet/models/__init__.py10
-rw-r--r--addons/hr_timesheet/models/hr_employee.py12
-rw-r--r--addons/hr_timesheet/models/hr_timesheet.py217
-rw-r--r--addons/hr_timesheet/models/ir_http.py21
-rw-r--r--addons/hr_timesheet/models/project.py252
-rw-r--r--addons/hr_timesheet/models/res_company.py69
-rw-r--r--addons/hr_timesheet/models/res_config_settings.py38
-rw-r--r--addons/hr_timesheet/models/uom.py10
8 files changed, 629 insertions, 0 deletions
diff --git a/addons/hr_timesheet/models/__init__.py b/addons/hr_timesheet/models/__init__.py
new file mode 100644
index 00000000..ee021539
--- /dev/null
+++ b/addons/hr_timesheet/models/__init__.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import hr_employee
+from . import hr_timesheet
+from . import ir_http
+from . import res_company
+from . import res_config_settings
+from . import project
+from . import uom
diff --git a/addons/hr_timesheet/models/hr_employee.py b/addons/hr_timesheet/models/hr_employee.py
new file mode 100644
index 00000000..5e4a2abb
--- /dev/null
+++ b/addons/hr_timesheet/models/hr_employee.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class HrEmployee(models.Model):
+ _inherit = 'hr.employee'
+
+ timesheet_cost = fields.Monetary('Timesheet Cost', currency_field='currency_id',
+ groups="hr.group_hr_user", default=0.0)
+ currency_id = fields.Many2one('res.currency', related='company_id.currency_id', readonly=True)
diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py
new file mode 100644
index 00000000..a074961e
--- /dev/null
+++ b/addons/hr_timesheet/models/hr_timesheet.py
@@ -0,0 +1,217 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from lxml import etree
+import re
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError, AccessError
+from odoo.osv import expression
+
+class AccountAnalyticLine(models.Model):
+ _inherit = 'account.analytic.line'
+
+ @api.model
+ def default_get(self, field_list):
+ result = super(AccountAnalyticLine, self).default_get(field_list)
+ if 'encoding_uom_id' in field_list:
+ result['encoding_uom_id'] = self.env.company.timesheet_encode_uom_id.id
+ if not self.env.context.get('default_employee_id') and 'employee_id' in field_list and result.get('user_id'):
+ result['employee_id'] = self.env['hr.employee'].search([('user_id', '=', result['user_id'])], limit=1).id
+ return result
+
+ def _domain_project_id(self):
+ domain = [('allow_timesheets', '=', True)]
+ if not self.user_has_groups('hr_timesheet.group_timesheet_manager'):
+ return expression.AND([domain,
+ ['|', ('privacy_visibility', '!=', 'followers'), ('allowed_internal_user_ids', 'in', self.env.user.ids)]
+ ])
+ return domain
+
+ def _domain_employee_id(self):
+ if not self.user_has_groups('hr_timesheet.group_hr_timesheet_approver'):
+ return [('user_id', '=', self.env.user.id)]
+ return []
+
+ def _domain_task_id(self):
+ if not self.user_has_groups('hr_timesheet.group_hr_timesheet_approver'):
+ return ['|', ('privacy_visibility', '!=', 'followers'), ('allowed_user_ids', 'in', self.env.user.ids)]
+ return []
+
+ task_id = fields.Many2one(
+ 'project.task', 'Task', compute='_compute_task_id', store=True, readonly=False, index=True,
+ domain="[('company_id', '=', company_id), ('project_id.allow_timesheets', '=', True), ('project_id', '=?', project_id)]")
+ project_id = fields.Many2one(
+ 'project.project', 'Project', compute='_compute_project_id', store=True, readonly=False,
+ domain=_domain_project_id)
+ user_id = fields.Many2one(compute='_compute_user_id', store=True, readonly=False)
+ employee_id = fields.Many2one('hr.employee', "Employee", domain=_domain_employee_id)
+ department_id = fields.Many2one('hr.department', "Department", compute='_compute_department_id', store=True, compute_sudo=True)
+ encoding_uom_id = fields.Many2one('uom.uom', compute='_compute_encoding_uom_id')
+
+ def _compute_encoding_uom_id(self):
+ for analytic_line in self:
+ analytic_line.encoding_uom_id = analytic_line.company_id.timesheet_encode_uom_id
+
+ @api.depends('task_id', 'task_id.project_id')
+ def _compute_project_id(self):
+ for line in self.filtered(lambda line: not line.project_id):
+ line.project_id = line.task_id.project_id
+
+ @api.depends('project_id')
+ def _compute_task_id(self):
+ for line in self.filtered(lambda line: not line.project_id):
+ line.task_id = False
+
+ @api.onchange('project_id')
+ def _onchange_project_id(self):
+ # TODO KBA in master - check to do it "properly", currently:
+ # This onchange is used to reset the task_id when the project changes.
+ # Doing it in the compute will remove the task_id when the project of a task changes.
+ if self.project_id != self.task_id.project_id:
+ self.task_id = False
+
+ @api.depends('employee_id')
+ def _compute_user_id(self):
+ for line in self:
+ line.user_id = line.employee_id.user_id if line.employee_id else line._default_user()
+
+ @api.depends('employee_id')
+ def _compute_department_id(self):
+ for line in self:
+ line.department_id = line.employee_id.department_id
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ default_user_id = self._default_user()
+ user_ids = list(map(lambda x: x.get('user_id', default_user_id), filter(lambda x: not x.get('employee_id') and x.get('project_id'), vals_list)))
+ employees = self.env['hr.employee'].search([('user_id', 'in', user_ids)])
+ user_map = {employee.user_id.id: employee.id for employee in employees}
+
+ for vals in vals_list:
+ # when the name is not provide by the 'Add a line', we set a default one
+ if vals.get('project_id') and not vals.get('name'):
+ vals['name'] = '/'
+ # compute employee only for timesheet lines, makes no sense for other lines
+ if not vals.get('employee_id') and vals.get('project_id'):
+ vals['employee_id'] = user_map.get(vals.get('user_id') or default_user_id)
+ vals.update(self._timesheet_preprocess(vals))
+
+ lines = super(AccountAnalyticLine, self).create(vals_list)
+ for line, values in zip(lines, vals_list):
+ if line.project_id: # applied only for timesheet
+ line._timesheet_postprocess(values)
+ return lines
+
+ def write(self, values):
+ # If it's a basic user then check if the timesheet is his own.
+ if not (self.user_has_groups('hr_timesheet.group_hr_timesheet_approver') or self.env.su) and any(self.env.user.id != analytic_line.user_id.id for analytic_line in self):
+ raise AccessError(_("You cannot access timesheets that are not yours."))
+
+ values = self._timesheet_preprocess(values)
+ if 'name' in values and not values.get('name'):
+ values['name'] = '/'
+ result = super(AccountAnalyticLine, self).write(values)
+ # applied only for timesheet
+ 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'], view_type=view_type)
+ return result
+
+ @api.model
+ def _apply_timesheet_label(self, view_arch, view_type='form'):
+ doc = etree.XML(view_arch)
+ encoding_uom = self.env.company.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)') % (re.sub(r'[\(\)]', '', encoding_uom.name or '')))
+ return etree.tostring(doc, encoding='unicode')
+
+ def _timesheet_get_portal_domain(self):
+ return ['&',
+ '|', '|', '|',
+ ('task_id.project_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id]),
+ ('task_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id]),
+ ('task_id.project_id.allowed_portal_user_ids', 'child_of', [self.env.user.id]),
+ ('task_id.allowed_user_ids', 'in', [self.env.user.id]),
+ ('task_id.project_id.privacy_visibility', '=', 'portal')]
+
+ 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.
+ :param values: dict values for `create`or `write`.
+ """
+ # project implies analytic account
+ if vals.get('project_id') and not vals.get('account_id'):
+ project = self.env['project.project'].browse(vals.get('project_id'))
+ vals['account_id'] = project.analytic_account_id.id
+ vals['company_id'] = project.analytic_account_id.company_id.id or project.company_id.id
+ if not project.analytic_account_id.active:
+ raise UserError(_('The project you are timesheeting on is not linked to an active analytic account. Set one on the project configuration.'))
+ # employee implies user
+ if vals.get('employee_id') and not vals.get('user_id'):
+ employee = self.env['hr.employee'].browse(vals['employee_id'])
+ vals['user_id'] = employee.user_id.id
+ # force customer partner, from the task or the project
+ if (vals.get('project_id') or vals.get('task_id')) and not vals.get('partner_id'):
+ partner_id = False
+ if vals.get('task_id'):
+ partner_id = self.env['project.task'].browse(vals['task_id']).partner_id.id
+ else:
+ partner_id = self.env['project.project'].browse(vals['project_id']).partner_id.id
+ if partner_id:
+ vals['partner_id'] = partner_id
+ # set timesheet UoM from the AA company (AA implies uom)
+ if 'product_uom_id' not in vals and all(v in vals for v in ['account_id', 'project_id']): # project_id required to check this is timesheet flow
+ analytic_account = self.env['account.analytic.account'].sudo().browse(vals['account_id'])
+ vals['product_uom_id'] = analytic_account.company_id.project_time_mode_id.id
+ return vals
+
+ def _timesheet_postprocess(self, values):
+ """ Hook to update record one by one according to the values of a `write` or a `create`. """
+ sudo_self = self.sudo() # this creates only one env for all operation that required sudo() in `_timesheet_postprocess_values`override
+ values_to_write = self._timesheet_postprocess_values(values)
+ for timesheet in sudo_self:
+ if values_to_write[timesheet.id]:
+ timesheet.write(values_to_write[timesheet.id])
+ return values
+
+ def _timesheet_postprocess_values(self, values):
+ """ Get the addionnal values to write on record
+ :param dict values: values for the model's fields, as a dictionary::
+ {'field_name': field_value, ...}
+ :return: a dictionary mapping each record id to its corresponding
+ dictionary values to write (may be empty).
+ """
+ result = {id_: {} for id_ in self.ids}
+ sudo_self = self.sudo() # this creates only one env for all operation that required sudo()
+ # (re)compute the amount (depending on unit_amount, employee_id for the cost, and account_id for currency)
+ if any(field_name in values for field_name in ['unit_amount', 'employee_id', 'account_id']):
+ for timesheet in sudo_self:
+ cost = timesheet.employee_id.timesheet_cost or 0.0
+ amount = -timesheet.unit_amount * cost
+ amount_converted = timesheet.employee_id.currency_id._convert(
+ amount, timesheet.account_id.currency_id, self.env.company, timesheet.date)
+ result[timesheet.id].update({
+ 'amount': amount_converted,
+ })
+ return result
+
+ def _is_timesheet_encode_uom_day(self):
+ company_uom = self.env.company.timesheet_encode_uom_id
+ return company_uom == self.env.ref('uom.product_uom_day')
+
+ def _convert_hours_to_days(self, time):
+ uom_hour = self.env.ref('uom.product_uom_hour')
+ uom_day = self.env.ref('uom.product_uom_day')
+ return round(uom_hour._compute_quantity(time, uom_day, raise_if_failure=False), 2)
+
+ def _get_timesheet_time_day(self):
+ return self._convert_hours_to_days(self.unit_amount)
diff --git a/addons/hr_timesheet/models/ir_http.py b/addons/hr_timesheet/models/ir_http.py
new file mode 100644
index 00000000..51e929b6
--- /dev/null
+++ b/addons/hr_timesheet/models/ir_http.py
@@ -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()
+ if self.env.user.has_group('base.group_user'):
+ company = self.env.company
+ 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
diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py
new file mode 100644
index 00000000..554948b6
--- /dev/null
+++ b/addons/hr_timesheet/models/project.py
@@ -0,0 +1,252 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import re
+from lxml import etree
+
+from odoo import models, fields, api, _
+from odoo.exceptions import UserError, ValidationError, RedirectWarning
+
+
+class Project(models.Model):
+ _inherit = "project.project"
+
+ allow_timesheets = fields.Boolean(
+ "Timesheets", compute='_compute_allow_timesheets', store=True, readonly=False,
+ default=True, help="Enable timesheeting on the project.")
+ analytic_account_id = fields.Many2one(
+ # note: replaces ['|', ('company_id', '=', False), ('company_id', '=', company_id)]
+ domain="""[
+ '|', ('company_id', '=', False), ('company_id', '=', company_id),
+ ('partner_id', '=?', partner_id),
+ ]"""
+ )
+
+ timesheet_ids = fields.One2many('account.analytic.line', 'project_id', 'Associated Timesheets')
+ timesheet_encode_uom_id = fields.Many2one('uom.uom', related='company_id.timesheet_encode_uom_id')
+ total_timesheet_time = fields.Integer(
+ compute='_compute_total_timesheet_time',
+ help="Total number of time (in the proper UoM) recorded in the project, rounded to the unit.")
+ encode_uom_in_days = fields.Boolean(compute='_compute_encode_uom_in_days')
+
+ def _compute_encode_uom_in_days(self):
+ self.encode_uom_in_days = self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day')
+
+ @api.depends('analytic_account_id')
+ def _compute_allow_timesheets(self):
+ without_account = self.filtered(lambda t: not t.analytic_account_id and t._origin)
+ without_account.update({'allow_timesheets': False})
+
+ @api.constrains('allow_timesheets', 'analytic_account_id')
+ def _check_allow_timesheet(self):
+ for project in self:
+ if project.allow_timesheets and not project.analytic_account_id:
+ raise ValidationError(_('To allow timesheet, your project %s should have an analytic account set.', project.name))
+
+ @api.depends('timesheet_ids')
+ def _compute_total_timesheet_time(self):
+ for project in self:
+ total_time = 0.0
+ for timesheet in project.timesheet_ids:
+ # Timesheets may be stored in a different unit of measure, so first
+ # we convert all of them to the reference unit
+ total_time += timesheet.unit_amount * timesheet.product_uom_id.factor_inv
+ # Now convert to the proper unit of measure set in the settings
+ total_time *= project.timesheet_encode_uom_id.factor
+ project.total_timesheet_time = int(round(total_time))
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """ Create an analytic account if project allow timesheet and don't provide one
+ Note: create it before calling super() to avoid raising the ValidationError from _check_allow_timesheet
+ """
+ defaults = self.default_get(['allow_timesheets', 'analytic_account_id'])
+ for values in vals_list:
+ allow_timesheets = values.get('allow_timesheets', defaults.get('allow_timesheets'))
+ analytic_account_id = values.get('analytic_account_id', defaults.get('analytic_account_id'))
+ if allow_timesheets and not analytic_account_id:
+ analytic_account = self._create_analytic_account_from_values(values)
+ values['analytic_account_id'] = analytic_account.id
+ return super(Project, self).create(vals_list)
+
+ def write(self, values):
+ # create the AA for project still allowing timesheet
+ if values.get('allow_timesheets') and not values.get('analytic_account_id'):
+ for project in self:
+ if not project.analytic_account_id:
+ project._create_analytic_account()
+ return super(Project, self).write(values)
+
+ @api.model
+ def _init_data_analytic_account(self):
+ self.search([('analytic_account_id', '=', False), ('allow_timesheets', '=', True)])._create_analytic_account()
+
+ def unlink(self):
+ """
+ If some projects to unlink have some timesheets entries, these
+ timesheets entries must be unlinked first.
+ In this case, a warning message is displayed through a RedirectWarning
+ and allows the user to see timesheets entries to unlink.
+ """
+ projects_with_timesheets = self.filtered(lambda p: p.timesheet_ids)
+ if projects_with_timesheets:
+ if len(projects_with_timesheets) > 1:
+ warning_msg = _("These projects have some timesheet entries referencing them. Before removing these projects, you have to remove these timesheet entries.")
+ else:
+ warning_msg = _("This project has some timesheet entries referencing it. Before removing this project, you have to remove these timesheet entries.")
+ raise RedirectWarning(
+ warning_msg, self.env.ref('hr_timesheet.timesheet_action_project').id,
+ _('See timesheet entries'), {'active_ids': projects_with_timesheets.ids})
+ return super(Project, self).unlink()
+
+
+class Task(models.Model):
+ _name = "project.task"
+ _inherit = "project.task"
+
+ analytic_account_active = fields.Boolean("Active Analytic Account", compute='_compute_analytic_account_active')
+ allow_timesheets = fields.Boolean("Allow timesheets", related='project_id.allow_timesheets', help="Timesheets can be logged on this task.", readonly=True)
+ remaining_hours = fields.Float("Remaining Hours", compute='_compute_remaining_hours', store=True, readonly=True, help="Total remaining time, can be re-estimated periodically by the assignee of the task.")
+ effective_hours = fields.Float("Hours Spent", compute='_compute_effective_hours', compute_sudo=True, store=True, help="Time spent on this task, excluding its sub-tasks.")
+ total_hours_spent = fields.Float("Total Hours", compute='_compute_total_hours_spent', store=True, help="Time spent on this task, including its sub-tasks.")
+ progress = fields.Float("Progress", compute='_compute_progress_hours', store=True, group_operator="avg", help="Display progress of current task.")
+ overtime = fields.Float(compute='_compute_progress_hours', store=True)
+ subtask_effective_hours = fields.Float("Sub-tasks Hours Spent", compute='_compute_subtask_effective_hours', store=True, help="Time spent on the sub-tasks (and their own sub-tasks) of this task.")
+ timesheet_ids = fields.One2many('account.analytic.line', 'task_id', 'Timesheets')
+ encode_uom_in_days = fields.Boolean(compute='_compute_encode_uom_in_days', default=lambda self: self._uom_in_days())
+
+ def _uom_in_days(self):
+ return self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day')
+
+ def _compute_encode_uom_in_days(self):
+ self.encode_uom_in_days = self._uom_in_days()
+
+ @api.depends('project_id.analytic_account_id.active')
+ def _compute_analytic_account_active(self):
+ """ Overridden in sale_timesheet """
+ for task in self:
+ task.analytic_account_active = task.project_id.analytic_account_id.active
+
+ @api.depends('timesheet_ids.unit_amount')
+ def _compute_effective_hours(self):
+ for task in self:
+ task.effective_hours = round(sum(task.timesheet_ids.mapped('unit_amount')), 2)
+
+ @api.depends('effective_hours', 'subtask_effective_hours', 'planned_hours')
+ def _compute_progress_hours(self):
+ for task in self:
+ if (task.planned_hours > 0.0):
+ task_total_hours = task.effective_hours + task.subtask_effective_hours
+ task.overtime = max(task_total_hours - task.planned_hours, 0)
+ if task_total_hours > task.planned_hours:
+ task.progress = 100
+ else:
+ task.progress = round(100.0 * task_total_hours / task.planned_hours, 2)
+ else:
+ task.progress = 0.0
+ task.overtime = 0
+
+ @api.depends('effective_hours', 'subtask_effective_hours', 'planned_hours')
+ def _compute_remaining_hours(self):
+ for task in self:
+ task.remaining_hours = task.planned_hours - task.effective_hours - task.subtask_effective_hours
+
+ @api.depends('effective_hours', 'subtask_effective_hours')
+ def _compute_total_hours_spent(self):
+ for task in self:
+ task.total_hours_spent = task.effective_hours + task.subtask_effective_hours
+
+ @api.depends('child_ids.effective_hours', 'child_ids.subtask_effective_hours')
+ def _compute_subtask_effective_hours(self):
+ for task in self:
+ task.subtask_effective_hours = sum(child_task.effective_hours + child_task.subtask_effective_hours for child_task in task.child_ids)
+
+ def action_view_subtask_timesheet(self):
+ self.ensure_one()
+ tasks = self._get_all_subtasks()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Timesheets'),
+ 'res_model': 'account.analytic.line',
+ 'view_mode': 'list,form',
+ 'domain': [('project_id', '!=', False), ('task_id', 'in', tasks.ids)],
+ }
+
+ def _get_timesheet(self):
+ # Is override in sale_timesheet
+ return self.timesheet_ids
+
+ def write(self, values):
+ # a timesheet must have an analytic account (and a project)
+ if 'project_id' in values and not values.get('project_id') and self._get_timesheet():
+ raise UserError(_('This task must be part of a project because there are some timesheets linked to it.'))
+ res = super(Task, self).write(values)
+
+ if 'project_id' in values:
+ project = self.env['project.project'].browse(values.get('project_id'))
+ if project.allow_timesheets:
+ # We write on all non yet invoiced timesheet the new project_id (if project allow timesheet)
+ self._get_timesheet().write({'project_id': values.get('project_id')})
+
+ return res
+
+ def name_get(self):
+ if self.env.context.get('hr_timesheet_display_remaining_hours'):
+ name_mapping = dict(super().name_get())
+ for task in self:
+ if task.allow_timesheets and task.planned_hours > 0 and task.encode_uom_in_days:
+ days_left = _("(%s days remaining)") % task._convert_hours_to_days(task.remaining_hours)
+ name_mapping[task.id] = name_mapping.get(task.id, '') + " ‒ " + days_left
+ elif task.allow_timesheets and task.planned_hours > 0:
+ hours, mins = (str(int(duration)).rjust(2, '0') for duration in divmod(abs(task.remaining_hours) * 60, 60))
+ hours_left = _(
+ "(%(sign)s%(hours)s:%(minutes)s remaining)",
+ sign='-' if task.remaining_hours < 0 else '',
+ hours=hours,
+ minutes=mins,
+ )
+ name_mapping[task.id] = name_mapping.get(task.id, '') + " ‒ " + hours_left
+ return list(name_mapping.items())
+ return super().name_get()
+
+ @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'])
+
+ if view_type == 'tree' and self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day'):
+ result['arch'] = self._apply_time_label(result['arch'])
+ return result
+
+ @api.model
+ def _apply_time_label(self, view_arch):
+ doc = etree.XML(view_arch)
+ encoding_uom = self.env.company.timesheet_encode_uom_id
+ for node in doc.xpath("//field[@widget='timesheet_uom'][not(@string)] | //field[@widget='timesheet_uom_no_toggle'][not(@string)]"):
+ name_with_uom = re.sub(_('Hours') + "|Hours", encoding_uom.name or '', self._fields[node.get('name')]._description_string(self.env), flags=re.IGNORECASE)
+ node.set('string', name_with_uom)
+
+ return etree.tostring(doc, encoding='unicode')
+
+ def unlink(self):
+ """
+ If some tasks to unlink have some timesheets entries, these
+ timesheets entries must be unlinked first.
+ In this case, a warning message is displayed through a RedirectWarning
+ and allows the user to see timesheets entries to unlink.
+ """
+ tasks_with_timesheets = self.filtered(lambda t: t.timesheet_ids)
+ if tasks_with_timesheets:
+ if len(tasks_with_timesheets) > 1:
+ warning_msg = _("These tasks have some timesheet entries referencing them. Before removing these tasks, you have to remove these timesheet entries.")
+ else:
+ warning_msg = _("This task has some timesheet entries referencing it. Before removing this task, you have to remove these timesheet entries.")
+ raise RedirectWarning(
+ warning_msg, self.env.ref('hr_timesheet.timesheet_action_task').id,
+ _('See timesheet entries'), {'active_ids': tasks_with_timesheets.ids})
+ return super(Task, self).unlink()
+
+ def _convert_hours_to_days(self, time):
+ uom_hour = self.env.ref('uom.product_uom_hour')
+ uom_day = self.env.ref('uom.product_uom_day')
+ return round(uom_hour._compute_quantity(time, uom_day, raise_if_failure=False), 2)
diff --git a/addons/hr_timesheet/models/res_company.py b/addons/hr_timesheet/models/res_company.py
new file mode 100644
index 00000000..429799ec
--- /dev/null
+++ b/addons/hr_timesheet/models/res_company.py
@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+
+
+class ResCompany(models.Model):
+ _inherit = 'res.company'
+
+ @api.model
+ def _default_project_time_mode_id(self):
+ uom = self.env.ref('uom.product_uom_hour', raise_if_not_found=False)
+ wtime = self.env.ref('uom.uom_categ_wtime')
+ if not uom:
+ uom = self.env['uom.uom'].search([('category_id', '=', wtime.id), ('uom_type', '=', 'reference')], limit=1)
+ if not uom:
+ uom = self.env['uom.uom'].search([('category_id', '=', wtime.id)], 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)
+ wtime = self.env.ref('uom.uom_categ_wtime')
+ if not uom:
+ uom = self.env['uom.uom'].search([('category_id', '=', wtime.id), ('uom_type', '=', 'reference')], limit=1)
+ if not uom:
+ uom = self.env['uom.uom'].search([('category_id', '=', wtime.id)], limit=1)
+ return uom
+
+ project_time_mode_id = fields.Many2one('uom.uom', string='Project Time Unit',
+ default=_default_project_time_mode_id,
+ 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=lambda self: [('category_id', '=', self.env.ref('uom.uom_categ_wtime').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).""")
+
+ @api.model_create_multi
+ def create(self, values):
+ company = super(ResCompany, self).create(values)
+ # use sudo as the user could have the right to create a company
+ # but not to create a project. On the other hand, when the company
+ # is created, it is not in the allowed_company_ids on the env
+ company.sudo()._create_internal_project_task()
+ return company
+
+ def _create_internal_project_task(self):
+ results = []
+ for company in self:
+ company = company.with_company(company)
+ internal_project = company.env['project.project'].sudo().create({
+ 'name': _('Internal'),
+ 'allow_timesheets': True,
+ 'company_id': company.id,
+ })
+
+ company.env['project.task'].sudo().create([{
+ 'name': _('Training'),
+ 'project_id': internal_project.id,
+ 'company_id': company.id,
+ }, {
+ 'name': _('Meeting'),
+ 'project_id': internal_project.id,
+ 'company_id': company.id,
+ }])
+ results.append(internal_project)
+ return results
diff --git a/addons/hr_timesheet/models/res_config_settings.py b/addons/hr_timesheet/models/res_config_settings.py
new file mode 100644
index 00000000..51f2fe5e
--- /dev/null
+++ b/addons/hr_timesheet/models/res_config_settings.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ module_project_timesheet_synchro = fields.Boolean("Awesome Timesheet",
+ compute="_compute_timesheet_modules", store=True, readonly=False)
+ module_project_timesheet_holidays = fields.Boolean("Record Time Off",
+ compute="_compute_timesheet_modules", store=True, readonly=False)
+ project_time_mode_id = fields.Many2one(
+ 'uom.uom', related='company_id.project_time_mode_id', string='Project Time Unit', readonly=False,
+ 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', readonly=False,
+ 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).""")
+ timesheet_min_duration = fields.Integer('Minimal duration', default=15, config_parameter='hr_timesheet.timesheet_min_duration')
+ timesheet_rounding = fields.Integer('Rounding up', default=15, config_parameter='hr_timesheet.timesheet_rounding')
+ is_encode_uom_days = fields.Boolean(compute='_compute_is_encode_uom_days')
+
+ @api.depends('timesheet_encode_uom_id')
+ def _compute_is_encode_uom_days(self):
+ product_uom_day = self.env.ref('uom.product_uom_day')
+ for settings in self:
+ settings.is_encode_uom_days = settings.timesheet_encode_uom_id == product_uom_day
+
+ @api.depends('module_hr_timesheet')
+ def _compute_timesheet_modules(self):
+ self.filtered(lambda config: not config.module_hr_timesheet).update({
+ 'module_project_timesheet_synchro': False,
+ 'module_project_timesheet_holidays': False,
+ })
diff --git a/addons/hr_timesheet/models/uom.py b/addons/hr_timesheet/models/uom.py
new file mode 100644
index 00000000..885fc9de
--- /dev/null
+++ b/addons/hr_timesheet/models/uom.py
@@ -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.")