With this commit, all instances of errors being raised inside `BaseModel.unlink` overrides are moved into methods decorated with `api.ondelete` which is safer.
253 lines
13 KiB
Python
253 lines
13 KiB
Python
# -*- 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()
|
||
|
||
@api.ondelete(at_uninstall=False)
|
||
def _unlink_except_contains_entries(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})
|
||
|
||
|
||
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')
|
||
|
||
@api.ondelete(at_uninstall=False)
|
||
def _unlink_except_contains_entries(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})
|
||
|
||
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)
|