diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/hr_timesheet/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/hr_timesheet/models')
| -rw-r--r-- | addons/hr_timesheet/models/__init__.py | 10 | ||||
| -rw-r--r-- | addons/hr_timesheet/models/hr_employee.py | 12 | ||||
| -rw-r--r-- | addons/hr_timesheet/models/hr_timesheet.py | 217 | ||||
| -rw-r--r-- | addons/hr_timesheet/models/ir_http.py | 21 | ||||
| -rw-r--r-- | addons/hr_timesheet/models/project.py | 252 | ||||
| -rw-r--r-- | addons/hr_timesheet/models/res_company.py | 69 | ||||
| -rw-r--r-- | addons/hr_timesheet/models/res_config_settings.py | 38 | ||||
| -rw-r--r-- | addons/hr_timesheet/models/uom.py | 10 |
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.") |
