19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:00 +01:00
parent a1137a1456
commit e1d89e11e3
2789 changed files with 1093187 additions and 605897 deletions

View file

@ -1,11 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_analytic_line_calendar_employee
from . import analytic_applicability
from . import hr_employee
from . import hr_employee_public
from . import hr_timesheet
from . import ir_http
from . import ir_ui_menu
from . import res_company
from . import res_config_settings
from . import project
from . import project_project
from . import project_task
from . import project_update
from . import project_collaborator
from . import uom
from . import uom_uom

View file

@ -0,0 +1,12 @@
from odoo import fields, models
class AccountAnalyticLineCalendarEmployee(models.Model):
_name = 'account.analytic.line.calendar.employee'
_description = 'Personal Filters on Employees for the Calendar view'
user_id = fields.Many2one('res.users', required=True, default=lambda self: self.env.user, ondelete='cascade', export_string_translation=False)
employee_id = fields.Many2one('hr.employee', export_string_translation=False)
checked = fields.Boolean(default=True, export_string_translation=False)
active = fields.Boolean(default=True, export_string_translation=False)

View file

@ -0,0 +1,15 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class AccountAnalyticApplicability(models.Model):
_inherit = 'account.analytic.applicability'
_description = "Analytic Plan's Applicabilities"
business_domain = fields.Selection(
selection_add=[
('timesheet', 'Timesheet'),
],
ondelete={'timesheet': 'cascade'},
)

View file

@ -0,0 +1,75 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from ast import literal_eval
from odoo import api, models, fields, _
from odoo.exceptions import UserError
from odoo.tools import SQL
class HrEmployee(models.Model):
_inherit = 'hr.employee'
has_timesheet = fields.Boolean(compute='_compute_has_timesheet', export_string_translation=False)
def _compute_has_timesheet(self):
if self.ids:
result = dict(self.env.execute_query(SQL(
""" SELECT id, EXISTS(
SELECT 1 FROM account_analytic_line
WHERE project_id IS NOT NULL AND employee_id = e.id
LIMIT 1)
FROM hr_employee e
WHERE id in %s """,
tuple(self.ids),
)))
else:
result = {}
for employee in self:
employee.has_timesheet = result.get(employee._origin.id, False)
@api.depends('company_id', 'user_id')
@api.depends_context('allowed_company_ids')
def _compute_display_name(self):
super()._compute_display_name()
allowed_company_ids = self.env.context.get('allowed_company_ids', [])
if len(allowed_company_ids) <= 1:
return
employees_count_per_user = {
user.id: count
for user, count in self.env['hr.employee'].sudo()._read_group(
[('user_id', 'in', self.user_id.ids), ('company_id', 'in', allowed_company_ids)],
['user_id'],
['__count'],
)
}
for employee in self:
if employees_count_per_user.get(employee.user_id.id, 0) > 1:
employee.display_name = f'{employee.display_name} - {employee.company_id.name}'
def action_unlink_wizard(self):
wizard = self.env['hr.employee.delete.wizard'].create({
'employee_ids': self.ids,
})
if not self.env.user.has_group('hr_timesheet.group_hr_timesheet_approver') and wizard.has_timesheet and not wizard.has_active_employee:
raise UserError(_('You cannot delete employees who have timesheets.'))
return {
'name': _('Confirmation'),
'view_mode': 'form',
'res_model': 'hr.employee.delete.wizard',
'views': [(self.env.ref('hr_timesheet.hr_employee_delete_wizard_form').id, 'form')],
'type': 'ir.actions.act_window',
'res_id': wizard.id,
'target': 'new',
'context': self.env.context,
}
def action_timesheet_from_employee(self):
action = self.env["ir.actions.act_window"]._for_xml_id("hr_timesheet.timesheet_action_from_employee")
context = literal_eval(action['context'].replace('active_id', str(self.id)))
context['create'] = context.get('create', True) and self.active
action['context'] = context
return action

View file

@ -0,0 +1,14 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class HrEmployeePublic(models.Model):
_inherit = 'hr.employee.public'
has_timesheet = fields.Boolean(related='employee_id.has_timesheet')
def action_timesheet_from_employee(self):
self.ensure_one()
if self.is_user:
return self.employee_id.action_timesheet_from_employee()

View file

