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,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

View file

@ -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]

View file

@ -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")

View file

@ -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'}

View file

@ -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"),
]

View file

@ -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

View 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)

View file

@ -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

View file

@ -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'

View file

@ -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')

View file

@ -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})"

View file

@ -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

View file

@ -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)
]

View file

@ -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']

View file

@ -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')