project.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. from collections import defaultdict
  4. from odoo import models, fields, api, _
  5. from odoo.tools.float_utils import float_compare
  6. from odoo.exceptions import UserError, ValidationError, RedirectWarning
  7. from odoo.addons.rating.models.rating_data import OPERATOR_MAPPING
  8. PROJECT_TASK_READABLE_FIELDS = {
  9. 'allow_subtasks',
  10. 'allow_timesheets',
  11. 'analytic_account_active',
  12. 'effective_hours',
  13. 'encode_uom_in_days',
  14. 'planned_hours',
  15. 'progress',
  16. 'overtime',
  17. 'remaining_hours',
  18. 'subtask_effective_hours',
  19. 'subtask_planned_hours',
  20. 'timesheet_ids',
  21. 'total_hours_spent',
  22. }
  23. class Project(models.Model):
  24. _inherit = "project.project"
  25. allow_timesheets = fields.Boolean(
  26. "Timesheets", compute='_compute_allow_timesheets', store=True, readonly=False,
  27. default=True)
  28. analytic_account_id = fields.Many2one(
  29. # note: replaces ['|', ('company_id', '=', False), ('company_id', '=', company_id)]
  30. domain="""[
  31. '|', ('company_id', '=', False), ('company_id', '=', company_id),
  32. ('partner_id', '=?', partner_id),
  33. ]"""
  34. )
  35. timesheet_ids = fields.One2many('account.analytic.line', 'project_id', 'Associated Timesheets')
  36. timesheet_count = fields.Integer(compute="_compute_timesheet_count", groups='hr_timesheet.group_hr_timesheet_user')
  37. timesheet_encode_uom_id = fields.Many2one('uom.uom', related='company_id.timesheet_encode_uom_id')
  38. total_timesheet_time = fields.Integer(
  39. compute='_compute_total_timesheet_time', groups='hr_timesheet.group_hr_timesheet_user',
  40. help="Total number of time (in the proper UoM) recorded in the project, rounded to the unit.")
  41. encode_uom_in_days = fields.Boolean(compute='_compute_encode_uom_in_days')
  42. is_internal_project = fields.Boolean(compute='_compute_is_internal_project', search='_search_is_internal_project')
  43. remaining_hours = fields.Float(compute='_compute_remaining_hours', string='Remaining Invoiced Time', compute_sudo=True)
  44. is_project_overtime = fields.Boolean('Project in Overtime', compute='_compute_remaining_hours', search='_search_is_project_overtime', compute_sudo=True)
  45. allocated_hours = fields.Float(string='Allocated Hours')
  46. def _compute_encode_uom_in_days(self):
  47. self.encode_uom_in_days = self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day')
  48. @api.depends('analytic_account_id')
  49. def _compute_allow_timesheets(self):
  50. without_account = self.filtered(lambda t: not t.analytic_account_id and t._origin)
  51. without_account.update({'allow_timesheets': False})
  52. @api.depends('company_id')
  53. def _compute_is_internal_project(self):
  54. for project in self:
  55. project.is_internal_project = project == project.company_id.internal_project_id
  56. @api.model
  57. def _search_is_internal_project(self, operator, value):
  58. if not isinstance(value, bool):
  59. raise ValueError(_('Invalid value: %s', value))
  60. if operator not in ['=', '!=']:
  61. raise ValueError(_('Invalid operator: %s', operator))
  62. query = """
  63. SELECT C.internal_project_id
  64. FROM res_company C
  65. WHERE C.internal_project_id IS NOT NULL
  66. """
  67. if (operator == '=' and value is True) or (operator == '!=' and value is False):
  68. operator_new = 'inselect'
  69. else:
  70. operator_new = 'not inselect'
  71. return [('id', operator_new, (query, ()))]
  72. @api.model
  73. def _get_view_cache_key(self, view_id=None, view_type='form', **options):
  74. """The override of _get_view changing the time field labels according to the company timesheet encoding UOM
  75. makes the view cache dependent on the company timesheet encoding uom"""
  76. key = super()._get_view_cache_key(view_id, view_type, **options)
  77. return key + (self.env.company.timesheet_encode_uom_id,)
  78. @api.model
  79. def _get_view(self, view_id=None, view_type='form', **options):
  80. arch, view = super()._get_view(view_id, view_type, **options)
  81. if view_type in ['tree', 'form'] and self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day'):
  82. arch = self.env['account.analytic.line']._apply_time_label(arch, related_model=self._name)
  83. return arch, view
  84. @api.depends('allow_timesheets', 'timesheet_ids')
  85. def _compute_remaining_hours(self):
  86. timesheets_read_group = self.env['account.analytic.line']._read_group(
  87. [('project_id', 'in', self.ids)],
  88. ['project_id', 'unit_amount'],
  89. ['project_id'],
  90. lazy=False)
  91. timesheet_time_dict = {res['project_id'][0]: res['unit_amount'] for res in timesheets_read_group}
  92. for project in self:
  93. project.remaining_hours = project.allocated_hours - timesheet_time_dict.get(project.id, 0)
  94. project.is_project_overtime = project.remaining_hours < 0
  95. @api.model
  96. def _search_is_project_overtime(self, operator, value):
  97. if not isinstance(value, bool):
  98. raise ValueError(_('Invalid value: %s') % value)
  99. if operator not in ['=', '!=']:
  100. raise ValueError(_('Invalid operator: %s') % operator)
  101. query = """
  102. SELECT Project.id
  103. FROM project_project AS Project
  104. JOIN project_task AS Task
  105. ON Project.id = Task.project_id
  106. WHERE Project.allocated_hours > 0
  107. AND Project.allow_timesheets = TRUE
  108. AND Task.parent_id IS NULL
  109. AND Task.is_closed IS FALSE
  110. GROUP BY Project.id
  111. HAVING Project.allocated_hours - SUM(Task.effective_hours) < 0
  112. """
  113. if (operator == '=' and value is True) or (operator == '!=' and value is False):
  114. operator_new = 'inselect'
  115. else:
  116. operator_new = 'not inselect'
  117. return [('id', operator_new, (query, ()))]
  118. @api.constrains('allow_timesheets', 'analytic_account_id')
  119. def _check_allow_timesheet(self):
  120. for project in self:
  121. if project.allow_timesheets and not project.analytic_account_id:
  122. raise ValidationError(_('You cannot use timesheets without an analytic account.'))
  123. @api.depends('timesheet_ids')
  124. def _compute_total_timesheet_time(self):
  125. timesheets_read_group = self.env['account.analytic.line'].read_group(
  126. [('project_id', 'in', self.ids)],
  127. ['project_id', 'unit_amount', 'product_uom_id'],
  128. ['project_id', 'product_uom_id'],
  129. lazy=False)
  130. timesheet_time_dict = defaultdict(list)
  131. uom_ids = set(self.timesheet_encode_uom_id.ids)
  132. for result in timesheets_read_group:
  133. uom_id = result['product_uom_id'] and result['product_uom_id'][0]
  134. if uom_id:
  135. uom_ids.add(uom_id)
  136. timesheet_time_dict[result['project_id'][0]].append((uom_id, result['unit_amount']))
  137. uoms_dict = {uom.id: uom for uom in self.env['uom.uom'].browse(uom_ids)}
  138. for project in self:
  139. # Timesheets may be stored in a different unit of measure, so first
  140. # we convert all of them to the reference unit
  141. # if the timesheet has no product_uom_id then we take the one of the project
  142. total_time = 0.0
  143. for product_uom_id, unit_amount in timesheet_time_dict[project.id]:
  144. factor = uoms_dict.get(product_uom_id, project.timesheet_encode_uom_id).factor_inv
  145. total_time += unit_amount * (1.0 if project.encode_uom_in_days else factor)
  146. # Now convert to the proper unit of measure set in the settings
  147. total_time *= project.timesheet_encode_uom_id.factor
  148. project.total_timesheet_time = int(round(total_time))
  149. @api.depends('timesheet_ids')
  150. def _compute_timesheet_count(self):
  151. timesheet_read_group = self.env['account.analytic.line']._read_group(
  152. [('project_id', 'in', self.ids)],
  153. ['project_id'],
  154. ['project_id']
  155. )
  156. timesheet_project_map = {project_info['project_id'][0]: project_info['project_id_count'] for project_info in timesheet_read_group}
  157. for project in self:
  158. project.timesheet_count = timesheet_project_map.get(project.id, 0)
  159. @api.model_create_multi
  160. def create(self, vals_list):
  161. """ Create an analytic account if project allow timesheet and don't provide one
  162. Note: create it before calling super() to avoid raising the ValidationError from _check_allow_timesheet
  163. """
  164. defaults = self.default_get(['allow_timesheets', 'analytic_account_id'])
  165. for vals in vals_list:
  166. allow_timesheets = vals.get('allow_timesheets', defaults.get('allow_timesheets'))
  167. analytic_account_id = vals.get('analytic_account_id', defaults.get('analytic_account_id'))
  168. if allow_timesheets and not analytic_account_id:
  169. analytic_account = self._create_analytic_account_from_values(vals)
  170. vals['analytic_account_id'] = analytic_account.id
  171. return super().create(vals_list)
  172. def write(self, values):
  173. # create the AA for project still allowing timesheet
  174. if values.get('allow_timesheets') and not values.get('analytic_account_id'):
  175. for project in self:
  176. if not project.analytic_account_id:
  177. project._create_analytic_account()
  178. return super(Project, self).write(values)
  179. def name_get(self):
  180. res = super().name_get()
  181. if len(self.env.context.get('allowed_company_ids', [])) <= 1:
  182. return res
  183. name_mapping = dict(res)
  184. for project in self:
  185. if project.is_internal_project:
  186. name_mapping[project.id] = f'{name_mapping[project.id]} - {project.company_id.name}'
  187. return list(name_mapping.items())
  188. @api.model
  189. def _init_data_analytic_account(self):
  190. self.search([('analytic_account_id', '=', False), ('allow_timesheets', '=', True)])._create_analytic_account()
  191. @api.ondelete(at_uninstall=False)
  192. def _unlink_except_contains_entries(self):
  193. """
  194. If some projects to unlink have some timesheets entries, these
  195. timesheets entries must be unlinked first.
  196. In this case, a warning message is displayed through a RedirectWarning
  197. and allows the user to see timesheets entries to unlink.
  198. """
  199. projects_with_timesheets = self.filtered(lambda p: p.timesheet_ids)
  200. if projects_with_timesheets:
  201. if len(projects_with_timesheets) > 1:
  202. warning_msg = _("These projects have some timesheet entries referencing them. Before removing these projects, you have to remove these timesheet entries.")
  203. else:
  204. warning_msg = _("This project has some timesheet entries referencing it. Before removing this project, you have to remove these timesheet entries.")
  205. raise RedirectWarning(
  206. warning_msg, self.env.ref('hr_timesheet.timesheet_action_project').id,
  207. _('See timesheet entries'), {'active_ids': projects_with_timesheets.ids})
  208. def _convert_project_uom_to_timesheet_encode_uom(self, time):
  209. uom_from = self.company_id.project_time_mode_id
  210. uom_to = self.env.company.timesheet_encode_uom_id
  211. return round(uom_from._compute_quantity(time, uom_to, raise_if_failure=False), 2)
  212. def action_project_timesheets(self):
  213. action = self.env['ir.actions.act_window']._for_xml_id('hr_timesheet.act_hr_timesheet_line_by_project')
  214. action['display_name'] = _("%(name)s's Timesheets", name=self.name)
  215. return action
  216. class Task(models.Model):
  217. _name = "project.task"
  218. _inherit = "project.task"
  219. analytic_account_active = fields.Boolean("Active Analytic Account", compute='_compute_analytic_account_active', compute_sudo=True)
  220. allow_timesheets = fields.Boolean("Allow timesheets", related='project_id.allow_timesheets', help="Timesheets can be logged on this task.", readonly=True)
  221. 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.")
  222. remaining_hours_percentage = fields.Float(compute='_compute_remaining_hours_percentage', search='_search_remaining_hours_percentage')
  223. effective_hours = fields.Float("Hours Spent", compute='_compute_effective_hours', compute_sudo=True, store=True)
  224. 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).")
  225. progress = fields.Float("Progress", compute='_compute_progress_hours', store=True, group_operator="avg")
  226. overtime = fields.Float(compute='_compute_progress_hours', store=True)
  227. subtask_effective_hours = fields.Float("Sub-tasks Hours Spent", compute='_compute_subtask_effective_hours', recursive=True, store=True, help="Time spent on the sub-tasks (and their own sub-tasks) of this task.")
  228. timesheet_ids = fields.One2many('account.analytic.line', 'task_id', 'Timesheets')
  229. encode_uom_in_days = fields.Boolean(compute='_compute_encode_uom_in_days', default=lambda self: self._uom_in_days())
  230. @property
  231. def SELF_READABLE_FIELDS(self):
  232. return super().SELF_READABLE_FIELDS | PROJECT_TASK_READABLE_FIELDS
  233. def _uom_in_days(self):
  234. return self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day')
  235. def _compute_encode_uom_in_days(self):
  236. self.encode_uom_in_days = self._uom_in_days()
  237. @api.depends('analytic_account_id.active', 'project_id.analytic_account_id.active')
  238. def _compute_analytic_account_active(self):
  239. """ Overridden in sale_timesheet """
  240. for task in self:
  241. task.analytic_account_active = task._get_task_analytic_account_id().active
  242. @api.depends('timesheet_ids.unit_amount')
  243. def _compute_effective_hours(self):
  244. if not any(self._ids):
  245. for task in self:
  246. task.effective_hours = sum(task.timesheet_ids.mapped('unit_amount'))
  247. return
  248. timesheet_read_group = self.env['account.analytic.line'].read_group([('task_id', 'in', self.ids)], ['unit_amount', 'task_id'], ['task_id'])
  249. timesheets_per_task = {res['task_id'][0]: res['unit_amount'] for res in timesheet_read_group}
  250. for task in self:
  251. task.effective_hours = timesheets_per_task.get(task.id, 0.0)
  252. @api.depends('effective_hours', 'subtask_effective_hours', 'planned_hours')
  253. def _compute_progress_hours(self):
  254. for task in self:
  255. if (task.planned_hours > 0.0):
  256. task_total_hours = task.effective_hours + task.subtask_effective_hours
  257. task.overtime = max(task_total_hours - task.planned_hours, 0)
  258. if float_compare(task_total_hours, task.planned_hours, precision_digits=2) >= 0:
  259. task.progress = 100
  260. else:
  261. task.progress = round(100.0 * task_total_hours / task.planned_hours, 2)
  262. else:
  263. task.progress = 0.0
  264. task.overtime = 0
  265. @api.depends('planned_hours', 'remaining_hours')
  266. def _compute_remaining_hours_percentage(self):
  267. for task in self:
  268. if task.planned_hours > 0.0:
  269. task.remaining_hours_percentage = task.remaining_hours / task.planned_hours
  270. else:
  271. task.remaining_hours_percentage = 0.0
  272. def _search_remaining_hours_percentage(self, operator, value):
  273. if operator not in OPERATOR_MAPPING:
  274. raise NotImplementedError(_('This operator %s is not supported in this search method.', operator))
  275. query = f"""
  276. SELECT id
  277. FROM {self._table}
  278. WHERE remaining_hours > 0
  279. AND planned_hours > 0
  280. AND remaining_hours / planned_hours {operator} %s
  281. """
  282. return [('id', 'inselect', (query, (value,)))]
  283. @api.depends('effective_hours', 'subtask_effective_hours', 'planned_hours')
  284. def _compute_remaining_hours(self):
  285. for task in self:
  286. task.remaining_hours = task.planned_hours - task.effective_hours - task.subtask_effective_hours
  287. @api.depends('effective_hours', 'subtask_effective_hours')
  288. def _compute_total_hours_spent(self):
  289. for task in self:
  290. task.total_hours_spent = task.effective_hours + task.subtask_effective_hours
  291. @api.depends('child_ids.effective_hours', 'child_ids.subtask_effective_hours')
  292. def _compute_subtask_effective_hours(self):
  293. for task in self.with_context(active_test=False):
  294. task.subtask_effective_hours = sum(child_task.effective_hours + child_task.subtask_effective_hours for child_task in task.child_ids)
  295. def action_view_subtask_timesheet(self):
  296. self.ensure_one()
  297. task_ids = self.with_context(active_test=False)._get_subtask_ids_per_task_id().get(self.id, [])
  298. action = self.env["ir.actions.actions"]._for_xml_id("hr_timesheet.timesheet_action_all")
  299. graph_view_id = self.env.ref("hr_timesheet.view_hr_timesheet_line_graph_by_employee").id
  300. new_views = []
  301. for view in action['views']:
  302. if view[1] == 'graph':
  303. view = (graph_view_id, 'graph')
  304. new_views.insert(0, view) if view[1] == 'tree' else new_views.append(view)
  305. action.update({
  306. 'display_name': _('Timesheets'),
  307. 'context': {'default_project_id': self.project_id.id, 'grid_range': 'week'},
  308. 'domain': [('project_id', '!=', False), ('task_id', 'in', task_ids)],
  309. 'views': new_views,
  310. })
  311. return action
  312. def _get_timesheet(self):
  313. # Is override in sale_timesheet
  314. return self.timesheet_ids
  315. def write(self, values):
  316. # a timesheet must have an analytic account (and a project)
  317. if 'project_id' in values and not values.get('project_id') and self._get_timesheet():
  318. raise UserError(_('This task must be part of a project because there are some timesheets linked to it.'))
  319. res = super(Task, self).write(values)
  320. if 'project_id' in values:
  321. project = self.env['project.project'].browse(values.get('project_id'))
  322. if project.allow_timesheets:
  323. # We write on all non yet invoiced timesheet the new project_id (if project allow timesheet)
  324. self._get_timesheet().write({'project_id': values.get('project_id')})
  325. return res
  326. def name_get(self):
  327. if self.env.context.get('hr_timesheet_display_remaining_hours'):
  328. name_mapping = dict(super().name_get())
  329. for task in self:
  330. if task.allow_timesheets and task.planned_hours > 0 and task.encode_uom_in_days:
  331. days_left = _("(%s days remaining)") % task._convert_hours_to_days(task.remaining_hours)
  332. name_mapping[task.id] = name_mapping.get(task.id, '') + u"\u00A0" + days_left
  333. elif task.allow_timesheets and task.planned_hours > 0:
  334. hours, mins = (str(int(duration)).rjust(2, '0') for duration in divmod(abs(task.remaining_hours) * 60, 60))
  335. hours_left = _(
  336. "(%(sign)s%(hours)s:%(minutes)s remaining)",
  337. sign='-' if task.remaining_hours < 0 else '',
  338. hours=hours,
  339. minutes=mins,
  340. )
  341. name_mapping[task.id] = name_mapping.get(task.id, '') + u"\u00A0" + hours_left
  342. return list(name_mapping.items())
  343. return super().name_get()
  344. @api.model
  345. def _get_view_cache_key(self, view_id=None, view_type='form', **options):
  346. """The override of _get_view changing the time field labels according to the company timesheet encoding UOM
  347. makes the view cache dependent on the company timesheet encoding uom"""
  348. key = super()._get_view_cache_key(view_id, view_type, **options)
  349. return key + (self.env.company.timesheet_encode_uom_id,)
  350. @api.model
  351. def _get_view(self, view_id=None, view_type='form', **options):
  352. """ Set the correct label for `unit_amount`, depending on company UoM """
  353. arch, view = super()._get_view(view_id, view_type, **options)
  354. # Use of sudo as the portal user doesn't have access to uom
  355. arch = self.env['account.analytic.line'].sudo()._apply_timesheet_label(arch)
  356. if view_type in ['tree', 'pivot', 'graph', 'form'] and self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day'):
  357. arch = self.env['account.analytic.line']._apply_time_label(arch, related_model=self._name)
  358. return arch, view
  359. @api.ondelete(at_uninstall=False)
  360. def _unlink_except_contains_entries(self):
  361. """
  362. If some tasks to unlink have some timesheets entries, these
  363. timesheets entries must be unlinked first.
  364. In this case, a warning message is displayed through a RedirectWarning
  365. and allows the user to see timesheets entries to unlink.
  366. """
  367. timesheet_data = self.env['account.analytic.line'].sudo()._read_group(
  368. [('task_id', 'in', self.ids)],
  369. ['task_id'],
  370. ['task_id'],
  371. )
  372. task_with_timesheets_ids = [res['task_id'][0] for res in timesheet_data]
  373. if task_with_timesheets_ids:
  374. if len(task_with_timesheets_ids) > 1:
  375. warning_msg = _("These tasks have some timesheet entries referencing them. Before removing these tasks, you have to remove these timesheet entries.")
  376. else:
  377. warning_msg = _("This task has some timesheet entries referencing it. Before removing this task, you have to remove these timesheet entries.")
  378. raise RedirectWarning(
  379. warning_msg, self.env.ref('hr_timesheet.timesheet_action_task').id,
  380. _('See timesheet entries'), {'active_ids': task_with_timesheets_ids})
  381. @api.model
  382. def _convert_hours_to_days(self, time):
  383. uom_hour = self.env.ref('uom.product_uom_hour')
  384. uom_day = self.env.ref('uom.product_uom_day')
  385. return round(uom_hour._compute_quantity(time, uom_day, raise_if_failure=False), 2)