@ -1,13 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from lxml import etree
from datetime import datetime, time
from statistics import mode
import re
from odoo import api, Command, fields, models, _, _lt
import pytz
from odoo import api, fields, models
from odoo.exceptions import UserError, AccessError, ValidationError
from odoo.osv import expression
from odoo.fields import Domain
from odoo.tools.translate import _
class AccountAnalyticLine(models.Model):
_inherit = 'account.analytic.line'
@ -17,21 +21,26 @@ class AccountAnalyticLine(models.Model):
return [
('employee_id', '=', employee_id),
('project_id', '!=', False),
('project_id.active', '=', True),
('project_id.allow_timesheets', '=', True)
]
@api.model
def _get_favorite_project_id(self, employee_id=False):
last_timesheet_ids = self.search(self._get_favorite_project_id_domain(employee_id), limit=5)
if len(last_timesheet_ids.project_id) == 1:
return last_timesheet_ids.project_id.id
return False
last_timesheets = self.search_fetch(
self._get_favorite_project_id_domain(employee_id), ['project_id'], limit=5
)
if not last_timesheets:
internal_project = self.env.company.internal_project_id
return internal_project.has_access('read') and internal_project.active and internal_project.allow_timesheets and internal_project.id
return mode([t.project_id.id for t in last_timesheets])
@api.model
def default_get(self, field_list):
result = super(AccountAnalyticLine, self).default_get(field_list)
if not self.env.context.get('default_employee_id') and 'employee_id' in field_list and result.get('user_id'):
def default_get(self, fields):
result = super().default_get(fields)
if not self.env.context.get('default_employee_id') and 'employee_id' in fields and result.get('user_id'):
result['employee_id'] = self.env['hr.employee'].search([('user_id', '=', result['user_id']), ('company_id', '=', result.get('company_id', self.env.company.id))], limit=1).id
if not self._context.get('default_project_id') and self._context.get('is_timesheet'):
if not self.env.context.get('default_project_id') and self.env.context.get('is_timesheet'):
employee_id = result.get('employee_id', self.env.context.get('default_employee_id', False))
favorite_project_id = self._get_favorite_project_id(employee_id)
if favorite_project_id:
@ -39,52 +48,81 @@ class AccountAnalyticLine(models.Model):
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'), ('message_partner_ids', 'in', [self.env.user.partner_id.id])]
])
domain = Domain([('allow_timesheets', '=', True), ('is_template', '=', False)])
if not self.env.user.has_group('hr_timesheet.group_timesheet_manager'):
domain &= Domain('privacy_visibility', 'in', ['employees', 'portal']) | Domain('message_partner_ids', 'in', [self.env.user.partner_id.id])
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 []
domain = Domain('company_id', 'in', self.env.context.get('allowed_company_ids'))
if not self.env.user.has_group('hr_timesheet.group_hr_timesheet_approver'):
domain &= Domain('user_id', '=', self.env.user.id)
return domain
task_id = fields.Many2one(
'project.task', 'Task', index='btree_not_null',
compute='_compute_task_id', store=True, readonly=False,
domain="[('allow_timesheets', '=', True), ('project_id', '=?', project_id)]")
ancestor_task_id = fields.Many2one('project.task', related='task_id.ancestor_id', store=True, index='btree_not_null')
domain="[('allow_timesheets', '=', True), ('project_id', '=?', project_id), ('has_template_ancestor', '=', False)]")
parent_task_id = fields.Many2one('project.task', related='task_id.parent_id', store=True, index='btree_not_null')
project_id = fields.Many2one(
'project.project', 'Project', domain=_domain_project_id, index=True,
compute='_compute_project_id', store=True, readonly=False)
user_id = fields.Many2one(compute='_compute_user_id', store=True, readonly=False)
employee_id = fields.Many2one('hr.employee', "Employee", domain=_domain_employee_id, context={'active_test': False},
help="Define an 'hourly cost' on the employee to track the cost of their time.")
job_title = fields.Char(related='employee_id.job_title')
index=True, help="Define an 'hourly cost' on the employee to track the cost of their time.")
job_title = fields.Char(related='employee_id.job_title', export_string_translation=False)
department_id = fields.Many2one('hr.department', "Department", compute='_compute_department_id', store=True, compute_sudo=True)
manager_id = fields.Many2one('hr.employee', "Manager", related='employee_id.parent_id', store=True)
encoding_uom_id = fields.Many2one('uom.uom', compute='_compute_encoding_uom_id')
encoding_uom_id = fields.Many2one('uom.uom', compute='_compute_encoding_uom_id', export_string_translation=False)
partner_id = fields.Many2one(compute='_compute_partner_id', store=True, readonly=False)
readonly_timesheet = fields.Boolean(compute="_compute_readonly_timesheet", compute_sudo=True, export_string_translation=False)
milestone_id = fields.Many2one('project.milestone', related='task_id.milestone_id')
message_partner_ids = fields.Many2many('res.partner', compute='_compute_message_partner_ids', search='_search_message_partner_ids')
calendar_display_name = fields.Char(compute="_compute_calendar_display_name", export_string_translation=False)
def name_get(self):
result = super().name_get()
timesheets_read = self.env[self._name].search_read([('project_id', '!=', False), ('id', 'in', self.ids)], ['id', 'project_id', 'task_id'])
if not timesheets_read:
return result
def _get_display_name(project_id, task_id):
""" Get the display name of the timesheet based on the project and task
:param project_id: tuple containing the id and the display name of the project
:param task_id: tuple containing the id and the display name of the task if a task exists in the timesheet
otherwise False.
:returns: the display name of the timesheet
"""
if task_id:
return '%s - %s' % (project_id[1], task_id[1])
return project_id[1]
timesheet_dict = {res['id']: _get_display_name(res['project_id'], res['task_id']) for res in timesheets_read}
return list({**dict(result), **timesheet_dict}.items())
def _search_message_partner_ids(self, operator, value):
followed_ids_by_model = dict(self.env['mail.followers']._read_group([
('partner_id', operator, value),
('res_model', 'in', ('project.project', 'project.task')),
], ['res_model'], ['res_id:array_agg']))
if not followed_ids_by_model:
return Domain.FALSE
domain = Domain.FALSE
if project_ids := followed_ids_by_model.get('project.project'):
domain |= Domain('project_id', 'in', project_ids)
if task_ids := followed_ids_by_model.get('project.task'):
domain |= Domain('task_id', 'in', task_ids)
return domain
@api.depends('project_id.message_partner_ids', 'task_id.message_partner_ids')
def _compute_message_partner_ids(self):
for line in self:
line.message_partner_ids = line.task_id.message_partner_ids | line.project_id.message_partner_ids
@api.depends('project_id', 'task_id')
def _compute_display_name(self):
analytic_line_with_project = self.filtered('project_id')
super(AccountAnalyticLine, self - analytic_line_with_project)._compute_display_name()
for analytic_line in analytic_line_with_project:
if analytic_line.task_id:
analytic_line.display_name = f"{analytic_line.project_id.sudo().display_name} - {analytic_line.task_id.sudo().display_name}"
else:
analytic_line.display_name = analytic_line.project_id.display_name
def _is_readonly(self):
self.ensure_one()
# is overridden in other timesheet related modules
return False
def _compute_readonly_timesheet(self):
# Since the mrp_module gives write access to portal user on timesheet, we check that the user is an internal one before giving the write access.
# It is not supposed to be needed, since portal user are not supposed to have access to the views using this field, but better be safe than sorry
if not self.env.user.has_group('base.group_user'):
self.readonly_timesheet = True
else:
readonly_timesheets = self.filtered(lambda timesheet: timesheet._is_readonly())
readonly_timesheets.readonly_timesheet = True
(self - readonly_timesheets).readonly_timesheet = False
def _compute_encoding_uom_id(self):
for analytic_line in self:
@ -92,11 +130,12 @@ class AccountAnalyticLine(models.Model):
@api.depends('task_id.partner_id', 'project_id.partner_id')
def _compute_partner_id(self):
super()._compute_partner_id()
for timesheet in self:
if timesheet.project_id:
timesheet.partner_id = timesheet.task_id.partner_id or timesheet.project_id.partner_id
@api.depends('task_id', 'task_id.project_id')
@api.depends('task_id.project_id')
def _compute_project_id(self):
for line in self:
if not line.task_id.project_id or line.project_id == line.task_id.project_id:
@ -105,8 +144,7 @@ class AccountAnalyticLine(models.Model):
@api.depends('project_id')
def _compute_task_id(self):
for line in self.filtered(lambda line: not line.project_id):
line.task_id = False
self.filtered(lambda t: not t.project_id).task_id = False
@api.onchange('project_id')
def _onchange_project_id(self):
@ -116,7 +154,7 @@ class AccountAnalyticLine(models.Model):
if self.project_id != self.task_id.project_id:
self.task_id = False
@api.depends('employee_id')
@api.depends('employee_id.user_id')
def _compute_user_id(self):
for line in self:
line.user_id = line.employee_id.user_id if line.employee_id else self._default_user()
@ -126,28 +164,115 @@ class AccountAnalyticLine(models.Model):
for line in self:
line.department_id = line.employee_id.department_id
def _compute_calendar_display_name(self):
companies = self.company_id
encoding_in_days_per_company = dict(zip(companies, [company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day') for company in companies]))
for line in self:
if not line.project_id:
line.calendar_display_name = ""
continue
if encoding_in_days_per_company[line.company_id]:
days = line._get_timesheet_time_day()
if days == int(days):
days = int(days)
line.calendar_display_name = self.env._(
"%(project_name)s (%(days)sd)",
project_name=line.project_id.display_name,
days=days,
)
else:
minutes = round(line.unit_amount * 60)
hours, minutes = divmod(abs(round(minutes)), 60)
if minutes:
line.calendar_display_name = self.env._(
"%(project_name)s (%(sign)s%(hours)sh%(minutes)s)",
project_name=line.project_id.display_name,
sign='-' if line.unit_amount < 0 else '',
hours=hours,
minutes=minutes,
)
else:
line.calendar_display_name = self.env._(
"%(project_name)s (%(sign)s%(hours)sh)",
project_name=line.project_id.display_name,
sign='-' if line.unit_amount < 0 else '',
hours=hours,
)
def _check_can_write(self, values):
# If it's a basic user then check if the timesheet is his own.
if (
not (self.env.user.has_group('hr_timesheet.group_hr_timesheet_approver') or self.env.su)
and any(analytic_line.user_id != self.env.user for analytic_line in self)
):
raise AccessError(_("You cannot access timesheets that are not yours."))
def _check_can_create(self):
# override in other modules to check current user has create access
pass
@api.model_create_multi
def create(self, vals_list):
user_timezone = self.env.tz
# Before creating a timesheet, we need to put a valid employee_id in the vals
default_user_id = self._default_user()
user_ids = []
employee_ids = []
# If batch creating from the calendar view, prefetch all employees to avoid fetching them one by one in the loop
if self.env.context.get('timesheet_calendar'):
self.env['hr.employee'].browse([vals.get('employee_id') for vals in vals_list])
# 1/ Collect the user_ids and employee_ids from each timesheet vals
for vals in vals_list:
vals.update(self._timesheet_preprocess(vals))
if not vals.get('project_id'):
skipped_vals = 0
valid_vals = 0
for vals in vals_list[:]:
if self.env.context.get('timesheet_calendar'):
if not 'employee_id' in vals:
vals['employee_id'] = self.env.user.employee_id.id
employee = self.env['hr.employee'].browse(vals['employee_id'])
date = fields.Date.from_string(vals.get('date', fields.Date.to_string(fields.Date.context_today(self))))
if not any(employee.resource_id._get_valid_work_intervals(
datetime.combine(date, time.min, tzinfo=user_timezone),
datetime.combine(date, time.max, tzinfo=user_timezone),
)[0][employee.resource_id.id]):
vals_list.remove(vals)
skipped_vals += 1
continue
task = self.env['project.task'].sudo().browse(vals.get('task_id'))
project = self.env['project.project'].sudo().browse(vals.get('project_id'))
if not (task or project):
# It is not a timesheet
continue
elif task:
if not task.project_id:
raise ValidationError(_('Timesheets cannot be created on a private task.'))
if not project:
vals['project_id'] = task.project_id.id
company = task.company_id or project.company_id or self.env['res.company'].browse(vals.get('company_id'))
vals['company_id'] = company.id
vals.update({
fname: account_id
for fname, account_id in self._timesheet_preprocess_get_accounts(vals).items()
if fname not in vals
})
if not vals.get('product_uom_id'):
vals['product_uom_id'] = company.project_time_mode_id.id
if not vals.get('name'):
vals['name'] = '/'
employee_id = vals.get('employee_id')
user_id = vals.get('user_id', default_user_id)
employee_id = vals.get('employee_id', self.env.context.get('default_employee_id', False))
if employee_id and employee_id not in employee_ids:
employee_ids.append(employee_id)
elif user_id not in user_ids:
user_ids.append(user_id)
else:
user_id = vals.get('user_id', default_user_id)
if user_id not in user_ids:
user_ids.append(user_id)
valid_vals += 1
# 2/ Search all employees related to user_ids and employee_ids, in the selected companies
employees = self.env['hr.employee'].sudo().search([
HrEmployee_sudo = self.env['hr.employee'].sudo()
employees = HrEmployee_sudo.search([
'&', '|', ('user_id', 'in', user_ids), ('id', 'in', employee_ids), ('company_id', 'in', self.env.companies.ids)
])
@ -166,17 +291,23 @@ class AccountAnalyticLine(models.Model):
employee_id_per_company_per_user[employee.user_id.id][employee.company_id.id] = employee.id
# 4/ Put valid employee_id in each vals
error_msg = _lt('Timesheets must be created with an active employee in the selected companies.')
error_msg = _('Timesheets must be created with an active employee in the selected companies.')
for vals in vals_list:
if not vals.get('project_id'):
continue
employee_in_id = vals.get('employee_id')
employee_in_id = vals.get('employee_id', self.env.context.get('default_employee_id', False))
if employee_in_id:
company = False
if not vals.get('company_id'):
company = HrEmployee_sudo.browse(employee_in_id).company_id
vals['company_id'] = company.id
if not vals.get('product_uom_id'):
vals['product_uom_id'] = company.project_time_mode_id.id if company else self.env['res.company'].browse(vals.get('company_id', self.env.company.id)).project_time_mode_id.id
if employee_in_id in valid_employee_per_id:
vals['user_id'] = valid_employee_per_id[employee_in_id].sudo().user_id.id # (A) OK
continue
else:
raise ValidationError(error_msg) # (C) KO
raise ValidationError(error_msg) # (C) KO
else:
user_id = vals.get('user_id', default_user_id) # (B)...
@ -185,137 +316,122 @@ class AccountAnalyticLine(models.Model):
employee_out_id = False
if employee_per_company:
company_id = list(employee_per_company)[0] if len(employee_per_company) == 1\
else vals.get('company_id', self.env.company.id)
else vals.get('company_id') or self.env.company.id
employee_out_id = employee_per_company.get(company_id, False)
if employee_out_id:
vals['employee_id'] = employee_out_id
vals['user_id'] = user_id
company = False
if not vals.get('company_id'):
company = HrEmployee_sudo.browse(employee_out_id).company_id
vals['company_id'] = company.id
if not vals.get('product_uom_id'):
vals['product_uom_id'] = company.project_time_mode_id.id if company else self.env['res.company'].browse(vals.get('company_id', self.env.company.id)).project_time_mode_id.id
else: # ...and raise an error if they fail
raise ValidationError(error_msg)
# 5/ Finally, create the timesheets
lines = super(AccountAnalyticLine, self).create(vals_list)
lines = super().create(vals_list)
lines._check_can_create()
for line, values in zip(lines, vals_list):
if line.project_id: # applied only for timesheet
line._timesheet_postprocess(values)
if self.env.context.get('timesheet_calendar'):
if skipped_vals:
type = "danger"
if valid_vals:
message = self.env._("Some timesheets were not created: employees arent working on the selected days")
else:
message = self.env._("No timesheets created: employees arent working on the selected days")
else:
type = "success"
message = self.env._("Timesheets successfully created")
self.env.user._bus_send('simple_notification', {
"type": type,
"message": message,
})
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."))
def write(self, vals):
values = vals
self._check_can_write(values)
task = self.env['project.task'].sudo().browse(values.get('task_id'))
project = self.env['project.project'].sudo().browse(values.get('project_id'))
if task and not task.project_id:
raise ValidationError(_('Timesheets cannot be created on a private task.'))
if project or task:
values['company_id'] = task.company_id.id or project.company_id.id
values.update({
fname: account_id
for fname, account_id in self._timesheet_preprocess_get_accounts(values).items()
if fname not in values
})
values = self._timesheet_preprocess(values)
if values.get('employee_id'):
employee = self.env['hr.employee'].browse(values['employee_id'])
if not employee.active:
raise UserError(_('You cannot set an archived employee to the existing timesheets.'))
raise UserError(_('You cannot set an archived employee on existing timesheets.'))
if 'name' in values and not values.get('name'):
values['name'] = '/'
result = super(AccountAnalyticLine, self).write(values)
if 'company_id' in values and not values.get('company_id'):
del values['company_id']
result = super().write(values)
# applied only for timesheet
self.filtered(lambda t: t.project_id)._timesheet_postprocess(values)
return result
@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,)
def get_views(self, views, options=None):
res = super().get_views(views, options)
if options and options.get('toolbar'):
wip_report_id = None
@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)
arch = self.sudo()._apply_timesheet_label(arch, view_type=view_type)
return arch, view
def get_wip_report_id():
return self.env['ir.model.data']._xmlid_to_res_id("mrp_account.wip_report", raise_if_not_found=False)
@api.model
def _apply_timesheet_label(self, view_node, view_type='form'):
doc = view_node
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', _('%s Spent') % (re.sub(r'[\(\)]', '', encoding_uom.name or '')))
return doc
@api.model
def _apply_time_label(self, view_node, related_model):
doc = view_node
Model = self.env[related_model]
# Just fetch the name of the uom in `timesheet_encode_uom_id` of the current company
encoding_uom_name = self.env.company.timesheet_encode_uom_id.with_context(prefetch_fields=False).sudo().name
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 '', Model._fields[node.get('name')]._description_string(self.env), flags=re.IGNORECASE)
node.set('string', name_with_uom)
return doc
for view_data in res['views'].values():
print_data_list = view_data.get('toolbar', {}).get('print')
if print_data_list:
if wip_report_id is None and re.search(r'widget="timesheet_uom(\w)*"', view_data['arch']):
wip_report_id = get_wip_report_id()
if wip_report_id:
view_data['toolbar']['print'] = [print_data for print_data in print_data_list if print_data['id'] != wip_report_id]
return res
def _timesheet_get_portal_domain(self):
if self.env.user.has_group('hr_timesheet.group_hr_timesheet_user'):
# Then, he is internal user, and we take the domain for this current user
return self.env['ir.rule']._compute_domain(self._name)
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.privacy_visibility', '=', 'portal'),
'&',
('task_id', '=', False),
'&',
('project_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id]),
('project_id.privacy_visibility', '=', 'portal')
]
return (
(
Domain('message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id])
| Domain('partner_id', 'child_of', [self.env.user.partner_id.commercial_partner_id.id])
)
& Domain('project_id.privacy_visibility', 'in', ['invited_users', '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 = self.env['project.project'].browse(vals.get('project_id', False))
task = self.env['project.task'].browse(vals.get('task_id', False))
# task implies project
if task and not project:
project = task.project_id
if not project:
raise ValidationError(_('You cannot create a timesheet on a private task.'))
vals['project_id'] = project.id
# task implies analytic account and tags
if task and not vals.get('account_id'):
task_analytic_account_id = task._get_task_analytic_account_id()
vals['account_id'] = task_analytic_account_id.id
vals['company_id'] = task_analytic_account_id.company_id.id or task.company_id.id
if not task_analytic_account_id.active:
raise UserError(_('You cannot add timesheets to a project or a task linked to an inactive analytic account.'))
# project implies analytic account
elif project and not vals.get('account_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(_('You cannot add timesheets to a project linked to an inactive analytic account.'))
# force customer partner, from the task or the project
if project and not vals.get('partner_id'):
partner_id = task.partner_id.id if task else project.partner_id.id
if partner_id:
vals['partner_id'] = partner_id
# set timesheet UoM from the AA company (AA implies uom)
if not vals.get('product_uom_id') 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'])
uom_id = analytic_account.company_id.project_time_mode_id.id
if not uom_id:
company_id = vals.get('company_id', False)
if not company_id:
project = self.env['project.project'].browse(vals.get('project_id'))
company_id = project.analytic_account_id.company_id.id or project.company_id.id
uom_id = self.env['res.company'].browse(company_id).project_time_mode_id.id
vals['product_uom_id'] = uom_id
return vals
def _timesheet_preprocess_get_accounts(self, vals):
project = self.env['project.project'].sudo().browse(vals.get('project_id'))
if not project:
return {}
company = self.env['res.company'].browse(vals.get('company_id'))
mandatory_plans = [plan for plan in self._get_mandatory_plans(company, business_domain='timesheet') if plan['column_name'] != 'account_id']
missing_plan_names = [plan['name'] for plan in mandatory_plans if not project[plan['column_name']]]
if missing_plan_names:
raise ValidationError(_(
"'%(missing_plan_names)s' analytic plan(s) required on the project '%(project_name)s' linked to the timesheet.",
missing_plan_names=missing_plan_names,
project_name=project.name,
))
return {
fname: project[fname].id
for fname in self._get_plan_fnames()
}
def _timesheet_postprocess(self, values):
""" Hook to update record one by one according to the values of a `write` or a `create`. """
@ -338,6 +454,17 @@ class AccountAnalyticLine(models.Model):
# (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:
if not timesheet.account_id.active:
project_plan, _other_plans = self.env['account.analytic.plan']._get_all_plans()
raise ValidationError(_(
"Timesheets must be created with at least an active analytic account defined in the plan '%(plan_name)s'.",
plan_name=project_plan.name
))
accounts = timesheet._get_analytic_accounts()
companies = timesheet.company_id | accounts.company_id | timesheet.task_id.company_id | timesheet.project_id.company_id
if len(companies) > 1:
raise ValidationError(_('The project, the task and the analytic accounts of the timesheet must belong to the same company.'))
cost = timesheet._hourly_cost()
amount = -timesheet.unit_amount * cost
amount_converted = timesheet.employee_id.currency_id._convert(
@ -347,6 +474,11 @@ class AccountAnalyticLine(models.Model):
})
return result
def _split_amount_fname(self):
# split the quantity instead of the amount, since the amount is postprocessed
# based on the quantity
return 'unit_amount' if self.project_id else super()._split_amount_fname()
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')
@ -382,9 +514,7 @@ class AccountAnalyticLine(models.Model):
if not uom_hours:
uom_hours = self.env['uom.uom'].create({
'name': "Hours",
'category_id': self.env.ref('uom.uom_categ_wtime').id,
'factor': 8,
'uom_type': "smaller",
'relative_factor': 1,
})
self.env['ir.model.data'].create({
'name': 'product_uom_hour',
@ -393,3 +523,33 @@ class AccountAnalyticLine(models.Model):
'res_id': uom_hours.id,
'noupdate': True,
})
@api.model
def _show_portal_timesheets(self):
"""
Determine if we show timesheet information in the portal. Meant to be overriden in website_timesheet.
"""
return True
def action_open_timesheet_view_portal(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_id': self.id,
'res_model': 'account.analytic.line',
'views': [(self.env.ref('hr_timesheet.timesheet_view_form_portal_user').id, 'form')],
'context': self.env.context,
}
@api.model
def get_unusual_days(self, date_from, date_to=None):
return self.env.user.employee_id._get_unusual_days(date_from, date_to)
@api.model
def get_import_templates(self):
if self.env.context.get('is_timesheet'):
return [{
'label': _('Import Template for Timesheets'),
'template': '/hr_timesheet/static/xls/timesheets_import_template.xlsx',
}]
return []

View file

@ -4,14 +4,14 @@
from odoo import api, models
class Http(models.AbstractModel):
class IrHttp(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 the current company.
"""
result = super(Http, self).session_info()
result = super().session_info()
if self.env.user._is_internal():
company_ids = self.env.user.company_ids

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
@ -9,7 +8,6 @@ class IrUiMenu(models.Model):
def _load_menus_blacklist(self):
res = super()._load_menus_blacklist()
time_menu = self.env.ref('hr_timesheet.timesheet_menu_activity_user', raise_if_not_found=False)
if time_menu and self.env.user.has_group('hr_timesheet.group_hr_timesheet_approver'):
if self.env.user.has_group('hr_timesheet.group_hr_timesheet_approver') and (time_menu := self.env.ref('hr_timesheet.timesheet_menu_activity_user', raise_if_not_found=False)):
res.append(time_menu.id)
return res

View file

@ -1,454 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from odoo import models, fields, api, _
from odoo.tools.float_utils import float_compare
from odoo.exceptions import UserError, ValidationError, RedirectWarning
from odoo.addons.rating.models.rating_data import OPERATOR_MAPPING
PROJECT_TASK_READABLE_FIELDS = {
'allow_subtasks',
'allow_timesheets',
'analytic_account_active',
'effective_hours',
'encode_uom_in_days',
'planned_hours',
'progress',
'overtime',
'remaining_hours',
'subtask_effective_hours',
'subtask_planned_hours',
'timesheet_ids',
'total_hours_spent',
}
class Project(models.Model):
_inherit = "project.project"
allow_timesheets = fields.Boolean(
"Timesheets", compute='_compute_allow_timesheets', store=True, readonly=False,
default=True)
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_count = fields.Integer(compute="_compute_timesheet_count", groups='hr_timesheet.group_hr_timesheet_user')
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', groups='hr_timesheet.group_hr_timesheet_user',
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')
is_internal_project = fields.Boolean(compute='_compute_is_internal_project', search='_search_is_internal_project')
remaining_hours = fields.Float(compute='_compute_remaining_hours', string='Remaining Invoiced Time', compute_sudo=True)
is_project_overtime = fields.Boolean('Project in Overtime', compute='_compute_remaining_hours', search='_search_is_project_overtime', compute_sudo=True)
allocated_hours = fields.Float(string='Allocated Hours')
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.depends('company_id')
def _compute_is_internal_project(self):
for project in self:
project.is_internal_project = project == project.company_id.internal_project_id
@api.model
def _search_is_internal_project(self, operator, value):
if not isinstance(value, bool):
raise ValueError(_('Invalid value: %s', value))
if operator not in ['=', '!=']:
raise ValueError(_('Invalid operator: %s', operator))
query = """
SELECT C.internal_project_id
FROM res_company C
WHERE C.internal_project_id IS NOT NULL
"""
if (operator == '=' and value is True) or (operator == '!=' and value is False):
operator_new = 'inselect'
else:
operator_new = 'not inselect'
return [('id', operator_new, (query, ()))]
@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):
arch, view = super()._get_view(view_id, view_type, **options)
if view_type in ['tree', '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.depends('allow_timesheets', 'timesheet_ids')
def _compute_remaining_hours(self):
timesheets_read_group = self.env['account.analytic.line']._read_group(
[('project_id', 'in', self.ids)],
['project_id', 'unit_amount'],
['project_id'],
lazy=False)
timesheet_time_dict = {res['project_id'][0]: res['unit_amount'] for res in timesheets_read_group}
for project in self:
project.remaining_hours = project.allocated_hours - timesheet_time_dict.get(project.id, 0)
project.is_project_overtime = project.remaining_hours < 0
@api.model
def _search_is_project_overtime(self, operator, value):
if not isinstance(value, bool):
raise ValueError(_('Invalid value: %s') % value)
if operator not in ['=', '!=']:
raise ValueError(_('Invalid operator: %s') % operator)
query = """
SELECT Project.id
FROM project_project AS Project
JOIN project_task AS Task
ON Project.id = Task.project_id
WHERE Project.allocated_hours > 0
AND Project.allow_timesheets = TRUE
AND Task.parent_id IS NULL
AND Task.is_closed IS FALSE
GROUP BY Project.id
HAVING Project.allocated_hours - SUM(Task.effective_hours) < 0
"""
if (operator == '=' and value is True) or (operator == '!=' and value is False):
operator_new = 'inselect'
else:
operator_new = 'not inselect'
return [('id', operator_new, (query, ()))]
@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(_('You cannot use timesheets without an analytic account.'))
@api.depends('timesheet_ids')
def _compute_total_timesheet_time(self):
timesheets_read_group = self.env['account.analytic.line'].read_group(
[('project_id', 'in', self.ids)],
['project_id', 'unit_amount', 'product_uom_id'],
['project_id', 'product_uom_id'],
lazy=False)
timesheet_time_dict = defaultdict(list)
uom_ids = set(self.timesheet_encode_uom_id.ids)
for result in timesheets_read_group:
uom_id = result['product_uom_id'] and result['product_uom_id'][0]
if uom_id:
uom_ids.add(uom_id)
timesheet_time_dict[result['project_id'][0]].append((uom_id, result['unit_amount']))
uoms_dict = {uom.id: uom for uom in self.env['uom.uom'].browse(uom_ids)}
for project in self:
# Timesheets may be stored in a different unit of measure, so first
# we convert all of them to the reference unit
# if the timesheet has no product_uom_id then we take the one of the project
total_time = 0.0
for product_uom_id, unit_amount in timesheet_time_dict[project.id]:
factor = uoms_dict.get(product_uom_id, project.timesheet_encode_uom_id).factor_inv
total_time += unit_amount * (1.0 if project.encode_uom_in_days else factor)
# 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.depends('timesheet_ids')
def _compute_timesheet_count(self):
timesheet_read_group = self.env['account.analytic.line']._read_group(
[('project_id', 'in', self.ids)],
['project_id'],
['project_id']
)
timesheet_project_map = {project_info['project_id'][0]: project_info['project_id_count'] for project_info in timesheet_read_group}
for project in self:
project.timesheet_count = timesheet_project_map.get(project.id, 0)
@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 vals in vals_list:
allow_timesheets = vals.get('allow_timesheets', defaults.get('allow_timesheets'))
analytic_account_id = vals.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(vals)
vals['analytic_account_id'] = analytic_account.id
return super().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)
def name_get(self):
res = super().name_get()
if len(self.env.context.get('allowed_company_ids', [])) <= 1:
return res
name_mapping = dict(res)
for project in self:
if project.is_internal_project:
name_mapping[project.id] = f'{name_mapping[project.id]} - {project.company_id.name}'
return list(name_mapping.items())
@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})
def _convert_project_uom_to_timesheet_encode_uom(self, time):
uom_from = self.company_id.project_time_mode_id
uom_to = self.env.company.timesheet_encode_uom_id
return round(uom_from._compute_quantity(time, uom_to, raise_if_failure=False), 2)
def action_project_timesheets(self):
action = self.env['ir.actions.act_window']._for_xml_id('hr_timesheet.act_hr_timesheet_line_by_project')
action['display_name'] = _("%(name)s's Timesheets", name=self.name)
return action
class Task(models.Model):
_name = "project.task"
_inherit = "project.task"
analytic_account_active = fields.Boolean("Active Analytic Account", compute='_compute_analytic_account_active', compute_sudo=True)
allow_timesheets = fields.Boolean(
"Allow timesheets",
compute='_compute_allow_timesheets', compute_sudo=True,
search='_search_allow_timesheets', 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("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.")
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())
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS | PROJECT_TASK_READABLE_FIELDS
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)], ['unit_amount', 'task_id'], ['task_id'])
timesheets_per_task = {res['task_id'][0]: res['unit_amount'] for res 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', '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 float_compare(task_total_hours, task.planned_hours, precision_digits=2) >= 0:
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('planned_hours', 'remaining_hours')
def _compute_remaining_hours_percentage(self):
for task in self:
if task.planned_hours > 0.0:
task.remaining_hours_percentage = task.remaining_hours / task.planned_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 planned_hours > 0
AND remaining_hours / planned_hours {operator} %s
"""
return [('id', 'inselect', (query, (value,)))]
@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.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 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
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, '') + u"\u00A0" + 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, '') + u"\u00A0" + hours_left
return list(name_mapping.items())
return super().name_get()
@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_id'],
)
task_with_timesheets_ids = [res['task_id'][0] for res 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)

View file

@ -0,0 +1,290 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from odoo import api, fields, models
from odoo.exceptions import RedirectWarning, ValidationError
from odoo.tools import SQL, float_round
from odoo.tools.translate import _
class ProjectProject(models.Model):
_inherit = "project.project"
allow_timesheets = fields.Boolean(
"Timesheets", compute='_compute_allow_timesheets', store=True, readonly=False,
default=True)
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),
]"""
)
analytic_account_active = fields.Boolean("Active Account", related="account_id.active", export_string_translation=False)
timesheet_ids = fields.One2many('account.analytic.line', 'project_id', 'Associated Timesheets', export_string_translation=False)
timesheet_encode_uom_id = fields.Many2one('uom.uom', compute='_compute_timesheet_encode_uom_id', export_string_translation=False)
total_timesheet_time = fields.Float(
compute='_compute_total_timesheet_time', groups='hr_timesheet.group_hr_timesheet_user',
string="Total amount of time (in the proper unit) recorded in the project, rounded to the unit.", export_string_translation=False)
encode_uom_in_days = fields.Boolean(compute='_compute_encode_uom_in_days', export_string_translation=False)
is_internal_project = fields.Boolean(compute='_compute_is_internal_project', search='_search_is_internal_project', export_string_translation=False)
remaining_hours = fields.Float(compute='_compute_remaining_hours', string='Time Remaining', compute_sudo=True)
is_project_overtime = fields.Boolean('Project in Overtime', compute='_compute_remaining_hours', search='_search_is_project_overtime', compute_sudo=True, export_string_translation=False)
allocated_hours = fields.Float(string='Allocated Time', tracking=True)
effective_hours = fields.Float(string='Time Spent', compute='_compute_remaining_hours', compute_sudo=True)
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('company_id', 'company_id.timesheet_encode_uom_id')
@api.depends_context('company')
def _compute_timesheet_encode_uom_id(self):
for project in self:
project.timesheet_encode_uom_id = project.company_id.timesheet_encode_uom_id or self.env.company.timesheet_encode_uom_id
@api.depends('account_id')
def _compute_allow_timesheets(self):
without_account = self.filtered(lambda t: t._origin and not t.account_id)
without_account.update({'allow_timesheets': False})
@api.depends('company_id')
def _compute_is_internal_project(self):
for project in self:
project.is_internal_project = project == project.company_id.internal_project_id
@api.model
def _search_is_internal_project(self, operator, value):
if operator not in ('in', 'not in'):
return NotImplemented
Company = self.env['res.company']
sql = Company._search(
[('internal_project_id', '!=', False)],
active_test=False, bypass_access=True,
).subselect("internal_project_id")
return [('id', operator, sql)]
@api.depends('allow_timesheets', 'timesheet_ids.unit_amount', 'allocated_hours')
def _compute_remaining_hours(self):
timesheets_read_group = self.env['account.analytic.line']._read_group(
[('project_id', 'in', self.ids)],
['project_id'],
['unit_amount:sum'],
)
timesheet_time_dict = {project.id: unit_amount_sum for project, unit_amount_sum in timesheets_read_group}
for project in self:
project.effective_hours = round(timesheet_time_dict.get(project.id, 0.0), 2)
project.remaining_hours = project.allocated_hours - project.effective_hours
project.is_project_overtime = project.remaining_hours < 0
@api.model
def _search_is_project_overtime(self, operator, value):
if operator not in ('in', 'not in'):
return NotImplemented
sql = SQL("""(
SELECT Project.id
FROM project_project AS Project
JOIN project_task AS Task
ON Project.id = Task.project_id
WHERE Project.allocated_hours > 0
AND Project.allow_timesheets = TRUE
AND Task.parent_id IS NULL
AND Task.state IN ('01_in_progress', '02_changes_requested', '03_approved', '04_waiting_normal')
GROUP BY Project.id
HAVING Project.allocated_hours - SUM(Task.effective_hours) < 0
)""")
return [('id', operator, sql)]
@api.constrains('allow_timesheets', 'account_id')
def _check_allow_timesheet(self):
for project in self:
if project.allow_timesheets and not project.account_id and not project.is_template:
project_plan, _other_plans = self.env['account.analytic.plan']._get_all_plans()
raise ValidationError(_(
"To use the timesheets feature, you need an analytic account for your project. Please set one up in the plan '%(plan_name)s' or turn off the timesheets feature.",
plan_name=project_plan.name
))
@api.depends('timesheet_ids', 'timesheet_encode_uom_id')
def _compute_total_timesheet_time(self):
timesheets_read_group = self.env['account.analytic.line']._read_group(
[('project_id', 'in', self.ids)],
['project_id', 'product_uom_id'],
['unit_amount:sum'],
)
timesheet_time_dict = defaultdict(list)
for project, product_uom, unit_amount_sum in timesheets_read_group:
timesheet_time_dict[project.id].append((product_uom, unit_amount_sum))
for project in self:
# Timesheets may be stored in a different unit of measure, so first
# we convert all of them to the reference unit
# if the timesheet has no product_uom_id then we take the one of the project
total_time = 0.0
for product_uom, unit_amount in timesheet_time_dict[project.id]:
factor = (product_uom or project.timesheet_encode_uom_id).factor
total_time += unit_amount * (1.0 if project.encode_uom_in_days else factor)
# Now convert to the proper unit of measure set in the settings
total_time /= project.timesheet_encode_uom_id.factor
project.total_timesheet_time = float_round(total_time, precision_digits=2)
@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', 'account_id', 'is_template'])
analytic_accounts_vals = [
vals for vals in vals_list
if (
vals.get('allow_timesheets', defaults.get('allow_timesheets')) and
not vals.get('account_id', defaults.get('account_id')) and not vals.get('is_template', defaults.get('is_template'))
)
]
if analytic_accounts_vals:
analytic_accounts = self.env['account.analytic.account'].create(self._get_values_analytic_account_batch(analytic_accounts_vals))
for vals, analytic_account in zip(analytic_accounts_vals, analytic_accounts):
vals['account_id'] = analytic_account.id
return super().create(vals_list)
def write(self, vals):
# create the AA for project still allowing timesheet
if vals.get('allow_timesheets') and not vals.get('account_id'):
project_wo_account = self.filtered(lambda project: not project.account_id and not project.is_template)
if project_wo_account:
project_wo_account._create_analytic_account()
return super().write(vals)
@api.depends('is_internal_project', 'company_id')
@api.depends_context('allowed_company_ids')
def _compute_display_name(self):
super()._compute_display_name()
if len(self.env.context.get('allowed_company_ids') or []) <= 1:
return
for project in self:
if project.is_internal_project:
project.display_name = f'{project.display_name} - {project.company_id.name}'
@api.model
def _init_data_analytic_account(self):
self.search([('account_id', '=', False), ('allow_timesheets', '=', True), ('is_template', '=', False)])._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})
@api.model
def get_create_edit_project_ids(self):
return []
def _convert_project_uom_to_timesheet_encode_uom(self, time):
uom_from = self.company_id.project_time_mode_id
uom_to = self.env.company.timesheet_encode_uom_id
return round(uom_from._compute_quantity(time, uom_to, raise_if_failure=False), 2)
def action_project_timesheets(self):
action = self.env['ir.actions.act_window']._for_xml_id('hr_timesheet.act_hr_timesheet_line_by_project')
if not self.env.context.get('from_embedded_action'):
action['display_name'] = _("%(name)s's Timesheets", name=self.name)
return action
# ----------------------------
# Project Updates
# ----------------------------
def _get_stat_buttons(self):
buttons = super()._get_stat_buttons()
if not self.allow_timesheets or not self.env.user.has_group("hr_timesheet.group_hr_timesheet_user"):
return buttons
encode_uom = self.env.company.timesheet_encode_uom_id
uom_ratio = self.env.ref('uom.product_uom_hour').factor / encode_uom.factor
allocated = self.allocated_hours * uom_ratio
effective = self.total_timesheet_time
color = ""
if allocated:
number = f"{round(effective)} / {round(allocated)} {encode_uom.name}"
success_rate = round(100 * effective / allocated)
if success_rate > 100:
number = self.env._(
"%(effective)s / %(allocated)s %(uom_name)s",
effective=round(effective),
allocated=round(allocated),
uom_name=encode_uom.name,
)
color = "text-danger"
else:
number = self.env._(
"%(effective)s / %(allocated)s %(uom_name)s (%(success_rate)s%%)",
effective=round(effective),
allocated=round(allocated),
uom_name=encode_uom.name,
success_rate=success_rate,
)
if success_rate >= 80:
color = "text-warning"
else:
color = "text-success"
else:
number = self.env._(
"%(effective)s %(uom_name)s",
effective=round(effective),
uom_name=encode_uom.name,
)
buttons.append({
"icon": f"clock-o {color}",
"text": self.env._("Timesheets"),
"number": number,
"action_type": "object",
"action": "action_project_timesheets",
"show": True,
"sequence": 2,
})
if allocated and success_rate > 100:
buttons.append({
"icon": f"warning {color}",
"text": self.env._("Extra Time"),
"number": self.env._(
"%(exceeding_hours)s %(uom_name)s (+%(exceeding_rate)s%%)",
exceeding_hours=round(effective - allocated),
uom_name=encode_uom.name,
exceeding_rate=round(100 * (effective - allocated) / allocated),
),
"action_type": "object",
"action": "action_project_timesheets",
"show": True,
"sequence": 3,
})
return buttons
def action_view_tasks(self):
# Using the timesheet filter hide context
action = super().action_view_tasks()
action['context']['allow_timesheets'] = self.allow_timesheets
return action
def _toggle_template_mode(self, is_template):
if not is_template and self.allow_timesheets and not self.account_id:
self._create_analytic_account()
super()._toggle_template_mode(is_template)

View file

@ -0,0 +1,288 @@
# -*- 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 ProjectTask(models.Model):
_inherit = "project.task"
project_id = fields.Many2one(domain="['|', ('company_id', '=', False), ('company_id', '=?', company_id), ('is_internal_project', '=', False), ('is_template', 'in', [is_template, 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")
overtime = fields.Float(compute='_compute_progress_hours', store=True)
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 medium priority
!! Set the task a high priority
!!! Set the task a urgent priority\n
Make sure to use the right format and order e.g. Improve the configuration screen 5h #feature #v16 @Mitchell !""",
)
@property
def TASK_PORTAL_READABLE_FIELDS(self):
return super().TASK_PORTAL_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:
return NotImplemented
if operator in ('in', 'not in'):
value = tuple(value)
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 or self.env.context.get('is_project_sharing')) and view[1] not in ['tree', 'kanban', 'form']:
continue
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 not task_with_timesheets_ids:
return
# Fetch task IDs with timesheets that the user has read access.
inaccessible_task_ids = set(task_with_timesheets_ids) - set(
self.env['account.analytic.line'].search([
('task_id', 'in', task_with_timesheets_ids)
]).mapped('task_id.id')
)
if inaccessible_task_ids:
raise UserError(
_("This task cant be deleted because its linked to timesheets. Please contact someone with higher access to remove the timesheets first, "
"and then youll be able to delete the task.")
)
if len(task_with_timesheets_ids) > 1:
warning_msg = _("Some timesheet entries are weighing down these tasks! Remove them first, then youll be able to delete the tasks!")
else:
warning_msg = _("Some timesheet entries are weighing down these tasks! Remove them first, then youll be able to delete the tasks!")
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)
def _get_portal_total_hours_dict(self):
if not (timesheetable_tasks := self.filtered('allow_timesheets')):
return {}
return {
'allocated_hours': sum(timesheetable_tasks.mapped('allocated_hours')),
'effective_hours': sum(timesheetable_tasks.mapped('effective_hours')),
}

View file

@ -0,0 +1,39 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
class ProjectUpdate(models.Model):
_inherit = "project.update"
display_timesheet_stats = fields.Boolean(compute="_compute_display_timesheet_stats", export_string_translation=False)
allocated_time = fields.Integer("Allocated Time", readonly=True)
timesheet_time = fields.Integer("Timesheet Time", readonly=True)
timesheet_percentage = fields.Integer(compute="_compute_timesheet_percentage", export_string_translation=False)
uom_id = fields.Many2one("uom.uom", "Unit", readonly=True, export_string_translation=False)
def _compute_timesheet_percentage(self):
for update in self:
update.timesheet_percentage = update.allocated_time and round(update.timesheet_time * 100 / update.allocated_time)
def _compute_display_timesheet_stats(self):
for update in self:
update.display_timesheet_stats = update.project_id.allow_timesheets
# ---------------------------------
# ORM Override
# ---------------------------------
@api.model_create_multi
def create(self, vals_list):
updates = super().create(vals_list)
encode_uom = self.env.company.timesheet_encode_uom_id
ratio = self.env.ref("uom.product_uom_hour").factor / encode_uom.factor
for update in updates:
project = update.project_id
project.sudo().last_update_id = update
update.write({
"uom_id": encode_uom,
"allocated_time": round(project.allocated_hours * ratio),
"timesheet_time": round(project.sudo().total_timesheet_time),
})
return updates

