19.0 vanilla

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

View file

@ -1,23 +1,27 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import hr_payroll_structure_type
from . import hr_job
from . import hr_version
from . import hr_contract_type
from . import hr_employee_base
from . import hr_employee
from . import hr_mixin
from . import hr_employee_category
from . import hr_employee_public
from . import hr_department
from . import hr_departure_reason
from . import hr_job
from . import hr_plan
from . import hr_plan_activity_type
from . import hr_work_location
from . import mail_activity_plan
from . import mail_activity_plan_template
from . import mail_alias
from . import mail_channel
from . import discuss_channel
from . import models
from . import res_config_settings
from . import res_partner
from . import res_users
from . import res_company
from . import res_partner
from . import res_partner_bank
from . import resource
from . import resource_calendar
from . import resource_calendar_leaves
from . import ir_ui_menu

View file

@ -5,8 +5,8 @@ from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class Channel(models.Model):
_inherit = 'mail.channel'
class DiscussChannel(models.Model):
_inherit = 'discuss.channel'
subscription_department_ids = fields.Many2many(
'hr.department', string='HR Departments',
@ -20,7 +20,7 @@ class Channel(models.Model):
def _subscribe_users_automatically_get_members(self):
""" Auto-subscribe members of a department to a channel """
new_members = super(Channel, self)._subscribe_users_automatically_get_members()
new_members = super()._subscribe_users_automatically_get_members()
for channel in self:
new_members[channel.id] = list(
set(new_members[channel.id]) |
@ -29,7 +29,7 @@ class Channel(models.Model):
return new_members
def write(self, vals):
res = super(Channel, self).write(vals)
res = super().write(vals)
if vals.get('subscription_department_ids'):
self._subscribe_users_automatically()
return res

View file

@ -1,13 +1,22 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo import api, fields, models
class ContractType(models.Model):
class HrContractType(models.Model):
_name = 'hr.contract.type'
_description = 'Contract Type'
_order = 'sequence'
name = fields.Char(required=True, translate=True)
code = fields.Char(compute='_compute_code', store=True, readonly=False)
sequence = fields.Integer()
country_id = fields.Many2one('res.country', domain=lambda self: [('id', 'in', self.env.companies.country_id.ids)])
@api.depends('name')
def _compute_code(self):
for contract_type in self:
if contract_type.code:
continue
contract_type.code = contract_type.name

View file

@ -1,44 +1,82 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import re
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.fields import Domain
class Department(models.Model):
_name = "hr.department"
class HrDepartment(models.Model):
_name = 'hr.department'
_description = "Department"
_inherit = ['mail.thread']
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = "name"
_rec_name = 'complete_name'
_parent_store = True
name = fields.Char('Department Name', required=True)
complete_name = fields.Char('Complete Name', compute='_compute_complete_name', recursive=True, store=True)
name = fields.Char('Department Name', required=True, translate=True)
complete_name = fields.Char('Complete Name', compute='_compute_complete_name', recursive=True, search='_search_complete_name')
active = fields.Boolean('Active', default=True)
company_id = fields.Many2one('res.company', string='Company', index=True, default=lambda self: self.env.company)
parent_id = fields.Many2one('hr.department', string='Parent Department', index=True, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
company_id = fields.Many2one('res.company', string='Company', compute="_compute_company_id", store=True, recursive=True, index=True, readonly=False, tracking=True, default=lambda self: self.env.company)
parent_id = fields.Many2one('hr.department', string='Parent Department', index=True, check_company=True)
child_ids = fields.One2many('hr.department', 'parent_id', string='Child Departments')
manager_id = fields.Many2one('hr.employee', string='Manager', tracking=True, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
manager_id = fields.Many2one('hr.employee', string='Manager', tracking=True, domain="['|', ('company_id', '=', False), ('company_id', 'in', allowed_company_ids)]")
member_ids = fields.One2many('hr.employee', 'department_id', string='Members', readonly=True)
total_employee = fields.Integer(compute='_compute_total_employee', string='Total Employee')
has_read_access = fields.Boolean(search="_search_has_read_access", store=False, export_string_translation=False)
total_employee = fields.Integer(compute='_compute_total_employee', string='Total Employee',
export_string_translation=False)
jobs_ids = fields.One2many('hr.job', 'department_id', string='Jobs')
plan_ids = fields.One2many('hr.plan', 'department_id')
plan_ids = fields.One2many('mail.activity.plan', 'department_id')
plans_count = fields.Integer(compute='_compute_plan_count')
note = fields.Text('Note')
color = fields.Integer('Color Index')
parent_path = fields.Char(index=True, unaccent=False)
parent_path = fields.Char(index=True)
master_department_id = fields.Many2one(
'hr.department', 'Master Department', compute='_compute_master_department_id', store=True)
def name_get(self):
if not self.env.context.get('hierarchical_naming', True):
return [(record.id, record.name) for record in self]
return super(Department, self).name_get()
@api.depends_context('hierarchical_naming')
def _compute_display_name(self):
if self.env.context.get('hierarchical_naming', True):
return super()._compute_display_name()
for record in self:
record.display_name = record.name
def _search_has_read_access(self, operator, value):
if operator != 'in':
return NotImplemented
if self.env['hr.employee'].has_access('read'):
return [(1, "=", 1)]
departments_ids = self.env['hr.department'].sudo().search([('manager_id', 'in', self.env.user.employee_ids.ids)]).ids
return [('id', 'child_of', departments_ids)]
def _search_complete_name(self, operator, value):
supported_operators = ["=", "!=", "ilike", "not ilike", "in", "not in", "=ilike"]
if operator not in supported_operators or not isinstance(value, (str, list)):
raise NotImplementedError(_('Operation not Supported.'))
department = self.env['hr.department'].search([])
if operator == '=':
department = department.filtered(lambda m: m.complete_name == value)
elif operator == '!=':
department = department.filtered(lambda m: m.complete_name != value)
elif operator == 'ilike':
department = department.filtered(lambda m: value.lower() in m.complete_name.lower())
elif operator == 'not ilike':
department = department.filtered(lambda m: value.lower() not in m.complete_name.lower())
elif operator == 'in':
department = department.filtered(lambda m: m.complete_name in value)
elif operator == 'not in':
department = department.filtered(lambda m: m.complete_name not in value)
elif operator == '=ilike':
pattern = re.compile(re.escape(value).replace('%', '.*').replace('_', '.'), flags=re.IGNORECASE)
department = department.filtered(lambda m: pattern.fullmatch(m.complete_name))
return [('id', 'in', department.ids)]
@api.model
def name_create(self, name):
return self.create({'name': name}).name_get()[0]
record = self.create({'name': name})
return record.id, record.display_name
@api.depends('name', 'parent_id.complete_name')
def _compute_complete_name(self):
@ -54,51 +92,49 @@ class Department(models.Model):
department.master_department_id = int(department.parent_path.split('/')[0])
def _compute_total_employee(self):
emp_data = self.env['hr.employee']._read_group([('department_id', 'in', self.ids)], ['department_id'], ['department_id'])
result = dict((data['department_id'][0], data['department_id_count']) for data in emp_data)
emp_data = self.env['hr.employee'].sudo()._read_group([('department_id', 'in', self.ids), ('company_id', 'in', self.env.companies.ids)], ['department_id'], ['__count'])
result = {department.id: count for department, count in emp_data}
for department in self:
department.total_employee = result.get(department.id, 0)
def _compute_plan_count(self):
plans_data = self.env['hr.plan']._read_group([('department_id', 'in', self.ids)], ['department_id'], ['department_id'])
plans_count = {x['department_id'][0]: x['department_id_count'] for x in plans_data}
plans_data = self.env['mail.activity.plan']._read_group(
domain=[
'|',
('department_id', '=', False),
('department_id', 'in', self.ids),
('company_id', 'in', self.env.companies.ids + [False])
],
groupby=['department_id'],
aggregates=['__count'],
)
plans_count = {department.id: count for department, count in plans_data}
for department in self:
department.plans_count = plans_count.get(department.id, 0)
department.plans_count = plans_count.get(department.id, 0) + plans_count.get(False, 0)
@api.constrains('parent_id')
def _check_parent_id(self):
if not self._check_recursion():
if self._has_cycle():
raise ValidationError(_('You cannot create recursive departments.'))
@api.model_create_multi
def create(self, vals_list):
# TDE note: auto-subscription of manager done by hand, because currently
# the tracking allows to track+subscribe fields linked to a res.user record
# An update of the limited behavior should come, but not currently done.
departments = super(Department, self.with_context(mail_create_nosubscribe=True)).create(vals_list)
for department, vals in zip(departments, vals_list):
manager = self.env['hr.employee'].browse(vals.get("manager_id"))
if manager.user_id:
department.message_subscribe(partner_ids=manager.user_id.partner_id.ids)
return departments
return super(HrDepartment, self.with_context(mail_create_nosubscribe=True)).create(vals_list)
@api.depends('parent_id', 'parent_id.company_id')
def _compute_company_id(self):
if self.parent_id and self.parent_id.company_id:
self.company_id = self.parent_id.company_id
def write(self, vals):
""" If updating manager of a department, we need to update all the employees
of department hierarchy, and subscribe the new manager.
"""
# TDE note: auto-subscription of manager done by hand, because currently
# the tracking allows to track+subscribe fields linked to a res.user record
# An update of the limited behavior should come, but not currently done.
if 'manager_id' in vals:
manager_id = vals.get("manager_id")
if manager_id:
manager = self.env['hr.employee'].browse(manager_id)
# subscribe the manager user
if manager.user_id:
self.message_subscribe(partner_ids=manager.user_id.partner_id.ids)
# set the employees's parent to the new manager
self._update_employee_manager(manager_id)
return super(Department, self).write(vals)
return super().write(vals)
def _update_employee_manager(self, manager_id):
employees = self.env['hr.employee']
@ -112,12 +148,11 @@ class Department(models.Model):
def get_formview_action(self, access_uid=None):
res = super().get_formview_action(access_uid=access_uid)
if (not self.user_has_groups('hr.group_hr_user') and
if (not self.env.user.has_group('hr.group_hr_user') and
self.env.context.get('open_employees_kanban', False)):
res.update({
'name': self.name,
'res_model': 'hr.employee.public',
'view_type': 'kanban',
'view_mode': 'kanban',
'views': [(False, 'kanban'), (False, 'form')],
'context': {'searchpanel_default_department_id': self.id},
@ -126,9 +161,82 @@ class Department(models.Model):
return res
def action_plan_from_department(self):
action = self.env['ir.actions.actions']._for_xml_id('hr.hr_plan_action')
action['context'] = {'default_department_id': self.id, 'search_default_department_id': self.id}
action = self.env['ir.actions.actions']._for_xml_id('hr.mail_activity_plan_action')
action['context'] = dict(ast.literal_eval(action.get('context')), default_department_id=self.id)
domain = [
'|',
('department_id', '=', False),
('department_id', 'in', self.ids),
]
if 'domain' in action:
allowed_company_ids = self.env.context.get('allowed_company_ids', [])
action['domain'] = Domain.AND([
ast.literal_eval(action['domain'].replace('allowed_company_ids', str(allowed_company_ids))), domain
])
else:
action['domain'] = domain
if self.plans_count == 0:
action['views'] = [(False, 'form')]
return action
def action_employee_from_department(self):
if self.env['hr.employee'].has_access('read'):
res_model = "hr.employee"
search_view_id = self.env.ref('hr.view_employee_filter').id
else:
res_model = "hr.employee.public"
search_view_id = self.env.ref('hr.hr_employee_public_view_search').id
return {
'name': _("Employees"),
'type': 'ir.actions.act_window',
'res_model': res_model,
'view_mode': 'list,kanban,form',
'views': [(False, 'list'), (False, 'kanban'), (False, 'form')],
'search_view_id': [search_view_id, 'search'],
'context': {
'searchpanel_default_department_id': self.id,
'default_department_id': self.id,
'search_default_group_department': 1,
'search_default_department_id': self.id,
'expand': 1
},
}
def get_children_department_ids(self):
return self.env['hr.department'].search([('id', 'child_of', self.ids)])
def action_open_view_child_departments(self):
self.ensure_one()
return {
"type": "ir.actions.act_window",
"res_model": "hr.department",
"views": [[False, "kanban"], [False, "list"], [False, "form"]],
"domain": [['id', 'in', self.get_children_department_ids().ids]],
"name": "Child departments",
}
def get_department_hierarchy(self):
if not self:
return {}
hierarchy = {
'parent': {
'id': self.parent_id.id,
'name': self.parent_id.name,
'employees': self.parent_id.total_employee,
} if self.parent_id else False,
'self': {
'id': self.id,
'name': self.name,
'employees': self.total_employee,
},
'children': [
{
'id': child.id,
'name': child.name,
'employees': child.total_employee
} for child in self.child_ids
]
}
return hierarchy

View file

@ -1,29 +1,29 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class DepartureReason(models.Model):
_name = "hr.departure.reason"
class HrDepartureReason(models.Model):
_name = 'hr.departure.reason'
_description = "Departure Reason"
_order = "sequence"
sequence = fields.Integer("Sequence", default=10)
name = fields.Char(string="Reason", required=True, translate=True)
# YTI TODO: Move reason_code to hr + adapt _unlink_except_default_departure_reasons
# to use the codes instead of refs
country_id = fields.Many2one('res.country', string='Country', default=lambda self: self.env.company.country_id)
country_code = fields.Char(related='country_id.code')
@api.model
def _get_default_departure_reasons(self):
return {
'fired': 342,
'resigned': 343,
'retired': 340,
}
return {self.env.ref(reason_ref) for reason_ref in (
'hr.departure_fired',
'hr.departure_resigned',
'hr.departure_retired',
)}
@api.ondelete(at_uninstall=False)
def _unlink_except_default_departure_reasons(self):
master_reasons = [self.env.ref('hr.departure_fired', False), self.env.ref('hr.departure_resigned', False), self.env.ref('hr.departure_retired', False)]
if any(reason in master_reasons for reason in self):
master_departure_codes = self._get_default_departure_reasons()
if any(reason in master_departure_codes for reason in self):
raise UserError(_('Default departure reasons cannot be deleted.'))

File diff suppressed because it is too large Load diff

View file

@ -1,287 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from ast import literal_eval
from pytz import timezone, UTC, utc
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import clean_context, format_time
class HrEmployeeBase(models.AbstractModel):
_name = "hr.employee.base"
_description = "Basic Employee"
_order = 'name'
name = fields.Char()
active = fields.Boolean("Active")
color = fields.Integer('Color Index', default=0)
department_id = fields.Many2one('hr.department', 'Department', domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
member_of_department = fields.Boolean("Member of department", compute='_compute_part_of_department', search='_search_part_of_department',
help="Whether the employee is a member of the active user's department or one of it's child department.")
job_id = fields.Many2one('hr.job', 'Job Position', domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
job_title = fields.Char("Job Title", compute="_compute_job_title", store=True, readonly=False)
company_id = fields.Many2one('res.company', 'Company')
address_id = fields.Many2one('res.partner', 'Work Address', compute="_compute_address_id", store=True, readonly=False,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
work_phone = fields.Char('Work Phone', compute="_compute_phones", store=True, readonly=False)
mobile_phone = fields.Char('Work Mobile', compute="_compute_work_contact_details", store=True, inverse='_inverse_work_contact_details')
work_email = fields.Char('Work Email', compute="_compute_work_contact_details", store=True, inverse='_inverse_work_contact_details')
work_contact_id = fields.Many2one('res.partner', 'Work Contact', copy=False)
related_contact_ids = fields.Many2many('res.partner', string='Related Contacts', compute='_compute_related_contacts')
related_contacts_count = fields.Integer('Number of related contacts', compute='_compute_related_contacts_count')
work_location_id = fields.Many2one('hr.work.location', 'Work Location', compute="_compute_work_location_id", store=True, readonly=False,
domain="[('address_id', '=', address_id), '|', ('company_id', '=', False), ('company_id', '=', company_id)]")
user_id = fields.Many2one('res.users')
resource_id = fields.Many2one('resource.resource')
resource_calendar_id = fields.Many2one('resource.calendar', domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
parent_id = fields.Many2one('hr.employee', 'Manager', compute="_compute_parent_id", store=True, readonly=False,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
coach_id = fields.Many2one(
'hr.employee', 'Coach', compute='_compute_coach', store=True, readonly=False,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
help='Select the "Employee" who is the coach of this employee.\n'
'The "Coach" has no specific rights or responsibilities by default.')
tz = fields.Selection(
string='Timezone', related='resource_id.tz', readonly=False,
help="This field is used in order to define in which timezone the resources will work.")
hr_presence_state = fields.Selection([
('present', 'Present'),
('absent', 'Absent'),
('to_define', 'To Define')], compute='_compute_presence_state', default='to_define')
last_activity = fields.Date(compute="_compute_last_activity")
last_activity_time = fields.Char(compute="_compute_last_activity")
hr_icon_display = fields.Selection([
('presence_present', 'Present'),
('presence_absent_active', 'Present but not active'),
('presence_absent', 'Absent'),
('presence_to_define', 'To define'),
('presence_undetermined', 'Undetermined')], compute='_compute_presence_icon')
show_hr_icon_display = fields.Boolean(compute='_compute_presence_icon')
employee_type = fields.Selection([
('employee', 'Employee'),
('student', 'Student'),
('trainee', 'Trainee'),
('contractor', 'Contractor'),
('freelance', 'Freelancer'),
], string='Employee Type', default='employee', required=True,
help="The employee type. Although the primary purpose may seem to categorize employees, this field has also an impact in the Contract History. Only Employee type is supposed to be under contract and will have a Contract History.")
def _get_valid_employee_for_user(self):
user = self.env.user
# retrieve the employee of the current active company for the user
employee = user.employee_id
if not employee:
# search for all employees as superadmin to not get blocked by multi-company rules
user_employees = user.employee_id.sudo().search([
('user_id', '=', user.id)
])
# the default company employee is most likely the correct one, but fallback to the first if not available
employee = user_employees.filtered(lambda r: r.company_id == user.company_id) or user_employees[:1]
return employee
@api.depends_context('uid', 'company')
@api.depends('department_id')
def _compute_part_of_department(self):
user_employee = self._get_valid_employee_for_user()
active_department = user_employee.department_id
if not active_department:
self.member_of_department = False
else:
def get_all_children(department):
children = department.child_ids
if not children:
return self.env['hr.department']
return children + get_all_children(children)
child_departments = active_department + get_all_children(active_department)
for employee in self:
employee.member_of_department = employee.department_id in child_departments
def _search_part_of_department(self, operator, value):
if operator not in ('=', '!=') or not isinstance(value, bool):
raise UserError(_('Operation not supported'))
user_employee = self._get_valid_employee_for_user()
# Double negation
if not value:
operator = '!=' if operator == '=' else '='
if not user_employee.department_id:
return [('id', operator, user_employee.id)]
return (['!'] if operator == '!=' else []) + [('department_id', 'child_of', user_employee.department_id.id)]
@api.depends('user_id.im_status')
def _compute_presence_state(self):
"""
This method is overritten in several other modules which add additional
presence criterions. e.g. hr_attendance, hr_holidays
"""
# Check on login
check_login = literal_eval(self.env['ir.config_parameter'].sudo().get_param('hr.hr_presence_control_login', 'False'))
employee_to_check_working = self.filtered(lambda e: e.user_id.im_status == 'offline')
working_now_list = employee_to_check_working._get_employee_working_now()
for employee in self:
state = 'to_define'
if check_login:
if employee.user_id.im_status in ['online', 'leave_online']:
state = 'present'
elif employee.user_id.im_status in ['offline', 'leave_offline'] and employee.id not in working_now_list:
state = 'absent'
employee.hr_presence_state = state
@api.depends('user_id')
def _compute_last_activity(self):
presences = self.env['bus.presence'].search_read([('user_id', 'in', self.mapped('user_id').ids)], ['user_id', 'last_presence'])
# transform the result to a dict with this format {user.id: last_presence}
presences = {p['user_id'][0]: p['last_presence'] for p in presences}
for employee in self:
tz = employee.tz
last_presence = presences.get(employee.user_id.id, False)
if last_presence:
last_activity_datetime = last_presence.replace(tzinfo=UTC).astimezone(timezone(tz)).replace(tzinfo=None)
employee.last_activity = last_activity_datetime.date()
if employee.last_activity == fields.Date.today():
employee.last_activity_time = format_time(self.env, last_presence, time_format='short')
else:
employee.last_activity_time = False
else:
employee.last_activity = False
employee.last_activity_time = False
@api.depends('parent_id')
def _compute_coach(self):
for employee in self:
manager = employee.parent_id
previous_manager = employee._origin.parent_id
if manager and (employee.coach_id == previous_manager or not employee.coach_id):
employee.coach_id = manager
elif not employee.coach_id:
employee.coach_id = False
@api.depends('job_id')
def _compute_job_title(self):
for employee in self.filtered('job_id'):
employee.job_title = employee.job_id.name
@api.depends('address_id')
def _compute_phones(self):
for employee in self:
if employee.address_id and employee.address_id.phone:
employee.work_phone = employee.address_id.phone
else:
employee.work_phone = False
@api.depends('work_contact_id', 'work_contact_id.mobile', 'work_contact_id.email')
def _compute_work_contact_details(self):
for employee in self:
if employee.work_contact_id:
employee.mobile_phone = employee.work_contact_id.mobile
employee.work_email = employee.work_contact_id.email
def _inverse_work_contact_details(self):
for employee in self:
if not employee.work_contact_id:
employee.work_contact_id = self.env['res.partner'].sudo().with_context(clean_context(self._context)).create({
'email': employee.work_email,
'mobile': employee.mobile_phone,
'name': employee.name,
'image_1920': employee.image_1920,
'company_id': employee.company_id.id
})
else:
employee.work_contact_id.sudo().write({
'email': employee.work_email,
'mobile': employee.mobile_phone,
})
@api.depends('work_contact_id')
def _compute_related_contacts(self):
for employee in self:
employee.related_contact_ids = employee.work_contact_id
@api.depends('related_contact_ids')
def _compute_related_contacts_count(self):
for employee in self:
employee.related_contacts_count = len(employee.related_contact_ids)
def action_related_contacts(self):
self.ensure_one()
return {
'name': _("Related Contacts"),
'type': 'ir.actions.act_window',
'view_mode': 'kanban,tree,form',
'res_model': 'res.partner',
'domain': [('id', 'in', self.related_contact_ids.ids)]
}
@api.depends('company_id')
def _compute_address_id(self):
for employee in self:
address = employee.company_id.partner_id.address_get(['default'])
employee.address_id = address['default'] if address else False
@api.depends('department_id')
def _compute_parent_id(self):
for employee in self.filtered('department_id.manager_id'):
employee.parent_id = employee.department_id.manager_id
@api.depends('resource_calendar_id', 'hr_presence_state')
def _compute_presence_icon(self):
"""
This method compute the state defining the display icon in the kanban view.
It can be overriden to add other possibilities, like time off or attendances recordings.
"""
working_now_list = self.filtered(lambda e: e.hr_presence_state == 'present')._get_employee_working_now()
for employee in self:
show_icon = True
if employee.hr_presence_state == 'present':
if employee.id in working_now_list:
icon = 'presence_present'
else:
icon = 'presence_absent_active'
elif employee.hr_presence_state == 'absent':
# employee is not in the working_now_list and he has a user_id
icon = 'presence_absent'
else:
# without attendance, default employee state is 'to_define' without confirmed presence/absence
# we need to check why they are not there
# Display an orange icon on internal users.
icon = 'presence_to_define'
if not employee.user_id:
# We don't want non-user employee to have icon.
show_icon = False
employee.hr_icon_display = icon
employee.show_hr_icon_display = show_icon
@api.depends('address_id')
def _compute_work_location_id(self):
to_reset = self.filtered(lambda e: e.address_id != e.work_location_id.address_id)
to_reset.work_location_id = False
@api.model
def _get_employee_working_now(self):
working_now = []
# We loop over all the employee tz and the resource calendar_id to detect working hours in batch.
all_employee_tz = set(self.mapped('tz'))
for tz in all_employee_tz:
employee_ids = self.filtered(lambda e: e.tz == tz)
resource_calendar_ids = employee_ids.mapped('resource_calendar_id')
for calendar_id in resource_calendar_ids:
res_employee_ids = employee_ids.filtered(lambda e: e.resource_calendar_id.id == calendar_id.id)
start_dt = fields.Datetime.now()
stop_dt = start_dt + timedelta(hours=1)
from_datetime = utc.localize(start_dt).astimezone(timezone(tz or 'UTC'))
to_datetime = utc.localize(stop_dt).astimezone(timezone(tz or 'UTC'))
# Getting work interval of the first is working. Functions called on resource_calendar_id
# are waiting for singleton
work_interval = res_employee_ids[0].resource_calendar_id._work_intervals_batch(from_datetime, to_datetime)[False]
# Employee that is not supposed to work have empty items.
if len(work_interval._items) > 0:
# The employees should be working now according to their work schedule
working_now += res_employee_ids.ids
return working_now

View file

@ -6,9 +6,9 @@ from random import randint
from odoo import fields, models
class EmployeeCategory(models.Model):
class HrEmployeeCategory(models.Model):
_name = 'hr.employee.category'
_name = "hr.employee.category"
_description = "Employee Category"
def _get_default_color(self):
@ -16,8 +16,9 @@ class EmployeeCategory(models.Model):
name = fields.Char(string="Tag Name", required=True)
color = fields.Integer(string='Color Index', default=_get_default_color)
employee_ids = fields.Many2many('hr.employee', 'employee_category_rel', 'category_id', 'emp_id', string='Employees')
employee_ids = fields.Many2many('hr.employee', 'employee_category_rel', 'category_id', 'employee_id', string='Employees')
_sql_constraints = [
('name_uniq', 'unique (name)', "Tag name already exists !"),
]
_name_uniq = models.Constraint(
'unique (name)',
'Tag name already exists!',
)

View file

@ -1,40 +1,63 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import timedelta
from pytz import timezone, UTC
from odoo import api, fields, models, tools
from odoo.tools import format_time
class HrEmployeePublic(models.Model):
_name = "hr.employee.public"
_inherit = ["hr.employee.base"]
_name = 'hr.employee.public'
_description = 'Public Employee'
_order = 'name'
_auto = False
_log_access = True # Include magic fields
_log_access = True # Include magic fields
# Fields coming from hr.employee.base
# Fields coming from hr.employee
create_date = fields.Datetime(readonly=True)
name = fields.Char(readonly=True)
active = fields.Boolean(readonly=True)
department_id = fields.Many2one(readonly=True)
job_id = fields.Many2one(readonly=True)
job_title = fields.Char(readonly=True)
company_id = fields.Many2one(readonly=True)
address_id = fields.Many2one(readonly=True)
department_id = fields.Many2one('hr.department', readonly=True)
member_of_department = fields.Boolean(compute='_compute_member_of_department', search='_search_part_of_department')
job_id = fields.Many2one('hr.job', readonly=True)
job_title = fields.Char(related='employee_id.job_title')
company_id = fields.Many2one('res.company', readonly=True)
address_id = fields.Many2one('res.partner', readonly=True)
mobile_phone = fields.Char(readonly=True)
work_phone = fields.Char(readonly=True)
work_email = fields.Char(readonly=True)
work_contact_id = fields.Many2one(readonly=True)
related_contact_ids = fields.Many2many(readonly=True)
work_location_id = fields.Many2one(readonly=True)
user_id = fields.Many2one(readonly=True)
resource_id = fields.Many2one(readonly=True)
resource_calendar_id = fields.Many2one(readonly=True)
tz = fields.Selection(readonly=True)
share = fields.Boolean(related='employee_id.share')
phone = fields.Char(related='employee_id.phone')
im_status = fields.Char(related='employee_id.im_status')
email = fields.Char(related='employee_id.email')
work_contact_id = fields.Many2one('res.partner', readonly=True)
work_location_id = fields.Many2one('hr.work.location', readonly=True)
work_location_name = fields.Char(related='employee_id.work_location_name')
work_location_type = fields.Selection(related='employee_id.work_location_type')
user_id = fields.Many2one('res.users', readonly=True)
resource_id = fields.Many2one('resource.resource', readonly=True)
tz = fields.Selection(related='resource_id.tz')
color = fields.Integer(readonly=True)
employee_type = fields.Selection(readonly=True)
hr_presence_state = fields.Selection([
('present', 'Present'),
('absent', 'Absent'),
('archive', 'Archived'),
('out_of_working_hour', 'Off-Hours')], compute='_compute_presence_state', default='out_of_working_hour')
hr_icon_display = fields.Selection(
selection='_get_selection_hr_icon_display',
compute='_compute_presence_icon')
show_hr_icon_display = fields.Boolean(compute='_compute_presence_icon')
last_activity = fields.Date(compute="_compute_last_activity")
last_activity_time = fields.Char(compute="_compute_last_activity")
resource_calendar_id = fields.Many2one('resource.calendar', readonly=True)
country_code = fields.Char(compute='_compute_country_code')
employee_id = fields.Many2one('hr.employee', 'Employee', compute="_compute_employee_id", search="_search_employee_id", compute_sudo=True)
# Manager-only fields
is_manager = fields.Boolean(compute='_compute_is_manager')
is_user = fields.Boolean(compute='_compute_is_user')
employee_id = fields.Many2one('hr.employee', 'Employee', readonly=True)
# hr.employee.public specific fields
child_ids = fields.One2many('hr.employee.public', 'parent_id', string='Direct subordinates', readonly=True)
image_1920 = fields.Image("Image", related='employee_id.image_1920', compute_sudo=True)
@ -50,28 +73,133 @@ class HrEmployeePublic(models.Model):
parent_id = fields.Many2one('hr.employee.public', 'Manager', readonly=True)
coach_id = fields.Many2one('hr.employee.public', 'Coach', readonly=True)
user_partner_id = fields.Many2one(related='user_id.partner_id', related_sudo=False, string="User's partner")
birthday_public_display_string = fields.Char("Public Date of Birth", related='employee_id.birthday_public_display_string')
def _search_employee_id(self, operator, value):
return [('id', operator, value)]
newly_hired = fields.Boolean('Newly Hired', compute='_compute_newly_hired', search='_search_newly_hired')
def _compute_employee_id(self):
def _get_selection_hr_icon_display(self):
return self.env['hr.employee']._fields['hr_icon_display']._description_selection(self.env)
def _compute_from_employee(self, field_names):
if isinstance(field_names, str):
field_names = [field_names]
employees_sudo = self.sudo().env['hr.employee'].browse(self.ids)
employee_per_id = {emp.id: emp for emp in employees_sudo}
for public_employee in self:
employee = employee_per_id[public_employee.id]
for field_name in field_names:
public_employee[field_name] = employee[field_name]
@api.depends('user_id')
def _compute_last_activity(self):
for employee in self:
employee.employee_id = self.env['hr.employee'].browse(employee.id)
tz = employee.tz
# sudo: res.users - can access presence of accessible user
if last_presence := employee.user_id.sudo().presence_ids.last_presence:
last_activity_datetime = last_presence.replace(tzinfo=UTC).astimezone(timezone(tz)).replace(tzinfo=None)
employee.last_activity = last_activity_datetime.date()
if employee.last_activity == fields.Date.today():
employee.last_activity_time = format_time(self.env, last_presence, time_format='short')
else:
employee.last_activity_time = False
else:
employee.last_activity = False
employee.last_activity_time = False
@api.depends('user_partner_id')
def _compute_related_contacts(self):
super()._compute_related_contacts()
def _compute_country_code(self):
self._compute_from_employee('country_code')
@api.depends_context('uid')
@api.depends('parent_id')
def _compute_is_manager(self):
all_reports = self.env['hr.employee.public'].search([('id', 'child_of', self.env.user.employee_id.id)]).ids
for employee in self:
employee.related_contact_ids |= employee.user_partner_id
employee.is_manager = employee.id in all_reports
@api.depends_context('uid')
def _compute_is_user(self):
user_employee_id = self.env.user.employee_id.id
for employee in self:
employee.is_user = employee.id == user_employee_id
def _compute_presence_state(self):
self._compute_from_employee('hr_presence_state')
def _compute_presence_icon(self):
self._compute_from_employee('hr_icon_display')
self._compute_from_employee('show_hr_icon_display')
def _compute_member_of_department(self):
self._compute_from_employee('member_of_department')
def _get_manager_only_fields(self):
return []
def _search_part_of_department(self, operator, value):
if operator != 'in':
return NotImplemented
user_employee = self._get_valid_employee_for_user()
if not user_employee.department_id:
return [('id', 'in', user_employee.ids)]
return [('department_id', 'child_of', user_employee.department_id.ids)]
def _get_valid_employee_for_user(self):
user = self.env.user
# retrieve the employee of the current active company for the user
employee = user.employee_id
if not employee:
# search for all employees as superadmin to not get blocked by multi-company rules
user_employees = user.employee_id.sudo().search([
('user_id', '=', user.id)
])
# the default company employee is most likely the correct one, but fallback to the first if not available
employee = user_employees.filtered(lambda r: r.company_id == user.company_id) or user_employees[:1]
return employee
@api.depends_context('uid')
def _compute_manager_only_fields(self):
manager_fields = self._get_manager_only_fields()
for employee in self:
if employee.is_manager:
employee_sudo = employee.employee_id.sudo()
for f in manager_fields:
employee[f] = employee_sudo[f]
else:
for f in manager_fields:
employee[f] = False
def _compute_newly_hired(self):
self._compute_from_employee('newly_hired')
def _search_newly_hired(self, operator, value):
if operator not in ('in', 'not in'):
return NotImplemented
new_hire_field = self.env['hr.employee']._get_new_hire_field()
new_hires = self.env['hr.employee'].sudo().search([
(new_hire_field, '>', fields.Datetime.now() - timedelta(days=90))
])
return [('id', operator, new_hires.ids)]
@api.model
def _get_fields(self):
return ','.join('emp.%s' % name for name, field in self._fields.items() if field.store and field.type not in ['many2many', 'one2many'])
base_fields = ('id', 'employee_id', 'name', 'active')
version_fields = self.env['hr.version']._fields
return 'e.id AS id,e.id AS employee_id,e.name AS name,e.active AS active,' + ','.join(
(f'v.{name}' if name in version_fields and version_fields[name].store else f'e.{name}')
for name, field in self._fields.items()
if name not in base_fields and field.store and field.column_type
)
def init(self):
tools.drop_view_if_exists(self.env.cr, self._table)
self.env.cr.execute("""CREATE or REPLACE VIEW %s as (
SELECT
%s
FROM hr_employee emp
FROM hr_employee e
JOIN hr_version v
ON v.id = e.current_version_id
)""" % (self._table, self._get_fields()))
def get_avatar_card_data(self, fields):
return self.read(fields)

View file

@ -2,12 +2,11 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.addons.web_editor.controllers.main import handle_history_divergence
from odoo.addons.html_editor.tools import handle_history_divergence
class Job(models.Model):
_name = "hr.job"
class HrJob(models.Model):
_name = 'hr.job'
_description = "Job Position"
_inherit = ['mail.thread']
_order = 'sequence'
@ -15,48 +14,80 @@ class Job(models.Model):
active = fields.Boolean(default=True)
name = fields.Char(string='Job Position', required=True, index='trigram', translate=True)
sequence = fields.Integer(default=10)
expected_employees = fields.Integer(compute='_compute_employees', string='Total Forecasted Employees', store=True,
help='Expected number of employees for this job position after new recruitment.')
no_of_employee = fields.Integer(compute='_compute_employees', string="Current Number of Employees", store=True,
help='Number of employees currently occupying this job position.')
expected_employees = fields.Integer(compute='_compute_employees', string='Total Forecasted Employees',
help='Expected number of employees for this job position after new recruitment.', groups="hr.group_hr_user")
no_of_employee = fields.Integer(compute='_compute_employees', string="Current Number of Employees",
help='Number of employees currently occupying this job position.', groups="hr.group_hr_user")
no_of_recruitment = fields.Integer(string='Target', copy=False,
help='Number of new employees you expect to recruit.', default=1)
no_of_hired_employee = fields.Integer(string='Hired Employees', copy=False,
help='Number of hired employees for this job position during recruitment phase.')
employee_ids = fields.One2many('hr.employee', 'job_id', string='Employees', groups='base.group_user')
description = fields.Html(string='Job Description', sanitize_attributes=False)
requirements = fields.Text('Requirements')
department_id = fields.Many2one('hr.department', string='Department', domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
contract_type_id = fields.Many2one('hr.contract.type', string='Employment Type')
requirements = fields.Text('Requirements', groups="hr.group_hr_user")
user_id = fields.Many2one(
"res.users",
"Recruiter",
domain="[('share', '=', False), ('company_ids', '=?', company_id)]",
default=lambda self: self.env.user,
groups="hr.group_hr_user",
tracking=True,
help="The Recruiter will be the default value for all Applicants in this job \
position. The Recruiter is automatically added to all meetings with the Applicant.",
)
# TODO (master): remove the field `allowed_user_ids`.
allowed_user_ids = fields.Many2many('res.users', compute='_compute_allowed_user_ids', readonly=True)
department_id = fields.Many2one('hr.department', string='Department', check_company=True, tracking=True, index='btree_not_null')
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company, tracking=True)
contract_type_id = fields.Many2one('hr.contract.type', string='Employment Type', tracking=True)
_sql_constraints = [
('name_company_uniq', 'unique(name, company_id, department_id)', 'The name of the job position must be unique per department in company!'),
('no_of_recruitment_positive', 'CHECK(no_of_recruitment >= 0)', 'The expected number of new employees must be positive.')
]
_name_company_uniq = models.Constraint(
'unique(name, company_id, department_id)',
'The name of the job position must be unique per department in company!',
)
_no_of_recruitment_positive = models.Constraint(
'CHECK(no_of_recruitment >= 0)',
'The expected number of new employees must be positive.',
)
@api.depends('no_of_recruitment', 'employee_ids.job_id', 'employee_ids.active')
def _compute_employees(self):
employee_data = self.env['hr.employee']._read_group([('job_id', 'in', self.ids)], ['job_id'], ['job_id'])
result = dict((data['job_id'][0], data['job_id_count']) for data in employee_data)
employee_data = self.env['hr.employee']._read_group([('job_id', 'in', self.ids)], ['job_id'], ['__count'])
result = {job.id: count for job, count in employee_data}
for job in self:
job.no_of_employee = result.get(job.id, 0)
job.expected_employees = result.get(job.id, 0) + job.no_of_recruitment
@api.depends("company_id")
def _compute_allowed_user_ids(self):
company_ids = self.mapped("company_id.id")
domain = [("share", "=", False)]
if company_ids:
domain += [("company_ids", "in", company_ids)]
users_by_company = dict(
self.env["res.users"]._read_group(
domain=domain,
groupby=["company_id"],
aggregates=["id:recordset"],
),
)
all_users = self.env["res.users"]
for users in users_by_company.values():
all_users |= users
for job in self:
job.allowed_user_ids = users_by_company.get(job.company_id, all_users)
@api.model_create_multi
def create(self, vals_list):
""" We don't want the current user to be follower of all created job """
return super(Job, self.with_context(mail_create_nosubscribe=True)).create(vals_list)
return super(HrJob, self.with_context(mail_create_nosubscribe=True)).create(vals_list)
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
self.ensure_one()
default = dict(default or {})
if 'name' not in default:
default['name'] = _("%s (copy)") % (self.name)
return super(Job, self).copy(default=default)
def copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [dict(vals, name=self.env._("%s (copy)", job.name)) for job, vals in zip(self, vals_list)]
def write(self, vals):
if len(self) == 1:
handle_history_divergence(self, 'description', vals)
return super(Job, self).write(vals)
return super().write(vals)

View file

@ -0,0 +1,26 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from .hr_employee import _ALLOW_READ_HR_EMPLOYEE
class HrMixin(models.AbstractModel):
_name = _description = 'hr.mixin'
# Those overrides deal with many2many fields to comodel 'hr.employee'. In
# the past, one could assign such a many2many field without having any
# access to its comodel. Since Odoo 19, one must have read access to the
# comodel to modify the relation. The hack consists in passing a special
# value in the context, and pretend 'hr.employee' records to be readable
# when that value is present.
@api.model_create_multi
def create(self, vals_list):
special_self = self.with_context(_allow_read_hr_employee=_ALLOW_READ_HR_EMPLOYEE)
records = super(HrMixin, special_self).create(vals_list)
return records.with_env(self.env)
def write(self, vals):
special_self = self.with_context(_allow_read_hr_employee=_ALLOW_READ_HR_EMPLOYEE)
return super(HrMixin, special_self).write(vals)

View file

@ -0,0 +1,18 @@
from odoo import fields, models
class HrPayrollStructureType(models.Model):
_name = 'hr.payroll.structure.type'
_description = 'Salary Structure Type'
name = fields.Char('Salary Structure Type')
default_resource_calendar_id = fields.Many2one(
'resource.calendar', 'Working Hours',
default=lambda self: self.env.company.resource_calendar_id)
country_id = fields.Many2one(
'res.country',
string='Country',
default=lambda self: self.env.company.country_id,
domain=lambda self: [('id', 'in', self.env.companies.country_id.ids)]
)
country_code = fields.Char(related="country_id.code")

View file

@ -1,27 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class HrPlan(models.Model):
_name = 'hr.plan'
_description = 'plan'
name = fields.Char('Name', required=True)
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company)
department_id = fields.Many2one('hr.department', check_company=True)
plan_activity_type_ids = fields.One2many(
'hr.plan.activity.type', 'plan_id',
string='Activities',
domain="[('company_id', '=', company_id)]")
active = fields.Boolean(default=True)
steps_count = fields.Integer(compute='_compute_steps_count')
@api.depends('plan_activity_type_ids')
def _compute_steps_count(self):
activity_type_data = self.env['hr.plan.activity.type']._read_group([('plan_id', 'in', self.ids)], ['plan_id'], ['plan_id'])
steps_count = {x['plan_id'][0]: x['plan_id_count'] for x in activity_type_data}
for plan in self:
plan.steps_count = steps_count.get(plan.id, 0)

View file

@ -1,68 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class HrPlanActivityType(models.Model):
_name = 'hr.plan.activity.type'
_description = 'Plan activity type'
_rec_name = 'summary'
_check_company_auto = True
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
activity_type_id = fields.Many2one(
'mail.activity.type', 'Activity Type',
default=lambda self: self.env.ref('mail.mail_activity_data_todo'),
domain=lambda self: ['|', ('res_model', '=', False), ('res_model', '=', 'hr.employee')],
ondelete='restrict'
)
summary = fields.Char('Summary', compute="_compute_default_summary", store=True, readonly=False)
responsible = fields.Selection([
('coach', 'Coach'),
('manager', 'Manager'),
('employee', 'Employee'),
('other', 'Other')], default='employee', string='Responsible', required=True)
responsible_id = fields.Many2one(
'res.users',
'Other Responsible',
check_company=True,
help='Specific responsible of activity if not linked to the employee.')
plan_id = fields.Many2one('hr.plan')
note = fields.Html('Note')
@api.depends('activity_type_id')
def _compute_default_summary(self):
for plan_type in self:
if plan_type.activity_type_id and plan_type.activity_type_id.summary:
plan_type.summary = plan_type.activity_type_id.summary
else:
plan_type.summary = False
def get_responsible_id(self, employee):
warning = False
if self.responsible == 'coach':
if not employee.coach_id:
warning = _('Coach of employee %s is not set.', employee.name)
responsible = employee.coach_id.user_id
if employee.coach_id and not responsible:
warning = _("The user of %s's coach is not set.", employee.name)
elif self.responsible == 'manager':
if not employee.parent_id:
warning = _('Manager of employee %s is not set.', employee.name)
responsible = employee.parent_id.user_id
if employee.parent_id and not responsible:
warning = _("The manager of %s should be linked to a user.", employee.name)
elif self.responsible == 'employee':
responsible = employee.user_id
if not responsible:
warning = _('The employee %s should be linked to a user.', employee.name)
elif self.responsible == 'other':
responsible = self.responsible_id
if not responsible:
warning = _('No specific user given on activity %s.', self.activity_type_id.name)
return {
'responsible': responsible,
'warning': warning,
}

View file

@ -0,0 +1,620 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from datetime import date
from dateutil.relativedelta import relativedelta
from babel.dates import format_date, get_date_format
from odoo import api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools import get_lang, babel_locale_parse
import logging
_logger = logging.getLogger(__name__)
def format_date_abbr(env, date):
lang = get_lang(env)
locale = babel_locale_parse(lang.code)
date_format = get_date_format('medium', locale=locale).pattern
return format_date(date, date_format, locale=locale)
class HrVersion(models.Model):
_name = 'hr.version'
_description = 'Version'
_inherit = ['mail.thread', 'mail.activity.mixin'] # TODO: remove later ? (see if still needed because contract template)
_mail_post_access = 'read'
_order = 'date_version'
_rec_name = 'name'
def _get_default_address_id(self):
address = self.env.company.partner_id.address_get(['default'])
return address['default'] if address else False
def _default_salary_structure(self):
return (
self.env['hr.payroll.structure.type'].sudo().search([('country_id', '=', self.env.company.country_id.id)], limit=1)
or self.env['hr.payroll.structure.type'].sudo().search([('country_id', '=', False)], limit=1)
)
company_id = fields.Many2one('res.company', compute='_compute_company_id', readonly=False,
store=True, default=lambda self: self.env.company, tracking=True)
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
tracking=True,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
index=True)
name = fields.Char(tracking=True)
display_name = fields.Char(compute='_compute_display_name')
active = fields.Boolean(default=True, tracking=True)
date_version = fields.Date(required=True, default=fields.Date.today, tracking=True, groups="hr.group_hr_user")
last_modified_uid = fields.Many2one('res.users', string='Last Modified by',
default=lambda self: self.env.uid, required=True, groups="hr.group_hr_user")
last_modified_date = fields.Datetime(string='Last Modified on', default=fields.Datetime.now, required=True,
groups="hr.group_hr_user")
# Personal Information
country_id = fields.Many2one(
'res.country', 'Nationality (Country)', groups="hr.group_hr_user", tracking=True)
identification_id = fields.Char(
string='Identification No',
help="Enter the employee's National Identification Number issued by the government (e.g., Aadhaar, SIN, NIN). This is used for official records and statutory compliance.",
groups="hr.group_hr_user",
tracking=True)
ssnid = fields.Char('SSN No', help='Social Security Number', groups="hr.group_hr_user", tracking=True)
passport_id = fields.Char('Passport No', groups="hr.group_hr_user", tracking=True)
passport_expiration_date = fields.Date('Passport Expiration Date', groups="hr.group_hr_user", tracking=True)
sex = fields.Selection([
('male', 'Male'),
('female', 'Female'),
('other', 'Other'),
], groups="hr.group_hr_user", tracking=True, help="This is the legal sex recognized by the state.", string='Gender')
private_street = fields.Char(string="Private Street", groups="hr.group_hr_user", tracking=True)
private_street2 = fields.Char(string="Private Street2", groups="hr.group_hr_user", tracking=True)
private_city = fields.Char(string="Private City", groups="hr.group_hr_user", tracking=True)
allowed_country_state_ids = fields.Many2many("res.country.state", compute='_compute_allowed_country_state_ids', groups="hr.group_hr_user")
private_state_id = fields.Many2one(
"res.country.state", string="Private State",
domain="[('id', 'in', allowed_country_state_ids)]",
groups="hr.group_hr_user", tracking=True)
private_zip = fields.Char(string="Private Zip", groups="hr.group_hr_user", tracking=True)
private_country_id = fields.Many2one("res.country", string="Private Country",
groups="hr.group_hr_user", tracking=True)
distance_home_work = fields.Integer(string="Home-Work Distance", groups="hr.group_hr_user", tracking=True)
km_home_work = fields.Integer(string="Home-Work Distance in Km", groups="hr.group_hr_user",
compute="_compute_km_home_work", inverse="_inverse_km_home_work", store=True, tracking=True)
distance_home_work_unit = fields.Selection([
('kilometers', 'km'),
('miles', 'mi'),
], 'Home-Work Distance unit', groups="hr.group_hr_user", default='kilometers', required=True, tracking=True)
marital = fields.Selection(
selection='_get_marital_status_selection',
string='Marital Status',
groups="hr.group_hr_user",
default='single',
required=True,
tracking=True)
spouse_complete_name = fields.Char(string="Spouse Legal Name", groups="hr.group_hr_user", tracking=True)
spouse_birthdate = fields.Date(string="Spouse Birthdate", groups="hr.group_hr_user", tracking=True)
children = fields.Integer(string='Dependent Children', groups="hr.group_hr_user", tracking=True)
# Work Information
employee_type = fields.Selection([
('employee', 'Employee'),
('worker', 'Worker'),
('student', 'Student'),
('trainee', 'Trainee'),
('contractor', 'Contractor'),
('freelance', 'Freelancer'),
], string='Employee Type', default='employee', required=True, groups="hr.group_hr_user", tracking=True)
department_id = fields.Many2one('hr.department', check_company=True, tracking=True, index=True)
member_of_department = fields.Boolean("Member of department", compute='_compute_part_of_department', search='_search_part_of_department',
help="Whether the employee is a member of the active user's department or one of it's child department.")
job_id = fields.Many2one('hr.job', check_company=True, tracking=True, index=True)
job_title = fields.Char(compute="_compute_job_title", inverse="_inverse_job_title", store=True, readonly=False,
string="Job Title", tracking=True)
is_custom_job_title = fields.Boolean(compute='_compute_is_custom_job_title', store=True, default=False, groups="hr.group_hr_user")
address_id = fields.Many2one(
'res.partner',
string='Work Address',
default=_get_default_address_id,
store=True,
readonly=False,
check_company=True,
tracking=True)
work_location_id = fields.Many2one('hr.work.location', 'Work Location',
domain="[('address_id', '=', address_id)]", tracking=True)
departure_reason_id = fields.Many2one("hr.departure.reason", string="Departure Reason",
groups="hr.group_hr_user", copy=False, ondelete='restrict', tracking=True)
departure_description = fields.Html(string="Additional Information", groups="hr.group_hr_user", copy=False)
departure_date = fields.Date(string="Departure Date", groups="hr.group_hr_user", copy=False, tracking=True)
resource_calendar_id = fields.Many2one('resource.calendar', inverse='_inverse_resource_calendar_id', check_company=True, string="Working Hours", tracking=True)
is_flexible = fields.Boolean(compute='_compute_is_flexible', store=True, groups="hr.group_hr_user")
is_fully_flexible = fields.Boolean(compute='_compute_is_flexible', store=True, groups="hr.group_hr_user")
tz = fields.Selection(related='employee_id.tz')
# Contract Information
contract_date_start = fields.Date('Contract Start Date', tracking=True, groups="hr.group_hr_manager")
contract_date_end = fields.Date(
'Contract End Date', tracking=True, help="End date of the contract (if it's a fixed-term contract).",
groups="hr.group_hr_manager")
trial_date_end = fields.Date('End of Trial Period', help="End date of the trial period (if there is one).",
groups="hr.group_hr_manager", tracking=True)
date_start = fields.Date(compute='_compute_dates', groups="hr.group_hr_manager", search="_search_start_date")
date_end = fields.Date(compute='_compute_dates', groups="hr.group_hr_manager", search="_search_end_date")
is_current = fields.Boolean(compute='_compute_is_current', groups="hr.group_hr_manager")
is_past = fields.Boolean(compute='_compute_is_past', groups="hr.group_hr_manager")
is_future = fields.Boolean(compute='_compute_is_future', groups="hr.group_hr_manager")
is_in_contract = fields.Boolean(compute='_compute_is_in_contract', groups="hr.group_hr_manager")
contract_template_id = fields.Many2one(
'hr.version', string="Contract Template", groups="hr.group_hr_user",
domain="[('company_id', '=', company_id), ('employee_id', '=', False)]", tracking=True,
help="Select a contract template to auto-fill the contract form with predefined values. You can still edit the fields as needed after applying the template.")
structure_type_id = fields.Many2one('hr.payroll.structure.type', string="Salary Structure Type",
compute="_compute_structure_type_id", readonly=False, store=True, tracking=True,
groups="hr.group_hr_manager", default=_default_salary_structure)
active_employee = fields.Boolean(related="employee_id.active", string="Active Employee", groups="hr.group_hr_user")
currency_id = fields.Many2one(string="Currency", related='company_id.currency_id', readonly=True)
wage = fields.Monetary('Wage', tracking=True, help="Employee's monthly gross wage.", aggregator="avg",
groups="hr.group_hr_manager")
contract_wage = fields.Monetary('Contract Wage', compute='_compute_contract_wage', groups="hr.group_hr_manager")
# [XBO] TODO: remove me in master
company_country_id = fields.Many2one('res.country', string="Company country",
related='company_id.country_id', readonly=True)
country_code = fields.Char(related='company_country_id.code', depends=['company_country_id'], readonly=True)
contract_type_id = fields.Many2one('hr.contract.type', "Contract Type", tracking=True,
groups="hr.group_hr_manager")
additional_note = fields.Text(string='Additional Note', groups="hr.group_hr_user", tracking=True)
def _get_hr_responsible_domain(self):
return "[('share', '=', False), ('company_ids', 'in', company_id), ('all_group_ids', 'in', %s)]" % self.env.ref('hr.group_hr_user').id
hr_responsible_id = fields.Many2one(
'res.users', 'HR Responsible', tracking=True,
help='Person responsible for validating the employee\'s contracts.', domain=_get_hr_responsible_domain,
default=lambda self: self.env.user, required=True, groups="hr.group_hr_user")
_check_contract_start_date_defined = models.Constraint(
'CHECK(contract_date_end IS NULL OR contract_date_start IS NOT NULL)',
'The contract must have a start date.',
)
_check_unique_date_version = models.UniqueIndex(
'(employee_id, date_version) WHERE active = TRUE AND employee_id IS NOT NULL',
'An employee cannot have multiple active versions sharing the same effective date.',
)
@api.depends('employee_id.company_id')
def _compute_company_id(self):
for version in self:
if version.employee_id:
version.company_id = version.employee_id.company_id
@api.depends('job_id.name')
def _compute_job_title(self):
for version in self.filtered('job_id'):
if version._origin.job_id != version.job_id or not version.is_custom_job_title:
version.job_title = version.job_id.name
def _inverse_job_title(self):
for version in self:
version.is_custom_job_title = version.job_title != version.job_id.name
@api.depends('job_id')
def _compute_is_custom_job_title(self):
for version in self.filtered('job_id'):
if version._origin.job_id != version.job_id:
version.is_custom_job_title = False
@api.depends("private_country_id")
def _compute_allowed_country_state_ids(self):
states = self.env["res.country.state"].search([])
for version in self:
if version.private_country_id:
version.allowed_country_state_ids = version.private_country_id.state_ids
else:
version.allowed_country_state_ids = states
@api.constrains('employee_id', 'contract_date_start', 'contract_date_end')
def _check_dates(self):
version_read_group = self.env['hr.version'].sudo()._read_group(
[
('id', 'not in', self.ids),
('employee_id', 'in', self.employee_id.ids),
('contract_date_start', '!=', False),
],
['employee_id', 'contract_date_start:day', 'contract_date_end:day'],
['id:recordset'],
)
dates_per_employee = defaultdict(list)
for employee, date_start, date_end, versions in version_read_group:
dates_per_employee[employee].append((date_start, date_end, versions))
for version in self.sudo(): # sudo needed to read contract dates
if not version.contract_date_start or not version.employee_id:
continue
if version.contract_date_end and version.contract_date_start > version.contract_date_end:
raise ValidationError(self.env._(
'Start date (%(start)s) must be earlier than contract end date (%(end)s).',
start=version.contract_date_start, end=version.contract_date_end,
))
if not version.active:
continue
contract_date_end = version.contract_date_end or date.max
contract_period_exists = False
for date_start, date_end, versions in dates_per_employee[version.employee_id]:
date_to = date_end or date.max
if date_start == version.contract_date_start and date_to == contract_date_end:
contract_period_exists = True
continue
if date_start <= contract_date_end and version.contract_date_start <= date_to:
raise ValidationError(self.env._(
"%s already has a contract running during the selected period.\n\n"
"Please either:\n\n"
"- Change the start date so that it doesn't overlap with the existing contract, or\n"
"- Create a new employee if this employee should have multiple active contracts.",
version.employee_id.display_name))
if not contract_period_exists:
dates_per_employee[version.employee_id].append((version.contract_date_start, version.contract_date_end, version))
def check_contract_finished(self):
if self.contract_date_start and not self.contract_date_end:
raise ValidationError(self.env._("Before creating a new contract, close the current one by setting an end date."))
@api.model_create_multi
def create(self, vals_list):
Version = self.env['hr.version']
for vals in vals_list:
if 'contract_template_id' in vals:
contract_vals = Version.get_values_from_contract_template(Version.browse(vals['contract_template_id']))
# take vals from template, but priority given to the original vals
vals.update({**contract_vals, **vals})
return super().create(vals_list)
@api.ondelete(at_uninstall=False)
def _unlink_except_last_version(self):
for employee_id, versions in self.grouped('employee_id').items():
if employee_id.version_ids == versions:
raise ValidationError(
self.env._('Employee %s must always have at least one active version.') % employee_id.name
)
def write(self, vals):
# Employee Versions Validation
if 'employee_id' in vals:
if self.filtered(lambda v: v.employee_id and v.employee_id.version_ids <= self and vals['employee_id'] != v.employee_id.id):
raise ValidationError(self.env._("Cannot unassign all the active versions of an employee."))
if 'active' in vals and not vals['active']:
if self.filtered(lambda v: v.employee_id and v.employee_id.version_ids <= self):
raise ValidationError(self.env._("Cannot archive all the active versions of an employee."))
if self.env.context.get('sync_contract_dates') or ("contract_date_start" not in vals and "contract_date_end" not in vals):
return super().write(vals)
for versions_by_employee in self.grouped('employee_id').values():
if len(versions_by_employee.grouped('contract_date_start').keys()) > 1:
raise ValidationError(self.env._("Cannot modify multiple versions contract dates with different contracts at once."))
multiple_versions = self
if vals.get("contract_date_start"):
unique_versions = multiple_versions.filtered(lambda v: len(v.employee_id.version_ids) == 1)
multiple_versions -= unique_versions
if len(unique_versions):
unique_versions.with_context(sync_contract_dates=True).write({
**vals,
"date_version": vals["contract_date_start"]
})
if not any(multiple_versions.mapped('contract_date_start')):
return super(HrVersion, multiple_versions).write(vals)
new_vals = {
f_name: f_value
for f_name, f_value in vals.items()
if (f_name != 'contract_date_start' or not f_value) and f_name != 'contract_date_end'
}
for employee, versions in multiple_versions.grouped('employee_id').items():
dates_vals = {}
first_version = next(iter(versions), versions)
if "contract_date_start" in vals:
dates_vals["contract_date_start"] = fields.Date.to_date(vals.get('contract_date_start'))
else:
dates_vals["contract_date_start"] = first_version.contract_date_start
if "contract_date_end" in vals:
dates_vals["contract_date_end"] = fields.Date.to_date(vals.get('contract_date_end'))
else:
dates_vals["contract_date_end"] = first_version.contract_date_end
if first_version.contract_date_start:
versions_to_sync = employee._get_contract_versions(
date_start=first_version.contract_date_start,
date_end=first_version.contract_date_end,
)
all_versions_to_sync = self.env['hr.version']
for contract_versions in versions_to_sync.values():
all_versions_to_sync |= next(iter(contract_versions.values()))
if all_versions_to_sync:
all_versions_to_sync.with_context(sync_contract_dates=True).write(dates_vals)
else:
versions.with_context(sync_contract_dates=True).write(dates_vals)
return super(HrVersion, multiple_versions).write(new_vals)
def get_formview_action(self, access_uid=None):
"""
Override this method in order to redirect many2one towards the right model
- Contract template -> hr.version
- Employee record -> hr.employee(.public) with version_id in context
"""
res = super().get_formview_action(access_uid=access_uid)
context = res.get('context', {})
if self.employee_id:
user = self.env.user
if access_uid:
user = self.env['res.users'].browse(access_uid)
res['res_model'] = 'hr.employee' if user.has_group('hr.group_hr_user') else 'hr.employee.public'
res['res_id'] = self.employee_id.id
res['context'] = dict(context, version_id=self.id)
else:
if not context.get('form_view_ref', False):
res['context'] = dict(context, form_view_ref='hr.hr_contract_template_form_view')
return res
@api.depends_context('lang')
@api.depends('date_version')
def _compute_display_name(self):
for version in self:
version.display_name = version.name if not version.employee_id else format_date_abbr(version.env, version.date_version)
def _compute_is_current(self):
today = fields.Date.today()
for version in self:
version.is_current = version.date_start <= today and (not version.date_end or version.date_end >= today)
def _compute_is_past(self):
today = fields.Date.today()
for version in self:
version.is_past = version.date_end and version.date_end < today
def _compute_is_future(self):
today = fields.Date.today()
for version in self:
version.is_future = version.date_start > today
def _compute_is_in_contract(self):
for version in self:
version.is_in_contract = version._is_in_contract()
def _is_in_contract(self, date=fields.Date.today()):
# Return True if the employee is in contract on a given date
if not self.contract_date_start:
return False
return self.date_start <= date and (not self.date_end or self.date_end >= date)
def _is_overlapping_period(self, date_from, date_to):
"""
Return True if the employee is at least in contract one day during the period given
:param date date_from: the start of the period
:param date date_to: the stop of the period
"""
if not (self.contract_date_start and date_from and date_to):
return False
period_start = date_from or date.min
period_end = date_to or date.max
contract_end = self.date_end or date.max
return period_start <= contract_end and self.date_start <= period_end
def _is_fully_flexible(self):
""" return True if the version has a fully flexible working calendar """
self.ensure_one()
return not self.resource_calendar_id
@api.depends('resource_calendar_id.flexible_hours')
def _compute_is_flexible(self):
for version in self:
version.is_fully_flexible = version._is_fully_flexible()
version.is_flexible = version.is_fully_flexible or version.resource_calendar_id.flexible_hours
@api.model
def _get_whitelist_fields_from_template(self):
# Add here any field that you want to copy from a contract template
# Those fields should have tracking=True in hr.version to see the change
return ['job_id', 'department_id', 'contract_type_id', 'structure_type_id', 'wage', 'resource_calendar_id', 'hr_responsible_id']
def get_values_from_contract_template(self, contract_template_id):
if not contract_template_id:
return {}
company = contract_template_id.company_id or self.env.company
whitelist = self.with_company(company)._get_whitelist_fields_from_template()
contract_template_vals = contract_template_id.copy_data()[0]
return {
field: value
for field, value in contract_template_vals.items()
if field in whitelist and not self.env['hr.version']._fields[field].related
}
@api.depends('wage')
def _compute_contract_wage(self):
for version in self:
version.contract_wage = version._get_contract_wage()
def _get_contract_wage(self):
if not self:
return 0
self.ensure_one()
return self[self._get_contract_wage_field()]
def _get_contract_wage_field(self):
self.ensure_one()
return 'wage'
def _get_normalized_wage(self):
""" This method is overridden in hr_payroll, as without that module, nothing allows to know
there's no way to determine the employee's pay frequency.
"""
wage = self._get_contract_wage()
# without payroll installed, we suppose that the employee with a specific schedule has a monthly salary
if self.resource_calendar_id:
if not self.resource_calendar_id.hours_per_week:
return 0
return wage * 12 / 52 / self.resource_calendar_id.hours_per_week
# without any calendar, the employee has a fully flexible schedule and is supposedly working on an hourly wage
return wage
def _get_valid_employee_for_user(self):
user = self.env.user
# retrieve the employee of the current active company for the user
employee = user.employee_id
if not employee:
# search for all employees as superadmin to not get blocked by multi-company rules
user_employees = user.employee_id.sudo().search([
('user_id', '=', user.id)
])
# the default company employee is most likely the correct one, but fallback to the first if not available
employee = user_employees.filtered(lambda r: r.company_id == user.company_id) or user_employees[:1]
return employee
@api.constrains('ssnid')
def _check_ssnid(self):
# By default, a Social Security Number is always valid, but each localization
# may want to add its own constraints
pass
@api.depends_context('uid', 'company')
@api.depends('department_id')
def _compute_part_of_department(self):
user_employee = self._get_valid_employee_for_user()
active_department = user_employee.department_id
if not active_department:
self.member_of_department = False
else:
def get_all_children(department):
children = department.child_ids
if not children:
return self.env['hr.department']
return children + get_all_children(children)
child_departments = active_department + get_all_children(active_department)
for version in self:
version.member_of_department = version.department_id in child_departments
def _search_part_of_department(self, operator, value):
if operator != 'in':
return NotImplemented
user_employee = self._get_valid_employee_for_user()
if not user_employee.department_id:
return [('id', 'in', user_employee.ids)]
return [('department_id', 'child_of', user_employee.department_id.ids)]
@api.depends('company_id')
def _compute_structure_type_id(self):
default_structure_by_country = {}
def _default_salary_structure(country_id):
default_structure = default_structure_by_country.get(country_id)
if default_structure is None:
default_structure = default_structure_by_country[country_id] = (
self.env['hr.payroll.structure.type'].search([('country_id', '=', country_id)], limit=1)
or self.env['hr.payroll.structure.type'].search([('country_id', '=', False)], limit=1)
)
return default_structure
for version in self:
if not version.structure_type_id or (version.structure_type_id.country_id and version.structure_type_id.country_id != version.company_id.country_id):
version.structure_type_id = _default_salary_structure(version.company_id.country_id.id)
@api.depends('distance_home_work', 'distance_home_work_unit')
def _compute_km_home_work(self):
for version in self:
version.km_home_work = version.distance_home_work * 1.609 if version.distance_home_work_unit == "miles" else version.distance_home_work
def _inverse_km_home_work(self):
for version in self:
version.distance_home_work = version.km_home_work / 1.609 if version.distance_home_work_unit == "miles" else version.km_home_work
@api.depends(
'contract_date_start', 'contract_date_end', 'date_version', 'employee_id',
'employee_id.version_ids.date_version')
def _compute_dates(self):
for version in self:
version.date_start = max(version.date_version, version.contract_date_start) \
if version.contract_date_start \
else version.date_version
next_version = self.env['hr.version'].search([
('employee_id', 'in', version.employee_id.ids),
('date_version', '>', version.date_version)], limit=1)
date_version_end = next_version.date_version + relativedelta(days=-1) if next_version else False
if date_version_end and version.contract_date_end:
version.date_end = min(date_version_end, version.contract_date_end)
elif date_version_end:
version.date_end = date_version_end
else:
version.date_end = version.contract_date_end
def _search_start_date(self, operator, value):
return [('contract_date_start', operator, value)]
def _search_end_date(self, operator, value):
return [('contract_date_end', operator, value)]
@api.model
def _get_marital_status_selection(self):
return [
('single', self.env._('Single')),
('married', self.env._('Married')),
('cohabitant', self.env._('Legal Cohabitant')),
('widower', self.env._('Widower')),
('divorced', self.env._('Divorced')),
]
def _inverse_resource_calendar_id(self):
for employee, versions in self.grouped('employee_id').items():
current_version = employee.current_version_id
for version in versions:
if version == current_version and employee.resource_id.calendar_id != version.resource_calendar_id:
employee.resource_id.calendar_id = version.resource_calendar_id
def _get_salary_costs_factor(self):
self.ensure_one()
return 12.0
def _is_struct_from_country(self, country_code):
self.ensure_one()
self_sudo = self.sudo()
return self_sudo.structure_type_id and self_sudo.structure_type_id.country_id.code == country_code
def _get_tz(self):
if self.resource_calendar_id and self.resource_calendar_id.tz:
return self.resource_calendar_id.tz
else:
return self.tz
def action_open_version(self):
self.ensure_one()
return {
'type': "ir.actions.act_window",
'res_model': "hr.employee",
'res_id': self.employee_id.id,
'views': [[False, "form"]],
'target': "current",
'context': {
'version_id': self.id,
},
}

View file

@ -4,13 +4,17 @@
from odoo import fields, models
class WorkLocation(models.Model):
_name = "hr.work.location"
class HrWorkLocation(models.Model):
_name = 'hr.work.location'
_description = "Work Location"
_order = 'name'
active = fields.Boolean(default=True)
name = fields.Char(string="Work Location", required=True)
company_id = fields.Many2one('res.company', required=True, default=lambda self: self.env.company)
address_id = fields.Many2one('res.partner', required=True, string="Work Address", domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
location_type = fields.Selection([
('home', 'Home'),
('office', 'Office'),
('other', 'Other')], string='Cover Image', default='office', required=True)
address_id = fields.Many2one('res.partner', required=True, string="Work Address", check_company=True)
location_number = fields.Char()

View file

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, api, tools
from odoo import models
class IrUiMenu(models.Model):
@ -9,7 +8,12 @@ class IrUiMenu(models.Model):
def _load_menus_blacklist(self):
res = super()._load_menus_blacklist()
emp_menu = self.env.ref('hr.menu_hr_employee', raise_if_not_found=False)
if emp_menu and self.env.user.has_group('hr.group_hr_user'):
if self.env.user.has_group('hr.group_hr_user') and (emp_menu := self.env.ref('hr.menu_hr_employee', raise_if_not_found=False)):
res.append(emp_menu.id)
else:
is_department_manager = bool(self.env["hr.department"].search_count([
('manager_id', 'in', self.env.user.employee_ids.ids)
], limit=1))
if not is_department_manager and (dep_menu := self.env.ref('hr.menu_hr_department_kanban', raise_if_not_found=False)):
res.append(dep_menu.id)
return res

View file

@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class MailActivityPlan(models.Model):
_inherit = 'mail.activity.plan'
department_id = fields.Many2one(
'hr.department', check_company=True, index='btree_not_null',
compute='_compute_department_id', ondelete='cascade', readonly=False, store=True)
department_assignable = fields.Boolean(compute='_compute_department_assignable')
@api.constrains('res_model')
def _check_compatibility_with_model(self):
""" Check that when the model is updated to a model different from employee,
there are no remaining specific values to employee. """
plan_tocheck = self.filtered(lambda plan: not plan.department_assignable)
failing_plans = plan_tocheck.filtered('department_id')
if failing_plans:
raise UserError(
_('Plan %(plan_names)s cannot use a department as it is used only for some HR plans.',
plan_names=', '.join(failing_plans.mapped('name')))
)
plan_tocheck = self.filtered(lambda plan: plan.res_model != 'hr.employee')
failing_templates = plan_tocheck.template_ids.filtered(
lambda tpl: tpl.responsible_type in {'coach', 'manager', 'employee'}
)
if failing_templates:
raise UserError(
_('Plan activities %(template_names)s cannot use coach, manager or employee responsible as it is used only for employee plans.',
template_names=', '.join(failing_templates.mapped('activity_type_id.name')))
)
@api.depends('res_model')
def _compute_department_assignable(self):
for plan in self:
plan.department_assignable = plan.res_model == 'hr.employee'
@api.depends('res_model')
def _compute_department_id(self):
for plan in self.filtered(lambda plan: not plan.department_assignable):
plan.department_id = False

View file

@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class MailActivityPlanTemplate(models.Model):
_inherit = 'mail.activity.plan.template'
responsible_type = fields.Selection(selection_add=[
('coach', 'Coach'),
('manager', 'Manager'),
('employee', 'Employee'),
], ondelete={'coach': 'cascade', 'manager': 'cascade', 'employee': 'cascade'})
@api.constrains('plan_id', 'responsible_type')
def _check_responsible_hr(self):
""" Ensure that hr types are used only on employee model """
for template in self.filtered(lambda tpl: tpl.plan_id.res_model != 'hr.employee'):
if template.responsible_type in {'coach', 'manager', 'employee'}:
raise ValidationError(_('Those responsible types are limited to Employee plans.'))
def _get_closest_parent_user(self, employee, responsible, error_message):
responsible_parent = responsible
viewed_responsible = [employee]
while True:
if not responsible_parent:
return {
'error': False,
'responsible': self.env.user,
'warning': error_message
}
if responsible_parent.user_id:
return {
'error': False,
'responsible': responsible_parent.user_id,
'warning': False
}
if responsible_parent in viewed_responsible:
return {
"error": _(
"Oops! It seems there is a problem with your team structure.\
We found a circular reporting loop and no one in that loop is linked to a user.\
Please double-check that everyone reports to the correct manager."
),
'warning': False,
"responsible": False,
}
else:
viewed_responsible.append(responsible_parent)
responsible_parent = responsible_parent.parent_id
def _determine_responsible(self, on_demand_responsible, employee):
if self.plan_id.res_model != 'hr.employee' or self.responsible_type not in {'coach', 'manager', 'employee'}:
return super()._determine_responsible(on_demand_responsible, employee)
result = {"error": "", "warning": "", "responsible": False}
if self.responsible_type == 'coach':
if not employee.coach_id:
result['error'] = _('Coach of employee %s is not set.', employee.name)
result['responsible'] = employee.coach_id.user_id
if employee.coach_id and not result['responsible']:
# If a plan cannot be launched due to the coach not being linked to an user,
# attempt to assign it to the coach's manager user. If that manager is also not linked
# to an user, continue searching upwards until a manager with a linked user is found.
# If no one is found still, assign to current user.
result = self._get_closest_parent_user(
employee=employee,
responsible=employee.coach_id.parent_id,
error_message=_(
"The user of %s's coach is not set.", employee.name
),
)
elif self.responsible_type == 'manager':
if not employee.parent_id:
result['error'] = _('Manager of employee %s is not set.', employee.name)
result['responsible'] = employee.parent_id.user_id
if employee.parent_id and not result['responsible']:
# If a plan cannot be launched due to the manager not being linked to an user,
# attempt to assign it to the manager's manager user. If that manager is also not linked
# to an user, continue searching upwards until a manager with a linked user is found.
# If no one is found still, assign to current user.
result = self._get_closest_parent_user(
employee=employee,
responsible=employee.parent_id.parent_id,
error_message=_(
"The manager of %s should be linked to a user.", employee.name
),
)
elif self.responsible_type == 'employee':
result['responsible'] = employee.user_id
if not result['responsible']:
# If a plan cannot be launched due to the employee not being linked to an user,
# attempt to assign it to the manager's user. If the manager is also not linked
# to an user, continue searching upwards until a manager with a linked user is found.
# If no one is found still, assign to current user.
result = self._get_closest_parent_user(
employee=employee,
responsible=employee.parent_id,
error_message=_(
"The employee %s should be linked to a user.", employee.name
),
)
if result['error'] or result['responsible']:
return result

View file

@ -4,7 +4,7 @@
from odoo import fields, models, _
class Alias(models.Model):
class MailAlias(models.Model):
_inherit = 'mail.alias'
alias_contact = fields.Selection(selection_add=[
@ -14,4 +14,4 @@ class Alias(models.Model):
def _get_alias_contact_description(self):
if self.alias_contact == 'employees':
return _('addresses linked to registered employees')
return super(Alias, self)._get_alias_contact_description()
return super()._get_alias_contact_description()

View file

@ -2,19 +2,20 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, tools, _
from odoo.addons.mail.tools.alias_error import AliasError
class BaseModel(models.AbstractModel):
class Base(models.AbstractModel):
_inherit = 'base'
def _alias_get_error_message(self, message, message_dict, alias):
def _alias_get_error(self, message, message_dict, alias):
if alias.alias_contact == 'employees':
email_from = tools.decode_message_header(message, 'From')
email_address = tools.email_split(email_from)[0]
email_from = tools.mail.decode_message_header(message, 'From')
email_address = tools.email_normalize(email_from, strict=False)
employee = self.env['hr.employee'].search([('work_email', 'ilike', email_address)], limit=1)
if not employee:
employee = self.env['hr.employee'].search([('user_id.email', 'ilike', email_address)], limit=1)
if not employee:
return _('restricted to employees')
return AliasError('error_hr_employee_restricted', _('restricted to employees'))
return False
return super(BaseModel, self)._alias_get_error_message(message, message_dict, alias)
return super()._alias_get_error(message, message_dict, alias)

View file

@ -1,11 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class Company(models.Model):
class ResCompany(models.Model):
_inherit = 'res.company'
hr_presence_control_email_amount = fields.Integer(string="# emails to send")
hr_presence_control_ip_list = fields.Char(string="Valid IP addresses")
employee_properties_definition = fields.PropertiesDefinition('Employee Properties')
hr_presence_control_login = fields.Boolean(string="Based on user status in system", default=True)
hr_presence_control_email = fields.Boolean(string="Based on number of emails sent")
hr_presence_control_ip = fields.Boolean(string="Based on IP Address")
hr_presence_control_attendance = fields.Boolean(string="Based on attendances")
contract_expiration_notice_period = fields.Integer("Contract Expiry Notice Period", default=7)
work_permit_expiration_notice_period = fields.Integer("Work Permit Expiry Notice Period", default=60)

View file

@ -1,8 +1,4 @@
# -*- coding: utf-8 -*-
import threading
from odoo import fields, models, api, _
from odoo.exceptions import ValidationError
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
@ -13,21 +9,11 @@ class ResConfigSettings(models.TransientModel):
related='company_id.resource_calendar_id', readonly=False)
module_hr_presence = fields.Boolean(string="Advanced Presence Control")
module_hr_skills = fields.Boolean(string="Skills Management")
module_hr_homeworking = fields.Boolean(string="Homeworking")
hr_presence_control_login = fields.Boolean(string="Based on user status in system", config_parameter='hr.hr_presence_control_login')
hr_presence_control_email = fields.Boolean(string="Based on number of emails sent", config_parameter='hr_presence.hr_presence_control_email')
hr_presence_control_ip = fields.Boolean(string="Based on IP Address", config_parameter='hr_presence.hr_presence_control_ip')
module_hr_attendance = fields.Boolean(string="Based on attendances")
hr_presence_control_login = fields.Boolean(related='company_id.hr_presence_control_login', readonly=False)
hr_presence_control_email = fields.Boolean(related='company_id.hr_presence_control_email', readonly=False)
hr_presence_control_ip = fields.Boolean(related='company_id.hr_presence_control_ip', readonly=False)
module_hr_attendance = fields.Boolean(related='company_id.hr_presence_control_attendance', readonly=False)
hr_presence_control_email_amount = fields.Integer(related="company_id.hr_presence_control_email_amount", readonly=False)
hr_presence_control_ip_list = fields.Char(related="company_id.hr_presence_control_ip_list", readonly=False)
hr_employee_self_edit = fields.Boolean(string="Employee Editing", config_parameter='hr.hr_employee_self_edit')
@api.constrains('module_hr_presence', 'hr_presence_control_email', 'hr_presence_control_ip')
def _check_advanced_presence(self):
test_mode = self.env.registry.in_test_mode() or getattr(threading.current_thread(), 'testing', False)
if self.env.context.get('install_mode', False) or test_mode:
return
for settings in self:
if settings.module_hr_presence and not (settings.hr_presence_control_email or settings.hr_presence_control_ip):
raise ValidationError(_('You should select at least one Advanced Presence Control option.'))
contract_expiration_notice_period = fields.Integer(string="Contract Expiry Notice Period", related='company_id.contract_expiration_notice_period', readonly=False)
work_permit_expiration_notice_period = fields.Integer(string="Work Permit Expiry Notice Period", related='company_id.work_permit_expiration_notice_period', readonly=False)

View file

@ -1,54 +1,111 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import AccessError
from odoo.exceptions import RedirectWarning, UserError
from odoo.addons.mail.tools.discuss import Store
class Partner(models.Model):
_inherit = ['res.partner']
class ResPartner(models.Model):
_inherit = 'res.partner'
employee_ids = fields.One2many(
'hr.employee', 'address_home_id', string='Employees', groups="hr.group_hr_user",
'hr.employee', 'work_contact_id', string='Employees', groups="hr.group_hr_user",
help="Related employees based on their private address")
employees_count = fields.Integer(compute='_compute_employees_count', groups="hr.group_hr_user")
def name_get(self):
""" Override to allow an employee to see its private address in his profile.
This avoids to relax access rules on `res.parter` and to add an `ir.rule`.
(advantage in both security and performance).
Use a try/except instead of systematically checking to minimize the impact on performance.
"""
try:
return super(Partner, self).name_get()
except AccessError as e:
if len(self) == 1 and self in self.env.user.employee_ids.mapped('address_home_id'):
return super(Partner, self.sudo()).name_get()
raise e
employee = fields.Boolean(help="Whether this contact is an Employee.", compute='_compute_employee', store=True, readonly=False, copy=False)
def _compute_employees_count(self):
for partner in self:
partner.employees_count = len(partner.employee_ids)
partner.employees_count = len(partner.sudo().employee_ids.filtered(lambda e: e.company_id in self.env.companies))
def action_open_employees(self):
self.ensure_one()
if self.employees_count > 1:
return {
'name': _('Related Employees'),
'type': 'ir.actions.act_window',
'res_model': 'hr.employee',
'view_mode': 'kanban',
'domain': [('id', 'in', self.employee_ids.ids),
('company_id', 'in', self.env.companies.ids)],
}
return {
'name': _('Related Employees'),
'name': _('Employee'),
'type': 'ir.actions.act_window',
'res_model': 'hr.employee',
'view_mode': 'kanban,tree,form',
'domain': [('id', 'in', self.employee_ids.ids)],
'res_id': self.employee_ids.filtered(lambda e: e.company_id in self.env.companies).id,
'view_mode': 'form',
}
class ResPartnerBank(models.Model):
_inherit = ['res.partner.bank']
def _get_all_addr(self):
self.ensure_one()
employee_id = self.env['hr.employee'].search(
[('id', 'in', self.employee_ids.ids)],
limit=1,
)
if not employee_id:
return super()._get_all_addr()
@api.depends_context('uid')
def _compute_display_name(self):
account_employee = self.browse()
if not self.user_has_groups('hr.group_hr_user'):
account_employee = self.sudo().filtered("partner_id.employee_ids")
for account in account_employee:
account.sudo(self.env.su).display_name = \
account.acc_number[:2] + "*" * len(account.acc_number[2:-4]) + account.acc_number[-4:]
super(ResPartnerBank, self - account_employee)._compute_display_name()
pstl_addr = {
'contact_type': 'employee',
'street': employee_id.private_street,
'zip': employee_id.private_zip,
'city': employee_id.private_city,
'country': employee_id.private_country_id.code,
}
return [pstl_addr] + super()._get_all_addr()
@api.depends('employee_ids')
def _compute_employee(self):
employee_data = self.env['hr.employee']._read_group(
domain=[('work_contact_id', 'in', self.ids)],
groupby=['work_contact_id'],
)
employees = {employee for [employee] in employee_data}
for partner in self:
partner.employee = partner in employees
@api.ondelete(at_uninstall=False)
def _unlink_contact_rel_employee(self):
partners = self.filtered(lambda partner: partner.sudo().employee_ids)
if len(self) == 1 and len(partners) == 1 and self.id == partners[0].id:
raise UserError(_('You cannot delete contact that are linked to an employee, please archive them instead.'))
if partners:
error_msg = _(
'You cannot delete contact(s) linked to employee(s).\n'
'Please archive them instead.\n\n'
'Affected contact(s): %(names)s', names=", ".join([u.name for u in partners]),
)
action_error = partners._action_show()
raise RedirectWarning(error_msg, action_error, _('Go to contact'))
def _action_show(self):
"""If self is a singleton, directly access the form view. If it is a recordset, open a list view"""
view_id = self.env.ref('base.view_partner_form').id
action = {
'type': 'ir.actions.act_window',
'res_model': 'res.partner',
'context': {'create': False},
}
if len(self) > 1:
action.update({
'name': _('Contacts'),
'view_mode': 'list,form',
'views': [[None, 'list'], [view_id, 'form']],
'domain': [('id', 'in', self.ids)],
})
else:
action.update({
'view_mode': 'form',
'views': [[view_id, 'form']],
'res_id': self.id,
})
return action
def _get_store_avatar_card_fields(self, target):
avatar_card_fields = super()._get_store_avatar_card_fields(target)
if target.is_internal(self.env):
# sudo: res.partner - internal users can access employee information of partner
employee_fields = self.sudo().employee_ids._get_store_avatar_card_fields(target)
avatar_card_fields.append(Store.Many("employee_ids", employee_fields, mode="ADD", sudo=True))
return avatar_card_fields

View file

@ -0,0 +1,58 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ResPartnerBank(models.Model):
_inherit = 'res.partner.bank'
bank_street = fields.Char(related='bank_id.street', readonly=False)
bank_street2 = fields.Char(related='bank_id.street2', readonly=False)
bank_zip = fields.Char(related='bank_id.zip', readonly=False)
bank_city = fields.Char(related='bank_id.city', readonly=False)
bank_state = fields.Many2one(related='bank_id.state', readonly=False)
bank_country = fields.Many2one(related='bank_id.country', readonly=False)
bank_email = fields.Char(related='bank_id.email', readonly=False)
bank_phone = fields.Char(related='bank_id.phone', readonly=False)
employee_id = fields.Many2many('hr.employee', 'Employee', compute="_compute_employee_id", search="_search_employee_id")
employee_salary_amount = fields.Float(string='Salary Allocation', compute='_compute_salary_amount', digits=(16, 4), readonly=True, store=False)
employee_salary_amount_is_percentage = fields.Boolean(compute='_compute_salary_amount', readonly=True, store=False)
currency_symbol = fields.Char(related='currency_id.symbol')
employee_has_multiple_bank_accounts = fields.Boolean(related="employee_id.has_multiple_bank_accounts")
@api.depends('employee_id.salary_distribution')
def _compute_salary_amount(self):
for bank in self:
if bank.employee_id and bank.employee_id.salary_distribution:
bank.employee_salary_amount, bank.employee_salary_amount_is_percentage = bank.employee_id.get_bank_account_salary_allocation(bank.id)
continue
bank.employee_salary_amount_is_percentage = True
if bank.employee_id.salary_distribution:
bank.employee_salary_amount = bank.employee_id.get_remaining_percentage()
else:
bank.employee_salary_amount = 0
def _search_employee_id(self, operator, value):
matching_employees = self.env['hr.employee'].sudo().search([('id', operator, value)])
return [('id', 'in', matching_employees.bank_account_ids.ids)]
def action_open_allocation_wizard(self):
self.ensure_one()
return self.employee_id.action_open_allocation_wizard()
@api.depends('partner_id')
def _compute_employee_id(self):
for bank in self:
if bank.partner_id.employee:
bank.employee_id = bank.partner_id.employee_ids.filtered(lambda e: e.company_id in self.env.companies)[:1]
else:
bank.employee_id = False
def _compute_display_name(self):
account_employee = self.browse()
if not self.env.user.has_group('hr.group_hr_user'):
account_employee = self.sudo().filtered("partner_id.employee_ids")
for account in account_employee:
account.sudo(self.env.su).display_name = \
account.acc_number[:2] + "*" * len(account.acc_number[2:-4]) + account.acc_number[-4:]
super(ResPartnerBank, self - account_employee)._compute_display_name()

View file

@ -1,24 +1,27 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
from markupsafe import Markup
from odoo import api, models, fields, _, SUPERUSER_ID
from odoo.exceptions import AccessError
from odoo.fields import Domain
from odoo.tools.misc import clean_context
from odoo.addons.mail.tools.discuss import Store
HR_READABLE_FIELDS = [
'active',
'child_ids',
'employee_id',
'address_home_id',
'employee_ids',
'employee_parent_id',
'hr_presence_state',
'last_activity',
'last_activity_time',
'can_edit',
'is_hr_user',
'is_system',
'employee_resource_calendar_id',
'work_contact_id',
'bank_account_ids',
]
HR_WRITABLE_FIELDS = [
@ -29,131 +32,85 @@ HR_WRITABLE_FIELDS = [
'private_state_id',
'private_zip',
'private_country_id',
'address_id',
'private_phone',
'private_email',
'barcode',
'birthday',
'category_ids',
'children',
'coach_id',
'country_of_birth',
'department_id',
'display_name',
'emergency_contact',
'emergency_phone',
'employee_bank_account_id',
'employee_country_id',
'gender',
'identification_id',
'is_address_home_a_company',
'employee_bank_account_ids',
'job_title',
'private_email',
'km_home_work',
'marital',
'mobile_phone',
'notes',
'employee_parent_id',
'passport_id',
'permit_no',
'employee_phone',
'pin',
'place_of_birth',
'spouse_birthdate',
'spouse_complete_name',
'visa_expire',
'visa_no',
'work_email',
'work_location_id',
'work_phone',
'certificate',
'study_field',
'study_school',
'private_lang',
'employee_type',
]
class User(models.Model):
_inherit = ['res.users']
class ResUsers(models.Model):
_inherit = 'res.users'
def _employee_ids_domain(self):
# employee_ids is considered a safe field and as such will be fetched as sudo.
# So try to enforce the security rules on the field to make sure we do not load employees outside of active companies
return [('company_id', 'in', self.env.company.ids + self.env.context.get('allowed_company_ids', []))]
# note: a user can only be linked to one employee per company (see sql constraint in ´hr.employee´)
# note: a user can only be linked to one employee per company (see sql constraint in `hr.employee`)
employee_ids = fields.One2many('hr.employee', 'user_id', string='Related employee', domain=_employee_ids_domain)
employee_id = fields.Many2one('hr.employee', string="Company employee",
compute='_compute_company_employee', search='_search_company_employee', store=False)
job_title = fields.Char(related='employee_id.job_title', readonly=False, related_sudo=False)
job_title = fields.Char(related='employee_id.job_title')
work_phone = fields.Char(related='employee_id.work_phone', readonly=False, related_sudo=False)
mobile_phone = fields.Char(related='employee_id.mobile_phone', readonly=False, related_sudo=False)
employee_phone = fields.Char(related='employee_id.phone', readonly=False, related_sudo=False)
work_email = fields.Char(related='employee_id.work_email', readonly=False, related_sudo=False)
category_ids = fields.Many2many(related='employee_id.category_ids', string="Employee Tags", readonly=False, related_sudo=False)
department_id = fields.Many2one(related='employee_id.department_id', readonly=False, related_sudo=False)
address_id = fields.Many2one(related='employee_id.address_id', readonly=False, related_sudo=False)
work_contact_id = fields.Many2one(related='employee_id.work_contact_id', readonly=False, related_sudo=False)
work_location_id = fields.Many2one(related='employee_id.work_location_id', readonly=False, related_sudo=False)
employee_parent_id = fields.Many2one(related='employee_id.parent_id', readonly=False, related_sudo=False)
coach_id = fields.Many2one(related='employee_id.coach_id', readonly=False, related_sudo=False)
address_home_id = fields.Many2one(related='employee_id.address_home_id', readonly=False, related_sudo=False)
private_street = fields.Char(related='address_home_id.street', string="Private Street", readonly=False, related_sudo=False)
private_street2 = fields.Char(related='address_home_id.street2', string="Private Street2", readonly=False, related_sudo=False)
private_city = fields.Char(related='address_home_id.city', string="Private City", readonly=False, related_sudo=False)
work_location_name = fields.Char(related="employee_id.work_location_name")
work_location_type = fields.Selection(related="employee_id.work_location_type")
private_street = fields.Char(related='employee_id.private_street', string="Private Street", readonly=False, related_sudo=False)
private_street2 = fields.Char(related='employee_id.private_street2', string="Private Street2", readonly=False, related_sudo=False)
private_city = fields.Char(related='employee_id.private_city', string="Private City", readonly=False, related_sudo=False)
private_state_id = fields.Many2one(
related='address_home_id.state_id', string="Private State", readonly=False, related_sudo=False,
related='employee_id.private_state_id', string="Private State", readonly=False, related_sudo=False,
domain="[('country_id', '=?', private_country_id)]")
private_zip = fields.Char(related='address_home_id.zip', readonly=False, string="Private Zip", related_sudo=False)
private_country_id = fields.Many2one(related='address_home_id.country_id', string="Private Country", readonly=False, related_sudo=False)
is_address_home_a_company = fields.Boolean(related='employee_id.is_address_home_a_company', readonly=False, related_sudo=False)
private_email = fields.Char(related='address_home_id.email', string="Private Email", readonly=False)
private_lang = fields.Selection(related='address_home_id.lang', string="Employee Lang", readonly=False)
private_zip = fields.Char(related='employee_id.private_zip', readonly=False, string="Private Zip", related_sudo=False)
private_country_id = fields.Many2one(related='employee_id.private_country_id', string="Private Country", readonly=False, related_sudo=False)
private_phone = fields.Char(related='employee_id.private_phone', readonly=False, related_sudo=False)
private_email = fields.Char(related='employee_id.private_email', string="Private Email", readonly=False)
km_home_work = fields.Integer(related='employee_id.km_home_work', readonly=False, related_sudo=False)
# res.users already have a field bank_account_id and country_id from the res.partner inheritance: don't redefine them
employee_bank_account_id = fields.Many2one(related='employee_id.bank_account_id', string="Employee's Bank Account Number", related_sudo=False, readonly=False)
employee_country_id = fields.Many2one(related='employee_id.country_id', string="Employee's Country", readonly=False, related_sudo=False)
identification_id = fields.Char(related='employee_id.identification_id', readonly=False, related_sudo=False)
passport_id = fields.Char(related='employee_id.passport_id', readonly=False, related_sudo=False)
gender = fields.Selection(related='employee_id.gender', readonly=False, related_sudo=False)
birthday = fields.Date(related='employee_id.birthday', readonly=False, related_sudo=False)
place_of_birth = fields.Char(related='employee_id.place_of_birth', readonly=False, related_sudo=False)
country_of_birth = fields.Many2one(related='employee_id.country_of_birth', readonly=False, related_sudo=False)
marital = fields.Selection(related='employee_id.marital', readonly=False, related_sudo=False)
spouse_complete_name = fields.Char(related='employee_id.spouse_complete_name', readonly=False, related_sudo=False)
spouse_birthdate = fields.Date(related='employee_id.spouse_birthdate', readonly=False, related_sudo=False)
children = fields.Integer(related='employee_id.children', readonly=False, related_sudo=False)
# This field no longer appears to be in use. To avoid breaking anything it must only be removed after the freeze of v19.
employee_bank_account_ids = fields.Many2many('res.partner.bank', related='employee_id.bank_account_ids', string="Employee's Bank Accounts", related_sudo=False, readonly=False)
emergency_contact = fields.Char(related='employee_id.emergency_contact', readonly=False, related_sudo=False)
emergency_phone = fields.Char(related='employee_id.emergency_phone', readonly=False, related_sudo=False)
visa_no = fields.Char(related='employee_id.visa_no', readonly=False, related_sudo=False)
permit_no = fields.Char(related='employee_id.permit_no', readonly=False, related_sudo=False)
visa_expire = fields.Date(related='employee_id.visa_expire', readonly=False, related_sudo=False)
additional_note = fields.Text(related='employee_id.additional_note', readonly=False, related_sudo=False)
barcode = fields.Char(related='employee_id.barcode', readonly=False, related_sudo=False)
pin = fields.Char(related='employee_id.pin', readonly=False, related_sudo=False)
certificate = fields.Selection(related='employee_id.certificate', readonly=False, related_sudo=False)
study_field = fields.Char(related='employee_id.study_field', readonly=False, related_sudo=False)
study_school = fields.Char(related='employee_id.study_school', readonly=False, related_sudo=False)
employee_count = fields.Integer(compute='_compute_employee_count')
hr_presence_state = fields.Selection(related='employee_id.hr_presence_state')
last_activity = fields.Date(related='employee_id.last_activity')
last_activity_time = fields.Char(related='employee_id.last_activity_time')
employee_type = fields.Selection(related='employee_id.employee_type', readonly=False, related_sudo=False)
employee_resource_calendar_id = fields.Many2one(related='employee_id.resource_calendar_id', string="Employee's Working Hours", readonly=True)
bank_account_ids = fields.Many2many(related="employee_id.bank_account_ids")
create_employee = fields.Boolean(store=False, default=True, copy=False, string="Technical field, whether to create an employee")
create_employee = fields.Boolean(store=False, default=False, copy=False, string="Technical field, whether to create an employee")
create_employee_id = fields.Many2one('hr.employee', store=False, copy=False, string="Technical field, bind user to this employee on create")
can_edit = fields.Boolean(compute='_compute_can_edit')
is_system = fields.Boolean(compute="_compute_is_system")
is_hr_user = fields.Boolean(compute='_compute_is_hr_user')
@api.depends_context('uid')
def _compute_is_system(self):
self.is_system = self.env.user._is_system()
def _compute_can_edit(self):
can_edit = self.env['ir.config_parameter'].sudo().get_param('hr.hr_employee_self_edit') or self.env.user.has_group('hr.group_hr_user')
def _compute_is_hr_user(self):
is_hr_user = self.env.user.has_group('hr.group_hr_user')
for user in self:
user.can_edit = can_edit
user.is_hr_user = is_hr_user
@api.depends('employee_ids')
def _compute_employee_count(self):
@ -168,17 +125,22 @@ class User(models.Model):
def SELF_WRITEABLE_FIELDS(self):
return super().SELF_WRITEABLE_FIELDS + HR_WRITABLE_FIELDS
@api.onchange("private_state_id")
def _onchange_private_state_id(self):
if self.private_state_id:
self.private_country_id = self.private_state_id.country_id
@api.model
def get_views(self, views, options=None):
# Requests the My Profile form view as last.
# Requests the My Preferences form view as last.
# Otherwise the fields of the 'search' view will take precedence
# and will omit the fields that are requested as SUPERUSER
# in `get_view()`.
profile_view = self.env.ref("hr.res_users_view_form_profile")
profile_form = profile_view and [profile_view.id, 'form']
if profile_form and profile_form in views:
views.remove(profile_form)
views.append(profile_form)
preferences_view = self.env.ref("hr.res_users_view_form_preferences")
preferences_form = preferences_view and [preferences_view.id, 'form']
if preferences_form and preferences_form in views:
views.remove(preferences_form)
views.append(preferences_form)
result = super().get_views(views, options)
return result
@ -190,12 +152,12 @@ class User(models.Model):
# However, in this case, we want the user to be able to read/write its own data,
# even if they are protected by groups.
# We make the front-end aware of those fields by sending all field definitions.
# Note: limit the `sudo` to the only action of "editing own profile" action in order to
# Note: limit the `sudo` to the only action of "editing own preferences" action in order to
# avoid breaking `groups` mecanism on res.users form view.
profile_view = self.env.ref("hr.res_users_view_form_profile")
if profile_view and view_id == profile_view.id:
preferences_view = self.env.ref("hr.res_users_view_form_preferences")
if preferences_view and view_id == preferences_view.id:
self = self.with_user(SUPERUSER_ID)
result = super(User, self).get_view(view_id, view_type, **options)
result = super().get_view(view_id, view_type, **options)
return result
@api.model_create_multi
@ -222,6 +184,14 @@ class User(models.Model):
"""
return ['name', 'email', 'image_1920', 'tz']
def _get_personal_info_partner_ids_to_notify(self, employee):
if employee.version_id.hr_responsible_id:
return (
_("You are receiving this message because you are the HR Responsible of this employee."),
employee.version_id.hr_responsible_id.partner_id.ids,
)
return ('', [])
def write(self, vals):
"""
Synchronize user and its related employee
@ -229,16 +199,34 @@ class User(models.Model):
their own data (otherwise sudo is applied for self data).
"""
hr_fields = {
field
field_name: field
for field_name, field in self._fields.items()
if field.related_field and field.related_field.model_name == 'hr.employee' and field_name in vals
}
can_edit_self = self.env['ir.config_parameter'].sudo().get_param('hr.hr_employee_self_edit') or self.env.user.has_group('hr.group_hr_user')
if hr_fields and not can_edit_self:
# Raise meaningful error message
raise AccessError(_("You are only allowed to update your preferences. Please contact a HR officer to update other information."))
result = super(User, self).write(vals)
employee_domain = [
*self.env['hr.employee']._check_company_domain(self.env.company),
('user_id', 'in', self.ids),
]
if hr_fields:
employees = self.env['hr.employee'].sudo().search(employee_domain)
get_field = self.env['ir.model.fields']._get
field_names = Markup().join([
Markup("<li>%s</li>") % get_field("res.users", fname).field_description for fname in hr_fields
])
for employee in employees:
reason_message, partner_ids = self._get_personal_info_partner_ids_to_notify(employee)
if partner_ids:
employee.message_notify(
body=Markup("<p>%s</p><p>%s</p><ul>%s</ul><p><em>%s</em></p>") % (
_('Personal information update.'),
_("The following fields were modified by %s", employee.name),
field_names,
reason_message,
),
partner_ids=partner_ids,
)
result = super().write(vals)
employee_values = {}
for fname in [f for f in self._get_employee_fields_to_sync() if f in vals]:
@ -248,14 +236,12 @@ class User(models.Model):
if 'email' in employee_values:
employee_values['work_email'] = employee_values.pop('email')
if 'image_1920' in vals:
without_image = self.env['hr.employee'].sudo().search([('user_id', 'in', self.ids), ('image_1920', '=', False)])
with_image = self.env['hr.employee'].sudo().search([('user_id', 'in', self.ids), ('image_1920', '!=', False)])
without_image = self.env['hr.employee'].sudo().search(employee_domain + [('image_1920', '=', False)])
with_image = self.env['hr.employee'].sudo().search(employee_domain + [('image_1920', '!=', False)])
without_image.write(employee_values)
if not can_edit_self:
employee_values.pop('image_1920')
with_image.write(employee_values)
else:
employees = self.env['hr.employee'].sudo().search([('user_id', 'in', self.ids)])
employees = self.env['hr.employee'].sudo().search(employee_domain)
if employees:
employees.write(employee_values)
return result
@ -263,8 +249,17 @@ class User(models.Model):
@api.model
def action_get(self):
if self.env.user.employee_id:
return self.env['ir.actions.act_window']._for_xml_id('hr.res_users_action_my')
return super(User, self).action_get()
action = self.env['ir.actions.act_window']._for_xml_id('hr.res_users_action_my')
groups = {
group_xml_id[0]: True
for group_xml_id in self.env.user.all_group_ids._get_external_ids().values()
if group_xml_id
}
action_context = ast.literal_eval(action['context']) if action['context'] else {}
action_context.update(groups)
action['context'] = str(action_context)
return action
return super().action_get()
@api.depends('employee_ids')
@api.depends_context('company')
@ -277,12 +272,68 @@ class User(models.Model):
user.employee_id = employee_per_user.get(user)
def _search_company_employee(self, operator, value):
return [('employee_ids', operator, value)]
# Equivalent to `[('employee_ids', operator, value)]`,
# but we inline the ids directly to simplify final queries and improve performance,
# as it's part of a few ir.rules.
# If we're going to inject too many `ids`, we fall back on the default behavior
# to avoid a performance regression.
IN_MAX = 10_000
domain = Domain('employee_ids', operator, value)
user_ids = self.env['res.users'].with_context(active_test=False)._search(domain, limit=IN_MAX).get_result_ids()
if len(user_ids) < IN_MAX:
return Domain('id', 'in', user_ids)
return domain
def action_create_employee(self):
self.ensure_one()
if self.env.company not in self.company_ids:
raise AccessError(_("You are not allowed to create an employee because the user does not have access rights for %s", self.env.company.name))
self.env['hr.employee'].create(dict(
name=self.name,
company_id=self.env.company.id,
**self.env['hr.employee']._sync_user(self)
))
def action_open_employees(self):
self.ensure_one()
employees = self.employee_ids
model = 'hr.employee' if self.env.user.has_group('hr.group_hr_user') else 'hr.employee.public'
if len(employees) > 1:
return {
'name': _('Related Employees'),
'type': 'ir.actions.act_window',
'res_model': model,
'view_mode': 'kanban,list,form',
'domain': [('id', 'in', employees.ids)],
}
return {
'name': _('Employee'),
'type': 'ir.actions.act_window',
'res_model': model,
'res_id': employees.id,
'view_mode': 'form',
}
def action_related_contact(self):
return {
'name': _("Related Contact"),
'res_id': self.partner_id.id,
'type': 'ir.actions.act_window',
'res_model': 'res.partner',
'view_mode': 'form',
}
def get_formview_action(self, access_uid=None):
""" Override this method in order to redirect many2one towards the full user form view
incase the user is ERP manager and the request coming from employee form."""
res = super().get_formview_action(access_uid=access_uid)
user = self.env.user
if access_uid:
user = self.env['res.users'].browse(access_uid).sudo()
if self.env.context.get('default_create_employee_id') and user.has_group('base.group_erp_manager'):
res['views'] = [(self.env.ref('base.view_users_form').id, 'form')]
return res

View file

@ -1,14 +1,137 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from pytz import utc, timezone
from datetime import datetime
from odoo import fields, models
from odoo.addons.resource.models.resource import Intervals
from collections import defaultdict
from datetime import datetime
from pytz import timezone
from odoo import api, fields, models
from odoo.tools.intervals import Intervals
class ResourceResource(models.Model):
_inherit = "resource.resource"
user_id = fields.Many2one(copy=False)
employee_id = fields.One2many('hr.employee', 'resource_id', domain="[('company_id', '=', company_id)]")
employee_id = fields.One2many('hr.employee', 'resource_id', check_company=True, context={'active_test': False})
job_title = fields.Char(compute='_compute_job_title', compute_sudo=True)
department_id = fields.Many2one('hr.department', compute='_compute_department_id', compute_sudo=True)
work_location_id = fields.Many2one(related='employee_id.work_location_id')
work_email = fields.Char(related='employee_id.work_email')
work_phone = fields.Char(related='employee_id.work_phone')
show_hr_icon_display = fields.Boolean(related='employee_id.show_hr_icon_display')
hr_icon_display = fields.Selection(related='employee_id.hr_icon_display')
calendar_id = fields.Many2one(inverse='_inverse_calendar_id')
@api.depends('employee_id')
def _compute_job_title(self):
for resource in self:
resource.job_title = resource.employee_id.job_title
@api.depends('employee_id')
def _compute_department_id(self):
for resource in self:
resource.department_id = resource.employee_id.department_id
@api.depends('employee_id')
def _compute_avatar_128(self):
is_hr_user = self.env.user.has_group('hr.group_hr_user')
if not is_hr_user:
public_employees = self.env['hr.employee.public'].with_context(active_test=False).search([
('resource_id', 'in', self.ids),
])
avatar_per_employee_id = {emp.id: emp.avatar_128 for emp in public_employees}
for resource in self:
employee = resource.employee_id
if not employee:
resource.avatar_128 = False
continue
if is_hr_user:
resource.avatar_128 = employee[0].avatar_128
else:
resource.avatar_128 = avatar_per_employee_id[employee[0].id]
def _inverse_calendar_id(self):
for resource in self:
if resource.calendar_id != resource.employee_id.resource_calendar_id:
resource.employee_id.resource_calendar_id = resource.calendar_id
def _get_resource_without_contract(self):
employee_ids_with_active_contracts = {
employee.id for [employee] in
self.env['hr.version']._read_group(
domain=[
('employee_id', 'in', self.employee_id.ids),
('contract_date_start', '!=', False),
],
groupby=['employee_id'],
)
}
return self.filtered(
lambda r: not r.employee_id
or not r.employee_id.id in employee_ids_with_active_contracts
)
def _get_contracts_valid_periods(self, start, end):
res = defaultdict(lambda: defaultdict(Intervals))
timezones = {resource.tz for resource in self}
date_start = min(start.astimezone(timezone(tz)).date() for tz in timezones)
date_end = max(end.astimezone(timezone(tz)).date() for tz in timezones)
contracts = self.employee_id._get_versions_with_contract_overlap_with_period(date_start, date_end)
for contract in contracts:
tz = timezone(contract.employee_id.tz)
res[contract.employee_id.resource_id.id][contract.resource_calendar_id] |= Intervals([(
tz.localize(datetime.combine(contract.contract_date_start, datetime.min.time())) if contract.contract_date_start > start.astimezone(tz).date() else start,
tz.localize(datetime.combine(contract.contract_date_end, datetime.max.time())) if contract.contract_date_end and contract.contract_date_end < end.astimezone(tz).date() else end,
self.env['resource.calendar.attendance']
)])
return res
def _get_calendars_validity_within_period(self, start, end, default_company=None):
assert start.tzinfo and end.tzinfo
if not self:
return super()._get_calendars_validity_within_period(start, end, default_company=default_company)
calendars_within_period_per_resource = defaultdict(lambda: defaultdict(Intervals)) # keys are [resource id:integer][calendar:self.env['resource.calendar']]
# Employees that have ever had an active contract
resource_without_contract = self._get_resource_without_contract()
if resource_without_contract:
calendars_within_period_per_resource.update(
super(ResourceResource, resource_without_contract)._get_calendars_validity_within_period(start, end, default_company=default_company)
)
resource_with_contract = self - resource_without_contract
if not resource_with_contract:
return calendars_within_period_per_resource
calendars_within_period_per_resource.update(resource_with_contract._get_contracts_valid_periods(start, end))
return calendars_within_period_per_resource
def _get_flexible_resources_calendars_validity_within_period(self, start, end):
assert start.tzinfo and end.tzinfo
resource_default_work_intervals = self._get_flexible_resources_default_work_intervals(start, end)
calendars_within_period_per_resource = defaultdict(lambda: defaultdict(Intervals)) # keys are [resource id:integer][calendar:self.env['resource.calendar']]
# Employees that have ever had an active contract
resource_without_contract = self.sudo()._get_resource_without_contract()
for resource in resource_without_contract:
calendar = False if resource._is_fully_flexible() else resource.calendar_id
calendars_within_period_per_resource[resource.id][calendar] = resource_default_work_intervals[resource.id]
resource_with_contract = self - resource_without_contract
if resource_with_contract:
resource_contracts_valid_periods = resource_with_contract.sudo()._get_contracts_valid_periods(start, end)
# r: calendar: Intervals
for resource_id, calendar_intervals in resource_contracts_valid_periods.items():
for calendar_id, intervals in calendar_intervals.items():
calendars_within_period_per_resource[resource_id][calendar_id] = intervals & resource_default_work_intervals[resource_id]
return calendars_within_period_per_resource
def _get_calendar_at(self, date_target, tz=False):
result = super()._get_calendar_at(date_target)
resources_with_employee = self.filtered(lambda r: r.employee_id)
employee_calendars = resources_with_employee.employee_id._get_calendars(date_target.astimezone(tz))
for resource in resources_with_employee:
result[resource] = employee_calendars[resource.employee_id.id]
return result

View file

@ -0,0 +1,24 @@
from odoo import fields, models
from odoo.fields import Domain
class ResourceCalendar(models.Model):
_inherit = 'resource.calendar'
def transfer_leaves_to(self, other_calendar, resources=None, from_date=None):
"""
Transfer some resource.calendar.leaves from 'self' to another calendar 'other_calendar'.
Transfered leaves linked to `resources` (or all if `resources` is None) and starting
after 'from_date' (or today if None).
"""
from_date = from_date or fields.Datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
domain = [
('calendar_id', 'in', self.ids),
('date_from', '>=', from_date),
]
domain = Domain.AND([domain, [('resource_id', 'in', resources.ids)]]) if resources else domain
self.env['resource.calendar.leaves'].search(domain).write({
'calendar_id': other_calendar.id,
})

View file

@ -0,0 +1,33 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from pytz import timezone, utc
from odoo import api, models
class ResourceCalendarLeaves(models.Model):
_inherit = 'resource.calendar.leaves'
@api.depends('date_from')
def _compute_calendar_id(self):
def date2datetime(date, tz):
dt = datetime.fromordinal(date.toordinal())
return tz.localize(dt).astimezone(utc).replace(tzinfo=None)
leaves_by_contract = self.grouped(lambda leave: leave.resource_id.employee_id.version_id)
# set aside leaves without version_id for super
remaining = leaves_by_contract.pop(
self.env['hr.version'],
self.env['resource.calendar.leaves'],
)
for contract, leaves in leaves_by_contract.items():
tz = timezone(contract.resource_calendar_id.tz or 'UTC')
start_dt = date2datetime(contract.date_start, tz)
end_dt = date2datetime(contract.date_end, tz) if contract.date_end else datetime.max
# only modify leaves that fall under the active contract
leaves.filtered(
lambda leave: leave.date_from and start_dt <= leave.date_from < end_dt
).calendar_id = contract.resource_calendar_id
super(ResourceCalendarLeaves, remaining)._compute_calendar_id()