Cambios Grandes, editro nuevo de preguntas, logica nueva con mas opciones de pregutnas con preguntas hijos hasta 5 niveles

This commit is contained in:
2025-11-25 22:23:21 -03:00
parent 99f0952378
commit 1ef07ad2c5
8 changed files with 1771 additions and 135 deletions

View File

@@ -0,0 +1,153 @@
-- Migración: Sistema de tipos de preguntas configurables (estilo Google Forms)
-- Fecha: 2025-11-25
-- Descripción: Permite crear tipos de preguntas personalizables con opciones definidas por el usuario
-- PASO 1: Agregar comentario explicativo a la columna options
COMMENT ON COLUMN questions.options IS 'Configuración JSON de la pregunta con choices personalizables. Ejemplos:
Boolean: {"type": "boolean", "choices": [{"value": "yes", "label": "Sí", "points": 1, "status": "ok"}, {"value": "no", "label": "No", "points": 0, "status": "critical"}]}
Single Choice: {"type": "single_choice", "choices": [{"value": "excellent", "label": "Excelente", "points": 3}, {"value": "good", "label": "Bueno", "points": 2}]}
Multiple Choice: {"type": "multiple_choice", "choices": [{"value": "lights", "label": "Luces"}, {"value": "wipers", "label": "Limpiaparabrisas"}]}
Scale: {"type": "scale", "min": 1, "max": 5, "step": 1, "labels": {"min": "Muy malo", "max": "Excelente"}}';
-- PASO 2: Actualizar el comentario de la columna type
COMMENT ON COLUMN questions.type IS 'Tipo de pregunta:
- boolean: Dos opciones personalizables (ej: Sí/No, Pasa/Falla, Bueno/Malo)
- single_choice: Selección única con opciones personalizadas
- multiple_choice: Selección múltiple con opciones personalizadas
- scale: Escala numérica personalizable (1-5, 1-10, etc.)
- text: Texto libre
- number: Valor numérico
- date: Fecha
- time: Hora';
-- PASO 3: Migrar datos existentes de pass_fail y good_bad al nuevo formato
-- Actualizar preguntas tipo pass_fail
UPDATE questions
SET
type = 'boolean',
options = jsonb_build_object(
'type', 'boolean',
'choices', jsonb_build_array(
jsonb_build_object(
'value', 'pass',
'label', 'Pasa',
'points', points,
'status', 'ok'
),
jsonb_build_object(
'value', 'fail',
'label', 'Falla',
'points', 0,
'status', 'critical'
)
)
)
WHERE type = 'pass_fail';
-- Actualizar preguntas tipo good_bad
UPDATE questions
SET
type = 'boolean',
options = jsonb_build_object(
'type', 'boolean',
'choices', jsonb_build_array(
jsonb_build_object(
'value', 'good',
'label', 'Bueno',
'points', points,
'status', 'ok'
),
jsonb_build_object(
'value', 'bad',
'label', 'Malo',
'points', 0,
'status', 'critical'
)
)
)
WHERE type = 'good_bad';
-- Actualizar preguntas tipo good_bad_regular (3 opciones)
UPDATE questions
SET
type = 'single_choice',
options = jsonb_build_object(
'type', 'single_choice',
'choices', jsonb_build_array(
jsonb_build_object(
'value', 'good',
'label', 'Bueno',
'points', points,
'status', 'ok'
),
jsonb_build_object(
'value', 'regular',
'label', 'Regular',
'points', FLOOR(points / 2),
'status', 'warning'
),
jsonb_build_object(
'value', 'bad',
'label', 'Malo',
'points', 0,
'status', 'critical'
)
)
)
WHERE type = 'good_bad_regular';
-- PASO 4: Actualizar preguntas de tipo text sin opciones
UPDATE questions
SET
options = jsonb_build_object(
'type', 'text',
'multiline', true,
'max_length', 500
)
WHERE type = 'text' AND (options IS NULL OR options::text = '{}');
-- PASO 5: Crear función helper para validar estructura de options
CREATE OR REPLACE FUNCTION validate_question_options()
RETURNS TRIGGER AS $$
BEGIN
-- Validar que options tenga el campo type
IF NEW.options IS NOT NULL AND NOT (NEW.options ? 'type') THEN
RAISE EXCEPTION 'El campo options debe contener una propiedad "type"';
END IF;
-- Validar que boolean y single_choice tengan choices
IF NEW.type IN ('boolean', 'single_choice', 'multiple_choice') THEN
IF NEW.options IS NULL OR NOT (NEW.options ? 'choices') THEN
RAISE EXCEPTION 'Las preguntas de tipo % requieren un array "choices" en options', NEW.type;
END IF;
-- Validar que boolean tenga exactamente 2 opciones
IF NEW.type = 'boolean' AND jsonb_array_length(NEW.options->'choices') != 2 THEN
RAISE EXCEPTION 'Las preguntas de tipo boolean deben tener exactamente 2 opciones';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- PASO 6: Crear trigger para validación (opcional, comentado por si causa problemas)
-- DROP TRIGGER IF EXISTS validate_options_trigger ON questions;
-- CREATE TRIGGER validate_options_trigger
-- BEFORE INSERT OR UPDATE ON questions
-- FOR EACH ROW
-- EXECUTE FUNCTION validate_question_options();
-- PASO 7: Índice para búsquedas por tipo de pregunta
CREATE INDEX IF NOT EXISTS idx_questions_type ON questions(type);
-- Verificación
SELECT
type,
COUNT(*) as total,
COUNT(CASE WHEN options IS NOT NULL THEN 1 END) as with_options
FROM questions
GROUP BY type
ORDER BY type;
SELECT 'Migración de tipos de preguntas flexibles completada exitosamente' AS status;

View File

@@ -0,0 +1,172 @@
-- Migración: Subpreguntas anidadas hasta 5 niveles
-- Fecha: 2025-11-25
-- Descripción: Agrega soporte y validación para subpreguntas anidadas hasta 5 niveles de profundidad
-- Agregar columna para tracking de nivel (opcional pero útil)
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS depth_level INTEGER DEFAULT 0;
-- Comentarios
COMMENT ON COLUMN questions.parent_question_id IS 'ID de la pregunta padre. NULL = pregunta principal. Soporta anidamiento hasta 5 niveles.';
COMMENT ON COLUMN questions.depth_level IS 'Nivel de profundidad: 0=principal, 1-5=subpreguntas anidadas';
-- Función para calcular profundidad de una pregunta
CREATE OR REPLACE FUNCTION calculate_question_depth(question_id INTEGER)
RETURNS INTEGER AS $$
DECLARE
current_parent_id INTEGER;
depth INTEGER := 0;
max_iterations INTEGER := 10; -- Protección contra loops infinitos
BEGIN
-- Obtener el parent_id de la pregunta
SELECT parent_question_id INTO current_parent_id
FROM questions
WHERE id = question_id;
-- Si no tiene padre, es nivel 0
IF current_parent_id IS NULL THEN
RETURN 0;
END IF;
-- Subir por la jerarquía contando niveles
WHILE current_parent_id IS NOT NULL AND depth < max_iterations LOOP
depth := depth + 1;
SELECT parent_question_id INTO current_parent_id
FROM questions
WHERE id = current_parent_id;
END LOOP;
RETURN depth;
END;
$$ LANGUAGE plpgsql;
-- Función trigger para validar profundidad máxima
CREATE OR REPLACE FUNCTION validate_question_depth()
RETURNS TRIGGER AS $$
DECLARE
calculated_depth INTEGER;
parent_depth INTEGER;
BEGIN
-- Si no tiene padre, es nivel 0
IF NEW.parent_question_id IS NULL THEN
NEW.depth_level := 0;
RETURN NEW;
END IF;
-- Validar que el padre existe y no es la misma pregunta
IF NEW.parent_question_id = NEW.id THEN
RAISE EXCEPTION 'Una pregunta no puede ser su propio padre';
END IF;
-- Calcular profundidad del padre
SELECT depth_level INTO parent_depth
FROM questions
WHERE id = NEW.parent_question_id;
IF parent_depth IS NULL THEN
-- Si el padre no tiene depth_level, calcularlo
parent_depth := calculate_question_depth(NEW.parent_question_id);
END IF;
-- La nueva pregunta es un nivel más profundo que su padre
calculated_depth := parent_depth + 1;
-- Validar que no excede 5 niveles
IF calculated_depth > 5 THEN
RAISE EXCEPTION 'No se permiten subpreguntas con profundidad mayor a 5. Esta pregunta tendría profundidad %, máximo permitido: 5', calculated_depth;
END IF;
-- Asignar el nivel calculado
NEW.depth_level := calculated_depth;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Crear trigger para INSERT y UPDATE
DROP TRIGGER IF EXISTS validate_depth_trigger ON questions;
CREATE TRIGGER validate_depth_trigger
BEFORE INSERT OR UPDATE OF parent_question_id ON questions
FOR EACH ROW
EXECUTE FUNCTION validate_question_depth();
-- Actualizar depth_level para preguntas existentes
UPDATE questions
SET depth_level = calculate_question_depth(id);
-- Crear índice para mejorar queries de subpreguntas
CREATE INDEX IF NOT EXISTS idx_questions_parent ON questions(parent_question_id) WHERE parent_question_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_questions_depth ON questions(depth_level);
-- Función helper para obtener árbol de subpreguntas
CREATE OR REPLACE FUNCTION get_question_tree(root_question_id INTEGER)
RETURNS TABLE (
id INTEGER,
parent_question_id INTEGER,
text TEXT,
type VARCHAR(30),
depth_level INTEGER,
show_if_answer VARCHAR(50),
path TEXT
) AS $$
BEGIN
RETURN QUERY
WITH RECURSIVE question_tree AS (
-- Pregunta raíz
SELECT
q.id,
q.parent_question_id,
q.text,
q.type,
q.depth_level,
q.show_if_answer,
q.id::TEXT as path
FROM questions q
WHERE q.id = root_question_id
UNION ALL
-- Subpreguntas recursivas
SELECT
q.id,
q.parent_question_id,
q.text,
q.type,
q.depth_level,
q.show_if_answer,
qt.path || ' > ' || q.id::TEXT
FROM questions q
INNER JOIN question_tree qt ON q.parent_question_id = qt.id
WHERE q.depth_level <= 5 -- Límite de seguridad
)
SELECT * FROM question_tree
ORDER BY depth_level, id;
END;
$$ LANGUAGE plpgsql;
-- Verificar estructura actual
SELECT
COUNT(*) as total_preguntas,
COUNT(CASE WHEN parent_question_id IS NULL THEN 1 END) as principales,
COUNT(CASE WHEN parent_question_id IS NOT NULL THEN 1 END) as subpreguntas,
MAX(depth_level) as max_profundidad
FROM questions;
-- Ver distribución por profundidad
SELECT
depth_level,
COUNT(*) as cantidad,
CASE
WHEN depth_level = 0 THEN 'Principales'
WHEN depth_level = 1 THEN 'Nivel 1'
WHEN depth_level = 2 THEN 'Nivel 2'
WHEN depth_level = 3 THEN 'Nivel 3'
WHEN depth_level = 4 THEN 'Nivel 4'
WHEN depth_level = 5 THEN 'Nivel 5'
END as descripcion
FROM questions
GROUP BY depth_level
ORDER BY depth_level;
SELECT '✓ Migración de subpreguntas anidadas completada' AS status;