View file

@ -10,34 +10,24 @@ class ResCompany(models.Model):
@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
return self.env.ref('uom.product_uom_hour', raise_if_not_found=False)
@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
return self.env.ref('uom.product_uom_hour', raise_if_not_found=False)
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)])
default=_default_timesheet_encode_uom_id)
internal_project_id = fields.Many2one(
'project.project', string="Internal Project",
help="Default project value for timesheet generated from time off type.")
"project.project", string="Internal Project",
domain=[("is_template", "=", False)],
help="Default project value for timesheet generated from time off type.",
)
@api.constrains('internal_project_id')
def _check_internal_project_id_company(self):
@ -45,8 +35,8 @@ class ResCompany(models.Model):
raise ValidationError(_('The Internal Project of a company should be in that company.'))
@api.model_create_multi
def create(self, values):
company = super(ResCompany, self).create(values)
def create(self, vals_list):
company = super().create(vals_list)
# 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
@ -74,9 +64,3 @@ class ResCompany(models.Model):
for company in self:
company.internal_project_id = projects_by_company.get(company.id, False)
return project_ids
def _is_timesheet_hour_uom(self):
return self.timesheet_encode_uom_id and self.timesheet_encode_uom_id == self.env.ref('uom.product_uom_hour')
def _timesheet_uom_text(self):
return self._is_timesheet_hour_uom() and _("hours") or _("days")

