Cambios Grandes, editro nuevo de preguntas, logica nueva con mas opciones de pregutnas con preguntas hijos hasta 5 niveles
This commit is contained in:
153
migrations/add_flexible_question_types.sql
Normal file
153
migrations/add_flexible_question_types.sql
Normal 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;
|
||||
172
migrations/add_nested_subquestions.sql
Normal file
172
migrations/add_nested_subquestions.sql
Normal 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;
|
||||
468
migrations/migrate_question_types.py
Normal file
468
migrations/migrate_question_types.py
Normal 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()
|
||||
Reference in New Issue
Block a user