From 58e164849302a9d8d64c3bc52f2217681d5ab205 Mon Sep 17 00:00:00 2001 From: Luis Ernesto Portillo Zaldivar Date: Tue, 15 Jul 2025 19:19:51 -0600 Subject: [PATCH] =?UTF-8?q?feat(#9):=20Implementar=20flujo=20de=20validaci?= =?UTF-8?q?=C3=B3n=20y=20seguridad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajustar permisos base: recepcionistas solo lectura, técnicos sin eliminar - Crear reglas de registro para control granular por estado - Implementar verificación de permisos en todas las transiciones - Agregar mail.thread a stock.lot para trazabilidad completa - Validar transiciones de estado y muestras asociadas - Actualizar vistas con restricciones según grupos de usuario - Mejorar mensajes del chatter con más contexto Co-Authored-By: Claude --- lims_management/models/lims_test.py | 201 +++++++++++++++++-- lims_management/models/stock_lot.py | 45 ++++- lims_management/security/ir.model.access.csv | 8 +- lims_management/security/lims_security.xml | 76 +++++++ lims_management/views/lims_test_views.xml | 20 +- pr_body_54.txt | 74 +++++++ 6 files changed, 399 insertions(+), 25 deletions(-) create mode 100644 pr_body_54.txt diff --git a/lims_management/models/lims_test.py b/lims_management/models/lims_test.py index 70d2de0..5708605 100644 --- a/lims_management/models/lims_test.py +++ b/lims_management/models/lims_test.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- from odoo import models, fields, api, _ -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError import logging _logger = logging.getLogger(__name__) @@ -190,6 +190,12 @@ class LimsTest(models.Model): def action_start_process(self): """Inicia el proceso de análisis.""" self.ensure_one() + + # Verificar permisos: solo técnicos y administradores + if not (self.env.user.has_group('lims_management.group_lims_technician') or + self.env.user.has_group('lims_management.group_lims_admin')): + raise UserError(_('No tiene permisos para iniciar el proceso de análisis. Solo técnicos y administradores pueden realizar esta acción.')) + if self.state != 'draft': raise UserError(_('Solo se pueden procesar pruebas en estado borrador.')) if not self.sample_id: @@ -203,20 +209,44 @@ class LimsTest(models.Model): # Log en el chatter self.message_post( body=_('Prueba iniciada por %s') % self.env.user.name, - subject=_('Proceso Iniciado') + subject=_('Proceso Iniciado'), + message_type='notification' ) + # Actualizar estado de la muestra si es necesario + if self.sample_id and self.sample_id.state == 'collected': + self.sample_id.write({'state': 'in_analysis'}) + self.sample_id.message_post( + body=_('Muestra en análisis para la prueba %s') % self.name, + subject=_('Estado actualizado'), + message_type='notification' + ) + return True def action_enter_results(self): """Marca como resultados ingresados.""" self.ensure_one() + + # Verificar permisos: solo técnicos y administradores + if not (self.env.user.has_group('lims_management.group_lims_technician') or + self.env.user.has_group('lims_management.group_lims_admin')): + raise UserError(_('No tiene permisos para ingresar resultados. Solo técnicos y administradores pueden realizar esta acción.')) + if self.state != 'in_process': raise UserError(_('Solo se pueden ingresar resultados en pruebas en proceso.')) if not self.result_ids: raise UserError(_('Debe ingresar al menos un resultado.')) + # Verificar que todos los resultados tengan valores ingresados + empty_results = self.result_ids.filtered( + lambda r: not r.value_text and not r.value_numeric and not r.value_boolean and r.parameter_id.value_type != 'boolean' + ) + if empty_results: + params = ', '.join(empty_results.mapped('parameter_id.name')) + raise UserError(_('Los siguientes parámetros no tienen resultados ingresados: %s') % params) + # Si no requiere validación, pasar directamente a validado if not self.require_validation: self.write({ @@ -226,13 +256,15 @@ class LimsTest(models.Model): }) self.message_post( body=_('Resultados ingresados y auto-validados por %s') % self.env.user.name, - subject=_('Resultados Validados') + subject=_('Resultados Validados'), + message_type='notification' ) else: self.state = 'result_entered' self.message_post( body=_('Resultados ingresados por %s') % self.env.user.name, - subject=_('Resultados Ingresados') + subject=_('Resultados Ingresados'), + message_type='notification' ) return True @@ -240,10 +272,23 @@ class LimsTest(models.Model): def action_validate(self): """Valida los resultados (solo administradores).""" self.ensure_one() + + # Verificar permisos: solo administradores + if not self.env.user.has_group('lims_management.group_lims_admin'): + raise UserError(_('No tiene permisos para validar resultados. Solo administradores pueden realizar esta acción.')) + if self.state != 'result_entered': raise UserError(_('Solo se pueden validar pruebas con resultados ingresados.')) - # TODO: Verificar permisos cuando se implemente seguridad + # Verificar que todos los resultados críticos tengan observaciones si están fuera de rango + critical_results = [] + for result in self.result_ids: + if result.is_out_of_range and result.parameter_id.is_critical: + if not result.notes: + critical_results.append(result.parameter_id.name) + + if critical_results: + raise UserError(_('Los siguientes parámetros críticos están fuera de rango y requieren observaciones: %s') % ', '.join(critical_results)) self.write({ 'state': 'validated', @@ -251,26 +296,56 @@ class LimsTest(models.Model): 'validation_date': fields.Datetime.now() }) - # Log en el chatter + # Log en el chatter con más detalles + out_of_range_count = len(self.result_ids.filtered('is_out_of_range')) + body = _('Resultados validados por %s') % self.env.user.name + if out_of_range_count: + body += _('
%d parámetros fuera de rango') % out_of_range_count + self.message_post( - body=_('Resultados validados por %s') % self.env.user.name, - subject=_('Resultados Validados') + body=body, + subject=_('Resultados Validados'), + message_type='notification' ) + # Actualizar estado de la muestra si todas las pruebas están validadas + if self.sample_id: + all_tests = self.env['lims.test'].search([ + ('sample_id', '=', self.sample_id.id), + ('state', '!=', 'cancelled') + ]) + if all(test.state == 'validated' for test in all_tests): + self.sample_id.write({'state': 'analyzed'}) + self.sample_id.message_post( + body=_('Todas las pruebas de la muestra han sido validadas'), + subject=_('Análisis completado'), + message_type='notification' + ) + return True def action_cancel(self): """Cancela la prueba.""" self.ensure_one() - if self.state == 'validated': - raise UserError(_('No se pueden cancelar pruebas validadas.')) + # Verificar permisos: técnicos y administradores pueden cancelar + if not (self.env.user.has_group('lims_management.group_lims_technician') or + self.env.user.has_group('lims_management.group_lims_admin')): + raise UserError(_('No tiene permisos para cancelar pruebas. Solo técnicos y administradores pueden realizar esta acción.')) + + if self.state == 'validated': + # Solo administradores pueden cancelar pruebas validadas + if not self.env.user.has_group('lims_management.group_lims_admin'): + raise UserError(_('No se pueden cancelar pruebas validadas. Solo administradores pueden realizar esta acción.')) + + old_state = self.state self.state = 'cancelled' - # Log en el chatter + # Log en el chatter con el estado anterior self.message_post( - body=_('Prueba cancelada por %s') % self.env.user.name, - subject=_('Prueba Cancelada') + body=_('Prueba cancelada por %s (estado anterior: %s)') % (self.env.user.name, dict(self._fields['state'].selection).get(old_state)), + subject=_('Prueba Cancelada'), + message_type='notification' ) return True @@ -278,6 +353,12 @@ class LimsTest(models.Model): def action_regenerate_results(self): """Regenera los resultados basados en la configuración actual del análisis.""" self.ensure_one() + + # Verificar permisos: solo técnicos y administradores + if not (self.env.user.has_group('lims_management.group_lims_technician') or + self.env.user.has_group('lims_management.group_lims_admin')): + raise UserError(_('No tiene permisos para regenerar resultados. Solo técnicos y administradores pueden realizar esta acción.')) + if self.state not in ['draft', 'in_process']: raise UserError(_('Solo se pueden regenerar resultados en pruebas en borrador o en proceso.')) @@ -292,7 +373,8 @@ class LimsTest(models.Model): self.message_post( body=_('Resultados regenerados por %s') % self.env.user.name, - subject=_('Resultados Regenerados') + subject=_('Resultados Regenerados'), + message_type='notification' ) return True @@ -300,9 +382,98 @@ class LimsTest(models.Model): def action_draft(self): """Regresa a borrador.""" self.ensure_one() + + # Verificar permisos: solo administradores pueden regresar a borrador + if not self.env.user.has_group('lims_management.group_lims_admin'): + raise UserError(_('No tiene permisos para regresar pruebas a borrador. Solo administradores pueden realizar esta acción.')) + if self.state not in ['cancelled']: raise UserError(_('Solo se pueden regresar a borrador pruebas canceladas.')) self.state = 'draft' - return True \ No newline at end of file + self.message_post( + body=_('Prueba regresada a borrador por %s') % self.env.user.name, + subject=_('Estado Restaurado'), + message_type='notification' + ) + + return True + + @api.constrains('state') + def _check_state_transition(self): + """Valida que las transiciones de estado sean válidas""" + for record in self: + # Definir transiciones válidas + valid_transitions = { + 'draft': ['in_process', 'cancelled'], + 'in_process': ['result_entered', 'cancelled'], + 'result_entered': ['validated', 'cancelled'], + 'validated': ['cancelled'], # Solo admin puede cancelar validados + 'cancelled': ['draft'] # Solo admin puede regresar a draft + } + + # Si es un registro nuevo, no hay transición que validar + if not record._origin.id: + continue + + old_state = record._origin.state + new_state = record.state + + # Si el estado no cambió, no hay nada que validar + if old_state == new_state: + continue + + # Verificar si la transición es válida + if old_state in valid_transitions: + if new_state not in valid_transitions[old_state]: + raise ValidationError( + _('Transición de estado no válida: No se puede cambiar de "%s" a "%s"') % + (dict(self._fields['state'].selection).get(old_state), + dict(self._fields['state'].selection).get(new_state)) + ) + + @api.constrains('sample_id', 'state') + def _check_sample_state(self): + """Valida que la muestra esté en un estado apropiado para la prueba""" + for record in self: + if record.sample_id and record.state in ['in_process', 'result_entered']: + # La muestra debe estar al menos recolectada + if record.sample_id.state in ['pending_collection', 'cancelled']: + raise ValidationError( + _('No se puede procesar una prueba con una muestra en estado "%s"') % + dict(record.sample_id._fields['state'].selection).get(record.sample_id.state) + ) + + @api.model + def create(self, vals): + """Override create para validaciones adicionales""" + # Si se está creando con un estado diferente a draft, verificar permisos + if vals.get('state') and vals['state'] != 'draft': + if not self.env.user.has_group('lims_management.group_lims_admin'): + raise UserError(_('Solo administradores pueden crear pruebas en estado diferente a borrador')) + + return super().create(vals) + + def write(self, vals): + """Override write para auditoría adicional""" + # Si se está cambiando el estado, registrar más detalles + if 'state' in vals: + for record in self: + old_state = record.state + # El write real se hace en el super() + + result = super().write(vals) + + # Registrar cambios importantes después del write + if 'sample_id' in vals: + for record in self: + if vals.get('sample_id'): + sample = self.env['stock.lot'].browse(vals['sample_id']) + record.message_post( + body=_('Muestra asignada: %s') % sample.name, + subject=_('Muestra Asignada'), + message_type='notification' + ) + + return result \ No newline at end of file diff --git a/lims_management/models/stock_lot.py b/lims_management/models/stock_lot.py index 02d42e1..de0e31b 100644 --- a/lims_management/models/stock_lot.py +++ b/lims_management/models/stock_lot.py @@ -4,7 +4,8 @@ from datetime import datetime import random class StockLot(models.Model): - _inherit = 'stock.lot' + _name = 'stock.lot' + _inherit = ['stock.lot', 'mail.thread', 'mail.activity.mixin'] is_lab_sample = fields.Boolean(string='Es Muestra de Laboratorio') @@ -86,31 +87,73 @@ class StockLot(models.Model): def action_collect(self): """Mark sample as collected""" + old_state = self.state self.write({'state': 'collected', 'collection_date': fields.Datetime.now()}) + self.message_post( + body='Muestra recolectada por %s' % self.env.user.name, + subject='Estado actualizado: Recolectada', + message_type='notification' + ) def action_receive(self): """Mark sample as received in laboratory""" + old_state = self.state self.write({'state': 'received'}) + self.message_post( + body='Muestra recibida en laboratorio por %s' % self.env.user.name, + subject='Estado actualizado: Recibida', + message_type='notification' + ) def action_start_analysis(self): """Start analysis process""" + old_state = self.state self.write({'state': 'in_process'}) + self.message_post( + body='Análisis iniciado por %s' % self.env.user.name, + subject='Estado actualizado: En Proceso', + message_type='notification' + ) def action_complete_analysis(self): """Mark analysis as completed""" + old_state = self.state self.write({'state': 'analyzed'}) + self.message_post( + body='Análisis completado por %s' % self.env.user.name, + subject='Estado actualizado: Analizada', + message_type='notification' + ) def action_store(self): """Store the sample""" + old_state = self.state self.write({'state': 'stored'}) + self.message_post( + body='Muestra almacenada por %s' % self.env.user.name, + subject='Estado actualizado: Almacenada', + message_type='notification' + ) def action_dispose(self): """Dispose of the sample""" + old_state = self.state self.write({'state': 'disposed'}) + self.message_post( + body='Muestra desechada por %s. Motivo de disposición registrado.' % self.env.user.name, + subject='Estado actualizado: Desechada', + message_type='notification' + ) def action_cancel(self): """Cancel the sample""" + old_state = self.state self.write({'state': 'cancelled'}) + self.message_post( + body='Muestra cancelada por %s' % self.env.user.name, + subject='Estado actualizado: Cancelada', + message_type='notification' + ) @api.onchange('sample_type_product_id') def _onchange_sample_type_product_id(self): diff --git a/lims_management/security/ir.model.access.csv b/lims_management/security/ir.model.access.csv index 36ea507..1e120c3 100644 --- a/lims_management/security/ir.model.access.csv +++ b/lims_management/security/ir.model.access.csv @@ -7,5 +7,9 @@ access_lims_parameter_range_user,lims.parameter.range.user,model_lims_parameter_ access_lims_parameter_range_manager,lims.parameter.range.manager,model_lims_parameter_range,group_lims_admin,1,1,1,1 access_sale_order_receptionist,sale.order.receptionist,sale.model_sale_order,group_lims_receptionist,1,1,1,0 access_stock_lot_user,stock.lot.user,stock.model_stock_lot,base.group_user,1,1,1,1 -access_lims_test_user,lims.test.user,model_lims_test,base.group_user,1,1,1,1 -access_lims_result_user,lims.result.user,model_lims_result,base.group_user,1,1,1,1 +access_lims_test_receptionist,lims.test.receptionist,model_lims_test,group_lims_receptionist,1,0,0,0 +access_lims_test_technician,lims.test.technician,model_lims_test,group_lims_technician,1,1,1,0 +access_lims_test_admin,lims.test.admin,model_lims_test,group_lims_admin,1,1,1,1 +access_lims_result_receptionist,lims.result.receptionist,model_lims_result,group_lims_receptionist,1,0,0,0 +access_lims_result_technician,lims.result.technician,model_lims_result,group_lims_technician,1,1,1,0 +access_lims_result_admin,lims.result.admin,model_lims_result,group_lims_admin,1,1,1,1 diff --git a/lims_management/security/lims_security.xml b/lims_management/security/lims_security.xml index c72d725..c63ab08 100644 --- a/lims_management/security/lims_security.xml +++ b/lims_management/security/lims_security.xml @@ -33,5 +33,81 @@ El usuario tiene acceso completo al módulo LIMS, incluyendo la validación de resultados, configuración y reportes. + + + + + + Recepcionista: Solo lectura en pruebas + + + + + + + [(1, '=', 1)] + + + + + Técnico: Editar solo pruebas no validadas + + + + + + + [('state', '!=', 'validated')] + + + + + Administrador: Acceso completo a pruebas + + + + + + + [(1, '=', 1)] + + + + + + + Recepcionista: Solo lectura en resultados + + + + + + + [(1, '=', 1)] + + + + + Técnico: Editar resultados de pruebas no validadas + + + + + + + [('test_id.state', '!=', 'validated')] + + + + + Administrador: Acceso completo a resultados + + + + + + + [(1, '=', 1)] + diff --git a/lims_management/views/lims_test_views.xml b/lims_management/views/lims_test_views.xml index d317dc6..e4b721c 100644 --- a/lims_management/views/lims_test_views.xml +++ b/lims_management/views/lims_test_views.xml @@ -11,23 +11,29 @@
@@ -58,7 +64,7 @@