View file

@ -7,30 +7,40 @@ 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("Time Off",
compute="_compute_timesheet_modules", store=True, readonly=False)
reminder_user_allow = fields.Boolean(string="Employee Reminder")
reminder_manager_allow = fields.Boolean(string="Manager Reminder")
reminder_allow = fields.Boolean(string="Approver Reminder")
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)
is_encode_uom_days = fields.Boolean(compute='_compute_is_encode_uom_days')
is_encode_uom_days = fields.Boolean(compute='_compute_is_encode_uom_days', export_string_translation=False)
timesheet_encode_method = fields.Selection([
('hours', 'Hours / Minutes'),
('days', 'Days / Half-Days'),
], string='Encoding Method', compute="_compute_timesheet_encode_method", inverse="_inverse_timesheet_encode_method", required=True)
@api.depends('timesheet_encode_uom_id')
def _compute_is_encode_uom_days(self):
product_uom_day = self.env.ref('uom.product_uom_day')
@api.depends('company_id')
def _compute_timesheet_encode_method(self):
uom_day = self.env.ref('uom.product_uom_day', raise_if_not_found=False)
for settings in self:
settings.is_encode_uom_days = settings.timesheet_encode_uom_id == product_uom_day
settings.timesheet_encode_method = 'days' if settings.company_id.timesheet_encode_uom_id == uom_day else 'hours'
def _inverse_timesheet_encode_method(self):
uom_day = self.env.ref('uom.product_uom_day', raise_if_not_found=False)
uom_hour = self.env.ref('uom.product_uom_hour', raise_if_not_found=False)
for settings in self:
settings.company_id.timesheet_encode_uom_id = uom_day if settings.timesheet_encode_method == 'days' else uom_hour
@api.depends('timesheet_encode_method')
def _compute_is_encode_uom_days(self):
for settings in self:
settings.is_encode_uom_days = settings.timesheet_encode_method == 'days'
@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,
})

View file

@ -4,7 +4,7 @@
from odoo import fields, models
class Uom(models.Model):
class UomUom(models.Model):
_inherit = 'uom.uom'
def _unprotected_uom_xml_ids(self):
@ -13,7 +13,8 @@ class Uom(models.Model):
# from deletion (and warn in case of modification)
return [
"product_uom_dozen",
"product_uom_pack_6",
]
# widget used in the webclient when this unit is the one used to encode timesheets.
timesheet_widget = fields.Char("Widget")
timesheet_widget = fields.Char("Widget", export_string_translation=False)