mirror of
https://github.com/bringout/oca-ocb-hr.git
synced 2026-04-26 11:52:05 +02:00
19.0 vanilla
This commit is contained in:
parent
a1137a1456
commit
e1d89e11e3
2789 changed files with 1093187 additions and 605897 deletions
|
|
@ -1,13 +1,14 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import hr_employee
|
||||
from . import hr_employee_public
|
||||
from . import hr_job
|
||||
from . import hr_resume_line
|
||||
from . import hr_resume_line_type
|
||||
from . import hr_skill
|
||||
from . import hr_individual_skill_mixin
|
||||
from . import hr_employee_skill
|
||||
from . import hr_employee_skill_log
|
||||
from . import hr_job_skill
|
||||
from . import hr_skill_level
|
||||
from . import hr_skill_type
|
||||
from . import res_users
|
||||
from . import resource_resource
|
||||
|
|
|
|||
|
|
@ -1,41 +1,209 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# 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 odoo import api, fields, models
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.fields import Domain
|
||||
from odoo.tools import convert
|
||||
|
||||
|
||||
class Employee(models.Model):
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
resume_line_ids = fields.One2many('hr.resume.line', 'employee_id', string="Resume lines")
|
||||
employee_skill_ids = fields.One2many('hr.employee.skill', 'employee_id', string="Skills")
|
||||
skill_ids = fields.Many2many('hr.skill', compute='_compute_skill_ids', store=True)
|
||||
employee_skill_ids = fields.One2many('hr.employee.skill', 'employee_id', string="Skills",
|
||||
domain=[('skill_type_id.active', '=', True)])
|
||||
current_employee_skill_ids = fields.One2many('hr.employee.skill',
|
||||
compute='_compute_current_employee_skill_ids', readonly=False)
|
||||
skill_ids = fields.Many2many('hr.skill', compute='_compute_skill_ids', store=True, groups="hr.group_hr_user")
|
||||
certification_ids = fields.One2many('hr.employee.skill', compute='_compute_certification_ids', readonly=False)
|
||||
display_certification_page = fields.Boolean(compute="_compute_display_certification_page")
|
||||
|
||||
@api.depends('employee_skill_ids')
|
||||
def _compute_current_employee_skill_ids(self):
|
||||
current_employee_skill_by_employee = self.employee_skill_ids.get_current_skills_by_employee()
|
||||
for employee in self:
|
||||
employee.current_employee_skill_ids = current_employee_skill_by_employee[employee.id]
|
||||
|
||||
@api.depends('employee_skill_ids.skill_id')
|
||||
def _compute_skill_ids(self):
|
||||
for employee in self:
|
||||
employee.skill_ids = employee.employee_skill_ids.skill_id
|
||||
|
||||
@api.depends('employee_skill_ids')
|
||||
def _compute_certification_ids(self):
|
||||
for employee in self:
|
||||
employee.certification_ids = employee.employee_skill_ids.filtered('is_certification')
|
||||
|
||||
def _compute_display_certification_page(self):
|
||||
self.display_certification_page = bool(self.env['hr.skill.type'].search_count([('is_certification', '=', True)], limit=1))
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
res = super(Employee, self).create(vals_list)
|
||||
if self.env.context.get('salary_simulation'):
|
||||
return res
|
||||
resume_lines_values = []
|
||||
for employee in res:
|
||||
line_type = self.env.ref('hr_skills.resume_type_experience', raise_if_not_found=False)
|
||||
resume_lines_values.append({
|
||||
'employee_id': employee.id,
|
||||
'name': employee.company_id.name or '',
|
||||
'date_start': employee.create_date.date(),
|
||||
'description': employee.job_title or '',
|
||||
'line_type_id': line_type and line_type.id,
|
||||
})
|
||||
self.env['hr.resume.line'].create(resume_lines_values)
|
||||
return res
|
||||
for vals in vals_list:
|
||||
vals_emp_skill = vals.pop('current_employee_skill_ids', [])\
|
||||
+ vals.pop('certification_ids', []) + vals.get('employee_skill_ids', [])
|
||||
vals['employee_skill_ids'] = self.env['hr.employee.skill']._get_transformed_commands(vals_emp_skill, self)
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if 'department_id' in vals:
|
||||
self.employee_skill_ids._create_logs()
|
||||
return res
|
||||
if 'current_employee_skill_ids' in vals or 'certification_ids' in vals or 'employee_skill_ids' in vals:
|
||||
vals_emp_skill = vals.pop('current_employee_skill_ids', []) + vals.pop('certification_ids', [])\
|
||||
+ vals.get('employee_skill_ids', [])
|
||||
vals['employee_skill_ids'] = self.env['hr.employee.skill']._get_transformed_commands(vals_emp_skill, self)
|
||||
return super().write(vals)
|
||||
|
||||
@api.model
|
||||
def _add_certification_activity_to_employees(self):
|
||||
today = fields.Date.today()
|
||||
three_months_later = today + relativedelta(months=3)
|
||||
return_val = self.env["mail.activity"]
|
||||
|
||||
jobs_with_certification = self.env["hr.job"].search([("job_skill_ids.is_certification", "=", True)])
|
||||
if not jobs_with_certification:
|
||||
return return_val
|
||||
|
||||
job_skill_level_mapping = defaultdict(dict)
|
||||
|
||||
for job in jobs_with_certification:
|
||||
for cert in job.job_skill_ids.filtered(lambda s: s.is_certification):
|
||||
key = (cert.skill_id, cert.skill_level_id)
|
||||
summary = f"{cert.skill_id.name}: {cert.skill_level_id.name}"
|
||||
job_skill_level_mapping[job][key] = summary
|
||||
|
||||
if not job_skill_level_mapping:
|
||||
return return_val
|
||||
|
||||
employee_domain = Domain.AND(
|
||||
[
|
||||
Domain("job_id", "in", jobs_with_certification.ids),
|
||||
Domain.OR(
|
||||
[
|
||||
Domain("user_id", "!=", False),
|
||||
Domain("parent_id.user_id", "!=", False),
|
||||
Domain("job_id.user_id", "!=", False),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
employees = self.env["hr.employee"].search(employee_domain)
|
||||
if not employees:
|
||||
return return_val
|
||||
|
||||
emp_skills = self.env["hr.employee.skill"].search(
|
||||
Domain.AND(
|
||||
[Domain("employee_id", "in", employees.ids), Domain("is_certification", "=", True)],
|
||||
),
|
||||
)
|
||||
|
||||
employee_cert_data = defaultdict(dict)
|
||||
for es in emp_skills:
|
||||
key = (es.skill_id, es.skill_level_id)
|
||||
employee_cert_data[es.employee_id][key] = es.valid_to
|
||||
|
||||
existing_activities = self.env["mail.activity"].search(
|
||||
Domain.AND(
|
||||
[
|
||||
Domain("active", "=", True),
|
||||
Domain("activity_category", "=", "upload_file"),
|
||||
Domain("res_model", "=", "hr.employee"),
|
||||
Domain("res_id", "in", employees.ids),
|
||||
],
|
||||
),
|
||||
)
|
||||
existing_activity_keys = {(act.res_id, act.summary) for act in existing_activities}
|
||||
|
||||
for employee in employees:
|
||||
job_id = employee.job_id
|
||||
responsible = employee.user_id or employee.parent_id.user_id or job_id.user_id
|
||||
if job_id not in job_skill_level_mapping or not responsible:
|
||||
continue
|
||||
|
||||
for skill_level_key, summary in job_skill_level_mapping[job_id].items():
|
||||
if (employee.id, summary) in existing_activity_keys:
|
||||
continue
|
||||
|
||||
valid_to_date = employee_cert_data.get(employee, {}).get(skill_level_key)
|
||||
if valid_to_date is not None and (valid_to_date is False or valid_to_date > three_months_later):
|
||||
continue
|
||||
|
||||
activity = employee.activity_schedule(
|
||||
act_type_xmlid="hr_skills.mail_activity_data_upload_certification",
|
||||
summary=summary,
|
||||
note="Certification missing or expiring soon",
|
||||
date_deadline=valid_to_date or today,
|
||||
user_id=responsible.id,
|
||||
)
|
||||
return_val += activity
|
||||
|
||||
return return_val
|
||||
|
||||
def _load_scenario(self):
|
||||
super()._load_scenario()
|
||||
demo_tag = self.env.ref('hr_skills.employee_resume_line_emp_eg_1', raise_if_not_found=False)
|
||||
if demo_tag:
|
||||
return
|
||||
convert.convert_file(self.env, 'hr', 'data/scenarios/hr_scenario.xml', None, mode='init')
|
||||
convert.convert_file(self.env, 'hr_skills', 'data/scenarios/hr_skills_scenario.xml', None, mode='init')
|
||||
|
||||
@api.model
|
||||
def get_internal_resume_lines(self, res_id, res_model):
|
||||
if not res_id:
|
||||
return []
|
||||
if res_model == 'res.users':
|
||||
res_id = self.env['res.users'].browse(res_id).employee_id.id
|
||||
if not self.env['hr.employee.public'].browse(res_id).has_access('read'):
|
||||
raise AccessError(self.env._("You cannot access the resume of this employee."))
|
||||
res = []
|
||||
employee_versions = self.env['hr.employee'].sudo().browse(res_id).version_ids
|
||||
if not employee_versions:
|
||||
return res
|
||||
interval_date_start = False
|
||||
for i in range(len(employee_versions) - 1):
|
||||
current_version = employee_versions[i]
|
||||
next_version = employee_versions[i + 1]
|
||||
current_date_start = max(current_version.date_version, current_version.contract_date_start or date.min)
|
||||
current_date_end = min(next_version.date_version + relativedelta(days=-1), current_version.contract_date_end or date.max)
|
||||
if not current_version.job_title:
|
||||
if interval_date_start:
|
||||
previous_version = employee_versions[i - 1]
|
||||
res.append({
|
||||
'id': previous_version.id,
|
||||
'job_title': previous_version.job_title,
|
||||
'date_start': interval_date_start,
|
||||
'date_end': current_date_start + relativedelta(days=-1),
|
||||
})
|
||||
interval_date_start = False
|
||||
elif current_version.job_title != next_version.job_title or current_date_end + relativedelta(days=1) != next_version.date_version:
|
||||
res.append({
|
||||
'id': current_version.id,
|
||||
'job_title': current_version.job_title,
|
||||
'date_start': interval_date_start or current_date_start,
|
||||
'date_end': current_date_end,
|
||||
})
|
||||
interval_date_start = False
|
||||
else:
|
||||
interval_date_start = interval_date_start or current_date_start
|
||||
|
||||
last_version = employee_versions[-1]
|
||||
if last_version.job_title:
|
||||
current_date_start = max(last_version.date_version, last_version.contract_date_start or date.min)
|
||||
res.append({
|
||||
'id': last_version.id,
|
||||
'job_title': last_version.job_title,
|
||||
'date_start': interval_date_start or current_date_start,
|
||||
'date_end': last_version.contract_date_end or False,
|
||||
})
|
||||
elif interval_date_start:
|
||||
previous_version = employee_versions[-2]
|
||||
res.append({
|
||||
'id': previous_version.id,
|
||||
'job_title': previous_version.job_title,
|
||||
'date_start': interval_date_start,
|
||||
'date_end': current_date_start + relativedelta(days=-1),
|
||||
})
|
||||
return res[::-1]
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class EmployeePublic(models.Model):
|
||||
class HrEmployeePublic(models.Model):
|
||||
_inherit = 'hr.employee.public'
|
||||
|
||||
resume_line_ids = fields.One2many('hr.resume.line', 'employee_id', string="Resume lines")
|
||||
employee_skill_ids = fields.One2many('hr.employee.skill', 'employee_id', string="Skills")
|
||||
employee_skill_ids = fields.One2many('hr.employee.skill', 'employee_id', string="Skills",
|
||||
domain=[('skill_type_id.active', '=', True)])
|
||||
current_employee_skill_ids = fields.One2many('hr.employee.skill', related='employee_id.current_employee_skill_ids')
|
||||
certification_ids = fields.One2many('hr.employee.skill', related='employee_id.certification_ids')
|
||||
display_certification_page = fields.Boolean(related="employee_id.display_certification_page")
|
||||
|
|
|
|||
|
|
@ -1,97 +1,48 @@
|
|||
# -*- 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
|
||||
|
||||
from collections import defaultdict
|
||||
from odoo import fields, models
|
||||
|
||||
class EmployeeSkill(models.Model):
|
||||
|
||||
class HrEmployeeSkill(models.Model):
|
||||
_name = 'hr.employee.skill'
|
||||
_description = "Skill level for an employee"
|
||||
_rec_name = 'skill_id'
|
||||
_inherit = 'hr.individual.skill.mixin'
|
||||
_description = "Skill level for employee"
|
||||
_order = "skill_type_id, skill_level_id"
|
||||
_rec_name = "skill_id"
|
||||
|
||||
employee_id = fields.Many2one('hr.employee', required=True, ondelete='cascade')
|
||||
skill_id = fields.Many2one('hr.skill', compute='_compute_skill_id', store=True, domain="[('skill_type_id', '=', skill_type_id)]", readonly=False, required=True, ondelete='cascade')
|
||||
skill_level_id = fields.Many2one('hr.skill.level', compute='_compute_skill_level_id', domain="[('skill_type_id', '=', skill_type_id)]", store=True, readonly=False, required=True, ondelete='cascade')
|
||||
skill_type_id = fields.Many2one('hr.skill.type', required=True, ondelete='cascade')
|
||||
level_progress = fields.Integer(related='skill_level_id.level_progress')
|
||||
employee_id = fields.Many2one('hr.employee', required=True, index=True, ondelete='cascade')
|
||||
|
||||
_sql_constraints = [
|
||||
('_unique_skill', 'unique (employee_id, skill_id)', "Two levels for the same skill is not allowed"),
|
||||
]
|
||||
def _linked_field_name(self):
|
||||
return 'employee_id'
|
||||
|
||||
@api.constrains('skill_id', 'skill_type_id')
|
||||
def _check_skill_type(self):
|
||||
for record in self:
|
||||
if record.skill_id not in record.skill_type_id.skill_ids:
|
||||
raise ValidationError(_("The skill %(name)s and skill type %(type)s doesn't match", name=record.skill_id.name, type=record.skill_type_id.name))
|
||||
def get_current_skills_by_employee(self):
|
||||
emp_skill_grouped = dict(self.grouped(lambda emp_skill: (emp_skill.employee_id, emp_skill.skill_id)))
|
||||
result_dict = defaultdict(lambda: self.env['hr.employee.skill'])
|
||||
for (employee, skill), emp_skills in emp_skill_grouped.items():
|
||||
filtered_emp_skill = emp_skills.filtered(
|
||||
lambda employee_skill: not employee_skill.valid_to or employee_skill.valid_to >= fields.Date.today()
|
||||
)
|
||||
if skill.skill_type_id.is_certification and not filtered_emp_skill:
|
||||
expired_skills = (emp_skills - filtered_emp_skill)
|
||||
expired_skills_group_by_valid_to = expired_skills.grouped('valid_to')
|
||||
max_valid_to = max(expired_skills.mapped('valid_to'))
|
||||
result_dict[employee.id] += expired_skills_group_by_valid_to[max_valid_to]
|
||||
continue
|
||||
result_dict[employee.id] += filtered_emp_skill
|
||||
return result_dict
|
||||
|
||||
@api.constrains('skill_type_id', 'skill_level_id')
|
||||
def _check_skill_level(self):
|
||||
for record in self:
|
||||
if record.skill_level_id not in record.skill_type_id.skill_level_ids:
|
||||
raise ValidationError(_("The skill level %(level)s is not valid for skill type: %(type)s", level=record.skill_level_id.name, type=record.skill_type_id.name))
|
||||
def open_hr_employee_skill_modal(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'hr.employee.skill',
|
||||
'res_id': self.id if self else False,
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'show_employee': True,
|
||||
'default_skill_type_id': self.env['hr.skill.type'].search([('is_certification', '=', True)], limit=1).id
|
||||
},
|
||||
'views': [(self.env.ref('hr_skills.employee_skill_view_inherit_certificate_form').id, 'form')],
|
||||
}
|
||||
|
||||
@api.depends('skill_type_id')
|
||||
def _compute_skill_id(self):
|
||||
for record in self:
|
||||
if record.skill_id.skill_type_id != record.skill_type_id:
|
||||
record.skill_id = False
|
||||
|
||||
@api.depends('skill_id')
|
||||
def _compute_skill_level_id(self):
|
||||
for record in self:
|
||||
if not record.skill_id:
|
||||
record.skill_level_id = False
|
||||
else:
|
||||
skill_levels = record.skill_type_id.skill_level_ids
|
||||
record.skill_level_id = skill_levels.filtered('default_level') or skill_levels[0] if skill_levels else False
|
||||
|
||||
def _create_logs(self):
|
||||
today = fields.Date.context_today(self)
|
||||
employee_skills = self.env['hr.employee.skill'].search([
|
||||
('employee_id', 'in', self.employee_id.ids)
|
||||
])
|
||||
employee_skill_logs = self.env['hr.employee.skill.log'].search([
|
||||
('employee_id', 'in', self.employee_id.ids),
|
||||
])
|
||||
|
||||
skills_by_employees = defaultdict(lambda: self.env['hr.employee.skill'])
|
||||
for skill in employee_skills:
|
||||
skills_by_employees[skill.employee_id.id] |= skill
|
||||
|
||||
logs_by_employees = defaultdict(lambda: self.env['hr.employee.skill.log'])
|
||||
for log in employee_skill_logs:
|
||||
logs_by_employees[log.employee_id.id] |= log
|
||||
|
||||
skill_to_create_vals = []
|
||||
for employee in skills_by_employees:
|
||||
employee_logs = logs_by_employees[employee]
|
||||
for employee_skill in skills_by_employees[employee]:
|
||||
existing_log = employee_logs.filtered(lambda l: l.department_id == employee_skill.employee_id.department_id and l.skill_id == employee_skill.skill_id and l.date == today)
|
||||
if existing_log:
|
||||
existing_log.write({'skill_level_id': employee_skill.skill_level_id.id})
|
||||
else:
|
||||
skill_to_create_vals.append({
|
||||
'employee_id': employee_skill.employee_id.id,
|
||||
'skill_id': employee_skill.skill_id.id,
|
||||
'skill_level_id': employee_skill.skill_level_id.id,
|
||||
'department_id': employee_skill.employee_id.department_id.id,
|
||||
'skill_type_id': employee_skill.skill_type_id.id,
|
||||
})
|
||||
|
||||
if skill_to_create_vals:
|
||||
self.env['hr.employee.skill.log'].create(skill_to_create_vals)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
employee_skills = super().create(vals_list)
|
||||
employee_skills._create_logs()
|
||||
return employee_skills
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
self._create_logs()
|
||||
return res
|
||||
def action_save(self):
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class HrEmployeeSkillLog(models.Model):
|
||||
_name = 'hr.employee.skill.log'
|
||||
_description = "Skills History"
|
||||
_rec_name = 'skill_id'
|
||||
_order = "employee_id,date"
|
||||
|
||||
employee_id = fields.Many2one('hr.employee', required=True, ondelete='cascade')
|
||||
department_id = fields.Many2one('hr.department')
|
||||
skill_id = fields.Many2one('hr.skill', compute='_compute_skill_id', store=True, domain="[('skill_type_id', '=', skill_type_id)]", readonly=False, required=True, ondelete='cascade')
|
||||
skill_level_id = fields.Many2one('hr.skill.level', compute='_compute_skill_level_id', domain="[('skill_type_id', '=', skill_type_id)]", store=True, readonly=False, required=True, ondelete='cascade')
|
||||
skill_type_id = fields.Many2one('hr.skill.type', required=True, ondelete='cascade')
|
||||
level_progress = fields.Integer(related='skill_level_id.level_progress', store=True, group_operator="avg")
|
||||
date = fields.Date(default=fields.Date.context_today)
|
||||
|
||||
_sql_constraints = [
|
||||
('_unique_skill_log', 'unique (employee_id, department_id, skill_id, date)', "Two levels for the same skill on the same day is not allowed"),
|
||||
]
|
||||
|
|
@ -0,0 +1,537 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from collections import defaultdict
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.fields import Domain
|
||||
|
||||
|
||||
class HrIndividualSkillMixin(models.AbstractModel):
|
||||
_name = 'hr.individual.skill.mixin'
|
||||
_description = "Skill level"
|
||||
_order = "skill_type_id, skill_level_id"
|
||||
_rec_name = "skill_id"
|
||||
|
||||
def _linked_field_name(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def _get_passive_fields(self):
|
||||
"""
|
||||
Return additional passive fields to be included during (versioned)skill creation.
|
||||
|
||||
Passive fields are preserved/included when new skill versions are created due to changes in any of the
|
||||
core/active fields (linked_field, skill_id, skill_level_id, skill_type_id),but modifying them DOES NOT
|
||||
trigger new (versioned)skill creation.
|
||||
|
||||
Core/Active fields (linked_field, skill_id, skill_level_id, skill_type_id) are automatically preserved
|
||||
and should NOT be included here.
|
||||
|
||||
:return: List of field names to copy to new skills
|
||||
:rtype: list[str]
|
||||
"""
|
||||
return []
|
||||
|
||||
def _can_edit_certification_validity_period(self):
|
||||
# If True, the overlapping constraint on a certification is released:
|
||||
# two certifications are overlapping each other if they have the same skill_level_id, valid_from and valid_to.
|
||||
# This behavior is wanted when the user can change the validity.
|
||||
return True
|
||||
|
||||
def _default_skill_type_id(self):
|
||||
if self.env.context.get('certificate_skill', False):
|
||||
return self.env['hr.skill.type'].search([('is_certification', '=', True)], limit=1)
|
||||
return self.env['hr.skill.type'].search([], limit=1)
|
||||
|
||||
skill_id = fields.Many2one('hr.skill', compute='_compute_skill_id', store=True,
|
||||
domain="[('skill_type_id', '=', skill_type_id)]", readonly=False, required=True, ondelete='cascade')
|
||||
skill_level_id = fields.Many2one('hr.skill.level', compute='_compute_skill_level_id',
|
||||
domain="[('skill_type_id', '=', skill_type_id)]", store=True, readonly=False, required=True, ondelete='cascade')
|
||||
skill_type_id = fields.Many2one('hr.skill.type',
|
||||
default=_default_skill_type_id,
|
||||
required=True, ondelete='cascade')
|
||||
level_progress = fields.Integer(related='skill_level_id.level_progress')
|
||||
color = fields.Integer(related="skill_type_id.color")
|
||||
valid_from = fields.Date(string="Validity Start", default=fields.Date.today())
|
||||
valid_to = fields.Date(string="Validity Stop")
|
||||
levels_count = fields.Integer(related="skill_type_id.levels_count")
|
||||
certification_skill_type_count = fields.Integer(compute="_compute_certification_skill_type_count",
|
||||
export_string_translation=False)
|
||||
is_certification = fields.Boolean(related="skill_type_id.is_certification",
|
||||
export_string_translation=False) # if is_certification change the model will not trigger the constrains
|
||||
display_warning_message = fields.Boolean()
|
||||
|
||||
@api.constrains(lambda self: [
|
||||
'valid_from', 'valid_to', 'skill_id', 'skill_type_id', 'skill_level_id', self._linked_field_name()
|
||||
])
|
||||
def _check_not_overlapping_regular_skill(self):
|
||||
"""
|
||||
The following is the core functionality and difference for the two models
|
||||
Skills:
|
||||
1. There can only be one active skill for each skill_id, f.ex only one level of English allowed.
|
||||
2. Skills should not be deleted, unless they were created within the last 24 hours. Skills should instead
|
||||
be archived to preserve the history of the skills linked to that particular record.
|
||||
3. Skills should not be written to, instead the previous skill should be archived and a new skill with
|
||||
the new values should be created. This is again to preserve the history of skills on the record.
|
||||
Certifications:
|
||||
1. There can be many certifications with the same skill_id and skill_level as long as the valid_from and
|
||||
valid_to fields are different, e.g. "Odoo:Certified 2025-1-1 to 2025-12-31" can exist alongside
|
||||
"Odoo:Certified 2024-6-1 to 2025-5-31".
|
||||
2. Certifications can be deleted at any point.
|
||||
3. Certifications should not be written to, instead the previous certification should be archived and a new
|
||||
certification with the new values should be created.
|
||||
For both models:
|
||||
1. There should not be multiple records with exactly the same values.
|
||||
2. An Active skill/certification is one with the valid_to field either unset or set to a date in the future
|
||||
"""
|
||||
overlapping_dict = self._get_overlapping_individual_skill([{
|
||||
f"{self._linked_field_name()}": skill_ind[self._linked_field_name()].id,
|
||||
"skill_id": skill_ind.skill_id.id,
|
||||
"id": skill_ind.id,
|
||||
"valid_from": skill_ind.valid_from,
|
||||
"valid_to": skill_ind.valid_to,
|
||||
"skill_level_id": skill_ind.skill_level_id.id,
|
||||
"is_certification": skill_ind.is_certification
|
||||
}
|
||||
for skill_ind in self])
|
||||
if overlapping_dict:
|
||||
errors = []
|
||||
for existing_ind_skill, new_ind_skills in overlapping_dict.items():
|
||||
errors.append(
|
||||
f"• {', '.join([str(ind_skill) for ind_skill in new_ind_skills])} conflicts with the existing skill/certification {existing_ind_skill.display_name} from {existing_ind_skill.valid_from} to {existing_ind_skill.valid_to}",
|
||||
)
|
||||
|
||||
error_msg = self.env._(
|
||||
"The following skills can't be created as they overlap or exactly match existing skills:\n%(collisions)s",
|
||||
collisions="\n".join(errors),
|
||||
)
|
||||
raise ValidationError(error_msg)
|
||||
|
||||
def _get_overlapping_individual_skill(self, vals_list):
|
||||
can_edit_certification_validity_period = self._can_edit_certification_validity_period()
|
||||
matching_skill_domain = Domain.FALSE
|
||||
overlapping_dict = defaultdict(list)
|
||||
certification_dict = defaultdict(list)
|
||||
regular_dict = defaultdict(list)
|
||||
for individual_skill_vals in vals_list:
|
||||
ind_domain = Domain.AND([
|
||||
Domain(f"{self._linked_field_name()}.id", "=", individual_skill_vals[self._linked_field_name()]),
|
||||
Domain("skill_id.id", "=", individual_skill_vals['skill_id']),
|
||||
Domain("id", "!=", individual_skill_vals['id']),
|
||||
])
|
||||
|
||||
if can_edit_certification_validity_period and individual_skill_vals['is_certification']:
|
||||
ind_domain = Domain.AND([
|
||||
ind_domain,
|
||||
Domain("skill_level_id.id", "=", individual_skill_vals['skill_level_id']),
|
||||
Domain('valid_from', '=', individual_skill_vals['valid_from']),
|
||||
Domain('valid_to', '=', individual_skill_vals['valid_to']),
|
||||
])
|
||||
key = (
|
||||
individual_skill_vals[self._linked_field_name()],
|
||||
individual_skill_vals['skill_id'],
|
||||
individual_skill_vals['skill_level_id'],
|
||||
fields.Date.from_string(individual_skill_vals['valid_from']),
|
||||
fields.Date.from_string(individual_skill_vals['valid_to']),
|
||||
)
|
||||
certification_dict[key].append(individual_skill_vals)
|
||||
else:
|
||||
ind_domain = Domain.AND([
|
||||
ind_domain,
|
||||
Domain.OR([
|
||||
Domain.AND([
|
||||
Domain('valid_from', '<=', individual_skill_vals['valid_from']),
|
||||
Domain.OR([
|
||||
Domain('valid_to', '=', False),
|
||||
Domain('valid_to', '>=', individual_skill_vals['valid_from']),
|
||||
]),
|
||||
]),
|
||||
Domain.AND([
|
||||
Domain('valid_from', '<=', individual_skill_vals['valid_to']),
|
||||
Domain.OR([
|
||||
Domain('valid_to', '=', False),
|
||||
Domain('valid_to', '>=', individual_skill_vals['valid_to']),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
])
|
||||
|
||||
key = (
|
||||
individual_skill_vals[self._linked_field_name()],
|
||||
individual_skill_vals['skill_id'],
|
||||
)
|
||||
regular_dict[key].append(individual_skill_vals)
|
||||
|
||||
matching_skill_domain = Domain.OR([matching_skill_domain, ind_domain])
|
||||
matching_individual_skills = self.env[self._name].search(matching_skill_domain)
|
||||
for matching_ind_skill in matching_individual_skills:
|
||||
if can_edit_certification_validity_period and matching_ind_skill.is_certification:
|
||||
similar_certifications = certification_dict.get((
|
||||
matching_ind_skill[self._linked_field_name()].id,
|
||||
matching_ind_skill.skill_id.id,
|
||||
matching_ind_skill.skill_level_id.id,
|
||||
fields.Date.from_string(matching_ind_skill.valid_from),
|
||||
fields.Date.from_string(matching_ind_skill.valid_to),
|
||||
))
|
||||
if similar_certifications:
|
||||
overlapping_dict[matching_ind_skill].extend(similar_certifications)
|
||||
else:
|
||||
similar_regular_skills = regular_dict.get((
|
||||
matching_ind_skill[self._linked_field_name()].id,
|
||||
matching_ind_skill.skill_id.id,
|
||||
), [])
|
||||
for similar_regular_skill in similar_regular_skills:
|
||||
if (matching_ind_skill.valid_from <= similar_regular_skill['valid_from'] and
|
||||
(not matching_ind_skill.valid_to or
|
||||
matching_ind_skill.valid_to >= similar_regular_skill['valid_from']
|
||||
)) or (matching_ind_skill.valid_from <= similar_regular_skill['valid_to'] and
|
||||
(not matching_ind_skill.valid_to or
|
||||
matching_ind_skill.valid_to >= similar_regular_skill['valid_to']
|
||||
)):
|
||||
overlapping_dict[matching_ind_skill].append(similar_regular_skill)
|
||||
return overlapping_dict
|
||||
|
||||
@api.constrains('valid_from', 'valid_to')
|
||||
def _check_date(self):
|
||||
error_ind_skill_msg = ""
|
||||
for ind_skill in self:
|
||||
if ind_skill.valid_to and ind_skill.valid_from > ind_skill.valid_to:
|
||||
error_ind_skill_msg += self.env._("• %(skill_name)s from %(valid_from)s to %(valid_to)s",
|
||||
skill_name=ind_skill.display_name, valid_from=ind_skill.valid_from, valid_to=ind_skill.valid_to
|
||||
)
|
||||
if error_ind_skill_msg:
|
||||
raise ValidationError(self.env._("The following skills have their valid stop date prior to their valid start date:\n") + error_ind_skill_msg)
|
||||
|
||||
@api.constrains('skill_id', 'skill_type_id')
|
||||
def _check_skill_type(self):
|
||||
for record in self:
|
||||
if record.skill_id not in record.skill_type_id.skill_ids:
|
||||
raise ValidationError(self.env._("The skill %(name)s and skill type %(type)s don't match",
|
||||
name=record.skill_id.name, type=record.skill_type_id.name))
|
||||
|
||||
@api.constrains('skill_type_id', 'skill_level_id')
|
||||
def _check_skill_level(self):
|
||||
for record in self:
|
||||
if record.skill_level_id not in record.skill_type_id.skill_level_ids:
|
||||
raise ValidationError(self.env._("The skill level %(level)s is not valid for skill type: %(type)s",
|
||||
level=record.skill_level_id.name, type=record.skill_type_id.name))
|
||||
|
||||
def _compute_certification_skill_type_count(self):
|
||||
certification_skill_type_count = self.env['hr.skill.type'].search_count(domain=[('is_certification', '=', True)])
|
||||
self.certification_skill_type_count = certification_skill_type_count
|
||||
|
||||
# To reset the validity period if the skill become certified or uncertified
|
||||
@api.onchange('is_certification')
|
||||
def _onchange_is_certification(self):
|
||||
self.valid_from = fields.Date.today()
|
||||
if not self.is_certification:
|
||||
self.valid_to = False
|
||||
|
||||
@api.depends('skill_type_id')
|
||||
def _compute_skill_id(self):
|
||||
for record in self:
|
||||
if record.skill_type_id:
|
||||
record.skill_id = record.skill_type_id.skill_ids[0] if record.skill_type_id.skill_ids else False
|
||||
else:
|
||||
record.skill_id = False
|
||||
|
||||
@api.depends('skill_id')
|
||||
def _compute_skill_level_id(self):
|
||||
for record in self:
|
||||
if not record.skill_id:
|
||||
record.skill_level_id = False
|
||||
else:
|
||||
skill_levels = record.skill_type_id.skill_level_ids
|
||||
record.skill_level_id = skill_levels.filtered('default_level') or skill_levels[0] if skill_levels else False
|
||||
|
||||
@api.depends('skill_id', 'skill_level_id')
|
||||
def _compute_display_name(self):
|
||||
for individual_skill in self:
|
||||
individual_skill.display_name = f"{individual_skill.skill_id.name}: {individual_skill.skill_level_id.name}"
|
||||
|
||||
@api.onchange('valid_to', 'valid_from')
|
||||
def _onchange_valid_date(self):
|
||||
self.display_warning_message = self.valid_to and self.valid_from and self.valid_to < self.valid_from
|
||||
|
||||
def _expire_individual_skills(self):
|
||||
"""
|
||||
This function archive all individual skill in self.
|
||||
If the individual skill is not expired (valid_to < today) then valid_to will be set to yesterday if
|
||||
it's possible (not break a constraint)
|
||||
Else the individual skill is delete
|
||||
|
||||
Example:
|
||||
An individual already have the skill English A2 (added one month ago) and we want to delete it
|
||||
output: [[1, id('English A2'), {'valid_to': yesterday}]]
|
||||
@return {List[COMMANDS]} List of WRITE, UNLINK commands
|
||||
"""
|
||||
yesterday = fields.Date.today() - relativedelta(days=1)
|
||||
to_remove = self.env[self._name]
|
||||
to_archive = self.env[self._name]
|
||||
for individual_skill in self:
|
||||
if individual_skill.valid_from >= yesterday or (individual_skill.valid_to and individual_skill.valid_to <= yesterday):
|
||||
to_remove += individual_skill
|
||||
else:
|
||||
to_archive += individual_skill
|
||||
if to_archive:
|
||||
overlapping_dict = self._get_overlapping_individual_skill([{
|
||||
f"{self._linked_field_name()}": skill[self._linked_field_name()].id,
|
||||
"skill_id": skill.skill_id.id,
|
||||
"id": skill.id,
|
||||
"valid_from": skill.valid_from,
|
||||
"valid_to": yesterday,
|
||||
"skill_level_id": skill.skill_level_id.id,
|
||||
"is_certification": skill.is_certification
|
||||
} for skill in to_archive])
|
||||
new_overlapped_skill_ids = []
|
||||
for new_skills in overlapping_dict.values():
|
||||
for new_skill in new_skills:
|
||||
new_overlapped_skill_ids.append(new_skill['id'])
|
||||
changed_to_remove = to_archive.filtered(lambda ind_skill: ind_skill.id in new_overlapped_skill_ids)
|
||||
to_archive -= changed_to_remove
|
||||
to_remove += changed_to_remove
|
||||
return [[2, skill.id] for skill in to_remove] + [[1, skill.id, {'valid_to': yesterday}] for skill in to_archive]
|
||||
|
||||
def _create_individual_skills(self, vals_list):
|
||||
"""
|
||||
This function transform CREATE commands into CREATE, WRITE and UNLINK commands in order to keep the
|
||||
logs and to follow the constraints
|
||||
|
||||
Example:
|
||||
An individual already have the skill English A2 (added one month ago) and we want to add the skill English B1
|
||||
This method will transform:
|
||||
{linked_field: id, skill_id: id('English'), skill_level_id: id('B1') skill_type_id: id('Languages')}
|
||||
into
|
||||
[
|
||||
[1, id('English A2'), {'valid_to': yesterday}],
|
||||
[0, 0, {
|
||||
linked_field: id,
|
||||
skill_id: id('English'),
|
||||
skill_level_id: id('B1'),
|
||||
skill_type_id: id('Languages')}
|
||||
]
|
||||
]
|
||||
@param {List[vals]} vals_list: list of right leaf of CREATE commands
|
||||
@return {List[COMMANDS]} List of CREATE, WRITE, UNLINK commands
|
||||
"""
|
||||
can_edit_certification_validity_period = self._can_edit_certification_validity_period()
|
||||
seen_skills = set()
|
||||
skills_to_archive = self.env[self._name]
|
||||
vals_to_return = []
|
||||
|
||||
validity_domain = Domain.OR(
|
||||
[
|
||||
Domain("valid_to", "=", False),
|
||||
Domain("valid_to", ">=", fields.Date.today()),
|
||||
]
|
||||
)
|
||||
|
||||
if can_edit_certification_validity_period:
|
||||
validity_domain = Domain.OR([
|
||||
validity_domain,
|
||||
Domain("is_certification", "=", True),
|
||||
])
|
||||
|
||||
existing_skills_domain = Domain.AND(
|
||||
[
|
||||
Domain.OR(
|
||||
[
|
||||
Domain.AND(
|
||||
[
|
||||
Domain(f"{self._linked_field_name()}", "=", vals.get(self._linked_field_name(), False)),
|
||||
Domain("skill_id", "=", vals.get("skill_id", False)),
|
||||
]
|
||||
)
|
||||
for vals in vals_list
|
||||
]
|
||||
),
|
||||
validity_domain
|
||||
]
|
||||
)
|
||||
|
||||
existing_skills = self.env[self._name].search(existing_skills_domain)
|
||||
existing_skills_grouped = existing_skills.grouped(
|
||||
lambda skill: (skill[self._linked_field_name()].id, skill.skill_id.id)
|
||||
)
|
||||
|
||||
if can_edit_certification_validity_period:
|
||||
existing_certifications = existing_skills.filtered(lambda s: s.is_certification)
|
||||
certification_set = {}
|
||||
for cert in existing_certifications:
|
||||
key = (
|
||||
cert[self._linked_field_name()].id,
|
||||
cert.skill_id.id,
|
||||
cert.skill_level_id.id,
|
||||
fields.Date.from_string(cert.valid_from),
|
||||
fields.Date.from_string(cert.valid_to),
|
||||
)
|
||||
certification_set[key] = cert
|
||||
|
||||
certification_types = set(
|
||||
self.env["hr.skill.type"]
|
||||
.browse([vals["skill_type_id"] for vals in vals_list])
|
||||
.filtered("is_certification")
|
||||
.ids
|
||||
)
|
||||
for vals in vals_list:
|
||||
individual_skill_id = vals.get(self._linked_field_name(), False)
|
||||
skill_id = vals["skill_id"]
|
||||
skill_type_id = vals["skill_type_id"]
|
||||
skill_level_id = vals["skill_level_id"]
|
||||
valid_from = fields.Date.from_string(vals.get("valid_from"))
|
||||
valid_to = fields.Date.from_string(vals.get("valid_to"))
|
||||
|
||||
if can_edit_certification_validity_period:
|
||||
is_certificate = skill_type_id in certification_types
|
||||
else:
|
||||
is_certificate = False
|
||||
|
||||
skill_key = (individual_skill_id, skill_id, valid_from, valid_to)
|
||||
|
||||
# Remove duplicate skills
|
||||
if skill_key in seen_skills:
|
||||
continue
|
||||
seen_skills.add(skill_key)
|
||||
|
||||
if is_certificate:
|
||||
key = (
|
||||
individual_skill_id,
|
||||
skill_id,
|
||||
skill_level_id,
|
||||
valid_from,
|
||||
valid_to,
|
||||
)
|
||||
# Remove duplicate certification
|
||||
if certification_set.get(key):
|
||||
continue
|
||||
else:
|
||||
# Archive existing regular skill if the person already have one with the same skill
|
||||
if existing_skill := existing_skills_grouped.get((individual_skill_id, skill_id)):
|
||||
skills_to_archive += existing_skill
|
||||
|
||||
vals_to_return.append(vals)
|
||||
|
||||
return skills_to_archive._expire_individual_skills() + [[0, 0, new_create_val] for new_create_val in vals_to_return]
|
||||
|
||||
def _write_individual_skills(self, commands):
|
||||
"""
|
||||
Transform a list of write commands into a list of create, write and unlink commands according to the logic of
|
||||
how skills should behave. The relevant logic is as follows:
|
||||
|
||||
* If "skill_type_id", "skill_id", "skill_level_id", self._linked_field_name() are not in vals, this method will
|
||||
behave like any standard write method.
|
||||
* Otherwise, the current record is archived, by changing valid_to to yesterday, and a new one is created with
|
||||
values from vals and self, with vals taking priority.
|
||||
|
||||
|
||||
:param commands: list of WRITE commands
|
||||
:return: List of CREATE, WRITE, UNLINK commands
|
||||
"""
|
||||
self_dict = self.grouped('id')
|
||||
result_command = []
|
||||
create_vals = []
|
||||
remove_from_expire = self.env[self._name]
|
||||
|
||||
def _get_passive_field_value(field, skill):
|
||||
"""
|
||||
Extracts the appropriate value from a field to be passed into a vals dict for record creation/writing.
|
||||
Returns the raw value for most fields but extracts id(s) for relational fields.
|
||||
|
||||
:param field: Field name as a string to process
|
||||
:param skill: Source record to extract value from
|
||||
:return: ORM-ready value for the field
|
||||
"""
|
||||
field_type = self._fields[field].type
|
||||
if field_type == "many2one":
|
||||
return skill[field].id
|
||||
if field_type == "many2many" or field_type == "one2many":
|
||||
return skill[field].ids
|
||||
return skill[field]
|
||||
|
||||
for command in commands:
|
||||
ind_skill = self_dict.get(command[1])
|
||||
vals = command[2]
|
||||
if not any(key in vals for key in ["skill_type_id", "skill_id", "skill_level_id", self._linked_field_name()]):
|
||||
result_command.append([1, ind_skill.id, vals])
|
||||
remove_from_expire += ind_skill
|
||||
continue
|
||||
|
||||
passive_vals = {
|
||||
field: vals.get(field, _get_passive_field_value(field, ind_skill))
|
||||
for field in self._get_passive_fields()
|
||||
}
|
||||
new_vals = {
|
||||
f'{self._linked_field_name()}': vals.get(self._linked_field_name(), ind_skill[self._linked_field_name()].id),
|
||||
'skill_id': vals.get('skill_id', ind_skill.skill_id.id),
|
||||
'skill_level_id': vals.get('skill_level_id', ind_skill.skill_level_id.id),
|
||||
'skill_type_id': vals.get('skill_type_id', ind_skill.skill_type_id.id),
|
||||
**passive_vals,
|
||||
}
|
||||
skill_type = self.env['hr.skill.type'].browse(new_vals['skill_type_id'])
|
||||
valid_from = vals.get('valid_from', ind_skill.valid_from if skill_type.is_certification else fields.Date.today())
|
||||
valid_to = vals.get('valid_to', ind_skill.valid_to if skill_type.is_certification else False)
|
||||
new_vals.update({
|
||||
'valid_from': valid_from,
|
||||
'valid_to': valid_to,
|
||||
})
|
||||
create_vals.append(new_vals)
|
||||
return result_command + (self - remove_from_expire)._expire_individual_skills() + self.env[self._name]._create_individual_skills(create_vals)
|
||||
|
||||
def _get_transformed_commands(self, commands, individuals):
|
||||
"""
|
||||
Transform a list of ORM commands to fit with the business constraints and preserve the logic of how skills and
|
||||
certifications should behave. The key behaviors are as follows:
|
||||
|
||||
Skills:
|
||||
1. Only one active skill per `skill_id` is allowed (e.g., one "English" skill per linked_field record).
|
||||
|
||||
Certifications (`is_certification=True`):
|
||||
1. Multiple certifications with the same `skill_id` and `level_id` are allowed if their date ranges differ (e.g.,
|
||||
"Odoo Certified (2024-01-01 → 2024-12-31)" and "Odoo Certified (2024-06-01 → 2025-05-31)" can coexist.)
|
||||
|
||||
Shared Rules:
|
||||
- Updates always create new records (archiving old ones) rather than in-place writes.
|
||||
- No two records can have all their fields identical.
|
||||
- A skill/certification is active if `valid_to` is unset or in the future.
|
||||
- A skill/certification that is not active is considered archived.
|
||||
- A skill/certification is only deleted if valid_from is from the past 24 hours or it is expired.
|
||||
|
||||
:param commands: list of CREATE, WRITE, and UNLINK commands
|
||||
:param individuals: a recordset of linked_field's model
|
||||
:return: List of CREATE, WRITE, and UNLINK commands
|
||||
"""
|
||||
if not commands:
|
||||
return
|
||||
updated_ids = set()
|
||||
updated_commands = []
|
||||
created_values = []
|
||||
unlinked_ids = set()
|
||||
for command in commands:
|
||||
if command[0] == 1:
|
||||
updated_ids.add(command[1])
|
||||
updated_commands.append(command)
|
||||
elif command[0] == 2:
|
||||
unlinked_ids.add(command[1])
|
||||
elif command[0] == 0:
|
||||
if individuals:
|
||||
for individual in individuals:
|
||||
individual_command = command[2]
|
||||
individual_command[self._linked_field_name()] = individual.id
|
||||
created_values.append(individual_command)
|
||||
else:
|
||||
created_values.append(command[2])
|
||||
mixed_command_ids = list(updated_ids & unlinked_ids)
|
||||
if mixed_command_ids:
|
||||
# reset updated values
|
||||
updated_ids = set()
|
||||
updated_commands = []
|
||||
for command in commands:
|
||||
if command[1] not in mixed_command_ids and command[0] == 1:
|
||||
updated_commands.append(command)
|
||||
updated_ids.append(command[1])
|
||||
# Process individual_skill_ids values
|
||||
unlinked_commands = self.env[self._name].browse(list(unlinked_ids))._expire_individual_skills()
|
||||
updated_commands = self.env[self._name].browse(list(updated_ids))._write_individual_skills(updated_commands)
|
||||
created_commands = self.env[self._name]._create_individual_skills(created_values)
|
||||
return unlinked_commands + updated_commands + created_commands
|
||||
66
odoo-bringout-oca-ocb-hr_skills/hr_skills/models/hr_job.py
Normal file
66
odoo-bringout-oca-ocb-hr_skills/hr_skills/models/hr_job.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from odoo import api, fields, models
|
||||
from odoo.fields import Domain
|
||||
|
||||
|
||||
class HrJob(models.Model):
|
||||
_inherit = "hr.job"
|
||||
|
||||
job_skill_ids = fields.One2many(
|
||||
comodel_name="hr.job.skill",
|
||||
inverse_name="job_id",
|
||||
string="Skills",
|
||||
domain=[("skill_type_id.active", "=", True)],
|
||||
)
|
||||
current_job_skill_ids = fields.One2many(
|
||||
comodel_name="hr.job.skill",
|
||||
compute="_compute_current_job_skill_ids",
|
||||
search="_search_current_job_skill_ids",
|
||||
readonly=False,
|
||||
)
|
||||
skill_ids = fields.Many2many(
|
||||
comodel_name="hr.skill",
|
||||
compute="_compute_skill_ids",
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends("job_skill_ids")
|
||||
def _compute_current_job_skill_ids(self):
|
||||
for job in self:
|
||||
job.current_job_skill_ids = job.job_skill_ids.filtered(
|
||||
lambda skill: not skill.valid_to or skill.valid_to >= fields.Date.today()
|
||||
)
|
||||
|
||||
def _search_current_job_skill_ids(self, operator, value):
|
||||
if operator not in ('in', 'not in', 'any'):
|
||||
raise NotImplementedError()
|
||||
job_skill_ids = []
|
||||
domain = Domain.OR([
|
||||
Domain('valid_to', '=', False),
|
||||
Domain('valid_to', '>=', fields.Date.today()),
|
||||
])
|
||||
if operator == 'any' and isinstance(value, Domain):
|
||||
domain = Domain.AND([domain, value])
|
||||
|
||||
elif operator in ('in', 'not in'):
|
||||
domain = Domain.AND([domain, Domain('id', 'in', value)])
|
||||
|
||||
job_skill_ids = self.env['hr.job.skill']._search(domain)
|
||||
return Domain('job_skill_ids', 'in', job_skill_ids)
|
||||
|
||||
@api.depends("job_skill_ids.skill_id")
|
||||
def _compute_skill_ids(self):
|
||||
for job in self:
|
||||
job.skill_ids = job.job_skill_ids.skill_id
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
vals_job_skill = vals.pop("current_job_skill_ids", []) + vals.get("job_skill_ids", [])
|
||||
vals["job_skill_ids"] = self.env["hr.job.skill"]._get_transformed_commands(vals_job_skill, self)
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
if "current_job_skill_ids" in vals or "job_skill_ids" in vals:
|
||||
vals_job_skill = vals.pop("current_job_skill_ids", []) + vals.get("job_skill_ids", [])
|
||||
vals["job_skill_ids"] = self.env["hr.job.skill"]._get_transformed_commands(vals_job_skill, self)
|
||||
return super().write(vals)
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class HrJobSkill(models.Model):
|
||||
_name = "hr.job.skill"
|
||||
_inherit = "hr.individual.skill.mixin"
|
||||
_description = "Skills for job positions"
|
||||
_order = "skill_type_id, skill_level_id desc"
|
||||
_rec_name = "skill_id"
|
||||
|
||||
job_id = fields.Many2one(
|
||||
comodel_name="hr.job",
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
|
||||
def _linked_field_name(self):
|
||||
return "job_id"
|
||||
|
||||
def _can_edit_certification_validity_period(self):
|
||||
return False
|
||||
|
|
@ -1,24 +1,61 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
import re
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResumeLine(models.Model):
|
||||
class HrResumeLine(models.Model):
|
||||
_name = 'hr.resume.line'
|
||||
_description = "Resume line of an employee"
|
||||
_order = "line_type_id, date_end desc, date_start desc"
|
||||
|
||||
employee_id = fields.Many2one('hr.employee', required=True, ondelete='cascade')
|
||||
name = fields.Char(required=True)
|
||||
date_start = fields.Date(required=True)
|
||||
employee_id = fields.Many2one('hr.employee', string="Employee", required=True, ondelete='cascade', index=True)
|
||||
avatar_128 = fields.Image(related='employee_id.avatar_128')
|
||||
company_id = fields.Many2one(related='employee_id.company_id')
|
||||
department_id = fields.Many2one(related='employee_id.department_id')
|
||||
name = fields.Char(required=True, translate=True)
|
||||
date_start = fields.Date(required=True, default=fields.Date.context_today)
|
||||
date_end = fields.Date()
|
||||
description = fields.Text(string="Description")
|
||||
duration = fields.Integer(string="Duration")
|
||||
description = fields.Html(string="Description", translate=True)
|
||||
line_type_id = fields.Many2one('hr.resume.line.type', string="Type")
|
||||
is_course = fields.Boolean(related='line_type_id.is_course')
|
||||
course_type = fields.Selection(
|
||||
string="Course Type",
|
||||
selection=[('external', 'External')],
|
||||
default='external',
|
||||
required=True
|
||||
)
|
||||
color = fields.Char(compute='_compute_color', default='#000000')
|
||||
external_url = fields.Char(string="External URL", compute='_compute_external_url', store=True, readonly=False)
|
||||
certificate_filename = fields.Char()
|
||||
certificate_file = fields.Binary(string="Certificate")
|
||||
resume_line_properties = fields.Properties(
|
||||
'Properties',
|
||||
definition='line_type_id.resume_line_type_properties_definition'
|
||||
)
|
||||
|
||||
# Used to apply specific template on a line
|
||||
display_type = fields.Selection([('classic', 'Classic')], string="Display Type", default='classic')
|
||||
_date_check = models.Constraint(
|
||||
'CHECK ((date_start <= date_end OR date_end IS NULL))',
|
||||
'The start date must be anterior to the end date.',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('date_check', "CHECK ((date_start <= date_end OR date_end IS NULL))", "The start date must be anterior to the end date."),
|
||||
]
|
||||
@api.onchange('external_url')
|
||||
def _onchange_external_url(self):
|
||||
if not self.name and self.external_url:
|
||||
website_name_match = re.search(r'((https|http):\/\/)?(www\.)?(.*)\.', self.external_url)
|
||||
if website_name_match:
|
||||
self.name = website_name_match.group(4).capitalize()
|
||||
|
||||
@api.depends('course_type')
|
||||
def _compute_external_url(self):
|
||||
for resume_line in self:
|
||||
if resume_line.course_type != 'external':
|
||||
resume_line.external_url = ''
|
||||
|
||||
@api.depends('course_type')
|
||||
def _compute_color(self):
|
||||
for resume_line in self:
|
||||
if resume_line.course_type == 'external':
|
||||
resume_line.color = '#a2a2a2'
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@
|
|||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResumeLineType(models.Model):
|
||||
class HrResumeLineType(models.Model):
|
||||
_name = 'hr.resume.line.type'
|
||||
_description = "Type of a resume line"
|
||||
_order = "sequence"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
name = fields.Char(required=True, translate=True)
|
||||
sequence = fields.Integer('Sequence', default=10)
|
||||
is_course = fields.Boolean('Course', default=False)
|
||||
resume_line_type_properties_definition = fields.PropertiesDefinition('Sections Properties')
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
# -*- 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 Skill(models.Model):
|
||||
class HrSkill(models.Model):
|
||||
_name = 'hr.skill'
|
||||
_description = "Skill"
|
||||
_order = "sequence, name"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
name = fields.Char(required=True, translate=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
skill_type_id = fields.Many2one('hr.skill.type', required=True, ondelete='cascade')
|
||||
skill_type_id = fields.Many2one('hr.skill.type', required=True, index=True, ondelete='cascade')
|
||||
color = fields.Integer(related='skill_type_id.color')
|
||||
|
||||
def name_get(self):
|
||||
if not self._context.get('from_skill_dropdown'):
|
||||
return super().name_get()
|
||||
return [(record.id, f"{record.name} ({record.skill_type_id.name})") for record in self]
|
||||
@api.depends('skill_type_id')
|
||||
@api.depends_context('from_skill_dropdown')
|
||||
def _compute_display_name(self):
|
||||
if not self.env.context.get('from_skill_dropdown'):
|
||||
return super()._compute_display_name()
|
||||
for record in self:
|
||||
record.display_name = f"{record.name} ({record.skill_type_id.name})"
|
||||
|
|
|
|||
|
|
@ -2,52 +2,44 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class SkillLevel(models.Model):
|
||||
class HrSkillLevel(models.Model):
|
||||
_name = 'hr.skill.level'
|
||||
_description = "Skill Level"
|
||||
_order = "level_progress desc"
|
||||
_order = "level_progress"
|
||||
|
||||
skill_type_id = fields.Many2one('hr.skill.type', ondelete='cascade')
|
||||
skill_type_id = fields.Many2one('hr.skill.type', index='btree_not_null', ondelete='cascade')
|
||||
name = fields.Char(required=True)
|
||||
level_progress = fields.Integer(string="Progress", help="Progress from zero knowledge (0%) to fully mastered (100%).")
|
||||
default_level = fields.Boolean(help="If checked, this level will be the default one selected when choosing this skill.")
|
||||
|
||||
_sql_constraints = [
|
||||
('check_level_progress', 'CHECK(level_progress BETWEEN 0 AND 100)', "Progress should be a number between 0 and 100."),
|
||||
]
|
||||
# This field is a technical field, created to be set exclusively by the front-end; it's why this computed field is
|
||||
# not stored and not readonly.
|
||||
# With this field, it's possible to know in onchange defined in the model hr_skill_type which
|
||||
# level became the new default_level.
|
||||
technical_is_new_default = fields.Boolean(compute="_compute_technical_is_new_default", readonly=False)
|
||||
|
||||
def name_get(self):
|
||||
if not self._context.get('from_skill_level_dropdown'):
|
||||
return super().name_get()
|
||||
return [(record.id, f"{record.name} ({record.level_progress}%)") for record in self]
|
||||
_check_level_progress = models.Constraint(
|
||||
'CHECK(level_progress BETWEEN 0 AND 100)',
|
||||
'Progress should be a number between 0 and 100.',
|
||||
)
|
||||
|
||||
# This compute is never trigger by a depends in purpose. The front-end will change this value when the
|
||||
# default_level will become true.
|
||||
def _compute_technical_is_new_default(self):
|
||||
self.technical_is_new_default = False
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
levels = super().create(vals_list)
|
||||
levels.skill_type_id._set_default_level()
|
||||
return levels
|
||||
skill_levels = super().create(vals_list)
|
||||
for level in skill_levels:
|
||||
if level.default_level:
|
||||
level.skill_type_id.skill_level_ids.filtered(lambda r: r.id != level.id).default_level = False
|
||||
return skill_levels
|
||||
|
||||
def write(self, values):
|
||||
levels = super().write(values)
|
||||
self.skill_type_id._set_default_level()
|
||||
return levels
|
||||
|
||||
def unlink(self):
|
||||
skill_types = self.skill_type_id
|
||||
res = super().unlink()
|
||||
skill_types._set_default_level()
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if vals.get('default_level'):
|
||||
self.skill_type_id.skill_level_ids.filtered(lambda r: r.id != self.id).default_level = False
|
||||
return res
|
||||
|
||||
@api.constrains('default_level', 'skill_type_id')
|
||||
def _constrains_default_level(self):
|
||||
for skill_type in set(self.mapped('skill_type_id')):
|
||||
if len(skill_type.skill_level_ids.filtered('default_level')) > 1:
|
||||
raise ValidationError(_('Only one default level is allowed per skill type.'))
|
||||
|
||||
def action_set_default(self):
|
||||
self.ensure_one()
|
||||
self.skill_type_id.skill_level_ids.with_context(no_skill_level_check=True).default_level = False
|
||||
self.default_level = True
|
||||
|
|
|
|||
|
|
@ -1,22 +1,73 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
from random import randint
|
||||
|
||||
from odoo import _, api, fields, models, Command
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class SkillType(models.Model):
|
||||
class HrSkillType(models.Model):
|
||||
_name = 'hr.skill.type'
|
||||
_description = "Skill Type"
|
||||
_order = "name"
|
||||
_order = "sequence, name"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
def _get_default_color(self):
|
||||
return randint(1, 11)
|
||||
|
||||
active = fields.Boolean('Active', default=True)
|
||||
sequence = fields.Integer("Sequence")
|
||||
name = fields.Char(required=True, translate=True)
|
||||
skill_ids = fields.One2many('hr.skill', 'skill_type_id', string="Skills")
|
||||
skill_level_ids = fields.One2many('hr.skill.level', 'skill_type_id', string="Levels")
|
||||
skill_level_ids = fields.One2many('hr.skill.level', 'skill_type_id', string="Levels", copy=True)
|
||||
color = fields.Integer('Color', default=_get_default_color)
|
||||
levels_count = fields.Integer(compute="_compute_levels_count", store=True, readonly=False, help="Number of levels linked to this skill type")
|
||||
is_certification = fields.Boolean('Certification', help="if checked the skill type become a certification type")
|
||||
|
||||
def _set_default_level(self):
|
||||
if self.env.context.get('no_skill_level_check'):
|
||||
return
|
||||
@api.constrains('skill_ids', 'skill_level_ids')
|
||||
def _check_no_null_skill_or_skill_level(self):
|
||||
incorrect_skill_type = self.env['hr.skill.type']
|
||||
for skill_type in self:
|
||||
if not skill_type.skill_ids or not skill_type.skill_level_ids:
|
||||
incorrect_skill_type |= skill_type
|
||||
if incorrect_skill_type:
|
||||
raise ValidationError(
|
||||
_("The following skills type must contain at least one skill and one level: %s",
|
||||
"\n".join(skill_type.name for skill_type in incorrect_skill_type)))
|
||||
|
||||
for types in self:
|
||||
if not types.skill_level_ids.filtered('default_level'):
|
||||
types.skill_level_ids[:1].default_level = True
|
||||
def _compute_display_name(self):
|
||||
for skill_type in self:
|
||||
if skill_type.is_certification:
|
||||
skill_type.display_name = skill_type.name + "\U0001F396" # Military Medal's unicode
|
||||
else:
|
||||
skill_type.display_name = skill_type.name
|
||||
|
||||
@api.depends('skill_level_ids')
|
||||
def _compute_levels_count(self):
|
||||
level_count_by_skill_type = dict(self.env['hr.skill.level']._read_group(
|
||||
domain=[('skill_type_id', 'in', self.ids)],
|
||||
groupby=['skill_type_id'],
|
||||
aggregates=['__count']
|
||||
))
|
||||
for skill_type in self:
|
||||
skill_type.levels_count = level_count_by_skill_type.get(skill_type, 0)
|
||||
|
||||
@api.onchange('skill_level_ids')
|
||||
def _onchange_skill_level_ids(self):
|
||||
for level in self.skill_level_ids:
|
||||
if level.technical_is_new_default:
|
||||
(self.skill_level_ids - level).write({'default_level': False})
|
||||
# This value need to be set to False, to reset it for the frontend.
|
||||
level.technical_is_new_default = False
|
||||
break
|
||||
|
||||
def copy_data(self, default=None):
|
||||
vals_list = super().copy_data(default=default)
|
||||
return [
|
||||
{
|
||||
**vals,
|
||||
"name": self.env._("%(skill_type_name)s (copy)", skill_type_name=skill_type.name),
|
||||
"color": 0,
|
||||
"skill_ids": [Command.create({"name": skill.name}) for skill in skill_type.skill_ids],
|
||||
}
|
||||
for skill_type, vals in zip(self, vals_list)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class User(models.Model):
|
||||
_inherit = ['res.users']
|
||||
|
||||
resume_line_ids = fields.One2many(related='employee_id.resume_line_ids', readonly=False)
|
||||
employee_skill_ids = fields.One2many(related='employee_id.employee_skill_ids', readonly=False)
|
||||
|
||||
@property
|
||||
def SELF_READABLE_FIELDS(self):
|
||||
return super().SELF_READABLE_FIELDS + ['resume_line_ids', 'employee_skill_ids']
|
||||
|
||||
@property
|
||||
def SELF_WRITEABLE_FIELDS(self):
|
||||
return super().SELF_WRITEABLE_FIELDS + ['resume_line_ids', 'employee_skill_ids']
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResourceResource(models.Model):
|
||||
_inherit = 'resource.resource'
|
||||
|
||||
employee_skill_ids = fields.One2many(related='employee_id.employee_skill_ids')
|
||||
Loading…
Add table
Add a link
Reference in a new issue