iteracion 2
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Talent app
|
||||
@@ -0,0 +1,14 @@
|
||||
from django.contrib import admin
|
||||
from .models import SpecialistCandidate, EvaluationRubric7D
|
||||
|
||||
@admin.register(SpecialistCandidate)
|
||||
class SpecialistCandidateAdmin(admin.ModelAdmin):
|
||||
list_display = ('rut', 'first_name', 'last_name', 'category_code', 'specialty_title', 'seniority', 'trl_level', 'status', 'availability')
|
||||
search_fields = ('rut', 'first_name', 'last_name', 'specialty_title')
|
||||
list_filter = ('category_code', 'seniority', 'trl_level', 'status', 'availability')
|
||||
|
||||
@admin.register(EvaluationRubric7D)
|
||||
class EvaluationRubric7DAdmin(admin.ModelAdmin):
|
||||
list_display = ('candidate', 'evaluator', 'evaluation_date', 'total_weighted_score', 'is_knockout', 'result_status', 'valid_until')
|
||||
search_fields = ('candidate__rut', 'candidate__first_name', 'candidate__last_name')
|
||||
list_filter = ('result_status', 'is_knockout', 'evaluation_date')
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class TalentConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'talent'
|
||||
verbose_name = 'Cartera de Especialistas y Talento I+D+i'
|
||||
@@ -0,0 +1,66 @@
|
||||
from django import forms
|
||||
from .models import SpecialistCandidate, EvaluationRubric7D
|
||||
from decimal import Decimal
|
||||
|
||||
class SpecialistCandidateForm(forms.ModelForm):
|
||||
hourly_rate_clp = forms.DecimalField(
|
||||
label="Tarifa por Hora (CLP)",
|
||||
required=False,
|
||||
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '1000'})
|
||||
)
|
||||
monthly_salary_target_clp = forms.DecimalField(
|
||||
label="Pretensión de Renta Mensual (CLP)",
|
||||
required=False,
|
||||
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '10000'})
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = SpecialistCandidate
|
||||
fields = [
|
||||
'rut', 'first_name', 'last_name', 'email', 'phone', 'city',
|
||||
'category_code', 'specialty_title', 'seniority', 'trl_level',
|
||||
'portfolio_url', 'github_url', 'linkedin_url',
|
||||
'hourly_rate_clp', 'monthly_salary_target_clp', 'availability',
|
||||
'status', 'summary'
|
||||
]
|
||||
widgets = {
|
||||
'rut': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 15.678.910-2', 'pattern': r'^\d{1,2}\.\d{3}\.\d{3}-[\dkK]$', 'title': 'Formato válido: XX.XXX.XXX-X', 'required': True}),
|
||||
'first_name': forms.TextInput(attrs={'class': 'form-control', 'required': True}),
|
||||
'last_name': forms.TextInput(attrs={'class': 'form-control', 'required': True}),
|
||||
'email': forms.EmailInput(attrs={'class': 'form-control', 'required': True}),
|
||||
'phone': forms.TextInput(attrs={'class': 'form-control', 'pattern': r'^\+?[0-9]{8,15}$', 'title': 'Número de teléfono válido (ej: +56912345678)'}),
|
||||
'city': forms.HiddenInput(),
|
||||
'category_code': forms.Select(attrs={'class': 'form-control', 'required': True}),
|
||||
'specialty_title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: Ingeniero en Control & Gemelos Digitales', 'required': True}),
|
||||
'seniority': forms.Select(attrs={'class': 'form-control', 'required': True}),
|
||||
'trl_level': forms.Select(attrs={'class': 'form-control', 'required': True}),
|
||||
'portfolio_url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'https://...'}),
|
||||
'github_url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'https://github.com/...'}),
|
||||
'linkedin_url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'https://linkedin.com/in/...'}),
|
||||
'availability': forms.Select(attrs={'class': 'form-control', 'required': True}),
|
||||
'status': forms.Select(attrs={'class': 'form-control', 'required': True}),
|
||||
'summary': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Perfil técnico, certificaciones y experiencia en proyectos I+D+i...'}),
|
||||
}
|
||||
|
||||
|
||||
class EvaluationRubric7DForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = EvaluationRubric7D
|
||||
fields = [
|
||||
'candidate', 'evaluation_date',
|
||||
'dim_case_study', 'dim_portfolio', 'dim_tech_specific', 'dim_cardinal_skills',
|
||||
'dim_experience', 'dim_principles_safety', 'dim_innovation_rndi',
|
||||
'feedback'
|
||||
]
|
||||
widgets = {
|
||||
'candidate': forms.Select(attrs={'class': 'form-control', 'id': 'id_candidate', 'required': True}),
|
||||
'evaluation_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control', 'required': True}),
|
||||
'dim_case_study': forms.NumberInput(attrs={'class': 'form-control rubro-slider-input', 'min': '0', 'max': '100', 'step': '1', 'id': 'dim_1', 'required': True}),
|
||||
'dim_portfolio': forms.NumberInput(attrs={'class': 'form-control rubro-slider-input', 'min': '0', 'max': '100', 'step': '1', 'id': 'dim_2', 'required': True}),
|
||||
'dim_tech_specific': forms.NumberInput(attrs={'class': 'form-control rubro-slider-input', 'min': '0', 'max': '100', 'step': '1', 'id': 'dim_3', 'required': True}),
|
||||
'dim_cardinal_skills': forms.NumberInput(attrs={'class': 'form-control rubro-slider-input', 'min': '0', 'max': '100', 'step': '1', 'id': 'dim_4', 'required': True}),
|
||||
'dim_experience': forms.NumberInput(attrs={'class': 'form-control rubro-slider-input', 'min': '0', 'max': '100', 'step': '1', 'id': 'dim_5', 'required': True}),
|
||||
'dim_principles_safety': forms.NumberInput(attrs={'class': 'form-control rubro-slider-input', 'min': '0', 'max': '100', 'step': '1', 'id': 'dim_6', 'required': True}),
|
||||
'dim_innovation_rndi': forms.NumberInput(attrs={'class': 'form-control rubro-slider-input', 'min': '0', 'max': '100', 'step': '1', 'id': 'dim_7', 'required': True}),
|
||||
'feedback': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Comentarios detallados de la evaluación...'}),
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# Generated by Django 6.1 on 2026-09-07 02:50
|
||||
|
||||
import datetime
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SpecialistCandidate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('rut', models.CharField(max_length=20, unique=True, verbose_name='RUT Especialista')),
|
||||
('first_name', models.CharField(max_length=100, verbose_name='Nombres')),
|
||||
('last_name', models.CharField(max_length=100, verbose_name='Apellidos')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='Correo Electrónico')),
|
||||
('phone', models.CharField(blank=True, max_length=30, verbose_name='Teléfono / WhatsApp')),
|
||||
('city', models.CharField(default='Santiago', max_length=100, verbose_name='Ciudad / Región de Residencia')),
|
||||
('category_code', models.CharField(choices=[('1', '1. Electricidad de Potencia e Infraestructura'), ('2', '2. Automatización y Control Industrial'), ('3', '3. Domótica e Inmótica (BMS & Climatización)'), ('4', '4. Electrónica y Hardware (PCB / Firmware)'), ('5', '5. Redes, Conectividad e Informática (TI & Ciberseguridad)'), ('6', '6. Tecnologías Inmersivas y Gemelos Digitales (VR/AR/LiDAR)'), ('7', '7. Software, Datos, IA y Cloud')], max_length=10, verbose_name='Partida / Categoría Principal (1 a 7)')),
|
||||
('specialty_title', models.CharField(max_length=150, verbose_name='Título / Especialidad Técnica Específica')),
|
||||
('seniority', models.CharField(choices=[('JUNIOR', 'Junior (1-3 años)'), ('SEMI_SENIOR', 'Semi-Senior (3-5 años)'), ('SENIOR', 'Senior (5-8 años)'), ('LEAD_SPECIALIST', 'Lead / Principal Specialist (+8 años)')], default='SEMI_SENIOR', max_length=30, verbose_name='Nivel de Seniority')),
|
||||
('trl_level', models.IntegerField(choices=[(1, 'TRL 1 - Principios básicos observados'), (2, 'TRL 2 - Concepto tecnológico formulado'), (3, 'TRL 3 - Prueba de concepto experimental'), (4, 'TRL 4 - Validación en laboratorio'), (5, 'TRL 5 - Validación en entorno relevante'), (6, 'TRL 6 - Demostración en entorno relevante'), (7, 'TRL 7 - Demostración de prototipo en entorno operacional'), (8, 'TRL 8 - Sistema completo y calificado'), (9, 'TRL 9 - Sistema probado con éxito en entorno real/industrial')], default=6, verbose_name='Nivel de Madurez Tecnológica Dominado (TRL 1-9)')),
|
||||
('portfolio_url', models.URLField(blank=True, verbose_name='URL Portafolio / Web Personal')),
|
||||
('github_url', models.URLField(blank=True, verbose_name='URL GitHub / Repositorio')),
|
||||
('linkedin_url', models.URLField(blank=True, verbose_name='URL Perfil LinkedIn')),
|
||||
('hourly_rate_clp', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Tarifa por Hora Pactada (CLP)')),
|
||||
('monthly_salary_target_clp', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Pretensión de Renta Mensual (CLP)')),
|
||||
('availability', models.CharField(choices=[('INMEDIATA', 'Disponibilidad Inmediata Full-Time'), ('DOS_SEMANAS', '2 Semanas (Plazo Aviso)'), ('UN_MES', '1 Mes'), ('FREELANCE_PROYECTO', 'Por Horas / Proyectos Específicos')], default='INMEDIATA', max_length=30, verbose_name='Disponibilidad')),
|
||||
('status', models.CharField(choices=[('POSTULANTE', 'Postulante Inicial'), ('EN_EVALUACION', 'En Proceso de Evaluación 7D'), ('APROBADO_CARTERA', 'Aprobado en Cartera de Especialistas'), ('EN_RESERVA', 'En Reserva / Banco de Talento'), ('DESCARTADO', 'Descartado / No Apto')], default='POSTULANTE', max_length=30, verbose_name='Estado en Cartera')),
|
||||
('summary', models.TextField(blank=True, verbose_name='Resumen de Experiencia y Competencias')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Especialista / Candidato',
|
||||
'verbose_name_plural': 'Cartera de Especialistas',
|
||||
'ordering': ['category_code', 'last_name', 'first_name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EvaluationRubric7D',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('evaluation_date', models.DateField(default=datetime.date.today, verbose_name='Fecha de Evaluación')),
|
||||
('dim_case_study', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='1. Caso Práctico / Prueba Técnica (25% - Knockout < 70)')),
|
||||
('dim_portfolio', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='2. Portafolio y Proyectos Previos (15%)')),
|
||||
('dim_tech_specific', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='3. Competencias Técnicas Específicas (15%)')),
|
||||
('dim_cardinal_skills', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='4. Competencias Cardinales / Blandas (15%)')),
|
||||
('dim_experience', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='5. Experiencia en Terreno / Industria (10%)')),
|
||||
('dim_principles_safety', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='6. Alineación con Principios y Seguridad EFINTEL (10%)')),
|
||||
('dim_innovation_rndi', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='7. Capacidad de Innovación e I+D+i (10%)')),
|
||||
('is_knockout', models.BooleanField(default=False, verbose_name='Descarte por Criterio Knockout (Prueba < 70)')),
|
||||
('total_weighted_score', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='Puntaje Ponderado Total (0-100)')),
|
||||
('result_status', models.CharField(choices=[('POSTULANTE', 'Postulante Inicial'), ('EN_EVALUACION', 'En Proceso de Evaluación 7D'), ('APROBADO_CARTERA', 'Aprobado en Cartera de Especialistas'), ('EN_RESERVA', 'En Reserva / Banco de Talento'), ('DESCARTADO', 'Descartado / No Apto')], max_length=30, verbose_name='Dictamen Final')),
|
||||
('valid_until', models.DateField(verbose_name='Vigencia de la Calificación (12 Meses)')),
|
||||
('feedback', models.TextField(blank=True, verbose_name='Observaciones, Fortalezas y Brechas Detectadas')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('evaluator', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Evaluador Líder')),
|
||||
('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='talent.specialistcandidate', verbose_name='Especialista Candidato')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Evaluación 7D de Especialista',
|
||||
'verbose_name_plural': 'Evaluaciones Rúbrica 7D',
|
||||
'ordering': ['-evaluation_date', '-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,145 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from decimal import Decimal
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
|
||||
class SpecialistCategoryEnum(models.TextChoices):
|
||||
CAT_1_ELECTRICIDAD = '1', '1. Electricidad de Potencia e Infraestructura'
|
||||
CAT_2_AUTOMATIZACION = '2', '2. Automatización y Control Industrial'
|
||||
CAT_3_DOMOTICA = '3', '3. Domótica e Inmótica (BMS & Climatización)'
|
||||
CAT_4_ELECTRONICA = '4', '4. Electrónica y Hardware (PCB / Firmware)'
|
||||
CAT_5_REDES = '5', '5. Redes, Conectividad e Informática (TI & Ciberseguridad)'
|
||||
CAT_6_INMERSIVAS = '6', '6. Tecnologías Inmersivas y Gemelos Digitales (VR/AR/LiDAR)'
|
||||
CAT_7_SOFTWARE_DATOS_IA_CLOUD = '7', '7. Software, Datos, IA y Cloud'
|
||||
|
||||
class SeniorityEnum(models.TextChoices):
|
||||
JUNIOR = 'JUNIOR', 'Junior (1-3 años)'
|
||||
SEMI_SENIOR = 'SEMI_SENIOR', 'Semi-Senior (3-5 años)'
|
||||
SENIOR = 'SENIOR', 'Senior (5-8 años)'
|
||||
LEAD_SPECIALIST = 'LEAD_SPECIALIST', 'Lead / Principal Specialist (+8 años)'
|
||||
|
||||
class TRLChoiceEnum(models.IntegerChoices):
|
||||
TRL_1 = 1, 'TRL 1 - Principios básicos observados'
|
||||
TRL_2 = 2, 'TRL 2 - Concepto tecnológico formulado'
|
||||
TRL_3 = 3, 'TRL 3 - Prueba de concepto experimental'
|
||||
TRL_4 = 4, 'TRL 4 - Validación en laboratorio'
|
||||
TRL_5 = 5, 'TRL 5 - Validación en entorno relevante'
|
||||
TRL_6 = 6, 'TRL 6 - Demostración en entorno relevante'
|
||||
TRL_7 = 7, 'TRL 7 - Demostración de prototipo en entorno operacional'
|
||||
TRL_8 = 8, 'TRL 8 - Sistema completo y calificado'
|
||||
TRL_9 = 9, 'TRL 9 - Sistema probado con éxito en entorno real/industrial'
|
||||
|
||||
class CandidateStatusEnum(models.TextChoices):
|
||||
POSTULANTE = 'POSTULANTE', 'Postulante Inicial'
|
||||
EN_EVALUACION = 'EN_EVALUACION', 'En Proceso de Evaluación 7D'
|
||||
APROBADO_CARTERA = 'APROBADO_CARTERA', 'Aprobado en Cartera de Especialistas'
|
||||
EN_RESERVA = 'EN_RESERVA', 'En Reserva / Banco de Talento'
|
||||
DESCARTADO = 'DESCARTADO', 'Descartado / No Apto'
|
||||
|
||||
class AvailabilityEnum(models.TextChoices):
|
||||
INMEDIATA = 'INMEDIATA', 'Disponibilidad Inmediata Full-Time'
|
||||
DOS_SEMANAS = 'DOS_SEMANAS', '2 Semanas (Plazo Aviso)'
|
||||
UN_MES = 'UN_MES', '1 Mes'
|
||||
FREELANCE_PROYECTO = 'FREELANCE_PROYECTO', 'Por Horas / Proyectos Específicos'
|
||||
|
||||
|
||||
class SpecialistCandidate(models.Model):
|
||||
"""
|
||||
Ficha de Especialista y Postulante para Cartera I+D+i EFINTEL.
|
||||
"""
|
||||
rut = models.CharField(max_length=20, unique=True, verbose_name="RUT Especialista")
|
||||
first_name = models.CharField(max_length=100, verbose_name="Nombres")
|
||||
last_name = models.CharField(max_length=100, verbose_name="Apellidos")
|
||||
email = models.EmailField(verbose_name="Correo Electrónico")
|
||||
phone = models.CharField(max_length=30, blank=True, verbose_name="Teléfono / WhatsApp")
|
||||
city = models.CharField(max_length=100, default="Santiago", verbose_name="Ciudad / Región de Residencia")
|
||||
|
||||
# Categorización Técnica
|
||||
category_code = models.CharField(max_length=10, choices=SpecialistCategoryEnum.choices, verbose_name="Partida / Categoría Principal (1 a 7)")
|
||||
specialty_title = models.CharField(max_length=150, verbose_name="Título / Especialidad Técnica Específica")
|
||||
seniority = models.CharField(max_length=30, choices=SeniorityEnum.choices, default=SeniorityEnum.SEMI_SENIOR, verbose_name="Nivel de Seniority")
|
||||
trl_level = models.IntegerField(choices=TRLChoiceEnum.choices, default=TRLChoiceEnum.TRL_6, verbose_name="Nivel de Madurez Tecnológica Dominado (TRL 1-9)")
|
||||
|
||||
# Portafolio y Enlaces
|
||||
portfolio_url = models.URLField(blank=True, verbose_name="URL Portafolio / Web Personal")
|
||||
github_url = models.URLField(blank=True, verbose_name="URL GitHub / Repositorio")
|
||||
linkedin_url = models.URLField(blank=True, verbose_name="URL Perfil LinkedIn")
|
||||
|
||||
# Condiciones
|
||||
hourly_rate_clp = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Tarifa por Hora Pactada (CLP)")
|
||||
monthly_salary_target_clp = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name="Pretensión de Renta Mensual (CLP)")
|
||||
availability = models.CharField(max_length=30, choices=AvailabilityEnum.choices, default=AvailabilityEnum.INMEDIATA, verbose_name="Disponibilidad")
|
||||
|
||||
status = models.CharField(max_length=30, choices=CandidateStatusEnum.choices, default=CandidateStatusEnum.POSTULANTE, verbose_name="Estado en Cartera")
|
||||
summary = models.TextField(blank=True, verbose_name="Resumen de Experiencia y Competencias")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.rut} - {self.first_name} {self.last_name} ({self.get_category_code_display()})"
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
|
||||
@property
|
||||
def latest_evaluation(self):
|
||||
return self.evaluations.order_by('-evaluation_date', '-created_at').first()
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Especialista / Candidato"
|
||||
verbose_name_plural = "Cartera de Especialistas"
|
||||
ordering = ['category_code', 'last_name', 'first_name']
|
||||
|
||||
|
||||
class EvaluationRubric7D(models.Model):
|
||||
"""
|
||||
Rúbrica de Evaluación Holística en 7 Dimensiones Ponderadas.
|
||||
Ponderaciones:
|
||||
1. Caso Práctico / Prueba Técnica: 25% (CRITERIO KNOCKOUT: < 70 Descarte Automático)
|
||||
2. Portafolio y Proyectos Previos: 15%
|
||||
3. Competencias Técnicas Específicas: 15%
|
||||
4. Competencias Cardinales / Blandas: 15%
|
||||
5. Experiencia Comprobable en Terreno: 10%
|
||||
6. Alineación con Principios y Seguridad: 10%
|
||||
7. Capacidad de Innovación e I+D+i: 10%
|
||||
Vigencia: 12 meses.
|
||||
"""
|
||||
candidate = models.ForeignKey(SpecialistCandidate, on_delete=models.CASCADE, related_name='evaluations', verbose_name="Especialista Candidato")
|
||||
evaluator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name="Evaluador Líder")
|
||||
evaluation_date = models.DateField(default=date.today, verbose_name="Fecha de Evaluación")
|
||||
|
||||
# 7 Dimensiones (Escala 0 - 100)
|
||||
dim_case_study = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="1. Caso Práctico / Prueba Técnica (25% - Knockout < 70)")
|
||||
dim_portfolio = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="2. Portafolio y Proyectos Previos (15%)")
|
||||
dim_tech_specific = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="3. Competencias Técnicas Específicas (15%)")
|
||||
dim_cardinal_skills = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="4. Competencias Cardinales / Blandas (15%)")
|
||||
dim_experience = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="5. Experiencia en Terreno / Industria (10%)")
|
||||
dim_principles_safety = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="6. Alineación con Principios y Seguridad EFINTEL (10%)")
|
||||
dim_innovation_rndi = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="7. Capacidad de Innovación e I+D+i (10%)")
|
||||
|
||||
# Flags y Resultado
|
||||
is_knockout = models.BooleanField(default=False, verbose_name="Descarte por Criterio Knockout (Prueba < 70)")
|
||||
total_weighted_score = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="Puntaje Ponderado Total (0-100)")
|
||||
result_status = models.CharField(max_length=30, choices=CandidateStatusEnum.choices, verbose_name="Dictamen Final")
|
||||
|
||||
valid_until = models.DateField(verbose_name="Vigencia de la Calificación (12 Meses)")
|
||||
feedback = models.TextField(blank=True, verbose_name="Observaciones, Fortalezas y Brechas Detectadas")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Auto-set validity to 12 months from evaluation date if not set
|
||||
if not self.valid_until and self.evaluation_date:
|
||||
self.valid_until = self.evaluation_date + timedelta(days=365)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"Evaluación 7D {self.candidate.full_name} - Score: {self.total_weighted_score}/100 ({self.get_result_status_display()})"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Evaluación 7D de Especialista"
|
||||
verbose_name_plural = "Evaluaciones Rúbrica 7D"
|
||||
ordering = ['-evaluation_date', '-created_at']
|
||||
@@ -0,0 +1,3 @@
|
||||
from .rubric import TalentScoringEngine
|
||||
|
||||
__all__ = ['TalentScoringEngine']
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
7-Dimension Scoring Engine with Knockout Criterion for EFINTEL Talent Pool.
|
||||
"""
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from datetime import date, timedelta
|
||||
from typing import Dict, Any
|
||||
|
||||
class TalentScoringEngine:
|
||||
# 7-Dimension Official Weighting
|
||||
WEIGHT_CASE_STUDY = Decimal('0.25') # 25% (Knockout if < 70)
|
||||
WEIGHT_PORTFOLIO = Decimal('0.15') # 15%
|
||||
WEIGHT_TECH_SPECIFIC = Decimal('0.15') # 15%
|
||||
WEIGHT_CARDINAL_SKILLS = Decimal('0.15') # 15%
|
||||
WEIGHT_EXPERIENCE = Decimal('0.10') # 10%
|
||||
WEIGHT_PRINCIPLES = Decimal('0.10') # 10%
|
||||
WEIGHT_INNOVATION = Decimal('0.10') # 10%
|
||||
|
||||
KNOCKOUT_THRESHOLD = Decimal('70.0')
|
||||
APPROVAL_THRESHOLD = Decimal('80.0')
|
||||
RESERVE_THRESHOLD = Decimal('65.0')
|
||||
VALIDITY_DAYS = 365
|
||||
|
||||
@classmethod
|
||||
def evaluate(
|
||||
cls,
|
||||
dim_case_study: Decimal,
|
||||
dim_portfolio: Decimal,
|
||||
dim_tech_specific: Decimal,
|
||||
dim_cardinal_skills: Decimal,
|
||||
dim_experience: Decimal,
|
||||
dim_principles_safety: Decimal,
|
||||
dim_innovation_rndi: Decimal,
|
||||
evaluation_date: date = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculates weighted 7D score, evaluates Knockout condition, determines status and validity.
|
||||
"""
|
||||
d1 = Decimal(str(dim_case_study or 0))
|
||||
d2 = Decimal(str(dim_portfolio or 0))
|
||||
d3 = Decimal(str(dim_tech_specific or 0))
|
||||
d4 = Decimal(str(dim_cardinal_skills or 0))
|
||||
d5 = Decimal(str(dim_experience or 0))
|
||||
d6 = Decimal(str(dim_principles_safety or 0))
|
||||
d7 = Decimal(str(dim_innovation_rndi or 0))
|
||||
|
||||
# Check Knockout condition on Dimension 1 (Caso Práctico / Prueba Técnica)
|
||||
is_knockout = d1 < cls.KNOCKOUT_THRESHOLD
|
||||
|
||||
# Calculate weighted total
|
||||
weighted_score = (
|
||||
(d1 * cls.WEIGHT_CASE_STUDY) +
|
||||
(d2 * cls.WEIGHT_PORTFOLIO) +
|
||||
(d3 * cls.WEIGHT_TECH_SPECIFIC) +
|
||||
(d4 * cls.WEIGHT_CARDINAL_SKILLS) +
|
||||
(d5 * cls.WEIGHT_EXPERIENCE) +
|
||||
(d6 * cls.WEIGHT_PRINCIPLES) +
|
||||
(d7 * cls.WEIGHT_INNOVATION)
|
||||
).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
|
||||
|
||||
# Determine Final Result Status
|
||||
if is_knockout:
|
||||
result_status = 'DESCARTADO'
|
||||
verdict_message = f"Descartado por Criterio Knockout: Caso Práctico ({d1}/100) inferior al mínimo aprobatorio de {cls.KNOCKOUT_THRESHOLD} puntos."
|
||||
elif weighted_score >= cls.APPROVAL_THRESHOLD:
|
||||
result_status = 'APROBADO_CARTERA'
|
||||
verdict_message = f"¡Aprobado en Cartera de Especialistas! Puntaje de {weighted_score}/100 superior al umbral de excelencia ({cls.APPROVAL_THRESHOLD})."
|
||||
elif weighted_score >= cls.RESERVE_THRESHOLD:
|
||||
result_status = 'EN_RESERVA'
|
||||
verdict_message = f"Calificado en Banco de Reserva ({weighted_score}/100). Cumple estándares operacionales para proyectos secundarios o convocatorias de contingencia."
|
||||
else:
|
||||
result_status = 'DESCARTADO'
|
||||
verdict_message = f"Descartado: Puntaje ponderado ({weighted_score}/100) no alcanza el umbral mínimo de corte ({cls.RESERVE_THRESHOLD})."
|
||||
|
||||
eval_date = evaluation_date or date.today()
|
||||
valid_until = eval_date + timedelta(days=cls.VALIDITY_DAYS)
|
||||
|
||||
return {
|
||||
'weighted_score': weighted_score,
|
||||
'is_knockout': is_knockout,
|
||||
'result_status': result_status,
|
||||
'verdict_message': verdict_message,
|
||||
'valid_until': valid_until,
|
||||
'breakdown': {
|
||||
'case_study_weighted': (d1 * cls.WEIGHT_CASE_STUDY).quantize(Decimal('0.01')),
|
||||
'portfolio_weighted': (d2 * cls.WEIGHT_PORTFOLIO).quantize(Decimal('0.01')),
|
||||
'tech_specific_weighted': (d3 * cls.WEIGHT_TECH_SPECIFIC).quantize(Decimal('0.01')),
|
||||
'cardinal_skills_weighted': (d4 * cls.WEIGHT_CARDINAL_SKILLS).quantize(Decimal('0.01')),
|
||||
'experience_weighted': (d5 * cls.WEIGHT_EXPERIENCE).quantize(Decimal('0.01')),
|
||||
'principles_weighted': (d6 * cls.WEIGHT_PRINCIPLES).quantize(Decimal('0.01')),
|
||||
'innovation_weighted': (d7 * cls.WEIGHT_INNOVATION).quantize(Decimal('0.01')),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'talent'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.candidate_list, name='talent_index'),
|
||||
path('especialistas/', views.candidate_list, name='candidate_list'),
|
||||
path('especialistas/nuevo/', views.candidate_create, name='candidate_create'),
|
||||
path('especialistas/<int:pk>/', views.candidate_detail, name='candidate_detail'),
|
||||
path('especialistas/<int:pk>/editar/', views.candidate_edit, name='candidate_edit'),
|
||||
|
||||
# Evaluaciones 7D
|
||||
path('evaluaciones/nueva/', views.evaluation_create, name='evaluation_create'),
|
||||
path('especialistas/<int:candidate_pk>/evaluar/', views.evaluation_create, name='evaluation_for_candidate'),
|
||||
path('evaluaciones/<int:pk>/', views.evaluation_detail, name='evaluation_detail'),
|
||||
]
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib import messages
|
||||
from django.http import JsonResponse
|
||||
from django.db.models import Q, Count, Avg
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
import json
|
||||
|
||||
from .models import SpecialistCandidate, EvaluationRubric7D, SpecialistCategoryEnum, SeniorityEnum, CandidateStatusEnum
|
||||
from .forms import SpecialistCandidateForm, EvaluationRubric7DForm
|
||||
from .services.rubric import TalentScoringEngine
|
||||
from expenses.chile_regions import REGIONS_COMMUNES
|
||||
|
||||
@login_required
|
||||
def candidate_list(request):
|
||||
candidates = SpecialistCandidate.objects.select_related().all().order_by('-created_at')
|
||||
|
||||
# Filter params
|
||||
category = request.GET.get('category')
|
||||
seniority = request.GET.get('seniority')
|
||||
trl = request.GET.get('trl')
|
||||
status = request.GET.get('status')
|
||||
query = request.GET.get('q')
|
||||
|
||||
if category:
|
||||
candidates = candidates.filter(category_code=category)
|
||||
if seniority:
|
||||
candidates = candidates.filter(seniority=seniority)
|
||||
if trl:
|
||||
candidates = candidates.filter(trl_level=int(trl))
|
||||
if status:
|
||||
candidates = candidates.filter(status=status)
|
||||
if query:
|
||||
candidates = candidates.filter(
|
||||
Q(first_name__icontains=query) |
|
||||
Q(last_name__icontains=query) |
|
||||
Q(rut__icontains=query) |
|
||||
Q(specialty_title__icontains=query) |
|
||||
Q(summary__icontains=query)
|
||||
)
|
||||
|
||||
# Statistics
|
||||
stats = {
|
||||
'total': SpecialistCandidate.objects.count(),
|
||||
'approved': SpecialistCandidate.objects.filter(status=CandidateStatusEnum.APROBADO_CARTERA).count(),
|
||||
'reserve': SpecialistCandidate.objects.filter(status=CandidateStatusEnum.EN_RESERVA).count(),
|
||||
'evaluating': SpecialistCandidate.objects.filter(status=CandidateStatusEnum.EN_EVALUACION).count(),
|
||||
}
|
||||
|
||||
return render(request, 'talent/candidate_list.html', {
|
||||
'candidates': candidates,
|
||||
'stats': stats,
|
||||
'categories': SpecialistCategoryEnum.choices,
|
||||
'seniorities': SeniorityEnum.choices,
|
||||
'statuses': CandidateStatusEnum.choices,
|
||||
'selected_category': category,
|
||||
'selected_seniority': seniority,
|
||||
'selected_trl': trl,
|
||||
'selected_status': status,
|
||||
'query': query,
|
||||
})
|
||||
|
||||
@login_required
|
||||
def candidate_create(request):
|
||||
if request.method == 'POST':
|
||||
form = SpecialistCandidateForm(request.POST)
|
||||
if form.is_valid():
|
||||
cand = form.save()
|
||||
messages.success(request, f"Especialista {cand.full_name} registrado en cartera.")
|
||||
return redirect('talent:candidate_detail', pk=cand.pk)
|
||||
else:
|
||||
form = SpecialistCandidateForm()
|
||||
return render(request, 'talent/candidate_form.html', {
|
||||
'form': form,
|
||||
'title': 'Registrar Nuevo Especialista I+D+i',
|
||||
'regions_json': json.dumps(REGIONS_COMMUNES)
|
||||
})
|
||||
|
||||
@login_required
|
||||
def candidate_edit(request, pk):
|
||||
cand = get_object_or_404(SpecialistCandidate, pk=pk)
|
||||
if request.method == 'POST':
|
||||
form = SpecialistCandidateForm(request.POST, instance=cand)
|
||||
if form.is_valid():
|
||||
cand = form.save()
|
||||
messages.success(request, f"Perfil de {cand.full_name} actualizado.")
|
||||
return redirect('talent:candidate_detail', pk=cand.pk)
|
||||
else:
|
||||
form = SpecialistCandidateForm(instance=cand)
|
||||
return render(request, 'talent/candidate_form.html', {
|
||||
'form': form,
|
||||
'title': f"Editar Especialista: {cand.full_name}",
|
||||
'regions_json': json.dumps(REGIONS_COMMUNES)
|
||||
})
|
||||
|
||||
@login_required
|
||||
def candidate_detail(request, pk):
|
||||
cand = get_object_or_404(SpecialistCandidate, pk=pk)
|
||||
evaluations = cand.evaluations.select_related('evaluator').order_by('-evaluation_date', '-created_at')
|
||||
latest_eval = evaluations.first()
|
||||
|
||||
return render(request, 'talent/candidate_detail.html', {
|
||||
'candidate': cand,
|
||||
'evaluations': evaluations,
|
||||
'latest_eval': latest_eval,
|
||||
})
|
||||
|
||||
@login_required
|
||||
def evaluation_create(request, candidate_pk=None):
|
||||
candidate = get_object_or_404(SpecialistCandidate, pk=candidate_pk) if candidate_pk else None
|
||||
|
||||
if request.method == 'POST':
|
||||
form = EvaluationRubric7DForm(request.POST)
|
||||
if form.is_valid():
|
||||
ev = form.save(commit=False)
|
||||
ev.evaluator = request.user
|
||||
|
||||
# Run Scoring Engine
|
||||
result = TalentScoringEngine.evaluate(
|
||||
dim_case_study=ev.dim_case_study,
|
||||
dim_portfolio=ev.dim_portfolio,
|
||||
dim_tech_specific=ev.dim_tech_specific,
|
||||
dim_cardinal_skills=ev.dim_cardinal_skills,
|
||||
dim_experience=ev.dim_experience,
|
||||
dim_principles_safety=ev.dim_principles_safety,
|
||||
dim_innovation_rndi=ev.dim_innovation_rndi,
|
||||
evaluation_date=ev.evaluation_date
|
||||
)
|
||||
|
||||
ev.total_weighted_score = result['weighted_score']
|
||||
ev.is_knockout = result['is_knockout']
|
||||
ev.result_status = result['result_status']
|
||||
ev.valid_until = result['valid_until']
|
||||
ev.save()
|
||||
|
||||
# Sync candidate status
|
||||
cand_obj = ev.candidate
|
||||
cand_obj.status = ev.result_status
|
||||
cand_obj.save(update_fields=['status'])
|
||||
|
||||
if ev.is_knockout:
|
||||
messages.error(request, f"Evaluación finalizada: {result['verdict_message']}")
|
||||
else:
|
||||
messages.success(request, f"Evaluación 7D guardada exitosamente. Score: {ev.total_weighted_score}/100 - {result['verdict_message']}")
|
||||
|
||||
return redirect('talent:candidate_detail', pk=cand_obj.pk)
|
||||
else:
|
||||
initial = {'evaluation_date': date.today()}
|
||||
if candidate:
|
||||
initial['candidate'] = candidate
|
||||
form = EvaluationRubric7DForm(initial=initial)
|
||||
|
||||
return render(request, 'talent/evaluation_form.html', {
|
||||
'form': form,
|
||||
'candidate': candidate,
|
||||
})
|
||||
|
||||
@login_required
|
||||
def evaluation_detail(request, pk):
|
||||
eval_obj = get_object_or_404(EvaluationRubric7D.objects.select_related('candidate', 'evaluator'), pk=pk)
|
||||
return render(request, 'talent/evaluation_detail.html', {'eval': eval_obj})
|
||||
Reference in New Issue
Block a user