oca-ocb-hr/odoo-bringout-oca-ocb-hr_skills/hr_skills/models/hr_individual_skill_mixin.py
Ernad Husremovic e1d89e11e3 19.0 vanilla
2026-03-09 09:31:00 +01:00

537 lines
26 KiB
Python

# 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