mirror of
https://github.com/bringout/oca-ocb-hr.git
synced 2026-04-26 00:52:04 +02:00
19.0 vanilla
This commit is contained in:
parent
a1137a1456
commit
e1d89e11e3
2789 changed files with 1093187 additions and 605897 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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'},
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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 aren’t working on the selected days")
|
||||
else:
|
||||
message = self.env._("No timesheets created: employees aren’t 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 []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 can’t be deleted because it’s linked to timesheets. Please contact someone with higher access to remove the timesheets first, "
|
||||
"and then you’ll 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 you’ll be able to delete the tasks!")
|
||||
else:
|
||||
warning_msg = _("Some timesheet entries are weighing down these tasks! Remove them first, then you’ll 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')),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue