diff --git a/lims_management/__manifest__.py b/lims_management/__manifest__.py
index 95f6fdc..95e14e7 100644
--- a/lims_management/__manifest__.py
+++ b/lims_management/__manifest__.py
@@ -45,6 +45,7 @@
'views/menus.xml',
],
'demo': [
+ 'demo/demo_users.xml',
'demo/z_lims_demo.xml',
'demo/z_analysis_demo.xml',
'demo/z_sample_demo.xml',
diff --git a/lims_management/demo/demo_users.xml b/lims_management/demo/demo_users.xml
new file mode 100644
index 0000000..496709a
--- /dev/null
+++ b/lims_management/demo/demo_users.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+ Recepcionista Demo
+ recepcionista
+ demo
+ recepcionista@example.com
+
+
+
+
+
+
+
+ Técnico Demo
+ tecnico
+ demo
+ tecnico@example.com
+
+
+
+
+
+
+
+ Administrador Lab Demo
+ administrador
+ demo
+ administrador@example.com
+
+
+
+
+
+
+
+ Recepcionista Demo
+ recepcionista@example.com
+
+
+
+
+
+ Técnico Demo
+ tecnico@example.com
+
+
+
+
+
+ Administrador Lab Demo
+ administrador@example.com
+
+
+
+
+
+
\ No newline at end of file
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 @@
+ invisible="state != 'draft'"
+ groups="lims_management.group_lims_technician"/>
+ invisible="state != 'in_process'"
+ groups="lims_management.group_lims_technician"/>
+ invisible="state != 'result_entered' or not require_validation"
+ groups="lims_management.group_lims_admin"/>
+ invisible="state in ['validated', 'cancelled']"
+ groups="lims_management.group_lims_technician"/>
+ invisible="state != 'cancelled'"
+ groups="lims_management.group_lims_admin"/>
+ confirm="¿Está seguro de regenerar los resultados? Esto eliminará los resultados actuales."
+ groups="lims_management.group_lims_technician"/>
@@ -58,7 +64,7 @@