import logging from odoo import models, fields, api, _ from odoo.exceptions import ValidationError _logger = logging.getLogger(__name__) class HrEfficiencyIndicator(models.Model): _name = 'hr.efficiency.indicator' _description = 'Efficiency Indicator Configuration' _order = 'sequence, name' name = fields.Char('Indicator Name', required=True) sequence = fields.Integer('Sequence', default=10) active = fields.Boolean('Active', default=True) # Indicator type for widget selection indicator_type = fields.Selection([ ('percentage', 'Percentage'), ('hours', 'Hours'), ('currency', 'Currency'), ('number', 'Number') ], string='Indicator Type', default='percentage', required=True, help='Type of indicator that determines how it will be displayed in views') # Formula configuration formula = fields.Text('Formula', required=True, help=""" Use the following variables in your formula: - available_hours: Available hours for the employee - planned_hours: Total planned hours - planned_billable_hours: Planned hours on billable projects - planned_non_billable_hours: Planned hours on non-billable projects - actual_billable_hours: Actual hours worked on billable projects - actual_non_billable_hours: Actual hours worked on non-billable projects Examples: - (planned_hours / available_hours) * 100 # Planning efficiency - ((actual_billable_hours + actual_non_billable_hours) / planned_hours) * 100 # Time tracking efficiency """) # Target and thresholds target_percentage = fields.Float('Target %', default=90.0, help='Target percentage for this indicator') weight = fields.Float('Weight %', default=50.0, help='Weight of this indicator in the overall efficiency calculation') # Priority for column background color priority = fields.Selection([ ('none', 'Sin Prioridad'), ('low', 'Baja (Verde)'), ('medium', 'Media (Amarillo)'), ('high', 'Alta (Rojo)') ], string='Prioridad', default='none', help='Prioridad del indicador que determina el color de fondo de la columna') # Display settings description = fields.Text('Description', help='Description of what this indicator measures') color_threshold_green = fields.Float('Green Threshold', default=90.0, help='Percentage above which to show green') color_threshold_yellow = fields.Float('Yellow Threshold', default=70.0, help='Percentage above which to show yellow') color_threshold_red = fields.Float('Red Threshold', default=50.0, help='Percentage above which to show red (below this shows danger)') # Computed field for priority background color priority_color = fields.Char('Priority Color', compute='_compute_priority_color', store=True, help='Color de fondo basado en la prioridad') @api.depends('priority') def _compute_priority_color(self): """Compute background color based on priority""" for record in self: if record.priority == 'low': record.priority_color = '#d4edda' # Verde claro elif record.priority == 'medium': record.priority_color = '#fff3cd' # Amarillo claro elif record.priority == 'high': record.priority_color = '#f8d7da' # Rojo claro else: record.priority_color = '' # Sin color (transparente) @api.constrains('weight') def _check_weight(self): for record in self: if record.weight < 0 or record.weight > 100: raise ValidationError(_('Weight must be between 0 and 100')) @api.constrains('target_percentage') def _check_target_percentage(self): for record in self: if record.target_percentage < 0 or record.target_percentage > 100: raise ValidationError(_('Target percentage must be between 0 and 100')) @api.model_create_multi def create(self, vals_list): """Create indicators and create dynamic fields""" records = super().create(vals_list) # Create dynamic fields for all indicators for record in records: self._create_dynamic_field(record) return records def write(self, vals): """Update indicator and update dynamic field""" result = super().write(vals) # Update dynamic field for this indicator for record in self: self._update_dynamic_field(record, vals) return result def unlink(self): """Delete indicator and delete dynamic field""" # FIRST: Mark indicators as inactive to exclude them from views self.write({'active': False}) # SECOND: Update views to remove field references BEFORE deleting fields self.env['hr.efficiency']._update_views_with_dynamic_fields() # THEN: Delete associated dynamic fields and manual fields for record in self: dynamic_field = self.env['hr.efficiency.dynamic.field'].search([ ('indicator_id', '=', record.id) ]) if dynamic_field: dynamic_field.unlink() # Also remove the corresponding manual field in ir.model.fields efficiency_model = 'hr.efficiency' technical_name = self.env[efficiency_model]._get_indicator_field_name(record.name) imf = self.env['ir.model.fields'].search([ ('model', '=', efficiency_model), ('name', '=', technical_name), ('state', '=', 'manual'), ], limit=1) if imf: imf.unlink() result = super().unlink() return result def _create_dynamic_field(self, indicator): """Create a dynamic field for the indicator""" # Create dynamic field record for all indicators existing_field = self.env['hr.efficiency.dynamic.field'].search([ ('indicator_id', '=', indicator.id) ]) if not existing_field: # Create dynamic field field_name = indicator.name.lower().replace(' ', '_').replace('-', '_') field_name = field_name.replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ó', 'o').replace('ú', 'u') field_name = field_name.replace('ñ', 'n') # Ensure it starts with a letter if not field_name[0].isalpha(): field_name = 'indicator_' + field_name # Determine widget based on indicator type widget_map = { 'percentage': 'percentage', 'hours': 'float_time', 'currency': 'monetary', 'number': 'float' } widget = widget_map.get(indicator.indicator_type, 'percentage') self.env['hr.efficiency.dynamic.field'].create({ 'name': field_name, 'label': indicator.name, 'indicator_id': indicator.id, 'sequence': indicator.sequence, 'active': indicator.active, 'show_in_list': True, 'show_in_form': True, 'show_in_search': False, 'widget': widget, 'decoration_success': indicator.color_threshold_green, 'decoration_warning': indicator.color_threshold_yellow, 'decoration_danger': indicator.color_threshold_red, 'priority_background_color': indicator.priority_color, }) # Ensure an ir.model.fields manual field exists (Studio-like) for ALL indicators efficiency_model = 'hr.efficiency' model_rec = self.env['ir.model']._get(efficiency_model) # Compute the technical field name like Studio (x_ prefix) technical_name = self.env[efficiency_model]._get_indicator_field_name(indicator.name) imf = self.env['ir.model.fields'].search([ ('model', '=', efficiency_model), ('name', '=', technical_name), ], limit=1) if not imf: self.env['ir.model.fields'].with_context(studio=True).create({ 'name': technical_name, 'model': efficiency_model, 'model_id': model_rec.id, 'ttype': 'float', 'field_description': indicator.name, 'help': indicator.description or indicator.name, 'state': 'manual', 'store': True, 'compute': False, }) else: # Keep label and help in sync update_vals = {} if imf.field_description != indicator.name: update_vals['field_description'] = indicator.name if imf.help != (indicator.description or indicator.name): update_vals['help'] = indicator.description or indicator.name if update_vals: imf.write(update_vals) # Update views to include the new field self.env['hr.efficiency']._update_views_with_dynamic_fields() # Recompute all indicators to populate the new stored field records = self.env['hr.efficiency'].search([]) if records: records._calculate_all_indicators() def _update_dynamic_field(self, indicator, vals=None): """Update the dynamic field for the indicator""" dynamic_field = self.env['hr.efficiency.dynamic.field'].search([ ('indicator_id', '=', indicator.id) ]) if dynamic_field: # Determine widget based on indicator type widget_map = { 'percentage': 'percentage', 'hours': 'float_time', 'currency': 'monetary', 'number': 'float' } widget = widget_map.get(indicator.indicator_type, 'percentage') dynamic_field.write({ 'label': indicator.name, 'active': indicator.active, 'widget': widget, 'decoration_success': indicator.color_threshold_green, 'decoration_warning': indicator.color_threshold_yellow, 'decoration_danger': indicator.color_threshold_red, 'priority_background_color': indicator.priority_color, }) # Sync corresponding ir.model.fields label and refresh views efficiency_model = 'hr.efficiency' technical_name = self.env[efficiency_model]._get_indicator_field_name(indicator.name) imf = self.env['ir.model.fields'].search([ ('model', '=', efficiency_model), ('name', '=', technical_name), ], limit=1) if imf: update_vals = {} if imf.field_description != indicator.name: update_vals['field_description'] = indicator.name if imf.help != (indicator.description or indicator.name): update_vals['help'] = indicator.description or indicator.name if update_vals: imf.write(update_vals) # Update views when active status, sequence, or bulk operations change if 'active' in vals or 'sequence' in vals or len(self) > 1: try: self.env['hr.efficiency']._update_views_with_dynamic_fields() except Exception as e: # Log the error but don't let it rollback the transaction import logging _logger = logging.getLogger(__name__) _logger.error(f"Error updating views after indicator change: {e}") # Continue with the transaction even if view update fails def evaluate_formula(self, efficiency_data): """ Evaluate the formula using the provided efficiency data """ try: # Create a safe environment for formula evaluation safe_dict = { 'available_hours': efficiency_data.get('available_hours', 0), 'planned_hours': efficiency_data.get('planned_hours', 0), 'planned_billable_hours': efficiency_data.get('planned_billable_hours', 0), 'planned_non_billable_hours': efficiency_data.get('planned_non_billable_hours', 0), 'actual_billable_hours': efficiency_data.get('actual_billable_hours', 0), 'actual_non_billable_hours': efficiency_data.get('actual_non_billable_hours', 0), 'total_actual_hours': efficiency_data.get('total_actual_hours', 0), 'expected_hours_to_date': efficiency_data.get('expected_hours_to_date', 0), 'wage': efficiency_data.get('wage', 0), 'utilization_rate': efficiency_data.get('utilization_rate', 100.0), 'overhead': efficiency_data.get('overhead', 40.0), 'precio_por_hora': efficiency_data.get('precio_por_hora', 0), } # Add math functions for safety import math safe_dict.update({ 'abs': abs, 'min': min, 'max': max, 'round': round, }) # Evaluate the formula result = eval(self.formula, {"__builtins__": {}}, safe_dict) return result except Exception as e: _logger.error(f"Error evaluating formula for indicator {self.name}: {e}") return 0.0 def get_color_class(self, percentage): """ Return the CSS color class based on the percentage """ if percentage >= self.color_threshold_green: return 'text-success' elif percentage >= self.color_threshold_yellow: return 'text-warning' elif percentage >= self.color_threshold_red: return 'text-danger' else: return 'text-danger' # Below red threshold - critical