1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262
|
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from collections import defaultdict
from odoo import models, fields, api, _
from odoo.exceptions import UserError, RedirectWarning
from odoo.tools import SQL
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", related='project_id.analytic_account_active', export_string_translation=False)
allow_timesheets = fields.Boolean(
"Allow timesheets",
compute='_compute_allow_timesheets', search='_search_allow_timesheets',
compute_sudo=True, readonly=True, export_string_translation=False)
remaining_hours = fields.Float("Time Remaining", 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', export_string_translation=False)
effective_hours = fields.Float("Time Spent", compute='_compute_effective_hours', compute_sudo=True, store=True)
total_hours_spent = fields.Float("Total Time Spent", 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, aggregator="avg", export_string_translation=False)
overtime = fields.Float(compute='_compute_progress_hours', store=True, export_string_translation=False)
subtask_effective_hours = fields.Float("Time 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', export_string_translation=False)
encode_uom_in_days = fields.Boolean(compute='_compute_encode_uom_in_days', default=lambda self: self._uom_in_days(), export_string_translation=False)
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('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(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))
sql = SQL("""(
SELECT id
FROM %s
WHERE remaining_hours > 0
AND allocated_hours > 0
AND remaining_hours / allocated_hours %s %s
)""", SQL.identifier(self._table), SQL(operator), value)
return [('id', 'in', sql)]
@api.depends('effective_hours', 'subtask_effective_hours', 'allocated_hours')
def _compute_remaining_hours(self):
for task in self:
if not task.allocated_hours:
task.remaining_hours = 0.0
else:
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()
is_internal_user = self.env.user.has_group('base.group_user')
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 not is_internal_user:
if view[1] == 'list':
tree_view_id = self.env['ir.model.data']._xmlid_to_res_id('hr_timesheet.hr_timesheet_line_portal_tree')
if tree_view_id:
new_views.insert(0, (tree_view_id, 'list'))
continue
elif view[1] == 'form':
form_view_id = self.env['ir.model.data']._xmlid_to_res_id('hr_timesheet.timesheet_view_form_portal_user')
if form_view_id:
new_views.append((form_view_id, 'form'))
continue
elif view[1] == 'kanban':
kanban_view_id = self.env['ir.model.data']._xmlid_to_res_id('hr_timesheet.view_kanban_account_analytic_line_portal_user')
if kanban_view_id:
new_views.append((kanban_view_id, 'kanban'))
continue
if view[1] == 'graph':
view = (graph_view_id, 'graph')
new_views.insert(0, view) if view[1] == 'list' else new_views.append(view)
action.update({
'display_name': _('Timesheets'),
'context': {'default_project_id': self.project_id.id},
'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
def _get_timesheet_report_data(self):
subtasks = self._get_all_subtasks()
timesheets_read_group = self.env['account.analytic.line']._read_group(
[('task_id', 'in', (self | subtasks).ids)],
['task_id'],
['id:recordset'],
)
timesheets_per_task = dict(timesheets_read_group)
subtask_ids_per_task_id = defaultdict(list)
for subtask in subtasks:
subtask_ids_per_task_id[subtask.parent_id.id].append(subtask.id)
return {
'subtask_ids_per_task_id': subtask_ids_per_task_id,
'timesheets_per_task': timesheets_per_task,
}
@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.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)
|