View File

@@ -0,0 +1,468 @@
#!/usr/bin/env python3
"""
Script de Migración de Tipos de Preguntas
==========================================
Migra preguntas existentes (pass_fail, good_bad) al nuevo formato configurable.
Requisitos:
pip install psycopg2-binary
Uso:
python migrate_question_types.py
Base de Datos:
Host: portianerp.rshtech.com.py
Database: syntria_db
User: syntria_user
"""
import psycopg2
import json
from datetime import datetime
from typing import Dict, List, Any
# Configuración de la base de datos
DB_CONFIG = {
'host': 'portianerp.rshtech.com.py',
'database': 'syntria_db',
'user': 'syntria_user',
'password': 'syntria_secure_2024',
'port': 5432
}
# Plantillas de conversión
MIGRATION_TEMPLATES = {
'pass_fail': {
'new_type': 'boolean',
'config': {
'type': 'boolean',
'choices': [
{
'value': 'pass',
'label': 'Pasa',
'points': None, # Se asignará dinámicamente
'status': 'ok'
},
{
'value': 'fail',
'label': 'Falla',
'points': 0,
'status': 'critical'
}
]
}
},
'good_bad': {
'new_type': 'boolean',
'config': {
'type': 'boolean',
'choices': [
{
'value': 'good',
'label': 'Bueno',
'points': None, # Se asignará dinámicamente
'status': 'ok'
},
{
'value': 'bad',
'label': 'Malo',
'points': 0,
'status': 'critical'
}
]
}
},
'good_bad_regular': {
'new_type': 'single_choice',
'config': {
'type': 'single_choice',
'choices': [
{
'value': 'good',
'label': 'Bueno',
'points': None, # Se asignará dinámicamente
'status': 'ok'
},
{
'value': 'regular',
'label': 'Regular',
'points': None, # Se calculará como points/2
'status': 'warning'
},
{
'value': 'bad',
'label': 'Malo',
'points': 0,
'status': 'critical'
}
],
'allow_other': False
}
},
'text': {
'new_type': 'text',
'config': {
'type': 'text',
'multiline': True,
'max_length': 500
}
},
'number': {
'new_type': 'number',
'config': {
'type': 'number',
'min': 0,
'max': 100,
'unit': ''
}
}
}
class QuestionMigrator:
def __init__(self):
self.conn = None
self.cursor = None
self.stats = {
'total_questions': 0,
'migrated': 0,
'skipped': 0,
'errors': 0,
'by_type': {}
}
def connect(self):
"""Conectar a la base de datos"""
try:
print(f"🔌 Conectando a {DB_CONFIG['host']}...")
self.conn = psycopg2.connect(**DB_CONFIG)
self.cursor = self.conn.cursor()
print("✅ Conexión exitosa\n")
return True
except Exception as e:
print(f"❌ Error de conexión: {e}")
return False
def disconnect(self):
"""Cerrar conexión"""
if self.cursor:
self.cursor.close()
if self.conn:
self.conn.close()
print("\n🔌 Conexión cerrada")
def backup_questions_table(self):
"""Crear backup de la tabla questions"""
try:
print("💾 Creando backup de la tabla questions...")
# Crear tabla de backup con timestamp
backup_table = f"questions_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
self.cursor.execute(f"""
CREATE TABLE {backup_table} AS
SELECT * FROM questions;
""")
self.cursor.execute(f"SELECT COUNT(*) FROM {backup_table}")
count = self.cursor.fetchone()[0]
self.conn.commit()
print(f"✅ Backup creado: {backup_table} ({count} registros)\n")
return backup_table
except Exception as e:
print(f"❌ Error creando backup: {e}")
return None
def get_questions_to_migrate(self) -> List[Dict[str, Any]]:
"""Obtener todas las preguntas que necesitan migración"""
try:
print("📊 Obteniendo preguntas para migrar...")
self.cursor.execute("""
SELECT
id,
checklist_id,
section,
text,
type,
points,
options,
allow_photos,
max_photos,
requires_comment_on_fail,
send_notification,
parent_question_id,
show_if_answer,
ai_prompt
FROM questions
ORDER BY id
""")
questions = []
for row in self.cursor.fetchall():
questions.append({
'id': row[0],
'checklist_id': row[1],
'section': row[2],
'text': row[3],
'type': row[4],
'points': row[5],
'options': row[6],
'allow_photos': row[7],
'max_photos': row[8],
'requires_comment_on_fail': row[9],
'send_notification': row[10],
'parent_question_id': row[11],
'show_if_answer': row[12],
'ai_prompt': row[13]
})
self.stats['total_questions'] = len(questions)
print(f"✅ Se encontraron {len(questions)} preguntas\n")
return questions
except Exception as e:
print(f"❌ Error obteniendo preguntas: {e}")
return []
def migrate_question(self, question: Dict[str, Any]) -> bool:
"""Migrar una pregunta al nuevo formato"""
try:
old_type = question['type']
# Si ya está en el nuevo formato, saltar
if old_type in ['boolean', 'single_choice', 'multiple_choice', 'scale', 'text', 'number', 'date', 'time']:
if question['options'] and isinstance(question['options'], dict) and 'type' in question['options']:
self.stats['skipped'] += 1
return True
# Obtener template de migración
if old_type not in MIGRATION_TEMPLATES:
print(f" ⚠️ Tipo desconocido '{old_type}' para pregunta #{question['id']}")
self.stats['skipped'] += 1
return True
template = MIGRATION_TEMPLATES[old_type]
new_type = template['new_type']
new_config = json.loads(json.dumps(template['config'])) # Deep copy
# Asignar puntos dinámicamente
if 'choices' in new_config:
for choice in new_config['choices']:
if choice['points'] is None:
choice['points'] = question['points']
# Para good_bad_regular, calcular puntos intermedios
if old_type == 'good_bad_regular':
new_config['choices'][1]['points'] = max(1, question['points'] // 2)
# Actualizar la pregunta
self.cursor.execute("""
UPDATE questions
SET
type = %s,
options = %s
WHERE id = %s
""", (new_type, json.dumps(new_config), question['id']))
# Actualizar estadísticas
self.stats['migrated'] += 1
if old_type not in self.stats['by_type']:
self.stats['by_type'][old_type] = 0
self.stats['by_type'][old_type] += 1
return True
except Exception as e:
print(f" ❌ Error migrando pregunta #{question['id']}: {e}")
self.stats['errors'] += 1
return False
def verify_migration(self):
"""Verificar que la migración fue exitosa"""
print("\n🔍 Verificando migración...")
try:
# Contar por tipo nuevo
self.cursor.execute("""
SELECT
type,
COUNT(*) as total,
COUNT(CASE WHEN options IS NOT NULL THEN 1 END) as with_config
FROM questions
GROUP BY type
ORDER BY type
""")
print("\n📊 Distribución de preguntas migradas:")
print("-" * 60)
for row in self.cursor.fetchall():
tipo, total, with_config = row
print(f" {tipo:20} | Total: {total:4} | Con config: {with_config:4}")
print("-" * 60)
# Verificar que todas las boolean tengan 2 choices
# Usar CAST para compatibilidad con JSON y JSONB
self.cursor.execute("""
SELECT id, text, options
FROM questions
WHERE type = 'boolean'
AND (
options IS NULL
OR json_array_length((options::json)->'choices') != 2
)
LIMIT 5
""")
invalid = self.cursor.fetchall()
if invalid:
print(f"\n⚠️ Advertencia: {len(invalid)} preguntas boolean con configuración inválida:")
for q_id, text, opts in invalid:
print(f" - #{q_id}: {text[:50]}...")
else:
print("\n✅ Todas las preguntas boolean tienen configuración válida")
return True
except Exception as e:
print(f"❌ Error en verificación: {e}")
return False
def print_statistics(self):
"""Imprimir estadísticas de la migración"""
print("\n" + "=" * 60)
print("📈 ESTADÍSTICAS DE MIGRACIÓN")
print("=" * 60)
print(f"Total de preguntas: {self.stats['total_questions']}")
print(f"Migradas exitosamente: {self.stats['migrated']}")
print(f"Omitidas: {self.stats['skipped']}")
print(f"Errores: {self.stats['errors']}")
print("\nPor tipo original:")
for tipo, count in self.stats['by_type'].items():
print(f" - {tipo:20}: {count:4} preguntas")
print("=" * 60)
def run(self, dry_run=False):
"""Ejecutar la migración completa"""
print("=" * 60)
print("🚀 MIGRACIÓN DE TIPOS DE PREGUNTAS")
print("=" * 60)
print(f"Modo: {'🔍 DRY RUN (sin cambios)' if dry_run else '✍️ MIGRACIÓN REAL'}")
print("=" * 60 + "\n")
if not self.connect():
return False
try:
# Crear backup
if not dry_run:
backup_table = self.backup_questions_table()
if not backup_table:
print("⚠️ No se pudo crear backup. ¿Continuar de todos modos? (y/n): ", end='')
if input().lower() != 'y':
return False
# Obtener preguntas
questions = self.get_questions_to_migrate()
if not questions:
print("❌ No se encontraron preguntas para migrar")
return False
# Migrar cada pregunta
print("🔄 Migrando preguntas...\n")
for i, question in enumerate(questions, 1):
old_type = question['type']
if self.migrate_question(question):
if i % 10 == 0:
print(f" Progreso: {i}/{len(questions)} preguntas procesadas...")
# Commit o rollback según modo
if dry_run:
self.conn.rollback()
print("\n🔍 DRY RUN completado - Cambios revertidos")
else:
self.conn.commit()
print("\n✅ Migración completada - Cambios guardados")
# Verificar migración
self.verify_migration()
# Mostrar estadísticas
self.print_statistics()
return True
except Exception as e:
print(f"\n❌ Error durante la migración: {e}")
if self.conn:
self.conn.rollback()
print("🔄 Cambios revertidos")
return False
finally:
self.disconnect()
def main():
"""Función principal"""
print("\n" + "=" * 60)
print(" MIGRACIÓN DE TIPOS DE PREGUNTAS - Sistema Configurable")
print(" Base de datos: syntria_db @ portianerp.rshtech.com.py")
print("=" * 60 + "\n")
print("Este script migrará las preguntas existentes al nuevo formato:")
print(" • pass_fail → boolean (Pasa/Falla)")
print(" • good_bad → boolean (Bueno/Malo)")
print(" • good_bad_regular → single_choice (Bueno/Regular/Malo)")
print(" • text → text (con configuración)")
print(" • number → number (con configuración)\n")
# Preguntar modo
print("Seleccione el modo de ejecución:")
print(" 1. DRY RUN - Ver cambios sin aplicarlos (recomendado primero)")
print(" 2. MIGRACIÓN REAL - Aplicar cambios permanentes")
print("\nOpción (1/2): ", end='')
try:
option = input().strip()
dry_run = (option != '2')
if not dry_run:
print("\n⚠️ ADVERTENCIA: Esto modificará la base de datos de producción.")
print("Se creará un backup automático antes de continuar.")
print("\n¿Continuar? (escriba 'SI' para confirmar): ", end='')
confirm = input().strip()
if confirm != 'SI':
print("\n❌ Migración cancelada por el usuario")
return
# Ejecutar migración
migrator = QuestionMigrator()
success = migrator.run(dry_run=dry_run)
if success:
if not dry_run:
print("\n✅ Migración completada exitosamente!")
print("\nPróximos pasos:")
print(" 1. Reiniciar el servidor backend")
print(" 2. Probar crear nuevas preguntas con el editor visual")
print(" 3. Verificar que las inspecciones existentes sigan funcionando")
else:
print("\n✅ DRY RUN completado!")
print("\nPara aplicar los cambios, ejecute nuevamente y seleccione opción 2")
else:
print("\n❌ La migración falló. Revise los errores arriba.")
except KeyboardInterrupt:
print("\n\n⚠️ Migración cancelada por el usuario")
except Exception as e:
print(f"\n❌ Error inesperado: {e}")
if __name__ == '__main__':
main()