From 9a0e60ed55bb4d462b56878ae9b16d168a484864 Mon Sep 17 00:00:00 2001 From: mav-adhoc Date: Wed, 25 Feb 2026 16:59:12 -0300 Subject: [PATCH 1/3] [ADD]hr_recruitment_ux: extend recruitment functionalities --- hr_recruitment_ux/README.rst | 72 +++++++++++++++++++ hr_recruitment_ux/__init__.py | 3 + hr_recruitment_ux/__manifest__.py | 37 ++++++++++ hr_recruitment_ux/models/__init__.py | 3 + .../models/talent_pool_add_applicants.py | 13 ++++ 5 files changed, 128 insertions(+) create mode 100644 hr_recruitment_ux/README.rst create mode 100644 hr_recruitment_ux/__init__.py create mode 100644 hr_recruitment_ux/__manifest__.py create mode 100644 hr_recruitment_ux/models/__init__.py create mode 100644 hr_recruitment_ux/models/talent_pool_add_applicants.py diff --git a/hr_recruitment_ux/README.rst b/hr_recruitment_ux/README.rst new file mode 100644 index 0000000..e5b716f --- /dev/null +++ b/hr_recruitment_ux/README.rst @@ -0,0 +1,72 @@ +.. |company| replace:: ADHOC SA + +.. |company_logo| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-logo.png + :alt: ADHOC SA + :target: https://www.adhoc.com.ar + +.. |icon| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-icon.png + +.. image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: https://www.gnu.org/licenses/agpl + :alt: License: AGPL-3 + +================== +HR Recruitment UX +================== + +This module adds: + +* Automatically archives applicants when they are added to a talent pool + +Installation +============ + +To install this module, you need to: + +#. Just install this module + +Configuration +============= + +To configure this module, you need to: + +#. Nothing to configure + +Usage +===== + +To use this module, you need to: + +#. Go to ... + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: http://runbot.adhoc.com.ar/ + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* |company| |icon| + +Contributors +------------ + +Maintainer +---------- + +|company_logo| + +This module is maintained by ADHOC SA. + +To contribute to this module, please visit https://www.adhoc.com.ar. diff --git a/hr_recruitment_ux/__init__.py b/hr_recruitment_ux/__init__.py new file mode 100644 index 0000000..31660d6 --- /dev/null +++ b/hr_recruitment_ux/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/hr_recruitment_ux/__manifest__.py b/hr_recruitment_ux/__manifest__.py new file mode 100644 index 0000000..4ad3b8f --- /dev/null +++ b/hr_recruitment_ux/__manifest__.py @@ -0,0 +1,37 @@ +############################################################################## +# +# Copyright (C) 2026 ADHOC SA (http://www.adhoc.com.ar) +# All Rights Reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## +{ + "name": "Recruitment UX", + "version": "19.0.1.0.0", + "category": "Human Resources", + "sequence": 14, + "author": "ADHOC SA", + "website": "www.adhoc.com.ar", + "license": "AGPL-3", + "summary": "", + "depends": [ + "hr_recruitment", + ], + "data": [], + "demo": [], + "installable": True, + "auto_install": True, + "application": False, +} diff --git a/hr_recruitment_ux/models/__init__.py b/hr_recruitment_ux/models/__init__.py new file mode 100644 index 0000000..b722ba0 --- /dev/null +++ b/hr_recruitment_ux/models/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import talent_pool_add_applicants diff --git a/hr_recruitment_ux/models/talent_pool_add_applicants.py b/hr_recruitment_ux/models/talent_pool_add_applicants.py new file mode 100644 index 0000000..8a24124 --- /dev/null +++ b/hr_recruitment_ux/models/talent_pool_add_applicants.py @@ -0,0 +1,13 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class TalentPoolAddApplicants(models.TransientModel): + _inherit = "talent.pool.add.applicants" + + def action_add_applicants_to_pool(self): + result = super().action_add_applicants_to_pool() + # Archive the original applicants after adding them to the talent pool + self.applicant_ids.write({"active": False}) + return result From bb1c13a2e7795ebbbd8f295cba02785eebc1b3b0 Mon Sep 17 00:00:00 2001 From: mav-adhoc Date: Tue, 2 Jun 2026 14:46:45 +0000 Subject: [PATCH 2/3] =?UTF-8?q?[ADD]=20hr=5Frecruitment=5Fux:=20SLA=20de?= =?UTF-8?q?=20d=C3=ADas=20para=20deteriorarse=20por=20vacante?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permite configurar días para deteriorarse específicos por vacante (hr.job), sin afectar otras posiciones que compartan la misma etapa. - Nuevo boolean `use_rotting_per_job` en hr.job: cuando activo, usa los días configurados en la vacante en lugar del threshold de la etapa. - Campo `rotting_threshold_days` en hr.job: threshold exclusivo de la vacante; 0 significa sin deterioro para esa posición. - Override de `_compute_rotting` y `_search_is_rotting` en hr.applicant para respetar la configuración por vacante manteniendo compatibilidad con el comportamiento estándar por etapa. --- hr_recruitment_ux/README.rst | 3 + hr_recruitment_ux/__manifest__.py | 4 +- hr_recruitment_ux/models/__init__.py | 2 + hr_recruitment_ux/models/hr_applicant.py | 120 +++++++++++++++++++++++ hr_recruitment_ux/models/hr_job.py | 20 ++++ hr_recruitment_ux/views/hr_job_views.xml | 17 ++++ 6 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 hr_recruitment_ux/models/hr_applicant.py create mode 100644 hr_recruitment_ux/models/hr_job.py create mode 100644 hr_recruitment_ux/views/hr_job_views.xml diff --git a/hr_recruitment_ux/README.rst b/hr_recruitment_ux/README.rst index e5b716f..98c5aeb 100644 --- a/hr_recruitment_ux/README.rst +++ b/hr_recruitment_ux/README.rst @@ -17,6 +17,9 @@ HR Recruitment UX This module adds: * Automatically archives applicants when they are added to a talent pool +* Per-vacancy rotting SLA: allows configuring specific "days to rot" on each job position, + overriding the stage threshold when needed. When enabled on a vacancy, applicants in that + position use the vacancy's threshold instead of the pipeline stage's. Installation ============ diff --git a/hr_recruitment_ux/__manifest__.py b/hr_recruitment_ux/__manifest__.py index 4ad3b8f..d025b3a 100644 --- a/hr_recruitment_ux/__manifest__.py +++ b/hr_recruitment_ux/__manifest__.py @@ -29,7 +29,9 @@ "depends": [ "hr_recruitment", ], - "data": [], + "data": [ + "views/hr_job_views.xml", + ], "demo": [], "installable": True, "auto_install": True, diff --git a/hr_recruitment_ux/models/__init__.py b/hr_recruitment_ux/models/__init__.py index b722ba0..912de22 100644 --- a/hr_recruitment_ux/models/__init__.py +++ b/hr_recruitment_ux/models/__init__.py @@ -1,3 +1,5 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import hr_applicant +from . import hr_job from . import talent_pool_add_applicants diff --git a/hr_recruitment_ux/models/hr_applicant.py b/hr_recruitment_ux/models/hr_applicant.py new file mode 100644 index 0000000..253a76d --- /dev/null +++ b/hr_recruitment_ux/models/hr_applicant.py @@ -0,0 +1,120 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from odoo import api, models +from odoo.exceptions import UserError +from odoo.fields import Domain +from odoo.tools import SQL + + +class HrApplicant(models.Model): + _inherit = "hr.applicant" + + def _get_rotting_depends_fields(self): + return super()._get_rotting_depends_fields() + [ + "job_id.use_rotting_per_job", + "job_id.rotting_threshold_days", + ] + + def _get_rotting_domain(self): + # Extend base domain to also include applicants with per-job rotting enabled. + # Per-job applicants are included regardless of stage threshold. + return super()._get_rotting_domain() | Domain( + [ + ("job_id.use_rotting_per_job", "=", True), + ("application_status", "=", "ongoing"), + ("date_closed", "=", False), + ] + ) + + def _is_rotting_feature_enabled(self): + return super()._is_rotting_feature_enabled() or bool( + not self or self.filtered(lambda r: r.job_id.use_rotting_per_job) + ) + + @api.depends(lambda self: self._get_rotting_depends_fields()) + def _compute_rotting(self): + # super() handles stage-based rotting for all records. + # Per-job applicants in 0-threshold stages are incorrectly set here; + # the loop below corrects them. + super()._compute_rotting() + if not self._is_rotting_feature_enabled(): + return + now = self.env.cr.now() + per_job = self.filtered( + lambda r: r.job_id.use_rotting_per_job and r.application_status == "ongoing" and not r.date_closed + ) + for applicant in per_job: + threshold = applicant.job_id.rotting_threshold_days + date_ref = applicant.date_last_stage_update or applicant.create_date + if threshold and date_ref and (date_ref + timedelta(days=threshold)) < now: + applicant.is_rotting = True + applicant.rotting_days = (now - date_ref).days + else: + applicant.is_rotting = False + applicant.rotting_days = 0 + + def _search_is_rotting(self, operator, value): + if operator not in ["in", "not in"]: + raise ValueError(self.env._('For performance reasons, use "=" operators on rotting fields.')) + if not self._is_rotting_feature_enabled(): + raise UserError(self.env._("Model configuration does not support the rotting feature")) + model_depends = [fname for fname in self._get_rotting_depends_fields() if "." not in fname] + self.flush_model(model_depends) + self.env[self[self._track_duration_field]._name].flush_model(["rotting_threshold_days"]) + self.env["hr.job"].flush_model(["use_rotting_per_job", "rotting_threshold_days"]) + base_query = self._search(self._get_rotting_domain()) + stage_table_alias_name = base_query.make_alias(self._table, self._track_duration_field) + + from_add_join = "" + if not base_query._joins or stage_table_alias_name not in base_query._joins: + from_add_join = """ + INNER JOIN %(stage_table)s AS %(stage_table_alias_name)s + ON %(stage_table_alias_name)s.id = %(table)s.%(stage_field)s + """ + + max_rotting_months = int( + self.env["ir.config_parameter"].sudo().get_param("crm.lead.rot.max.months", default=12) + ) + + # effective_threshold logic: + # - use_rotting_per_job=True and threshold>0 → use job threshold + # - use_rotting_per_job=True and threshold=0 → NULLIF returns NULL → excluded (no rotting) + # - use_rotting_per_job=False → use stage threshold (standard behavior) + query = f""" + WITH perishables AS ( + SELECT %(table)s.id AS id, + CASE WHEN hr_job.use_rotting_per_job + THEN NULLIF(hr_job.rotting_threshold_days, 0) + ELSE %(stage_table_alias_name)s.rotting_threshold_days + END AS effective_threshold, + %(table)s.date_last_stage_update + FROM %(from_clause)s + {from_add_join} + LEFT JOIN hr_job ON hr_job.id = %(table)s.job_id + WHERE + %(table)s.date_last_stage_update > %(today)s - INTERVAL '%(max_rotting_months)s months' + AND %(where_clause)s + ) + SELECT id + FROM perishables + WHERE + effective_threshold > 0 + AND %(today)s >= date_last_stage_update + effective_threshold * interval '1 day' + """ + self.env.cr.execute( + SQL( + query, + table=SQL.identifier(self._table), + stage_table=SQL.identifier(self[self._track_duration_field]._table), + stage_table_alias_name=SQL.identifier(stage_table_alias_name), + stage_field=SQL.identifier(self._track_duration_field), + today=self.env.cr.now(), + where_clause=base_query.where_clause, + from_clause=base_query.from_clause, + max_rotting_months=max_rotting_months, + ) + ) + rows = self.env.cr.dictfetchall() + return [("id", operator, [r["id"] for r in rows])] diff --git a/hr_recruitment_ux/models/hr_job.py b/hr_recruitment_ux/models/hr_job.py new file mode 100644 index 0000000..18fa08f --- /dev/null +++ b/hr_recruitment_ux/models/hr_job.py @@ -0,0 +1,20 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class HrJob(models.Model): + _inherit = "hr.job" + + use_rotting_per_job = fields.Boolean( + string="Usar días para deteriorarse por vacante", + help="Cuando está activo, los días para deteriorarse se configuran a nivel de vacante " + "en lugar de usar los días configurados en la etapa.", + ) + rotting_threshold_days = fields.Integer( + string="Días para deteriorarse", + default=0, + help="Cantidad de días antes de que los postulantes de esta vacante se deterioren. " + "Se usa solo cuando 'Usar días para deteriorarse por vacante' está activo. " + "0 = sin deterioro para esta vacante.", + ) diff --git a/hr_recruitment_ux/views/hr_job_views.xml b/hr_recruitment_ux/views/hr_job_views.xml new file mode 100644 index 0000000..883d17d --- /dev/null +++ b/hr_recruitment_ux/views/hr_job_views.xml @@ -0,0 +1,17 @@ + + + + + hr.job.form.inherit.recruitment.ux + hr.job + + + + + + + + + + From b1575ad709f03d487d3e8bfe3273c4d0f039b322 Mon Sep 17 00:00:00 2001 From: mav-adhoc Date: Tue, 2 Jun 2026 15:12:35 +0000 Subject: [PATCH 3/3] [IMP] hr_recruitment_ux: simplificar override de _compute_rotting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reemplaza la implementación compleja (_get_rotting_domain, _is_rotting_feature_enabled, _search_is_rotting) por un único override de _compute_rotting que llama a super() y luego corrige los postulantes con SLA por vacante activo. --- hr_recruitment_ux/models/hr_applicant.py | 88 ------------------------ 1 file changed, 88 deletions(-) diff --git a/hr_recruitment_ux/models/hr_applicant.py b/hr_recruitment_ux/models/hr_applicant.py index 253a76d..052f425 100644 --- a/hr_recruitment_ux/models/hr_applicant.py +++ b/hr_recruitment_ux/models/hr_applicant.py @@ -3,9 +3,6 @@ from datetime import timedelta from odoo import api, models -from odoo.exceptions import UserError -from odoo.fields import Domain -from odoo.tools import SQL class HrApplicant(models.Model): @@ -17,30 +14,9 @@ def _get_rotting_depends_fields(self): "job_id.rotting_threshold_days", ] - def _get_rotting_domain(self): - # Extend base domain to also include applicants with per-job rotting enabled. - # Per-job applicants are included regardless of stage threshold. - return super()._get_rotting_domain() | Domain( - [ - ("job_id.use_rotting_per_job", "=", True), - ("application_status", "=", "ongoing"), - ("date_closed", "=", False), - ] - ) - - def _is_rotting_feature_enabled(self): - return super()._is_rotting_feature_enabled() or bool( - not self or self.filtered(lambda r: r.job_id.use_rotting_per_job) - ) - @api.depends(lambda self: self._get_rotting_depends_fields()) def _compute_rotting(self): - # super() handles stage-based rotting for all records. - # Per-job applicants in 0-threshold stages are incorrectly set here; - # the loop below corrects them. super()._compute_rotting() - if not self._is_rotting_feature_enabled(): - return now = self.env.cr.now() per_job = self.filtered( lambda r: r.job_id.use_rotting_per_job and r.application_status == "ongoing" and not r.date_closed @@ -54,67 +30,3 @@ def _compute_rotting(self): else: applicant.is_rotting = False applicant.rotting_days = 0 - - def _search_is_rotting(self, operator, value): - if operator not in ["in", "not in"]: - raise ValueError(self.env._('For performance reasons, use "=" operators on rotting fields.')) - if not self._is_rotting_feature_enabled(): - raise UserError(self.env._("Model configuration does not support the rotting feature")) - model_depends = [fname for fname in self._get_rotting_depends_fields() if "." not in fname] - self.flush_model(model_depends) - self.env[self[self._track_duration_field]._name].flush_model(["rotting_threshold_days"]) - self.env["hr.job"].flush_model(["use_rotting_per_job", "rotting_threshold_days"]) - base_query = self._search(self._get_rotting_domain()) - stage_table_alias_name = base_query.make_alias(self._table, self._track_duration_field) - - from_add_join = "" - if not base_query._joins or stage_table_alias_name not in base_query._joins: - from_add_join = """ - INNER JOIN %(stage_table)s AS %(stage_table_alias_name)s - ON %(stage_table_alias_name)s.id = %(table)s.%(stage_field)s - """ - - max_rotting_months = int( - self.env["ir.config_parameter"].sudo().get_param("crm.lead.rot.max.months", default=12) - ) - - # effective_threshold logic: - # - use_rotting_per_job=True and threshold>0 → use job threshold - # - use_rotting_per_job=True and threshold=0 → NULLIF returns NULL → excluded (no rotting) - # - use_rotting_per_job=False → use stage threshold (standard behavior) - query = f""" - WITH perishables AS ( - SELECT %(table)s.id AS id, - CASE WHEN hr_job.use_rotting_per_job - THEN NULLIF(hr_job.rotting_threshold_days, 0) - ELSE %(stage_table_alias_name)s.rotting_threshold_days - END AS effective_threshold, - %(table)s.date_last_stage_update - FROM %(from_clause)s - {from_add_join} - LEFT JOIN hr_job ON hr_job.id = %(table)s.job_id - WHERE - %(table)s.date_last_stage_update > %(today)s - INTERVAL '%(max_rotting_months)s months' - AND %(where_clause)s - ) - SELECT id - FROM perishables - WHERE - effective_threshold > 0 - AND %(today)s >= date_last_stage_update + effective_threshold * interval '1 day' - """ - self.env.cr.execute( - SQL( - query, - table=SQL.identifier(self._table), - stage_table=SQL.identifier(self[self._track_duration_field]._table), - stage_table_alias_name=SQL.identifier(stage_table_alias_name), - stage_field=SQL.identifier(self._track_duration_field), - today=self.env.cr.now(), - where_clause=base_query.where_clause, - from_clause=base_query.from_clause, - max_rotting_months=max_rotting_months, - ) - ) - rows = self.env.cr.dictfetchall() - return [("id", operator, [r["id"] for r in rows])]