249 lines
12 KiB
Python
249 lines
12 KiB
Python
|
# -*- coding: utf-8 -*-
|
|||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|||
|
import re
|
|||
|
|
|||
|
from odoo import models, fields, api, _
|
|||
|
from odoo.exceptions import UserError, RedirectWarning
|
|||
|
from odoo.addons.rating.models.rating_data import OPERATOR_MAPPING
|
|||
|
|
|||
|
PROJECT_TASK_READABLE_FIELDS = {
|
|||
|
'allow_timesheets',
|
|||
|
'analytic_account_active',
|
|||
|
'effective_hours',
|
|||
|
'encode_uom_in_days',
|
|||
|
'allocated_hours',
|
|||
|
'progress',
|
|||
|
'overtime',
|
|||
|
'remaining_hours',
|
|||
|
'subtask_effective_hours',
|
|||
|
'subtask_allocated_hours',
|
|||
|
'timesheet_ids',
|
|||
|
'total_hours_spent',
|
|||
|
}
|
|||
|
|
|||
|
class Task(models.Model):
|
|||
|
_name = "project.task"
|
|||
|
_inherit = "project.task"
|
|||
|
|
|||
|
project_id = fields.Many2one(domain="['|', ('company_id', '=', False), ('company_id', '=?', company_id), ('is_internal_project', '=', False)]")
|
|||
|
analytic_account_active = fields.Boolean("Active Analytic Account", compute='_compute_analytic_account_active', compute_sudo=True, recursive=True)
|
|||
|
allow_timesheets = fields.Boolean(
|
|||
|
"Allow timesheets",
|
|||
|
compute='_compute_allow_timesheets', search='_search_allow_timesheets',
|
|||
|
compute_sudo=True, readonly=True,
|
|||
|
help="Timesheets can be logged on this task.")
|
|||
|
remaining_hours = fields.Float("Remaining Hours", compute='_compute_remaining_hours', store=True, readonly=True, help="Number of allocated hours minus the number of hours spent.")
|
|||
|
remaining_hours_percentage = fields.Float(compute='_compute_remaining_hours_percentage', search='_search_remaining_hours_percentage')
|
|||
|
effective_hours = fields.Float("Hours Spent", compute='_compute_effective_hours', compute_sudo=True, store=True)
|
|||
|
total_hours_spent = fields.Float("Total Hours", compute='_compute_total_hours_spent', store=True, help="Time spent on this task and its sub-tasks (and their own sub-tasks).")
|
|||
|
progress = fields.Float("Progress", compute='_compute_progress_hours', store=True, group_operator="avg")
|
|||
|
overtime = fields.Float(compute='_compute_progress_hours', store=True)
|
|||
|
subtask_effective_hours = fields.Float("Hours Spent on Sub-Tasks", compute='_compute_subtask_effective_hours', recursive=True, 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())
|
|||
|
display_name = fields.Char(help="""Use these keywords in the title to set new tasks:\n
|
|||
|
30h Allocate 30 hours to the task
|
|||
|
#tags Set tags on the task
|
|||
|
@user Assign the task to a user
|
|||
|
! Set the task a high priority\n
|
|||
|
Make sure to use the right format and order e.g. Improve the configuration screen 5h #feature #v16 @Mitchell !""",
|
|||
|
)
|
|||
|
@property
|
|||
|
def SELF_READABLE_FIELDS(self):
|
|||
|
return super().SELF_READABLE_FIELDS | PROJECT_TASK_READABLE_FIELDS
|
|||
|
|
|||
|
@api.constrains('project_id')
|
|||
|
def _check_project_root(self):
|
|||
|
private_tasks = self.filtered(lambda t: not t.project_id)
|
|||
|
if private_tasks and self.env['account.analytic.line'].sudo().search_count([('task_id', 'in', private_tasks.ids)], limit=1):
|
|||
|
raise UserError(_("This task cannot be private because there are some timesheets linked to it."))
|
|||
|
|
|||
|
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.allow_timesheets')
|
|||
|
def _compute_allow_timesheets(self):
|
|||
|
for task in self:
|
|||
|
task.allow_timesheets = task.project_id.allow_timesheets
|
|||
|
|
|||
|
def _search_allow_timesheets(self, operator, value):
|
|||
|
query = self.env['project.project'].sudo()._search([
|
|||
|
('allow_timesheets', operator, value),
|
|||
|
])
|
|||
|
return [('project_id', 'in', query)]
|
|||
|
|
|||
|
@api.depends('analytic_account_id.active', '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._get_task_analytic_account_id().active
|
|||
|
|
|||
|
@api.depends('timesheet_ids.unit_amount')
|
|||
|
def _compute_effective_hours(self):
|
|||
|
if not any(self._ids):
|
|||
|
for task in self:
|
|||
|
task.effective_hours = sum(task.timesheet_ids.mapped('unit_amount'))
|
|||
|
return
|
|||
|
timesheet_read_group = self.env['account.analytic.line']._read_group([('task_id', 'in', self.ids)], ['task_id'], ['unit_amount:sum'])
|
|||
|
timesheets_per_task = {task.id: amount for task, amount in timesheet_read_group}
|
|||
|
for task in self:
|
|||
|
task.effective_hours = timesheets_per_task.get(task.id, 0.0)
|
|||
|
|
|||
|
@api.depends('effective_hours', 'subtask_effective_hours', 'allocated_hours')
|
|||
|
def _compute_progress_hours(self):
|
|||
|
for task in self:
|
|||
|
if (task.allocated_hours > 0.0):
|
|||
|
task_total_hours = task.effective_hours + task.subtask_effective_hours
|
|||
|
task.overtime = max(task_total_hours - task.allocated_hours, 0)
|
|||
|
task.progress = round(100.0 * task_total_hours / task.allocated_hours, 2)
|
|||
|
else:
|
|||
|
task.progress = 0.0
|
|||
|
task.overtime = 0
|
|||
|
|
|||
|
@api.depends('allocated_hours', 'remaining_hours')
|
|||
|
def _compute_remaining_hours_percentage(self):
|
|||
|
for task in self:
|
|||
|
if task.allocated_hours > 0.0:
|
|||
|
task.remaining_hours_percentage = task.remaining_hours / task.allocated_hours
|
|||
|
else:
|
|||
|
task.remaining_hours_percentage = 0.0
|
|||
|
|
|||
|
def _search_remaining_hours_percentage(self, operator, value):
|
|||
|
if operator not in OPERATOR_MAPPING:
|
|||
|
raise NotImplementedError(_('This operator %s is not supported in this search method.', operator))
|
|||
|
query = f"""
|
|||
|
SELECT id
|
|||
|
FROM {self._table}
|
|||
|
WHERE remaining_hours > 0
|
|||
|
AND allocated_hours > 0
|
|||
|
AND remaining_hours / allocated_hours {operator} %s
|
|||
|
"""
|
|||
|
return [('id', 'inselect', (query, (value,)))]
|
|||
|
|
|||
|
@api.depends('effective_hours', 'subtask_effective_hours', 'allocated_hours')
|
|||
|
def _compute_remaining_hours(self):
|
|||
|
for task in self:
|
|||
|
task.remaining_hours = task.allocated_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.with_context(active_test=False):
|
|||
|
task.subtask_effective_hours = sum(child_task.effective_hours + child_task.subtask_effective_hours for child_task in task.child_ids)
|
|||
|
|
|||
|
def _get_group_pattern(self):
|
|||
|
return {
|
|||
|
**super()._get_group_pattern(),
|
|||
|
'allocated_hours': r'\s(\d+(?:\.\d+)?)[hH]',
|
|||
|
}
|
|||
|
|
|||
|
def _prepare_pattern_groups(self):
|
|||
|
return [self._get_group_pattern()['allocated_hours']] + super()._prepare_pattern_groups()
|
|||
|
|
|||
|
def _get_cannot_start_with_patterns(self):
|
|||
|
return super()._get_cannot_start_with_patterns() + [r'(?!\d+(?:\.\d+)?(?:h|H))']
|
|||
|
|
|||
|
def _extract_allocated_hours(self):
|
|||
|
allocated_hours_group = self._get_group_pattern()['allocated_hours']
|
|||
|
if self.allow_timesheets:
|
|||
|
self.allocated_hours = sum(float(num) for num in re.findall(allocated_hours_group, self.display_name))
|
|||
|
self.display_name, dummy = re.subn(allocated_hours_group, '', self.display_name)
|
|||
|
|
|||
|
def _get_groups(self):
|
|||
|
return [lambda task: task._extract_allocated_hours()] + super()._get_groups()
|
|||
|
|
|||
|
def action_view_subtask_timesheet(self):
|
|||
|
self.ensure_one()
|
|||
|
task_ids = self.with_context(active_test=False)._get_subtask_ids_per_task_id().get(self.id, [])
|
|||
|
action = self.env["ir.actions.actions"]._for_xml_id("hr_timesheet.timesheet_action_all")
|
|||
|
graph_view_id = self.env.ref("hr_timesheet.view_hr_timesheet_line_graph_by_employee").id
|
|||
|
new_views = []
|
|||
|
for view in action['views']:
|
|||
|
if view[1] == 'graph':
|
|||
|
view = (graph_view_id, 'graph')
|
|||
|
new_views.insert(0, view) if view[1] == 'tree' else new_views.append(view)
|
|||
|
action.update({
|
|||
|
'display_name': _('Timesheets'),
|
|||
|
'context': {'default_project_id': self.project_id.id, 'grid_range': 'week'},
|
|||
|
'domain': [('project_id', '!=', False), ('task_id', 'in', task_ids)],
|
|||
|
'views': new_views,
|
|||
|
})
|
|||
|
return action
|
|||
|
|
|||
|
def _get_timesheet(self):
|
|||
|
# Is override in sale_timesheet
|
|||
|
return self.timesheet_ids
|
|||
|
|
|||
|
@api.depends('allow_timesheets', 'allocated_hours', 'encode_uom_in_days', 'remaining_hours')
|
|||
|
@api.depends_context('hr_timesheet_display_remaining_hours')
|
|||
|
def _compute_display_name(self):
|
|||
|
super()._compute_display_name()
|
|||
|
if self.env.context.get('hr_timesheet_display_remaining_hours'):
|
|||
|
for task in self:
|
|||
|
if task.allow_timesheets and task.allocated_hours > 0 and task.encode_uom_in_days:
|
|||
|
days_left = _("(%s days remaining)", task._convert_hours_to_days(task.remaining_hours))
|
|||
|
task.display_name = task.display_name + "\u00A0" + days_left
|
|||
|
elif task.allow_timesheets and task.allocated_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,
|
|||
|
)
|
|||
|
task.display_name = task.display_name + "\u00A0" + hours_left
|
|||
|
|
|||
|
@api.model
|
|||
|
def _get_view_cache_key(self, view_id=None, view_type='form', **options):
|
|||
|
"""The override of _get_view changing the time field labels according to the company timesheet encoding UOM
|
|||
|
makes the view cache dependent on the company timesheet encoding uom"""
|
|||
|
key = super()._get_view_cache_key(view_id, view_type, **options)
|
|||
|
return key + (self.env.company.timesheet_encode_uom_id,)
|
|||
|
|
|||
|
@api.model
|
|||
|
def _get_view(self, view_id=None, view_type='form', **options):
|
|||
|
""" Set the correct label for `unit_amount`, depending on company UoM """
|
|||
|
arch, view = super()._get_view(view_id, view_type, **options)
|
|||
|
# Use of sudo as the portal user doesn't have access to uom
|
|||
|
arch = self.env['account.analytic.line'].sudo()._apply_timesheet_label(arch)
|
|||
|
|
|||
|
if view_type in ['tree', 'pivot', 'graph', 'form'] and self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day'):
|
|||
|
arch = self.env['account.analytic.line']._apply_time_label(arch, related_model=self._name)
|
|||
|
|
|||
|
return arch, view
|
|||
|
|
|||
|
@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.
|
|||
|
"""
|
|||
|
timesheet_data = self.env['account.analytic.line'].sudo()._read_group(
|
|||
|
[('task_id', 'in', self.ids)],
|
|||
|
['task_id'],
|
|||
|
)
|
|||
|
task_with_timesheets_ids = [task.id for task, in timesheet_data]
|
|||
|
if task_with_timesheets_ids:
|
|||
|
if len(task_with_timesheets_ids) > 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': task_with_timesheets_ids})
|
|||
|
|
|||
|
@api.model
|
|||
|
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)
|