Subir archivos a "/"

This commit is contained in:
2026-07-01 20:38:56 +00:00
commit c9940ec377
5 changed files with 1961 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
import pyodbc
import config
conn_str = (
f"DRIVER={config.DB_DRIVER};"
f"SERVER={config.DB_SERVER};"
f"DATABASE={config.DB_NAME};"
f"Trusted_Connection=yes;"
)
try:
conn = pyodbc.connect(conn_str)
cursor = conn.cursor()
cursor.execute("SELECT table_name FROM information_schema.tables WHERE table_type = 'BASE TABLE'")
tables = [row[0] for row in cursor.fetchall()]
print("Tables:")
for t in tables:
print(f" - {t}")
# Check if we can describe Recursos, Operarios, etc.
for t in ['Recursos', 'Operarios', 'Articulos', 'Relevamientos']:
if t in tables:
print(f"\nColumns in {t}:")
cursor.execute(f"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = '{t}'")
for row in cursor.fetchall():
print(f" {row[0]}: {row[1]}")
conn.close()
except Exception as e:
print("Error:", e)
+28
View File
@@ -0,0 +1,28 @@
import os
from typing import Final
# Colores originales (UI/UX no modificable)
COLOR_BARRA_SUPERIOR: Final[str] = "#2c3e50"
COLOR_BOTON_ACTIVO: Final[str] = "#1abc9c"
COLOR_BG_INICIO: Final[str] = "#ecf0f1"
COLOR_BG_TAREAS: Final[str] = "#d5dbdb"
COLOR_BG_TABLA_HEAD: Final[str] = "#f0f0f0"
COLOR_BOTON_FINALIZAR: Final[str] = "#2980b9"
# Configuración de ventana
TITULO_APP: Final[str] = "Metodos y Tiempos"
GEOMETRIA: Final[str] = "700x600"
ICONO_PATH: Final[str] = "logo.ico"
# Configuración de Base de Datos
DB_DRIVER: Final[str] = "{ODBC Driver 17 for SQL Server}"
DB_SERVER: Final[str] = os.getenv("DB_SERVER", "localhost\\SQLEXPRESS")
DB_NAME: Final[str] = os.getenv("DB_NAME", "MetodosTiemposDB")
DB_USER: Final[str] = os.getenv("DB_USER", "sa")
DB_PASSWORD: Final[str] = os.getenv("DB_PASSWORD", "secret")
# Log Configuration
LOG_FILE: Final[str] = "app_metodos_tiempos.log"
# Seguridad
MASTER_PASSWORD: Final[str] = "LongvieAdmin2026"
+832
View File
@@ -0,0 +1,832 @@
import tkinter as tk
from tkinter import messagebox, filedialog, simpledialog
from typing import Dict, List, Optional, Any
import gestor_pdf
import vistas
import config
from modelos import Usuario, Tarea
from logger import get_logger
from dao import UsuarioDAO, TareaDAO
from seguridad import Seguridad
logger = get_logger(__name__)
class Controlador:
"""
Controlador principal MVC.
Encapsula la lógica de negocio (Mock data, RBAC) y las funciones de archivo (PDF)
que antes residían en main.py.
"""
def __init__(self, root: Any) -> None:
# root es la instancia de Aplicacion (tk.Tk)
self.root = root
self.usuario_actual: Optional[Usuario] = None
def verificar_credenciales(self, usuario: str, contrasena: str) -> bool:
"""Verifica credenciales en SQL Server e inicia sesión."""
try:
user = UsuarioDAO.obtener_por_username(usuario)
if user and Seguridad.verificar_password(contrasena, user.password_hash):
self.usuario_actual = user
logger.info(f"Usuario {usuario} autenticado exitosamente como {user.rol}.")
# Auditoría
Seguridad.registrar_auditoria(user.id_usuario, "Login exitoso")
# Al loguearse, mostrar la barra de menú nativa
self.root.config(menu=self.root.barra_menus)
self.root.title(f"{config.TITULO_APP} - Dashboard")
# Redirigir a Dashboard
self.root.mostrar_frame(vistas.PaginaTareas)
return True
logger.warning(f"Intento fallido para el usuario: {usuario}")
return False
except Exception:
logger.exception("Error al verificar credenciales en BD.")
return False
def cerrar_sesion(self) -> None:
"""
Cierra la sesión del usuario actual.
Limpia el estado de autenticación, oculta la barra de menú nativa
y redirige a la pantalla de Login.
"""
if self.usuario_actual:
Seguridad.registrar_auditoria(
self.usuario_actual.id_usuario, "Cerró sesión"
)
logger.info(f"Usuario {self.usuario_actual.username} cerró sesión.")
self.usuario_actual = None
self.root.config(menu="")
self.root.title(config.TITULO_APP)
self.root.mostrar_frame(vistas.PaginaLogin)
def obtener_tareas_dashboard(self) -> List[Tarea]:
"""Obtiene tareas filtradas según el rol del usuario."""
if not self.usuario_actual:
return []
if self.usuario_actual.rol == "JEFE/GERENTE":
return TareaDAO.obtener_todas()
else:
return TareaDAO.obtener_tareas_por_usuario(self.usuario_actual.id_usuario)
def obtener_metodistas(self) -> List[Usuario]:
"""Obtiene los usuarios con rol METODISTA."""
return UsuarioDAO.obtener_metodistas()
def obtener_opciones_estado(self) -> List[str]:
"""Devuelve las opciones de estado permitidas según el rol del usuario."""
if not self.usuario_actual:
return []
if self.usuario_actual.rol == "JEFE/GERENTE":
return ["Pendiente", "En Proceso", "Finalizado", "Cerrado"]
else:
return ["Pendiente", "En Proceso", "Finalizado"]
def crear_tarea(self, titulo: str, descripcion: str, id_usuario_asignado: int) -> bool:
"""Delega la creación de la tarea al DAO."""
exito = TareaDAO.crear_tarea(titulo, descripcion, id_usuario_asignado)
if exito and self.usuario_actual:
Seguridad.registrar_auditoria(self.usuario_actual.id_usuario, f"Creó tarea: {titulo}")
return exito
def borrar_tarea(self, id_tarea: int) -> bool:
"""Delega el borrado de la tarea al DAO."""
exito = TareaDAO.borrar_tarea(id_tarea)
if exito and self.usuario_actual:
Seguridad.registrar_auditoria(self.usuario_actual.id_usuario, f"Borrador tarea ID: {id_tarea}")
return exito
def cambiar_estado_tarea(self, id_tarea: int, nuevo_estado: str) -> bool:
try:
exito = TareaDAO.actualizar_estado(id_tarea, nuevo_estado)
if exito and self.usuario_actual:
Seguridad.registrar_auditoria(self.usuario_actual.id_usuario, f"Cambio de estado en tarea {id_tarea} a {nuevo_estado}")
logger.info(f"Tarea {id_tarea} cambió a estado {nuevo_estado}.")
return exito
except Exception:
logger.exception("Error al cambiar estado de la tarea.")
return False
def registrar_nuevo_usuario(self, nombre: str, username: str, rol: str, contrasena: str) -> None:
"""Registra un usuario en el sistema previniendo escalada de privilegios."""
if rol == "JEFE/GERENTE":
master_pwd = simpledialog.askstring("Autorización Requerida",
"Ingrese la Contraseña Maestra para crear un Administrador:",
show="*", parent=self.root)
if master_pwd != config.MASTER_PASSWORD:
messagebox.showerror("Acceso Denegado", "Contraseña maestra incorrecta. Operación cancelada.")
return
# Verificar si ya existe para dar feedback claro
if UsuarioDAO.obtener_por_username(username) is not None:
messagebox.showerror("Error", "El nombre de usuario ya está en uso.")
return
hash_pwd = Seguridad.generar_hash(contrasena)
if UsuarioDAO.crear_usuario(username, hash_pwd, rol, nombre):
messagebox.showinfo("Éxito", f"Usuario '{username}' ({rol}) creado correctamente.")
if self.usuario_actual:
Seguridad.registrar_auditoria(self.usuario_actual.id_usuario, f"Creó usuario: {username} ({rol})")
self.root.mostrar_frame(vistas.PaginaLogin)
else:
messagebox.showerror("Error", "No se pudo crear el usuario en la base de datos.")
# --- LÓGICA MOVIDA DESDE MAIN.PY ---
def nueva_plantilla(self) -> None:
"""Renderiza la vista original preexistente para crear una nueva plantilla de cronometraje."""
self.root.mostrar_frame(vistas.Plantilla)
def abrir_archivo(self) -> None:
"""Abre un archivo utilizando filedialog."""
tipos = [('Archivos de texto', '*.txt'), ('Todos los archivos', '*.*')]
ruta = filedialog.askopenfilename(title="Seleccionar un archivo", filetypes=tipos)
if ruta:
messagebox.showinfo("Abrir", f"Archivo seleccionado: {ruta}")
def guardar_archivo(self) -> bool:
"""
Guarda el PDF con auto-naming basado en el código del artículo.
Lee el código del artículo desde la vista Plantilla para generar
un nombre de archivo predeterminado. Extrae los datos completos
de la grilla y delega al gestor_pdf para el renderizado landscape.
Returns:
bool: True si el PDF se generó exitosamente, False en caso contrario.
"""
# Fase 1: Auto-Naming — leer código del artículo desde la vista
vista_plantilla = self.root.frames.get(vistas.Plantilla)
nombre_articulo: str = "REPORTE_NUEVO"
if vista_plantilla and hasattr(vista_plantilla, 'entry_articulo_codigo'):
codigo = vista_plantilla.entry_articulo_codigo.get().strip()
if codigo:
nombre_articulo = codigo
ruta = filedialog.asksaveasfilename(
defaultextension=".pdf",
filetypes=[("Archivos PDF", "*.pdf")],
initialfile=f"{nombre_articulo}.pdf"
)
if not ruta:
return False
try:
if vista_plantilla and hasattr(vista_plantilla, 'obtener_datos_limpios'):
datos_completos: Dict[str, Any] = vista_plantilla.obtener_datos_limpios()
exito = gestor_pdf.generar_reporte_pdf(datos_completos, ruta)
if exito:
messagebox.showinfo("Éxito", "PDF generado correctamente.")
return True
else:
messagebox.showerror("Error", "Fallo al generar PDF. Revise el log del sistema.")
return False
else:
logger.error("No se encontró la vista Plantilla para obtener datos.")
messagebox.showerror("Error", "La vista actual no soporta el guardado de datos.")
return False
except Exception:
logger.exception("Error inesperado al intentar guardar el PDF.")
messagebox.showerror("Error", "Ocurrió un error crítico. Revise el archivo de log.")
return False
def obtener_siguiente_id_relevamiento(self) -> int:
"""Obtiene el correlativo máximo + 1 de Relevamientos de la BD."""
from dao import RelevamientoDAO
return RelevamientoDAO.obtener_siguiente_id()
def obtener_lista_maquinas(self) -> List[str]:
"""Obtiene la lista de nombres de máquinas registradas."""
from dao import RecursoDAO
return [r.nombre for r in RecursoDAO.obtener_por_tipo("MAQUINA")]
def obtener_lista_hms(self) -> List[str]:
"""Obtiene la lista de nombres de herramientas/matrices registradas."""
from dao import RecursoDAO
return [r.nombre for r in RecursoDAO.obtener_por_tipo("HM")]
def obtener_lista_operaciones(self) -> List[str]:
"""Obtiene la lista de nombres de operaciones registradas.
Reutiliza la tabla Recursos almacenando las operaciones bajo
el tipo 'OPERACION'. Retorna una lista de strings con los
nombres disponibles para poblar el Combobox autocompletable
del campo Operación en la Plantilla.
Returns:
Lista de nombres de operaciones registradas. Lista vacía
si no existen registros.
"""
from dao import RecursoDAO
return [r.nombre for r in RecursoDAO.obtener_por_tipo("OPERACION")]
def obtener_lista_operarios(self) -> List[str]:
"""Obtiene la lista de nombres de operarios registrados."""
from dao import OperarioDAO
return [o.nombre for o in OperarioDAO.obtener_todos()]
def crear_recurso_silencioso(self, nombre: str, tipo: str) -> bool:
"""Crea silenciosamente un recurso (maquina o HM) si no existe."""
from dao import RecursoDAO
recurso = RecursoDAO.crear_silencioso(nombre, tipo)
return recurso is not None
def crear_recurso(self, nombre: str, tipo: str) -> bool:
"""
Crea un nuevo recurso (MAQUINA o HM) de forma explícita en la base de datos.
Asegura que el recurso no exista previamente antes de crearlo.
Args:
nombre (str): Nombre o descripción del recurso.
tipo (str): Tipo del recurso ('MAQUINA' o 'HM').
Returns:
bool: True si el recurso se crea exitosamente o ya existía, False en caso de error.
"""
from dao import RecursoDAO
try:
nombre_limpio = nombre.strip()
if not nombre_limpio:
logger.warning("Intento de crear un recurso con nombre vacío.")
return False
# Verificar existencia previa
existente = RecursoDAO.obtener_por_descripcion_y_tipo(nombre_limpio, tipo)
if existente:
logger.info(f"El recurso '{nombre_limpio}' de tipo '{tipo}' ya existe.")
return True
recurso = RecursoDAO.crear_silencioso(nombre_limpio, tipo)
if recurso:
logger.info(f"Recurso '{nombre_limpio}' de tipo '{tipo}' creado explícitamente.")
if self.usuario_actual:
from seguridad import Seguridad
Seguridad.registrar_auditoria(
self.usuario_actual.id_usuario,
f"Creó recurso explícito: {nombre_limpio} ({tipo})"
)
return True
return False
except Exception:
logger.exception(f"Error inesperado al crear recurso '{nombre}' del tipo '{tipo}'.")
return False
def borrar_recurso(self, nombre: str, tipo: str) -> bool:
"""
Elimina un recurso (MAQUINA o HM) por nombre y tipo.
Delega al DAO, registra auditoría si hay usuario autenticado,
y captura excepciones de forma granular (Regla 7).
Args:
nombre: Descripción/nombre del recurso a eliminar.
tipo: Tipo del recurso ('MAQUINA' o 'HM').
Returns:
True si la eliminación fue exitosa, False en caso contrario.
"""
from dao import RecursoDAO
try:
nombre_limpio = nombre.strip()
if not nombre_limpio:
logger.warning("Intento de eliminar un recurso con nombre vacío.")
return False
exito = RecursoDAO.borrar_por_nombre_y_tipo(nombre_limpio, tipo)
if exito and self.usuario_actual:
Seguridad.registrar_auditoria(
self.usuario_actual.id_usuario,
f"Eliminó recurso: {nombre_limpio} ({tipo})"
)
return exito
except Exception:
logger.exception(f"Error inesperado al eliminar recurso '{nombre}' ({tipo}).")
return False
def borrar_relevamiento(self, id_relevamiento: int) -> bool:
"""
Elimina un relevamiento completo (cabecera + tiempos) por ID.
Delega al DAO, registra auditoría si hay usuario autenticado,
y captura excepciones de forma granular (Regla 7).
Args:
id_relevamiento: ID del relevamiento a eliminar.
Returns:
True si la eliminación fue exitosa, False en caso contrario.
"""
from dao import RelevamientoDAO
try:
exito = RelevamientoDAO.borrar_por_id(id_relevamiento)
if exito and self.usuario_actual:
Seguridad.registrar_auditoria(
self.usuario_actual.id_usuario,
f"Eliminó relevamiento ID: {id_relevamiento}"
)
return exito
except Exception:
logger.exception(f"Error inesperado al eliminar relevamiento {id_relevamiento}.")
return False
def crear_operario_silencioso(self, nombre: str) -> bool:
"""Crea silenciosamente un operario si no existe."""
from dao import OperarioDAO
operario = OperarioDAO.crear_silencioso(nombre)
return operario is not None
def guardar_relevamiento_bd(self, relevamiento: Any) -> bool:
"""
Delega la persistencia del relevamiento transaccional al RelevamientoDAO.
Aplica trazabilidad estricta y manejo de errores (Regla 7 y Prohibición de Silenciar Errores).
"""
from dao import RelevamientoDAO
from tkinter import messagebox
import pprint
# Trazabilidad requerida en consola
print("DEBUG - Intentando guardar relevamiento. Estructura de datos:")
try:
pprint.pprint(vars(relevamiento))
except Exception:
print(relevamiento)
try:
exito = RelevamientoDAO.guardar(relevamiento)
return exito
except Exception as e:
logger.exception("Error DB al intentar guardar el relevamiento.")
messagebox.showerror("Error de Base de Datos", f"Se produjo un error crítico al guardar en la base de datos:\n\n{str(e)}")
return False
def abrir_relevamiento_desde_id(self, id_seleccionado: int) -> None:
"""Carga un relevamiento completo desde la BD y abre la vista Plantilla inyectando los datos."""
print(f"DEBUG: Intentando cargar ID {id_seleccionado}")
from dao import RelevamientoDAO
import vistas
relevamiento = RelevamientoDAO.obtener_por_id(id_seleccionado)
if relevamiento:
self.root.mostrar_frame(vistas.Plantilla)
vista_plantilla = self.root.frames[vistas.Plantilla]
id_tarea_asociada = relevamiento.id_tarea or 0
vista_plantilla.cargar_datos(relevamiento, id_tarea_asociada)
def abrir_relevamiento_desde_tarea(self, id_tarea: int) -> None:
"""
Busca si la tarea tiene un relevamiento asociado.
Si existe, lo carga y abre la vista Plantilla.
Si no existe, abre una Plantilla limpia vinculada a la tarea.
Args:
id_tarea (int): El ID de la tarea del Treeview.
"""
from dao import RelevamientoDAO
import vistas
try:
relevamiento = RelevamientoDAO.obtener_por_tarea(id_tarea)
# Cambiar de vista a Plantilla
self.root.mostrar_frame(vistas.Plantilla)
# Obtener la instancia de la vista Plantilla
vista_plantilla = self.root.frames[vistas.Plantilla]
if relevamiento:
logger.info(f"Cargando relevamiento existente {relevamiento.id_relevamiento} para la tarea {id_tarea}.")
vista_plantilla.cargar_datos(relevamiento, id_tarea)
else:
logger.info(f"Inicializando relevamiento nuevo para la tarea {id_tarea}.")
vista_plantilla.cargar_datos_nuevos(id_tarea)
except Exception:
logger.exception(f"Error al intentar abrir el relevamiento de la tarea {id_tarea}.")
from tkinter import messagebox
messagebox.showerror("Error", f"No se pudo cargar el relevamiento asociado a la tarea {id_tarea}.")
def obtener_resumen_relevamientos(self) -> List[tuple]:
"""Obtiene un resumen de todos los relevamientos desde el DAO."""
from dao import RelevamientoDAO
return RelevamientoDAO.obtener_todos_resumen()
def buscar_relevamientos_historicos(self, filtros: Dict[str, str]) -> List[tuple]:
"""
Busca relevamientos históricos aplicando filtros combinados.
Recibe los filtros de la vista, delega la búsqueda al DAO
y retorna los resultados. Los errores se registran vía logger (Regla 7).
Args:
filtros: Diccionario con claves opcionales ('articulo', 'operario',
'maquina', 'hm') mapeadas a cadenas de texto.
Returns:
Lista de tuplas con formato:
(ID, Fecha, Artículo, Operación, Máquina, HM, Operario).
"""
from dao import RelevamientoDAO
try:
return RelevamientoDAO.buscar_historico(filtros)
except Exception:
logger.exception("Error al buscar relevamientos históricos desde el controlador.")
return []
def importar_desde_excel(self, ruta_archivo: str) -> None:
"""
Importa datos desde una planilla Excel (.xlsx/.xlsm) y los carga en la vista Plantilla.
Realiza lectura completa de cabecera (operación en F2, descripción en F3,
código artículo en B7, máquina/HM/operario/fecha en columna B) y escaneo
dinámico de la grilla de tiempos a partir de la columna D (índice 4).
El orden de inyección es crítico: primero se muestra el frame (disparando
actualizar_vista/limpiar_datos) y luego se cargan los datos, evitando que
el ciclo de vida de Tkinter borre la información recién inyectada.
Args:
ruta_archivo: Ruta absoluta al archivo Excel (.xlsx/.xlsm).
Raises:
No lanza excepciones al exterior; captura cualquier error y lo
muestra al usuario vía messagebox, registrando el detalle en el log.
"""
import sys
import subprocess
import datetime
from tkinter import messagebox
from modelos import Articulo, Recurso, Operario, Relevamiento, TiemposElemento
try:
# 1. Asegurar dependencias de forma silenciosa
try:
import openpyxl
except ImportError:
subprocess.check_call([sys.executable, "-m", "pip", "install", "openpyxl"])
import openpyxl
# 2. Cargar hoja activa
wb = openpyxl.load_workbook(ruta_archivo, data_only=True)
hoja = wb.worksheets[0]
def safe_str(cell_ref: str) -> str:
"""Extrae el valor de una celda como string limpio, o cadena vacía si es None."""
val = hoja[cell_ref].value
return str(val).strip() if val is not None else ""
def safe_float(val: Any, default: float = 0.0) -> float:
"""Convierte un valor de celda a float de forma segura, tolerando comas y '%'."""
if val is None:
return default
if isinstance(val, (int, float)):
return float(val)
try:
return float(str(val).replace(',', '.').replace('%', '').strip())
except ValueError:
return default
# 3. Lectura de cabecera (coordenadas confirmadas por PoC)
art_codigo = safe_str('A6')
operacion = safe_str('F2')
descripcion = safe_str('F3')
maquina = safe_str('A20')
hm = safe_str('A22')
operario = safe_str('A26')
# --- LECTURA DE FECHA Y NÚMERO DE CRONOMETRAJE ---
fecha_val = hoja['A28'].value
if isinstance(fecha_val, datetime.datetime):
fecha_obj = fecha_val.date()
elif isinstance(fecha_val, datetime.date):
fecha_obj = fecha_val
elif isinstance(fecha_val, str):
try:
fecha_obj = datetime.datetime.strptime(fecha_val.strip(), "%d/%m/%Y").date()
except ValueError:
fecha_obj = datetime.date.today()
else:
fecha_obj = datetime.date.today()
# Guardamos el número de la celda A30 en una variable
num_crono_excel = safe_str('A30')
piezas_golpe = int(safe_float(hoja['E43'].value, 1.0))
articulos_golpe = int(safe_float(hoja['E45'].value, 1.0))
operarios_maq = int(safe_float(hoja['E47'].value, 1.0))
# 4. Escaneo dinámico de la grilla de tiempos
tiempos_lista: List[TiemposElemento] = []
col = 4 # Inicio de matriz en Columna D
columnas_vacias = 0
while True:
# Buscar nombre del elemento (filas 4-9, ignorando encabezados de sub-columna)
elem_nombre: Optional[str] = None
for fila_busqueda in range(4, 10):
val = hoja.cell(row=fila_busqueda, column=col).value
if val and str(val).strip() and str(val).strip().lower() not in ['v', 'min', 'm/seg', 'l']:
elem_nombre = str(val).strip()
break
# Verificar si hay datos numéricos en la zona de observaciones (filas 11-30)
tiene_datos = False
for r in range(11, 31):
if (hoja.cell(row=r, column=col).value is not None or
hoja.cell(row=r, column=col + 1).value is not None or
hoja.cell(row=r, column=col + 2).value is not None):
tiene_datos = True
break
# Tolerancia de 2 columnas vacías consecutivas antes de cortar
if not elem_nombre and not tiene_datos:
columnas_vacias += 1
if columnas_vacias >= 2:
break
col += 3
continue
columnas_vacias = 0
# Auto-naming para elementos sin título en celdas combinadas
if not elem_nombre:
idx_elemento = ((col - 4) // 3) + 1
elem_nombre = f"Elemento {idx_elemento}"
# Elementos Medidos (fila 32), Lote Frec. (fila 33) y Suplemento (fila 38)
el_medidos = int(safe_float(hoja.cell(row=32, column=col + 1).value, 1.0))
lote_f = safe_float(hoja.cell(row=33, column=col + 1).value, 1.0)
sup_raw = safe_float(hoja.cell(row=38, column=col + 1).value, 0.0)
# FIX: Convertimos todo a decimal puro (0.01, 0.05) para evitar el doble escalado en vistas.py
suplemento = sup_raw / 100.0 if sup_raw >= 1.0 else sup_raw
# Recorrer observaciones (filas 11 a 30)
for row_idx in range(11, 31):
v_val = hoja.cell(row=row_idx, column=col).value
min_val = hoja.cell(row=row_idx, column=col + 1).value
seg_val = hoja.cell(row=row_idx, column=col + 2).value
# Solo procesar si hay al menos un dato en la fila
if v_val is not None or min_val is not None or seg_val is not None:
# Valoración (normalizar factor decimal a porcentaje)
v_f = safe_float(v_val, 100.0)
if 0 < v_f <= 1.5:
v_f *= 100.0
# Minutos y segundos
m_f = safe_float(min_val, 0.0)
s_f = safe_float(seg_val, 0.0)
# Sincronización MIN ↔ SEG
if s_f == 0.0 and m_f > 0.0:
s_f = m_f * 60.0
elif m_f == 0.0 and s_f > 0.0:
m_f = s_f / 60.0
tiempos_lista.append(TiemposElemento(
elemento=elem_nombre,
valoracion=v_f,
minutos=m_f,
segundos=s_f,
elementos_medidos=el_medidos,
lote_frecuencial=lote_f,
suplemento=suplemento
))
# Avanzar al siguiente bloque de 3 columnas
col += 3
if col > 100:
break
# 5. Construir el objeto Relevamiento (borrador nuevo)
art_obj = Articulo(id_articulo="0", codigo=art_codigo, descripcion=descripcion) if art_codigo else None
maq_obj = Recurso(id_recurso="0", nombre=maquina, tipo="MAQUINA") if maquina else None
hm_obj = Recurso(id_recurso="0", nombre=hm, tipo="HM") if hm else None
op_obj = Operario(id_operario="0", nombre=operario, legajo="") if operario else None
relevamiento = Relevamiento(
id_relevamiento=None,
id_tarea=None,
articulo=art_obj,
operacion=operacion,
recurso=maq_obj,
hm=hm_obj,
operario=op_obj,
fecha=fecha_obj,
postura="pie",
piezas_por_golpe=piezas_golpe,
articulos_por_golpe=articulos_golpe,
operarios_maquina=operarios_maq,
tiempos=tiempos_lista,
)
relevamiento.numero_importado = num_crono_excel
# 6. Inyectar en la vista Plantilla
# FIX CICLO DE VIDA TKINTER: primero mostrar (dispara limpiar_datos),
# luego cargar datos para que no sean borrados por actualizar_vista().
import vistas
self.root.mostrar_frame(vistas.Plantilla)
vista_plantilla = self.root.frames[vistas.Plantilla]
vista_plantilla.cargar_datos(relevamiento, 0)
messagebox.showinfo(
"Importación Exitosa",
"Planilla cargada correctamente como borrador nuevo."
)
except Exception as e:
import traceback
traceback.print_exc()
logger.exception("Error crítico durante la importación de Excel.")
messagebox.showerror(
"Error de Importación",
f"Hubo un error al leer el archivo Excel:\n\n{str(e)}"
)
def calcular_metricas_globales(self, suma_TE_columnas: float, piezas_por_golpe: float, articulos_por_golpe: float) -> Dict[str, Any]:
"""
Realiza el cálculo en cascada de las métricas globales y de Epicor.
Retorna un diccionario con los valores formateados a 3 decimales o vacíos en caso de error/división por cero.
"""
res = {
"te_operacion": 0.0,
"cadencia_ph": 0.0,
"cadencia_h1000": 0.0,
"estandar_epicor": 0.0,
"dotacion_epicor": 0.0
}
try:
if piezas_por_golpe <= 0.0 or articulos_por_golpe <= 0.0 or suma_TE_columnas <= 0.0:
return res
# TE_Operacion = suma_TE_columnas / piezas_por_golpe
te_operacion = suma_TE_columnas / piezas_por_golpe
res["te_operacion"] = te_operacion
# cadencia_pcs_hr = 60 / TE_Operacion
if te_operacion <= 0.0:
return res
cadencia_ph = 60.0 / te_operacion
res["cadencia_ph"] = cadencia_ph
# cadencia_hrs_1000 = 1000 / cadencia_pcs_hr
if cadencia_ph <= 0.0:
return res
cadencia_h1000 = 1000.0 / cadencia_ph
res["cadencia_h1000"] = cadencia_h1000
# estandar_epicor = cadencia_hrs_1000
estandar_epicor = cadencia_h1000
res["estandar_epicor"] = estandar_epicor
# dotacion_epicor = estandar_epicor / articulos_por_golpe
dotacion_epicor = estandar_epicor / articulos_por_golpe
res["dotacion_epicor"] = dotacion_epicor
except ZeroDivisionError:
logger.warning("ZeroDivisionError detectado y manejado silenciosamente en calcular_metricas_globales.")
except Exception:
logger.exception("Error inesperado en calcular_metricas_globales.")
return res
def crear_backup_bd(self) -> None:
"""
Solicita al usuario un directorio de destino y ejecuta un backup
nativo de SQL Server (BACKUP DATABASE) a través de ``database.ejecutar_backup``.
El nombre del archivo se genera automáticamente con un timestamp
(formato ``YYYYMMDD_HHMMSS``) para evitar colisiones. Las barras
diagonales se normalizan a backslash porque SQL Server solo acepta
rutas Windows nativas.
Muestra un ``messagebox.showinfo`` con la ruta del backup si tiene
éxito, o un ``messagebox.showerror`` si la operación falla.
Returns:
None. La función comunica el resultado directamente al usuario
a través de diálogos modales de tkinter.
"""
import datetime
import database
# 1. Solicitar directorio de destino al usuario
directorio: str = filedialog.askdirectory(
title="Seleccione la carpeta para guardar el Backup"
)
if not directorio:
logger.info("Operación de backup cancelada por el usuario.")
return
# 2. Construir nombre de archivo con timestamp único
fecha_str: str = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
ruta_completa: str = f"{directorio}/Backup_MetodosTiempos_{fecha_str}.bak"
# 3. Normalizar barras: SQL Server requiere rutas Windows con backslash
ruta_completa = ruta_completa.replace("/", "\\")
logger.info("Iniciando backup de base de datos hacia: %s", ruta_completa)
# 4. Ejecutar el backup delegando a la capa de datos
exito: bool = database.ejecutar_backup(ruta_completa)
# 5. Feedback al usuario
if exito:
messagebox.showinfo(
"Backup Exitoso",
f"El backup de la base de datos se completó correctamente.\n\n"
f"Ubicación:\n{ruta_completa}"
)
if self.usuario_actual:
Seguridad.registrar_auditoria(
self.usuario_actual.id_usuario,
f"Backup de BD creado en: {ruta_completa}"
)
else:
messagebox.showerror(
"Error de Backup",
"No se pudo completar el backup de la base de datos.\n\n"
"Revise el archivo de log para más detalles."
)
def blanquear_credenciales(self, nombre_completo: str, clave_maestra: str, nueva_pwd: str) -> bool:
"""
Blanquea (resetea) la contraseña de un usuario identificado por su nombre completo.
Flujo de validación:
1. Verifica la clave maestra contra ``config.MASTER_PASSWORD``.
2. Busca al usuario en la BD por nombre completo exacto via DAO.
3. Genera un nuevo hash bcrypt y actualiza la BD.
4. Informa al usuario su username recuperado y registra la acción en auditoría.
Este método está diseñado para ejecutarse desde la pantalla de Login,
donde no existe sesión activa. La auditoría se registra con el ID del
usuario modificado (no del usuario logueado, que no existe en este contexto).
Args:
nombre_completo: Nombre completo del usuario a recuperar (coincidencia exacta).
clave_maestra: Contraseña maestra ingresada para autorizar la operación.
nueva_pwd: Nueva contraseña en texto plano que será hasheada con bcrypt.
Returns:
True si la contraseña fue actualizada exitosamente, False en caso contrario.
"""
# --- Validación de clave maestra ---
if clave_maestra != config.MASTER_PASSWORD:
messagebox.showerror(
"Acceso Denegado",
"Contraseña maestra incorrecta. Operación cancelada."
)
logger.warning("Intento de blanqueo de credenciales con clave maestra incorrecta.")
return False
# --- Búsqueda del usuario por nombre completo ---
usuario: Optional[Usuario] = UsuarioDAO.obtener_por_nombre_completo(nombre_completo)
if usuario is None:
messagebox.showerror(
"Usuario No Encontrado",
f"No se encontró ningún usuario con el nombre exacto:\n'{nombre_completo}'.\n\n"
f"Verifique que el nombre esté escrito correctamente (respetando mayúsculas y acentos)."
)
logger.warning(f"Blanqueo fallido: no se encontró usuario con nombre '{nombre_completo}'.")
return False
# --- Generación de hash y actualización ---
try:
nuevo_hash: str = Seguridad.generar_hash(nueva_pwd)
exito: bool = UsuarioDAO.actualizar_password(usuario.id_usuario, nuevo_hash)
if exito:
messagebox.showinfo(
"Recuperación Exitosa",
f"Operación exitosa.\n\n"
f"Su usuario recuperado es: {usuario.username}\n"
f"La contraseña ha sido actualizada."
)
# Auditoría con el ID del usuario modificado (no hay sesión activa)
Seguridad.registrar_auditoria(
usuario.id_usuario,
f"Blanqueo de contraseña ejecutado para '{usuario.username}'"
)
logger.info(f"Blanqueo de credenciales exitoso para usuario '{usuario.username}' (ID={usuario.id_usuario}).")
return True
else:
messagebox.showerror(
"Error de Actualización",
"No se pudo actualizar la contraseña en la base de datos.\n"
"Revise el archivo de log para más detalles."
)
return False
except Exception:
logger.exception(f"Error crítico durante el blanqueo de credenciales para '{nombre_completo}'.")
messagebox.showerror(
"Error Crítico",
"Ocurrió un error inesperado durante la operación.\n"
"Revise el archivo de log para más detalles."
)
return False
+906
View File
@@ -0,0 +1,906 @@
from typing import Dict, List, Optional
from database import get_db_cursor
from modelos import Articulo, Operario, Recurso, Relevamiento, Usuario, Tarea
from logger import get_logger
logger = get_logger(__name__)
class ArticuloDAO:
@staticmethod
def obtener_todos() -> List[Articulo]:
"""Obtiene todos los artículos usando consultas parametrizadas (Regla 6)."""
query = "SELECT id_articulo, ISNULL(codigo, '') AS codigo, descripcion FROM Articulos"
articulos: List[Articulo] = []
try:
with get_db_cursor() as cursor:
cursor.execute(query)
for row in cursor.fetchall():
articulos.append(Articulo(id_articulo=row[0], codigo=row[1], descripcion=row[2]))
except Exception:
logger.exception("Falló la obtención de artículos.")
return articulos
@staticmethod
def obtener_por_codigo(codigo: str) -> Optional[Articulo]:
"""
Obtiene un artículo por su código alfanumérico usando consulta parametrizada (Regla 6).
Args:
codigo: Código alfanumérico del artículo ingresado por el usuario.
Returns:
Articulo si existe, None en caso contrario.
"""
query = "SELECT id_articulo, ISNULL(codigo, '') AS codigo, descripcion FROM Articulos WHERE codigo = ?"
try:
with get_db_cursor() as cursor:
cursor.execute(query, (codigo,))
row = cursor.fetchone()
if row:
return Articulo(id_articulo=row[0], codigo=row[1], descripcion=row[2])
except Exception:
logger.exception(f"Error al buscar artículo por código: {codigo}")
return None
@staticmethod
def obtener_o_crear(codigo: str, descripcion: str) -> Articulo:
"""
Obtiene un artículo por código o lo crea con código+descripción de forma transaccional (Regla 6).
Args:
codigo: Código alfanumérico del artículo (ej. 'CHAPA-123').
descripcion: Descripción textual del artículo.
Returns:
Articulo existente o recién creado.
Raises:
Exception: Si falla la inserción en la BD.
"""
try:
art = ArticuloDAO.obtener_por_codigo(codigo)
if art:
return art
query = "INSERT INTO Articulos (codigo, descripcion) OUTPUT INSERTED.id_articulo VALUES (?, ?)"
with get_db_cursor(commit=True) as cursor:
cursor.execute(query, (codigo, descripcion))
new_id = cursor.fetchone()[0]
return Articulo(id_articulo=new_id, codigo=codigo, descripcion=descripcion)
except Exception:
logger.exception(f"Error al obtener o crear artículo: código={codigo}, desc={descripcion}")
raise
class RecursoDAO:
@staticmethod
def obtener_por_tipo(tipo: str) -> List[Recurso]:
"""
Obtiene los recursos filtrados por tipo (MAQUINA o HM) de forma segura (Regla 4 y 6).
"""
query = "SELECT id_recurso, descripcion, tipo FROM Recursos WHERE tipo = ?"
recursos = []
try:
with get_db_cursor() as cursor:
cursor.execute(query, (tipo,))
for row in cursor.fetchall():
recursos.append(Recurso(id_recurso=str(row[0]), nombre=row[1], tipo=row[2]))
except Exception:
logger.exception(f"Error al obtener recursos del tipo {tipo}.")
return recursos
@staticmethod
def obtener_por_descripcion_y_tipo(descripcion: str, tipo: str) -> Optional[Recurso]:
"""
Obtiene un recurso por su descripción y tipo.
"""
query = "SELECT id_recurso, descripcion, tipo FROM Recursos WHERE descripcion = ? AND tipo = ?"
try:
with get_db_cursor() as cursor:
cursor.execute(query, (descripcion, tipo))
row = cursor.fetchone()
if row:
return Recurso(id_recurso=str(row[0]), nombre=row[1], tipo=row[2])
except Exception:
logger.exception("Error al buscar recurso por descripción y tipo.")
return None
@staticmethod
def crear_silencioso(descripcion: str, tipo: str) -> Optional[Recurso]:
"""
Inserta un nuevo recurso de forma silenciosa si no existe.
"""
try:
existing = RecursoDAO.obtener_por_descripcion_y_tipo(descripcion, tipo)
if existing:
return existing
query = "INSERT INTO Recursos (descripcion, tipo) OUTPUT INSERTED.id_recurso VALUES (?, ?)"
with get_db_cursor(commit=True) as cursor:
cursor.execute(query, (descripcion, tipo))
row = cursor.fetchone()
if row:
return Recurso(id_recurso=str(row[0]), nombre=descripcion, tipo=tipo)
except Exception:
logger.exception(f"Error al crear recurso silenciosamente: {descripcion} ({tipo})")
return None
@staticmethod
def borrar_por_nombre_y_tipo(descripcion: str, tipo: str) -> bool:
"""
Elimina un recurso por su descripción y tipo de forma transaccional.
Usa consulta parametrizada (Regla 6) y transacción explícita.
Si el recurso está referenciado por Relevamientos, la BD rechazará
la operación por integridad referencial.
Args:
descripcion: Nombre/descripción del recurso a eliminar.
tipo: Tipo del recurso ('MAQUINA' o 'HM').
Returns:
True si se eliminó al menos un registro, False en caso contrario.
Raises:
No lanza excepciones al exterior; registra en logger (Regla 7).
"""
query = "DELETE FROM Recursos WHERE descripcion = ? AND tipo = ?"
try:
with get_db_cursor(commit=True) as cursor:
cursor.execute(query, (descripcion, tipo))
eliminado = cursor.rowcount > 0
if eliminado:
logger.info(f"Recurso '{descripcion}' ({tipo}) eliminado de la BD.")
else:
logger.warning(f"No se encontró recurso '{descripcion}' ({tipo}) para eliminar.")
return eliminado
except Exception:
logger.exception(f"Error al eliminar recurso '{descripcion}' ({tipo}).")
return False
class OperarioDAO:
@staticmethod
def obtener_todos() -> List[Operario]:
"""
Obtiene todos los operarios registrados.
"""
query = "SELECT id_operario, nombre FROM Operarios"
operarios = []
try:
with get_db_cursor() as cursor:
cursor.execute(query)
for row in cursor.fetchall():
operarios.append(Operario(id_operario=str(row[0]), nombre=row[1], legajo=""))
except Exception:
logger.exception("Error al obtener todos los operarios.")
return operarios
@staticmethod
def obtener_por_nombre(nombre: str) -> Optional[Operario]:
"""
Obtiene un operario por su nombre.
"""
query = "SELECT id_operario, nombre FROM Operarios WHERE nombre = ?"
try:
with get_db_cursor() as cursor:
cursor.execute(query, (nombre,))
row = cursor.fetchone()
if row:
return Operario(id_operario=str(row[0]), nombre=row[1], legajo="")
except Exception:
logger.exception("Error al buscar operario por nombre.")
return None
@staticmethod
def crear_silencioso(nombre: str) -> Optional[Operario]:
"""
Inserta un nuevo operario de forma silenciosa si no existe.
"""
try:
existing = OperarioDAO.obtener_por_nombre(nombre)
if existing:
return existing
query = "INSERT INTO Operarios (nombre) OUTPUT INSERTED.id_operario VALUES (?)"
with get_db_cursor(commit=True) as cursor:
cursor.execute(query, (nombre,))
row = cursor.fetchone()
if row:
return Operario(id_operario=str(row[0]), nombre=nombre, legajo="")
except Exception:
logger.exception(f"Error al crear operario silenciosamente: {nombre}")
return None
class RelevamientoDAO:
@staticmethod
def obtener_siguiente_id() -> int:
"""
Obtiene el siguiente ID correlativo para el relevamiento.
"""
query = "SELECT ISNULL(MAX(id_relevamiento), 0) + 1 FROM Relevamientos"
try:
with get_db_cursor() as cursor:
cursor.execute(query)
row = cursor.fetchone()
if row:
return int(row[0])
except Exception:
logger.exception("Error al obtener el siguiente ID de relevamiento.")
return 1
@staticmethod
def guardar(relevamiento: 'Relevamiento') -> bool:
"""
Guarda un relevamiento y sus tiempos asociados en una sola transacción estricta.
Soporta inserción (INSERT) o actualización (UPDATE) si ya existe.
Cumple la Regla 6 (Transacciones explícitas) y la creación dinámica de entidades.
"""
query_insert = """
INSERT INTO Relevamientos
(id_articulo, operacion, id_recurso, id_hm, id_operario, fecha, postura, piezas_por_golpe, articulos_por_golpe, operarios_maquina, id_tarea)
OUTPUT INSERTED.id_relevamiento
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
query_update = """
UPDATE Relevamientos SET
id_articulo = ?, operacion = ?, id_recurso = ?, id_hm = ?, id_operario = ?,
fecha = ?, postura = ?, piezas_por_golpe = ?, articulos_por_golpe = ?, operarios_maquina = ?, id_tarea = ?
WHERE id_relevamiento = ?
"""
query_delete_tiempos = """
DELETE FROM TiemposElemento WHERE id_relevamiento = ?
"""
query_tiempo = """
INSERT INTO TiemposElemento (id_relevamiento, elemento, valoracion, minutos, segundos, elementos_medidos, lote_frecuencial, suplemento)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
"""
from database import get_db_connection
with get_db_connection() as conn:
cursor = conn.cursor()
try:
art_id = relevamiento.articulo.id_articulo if relevamiento.articulo else None
# --- Gestión dinámica de Entidades Maestras ---
# Máquina
rec_id = None
maq_nombre = None
if isinstance(relevamiento.recurso, str):
maq_nombre = relevamiento.recurso.strip()
elif relevamiento.recurso and hasattr(relevamiento.recurso, 'nombre'):
maq_nombre = relevamiento.recurso.nombre.strip() if relevamiento.recurso.nombre else None
if maq_nombre:
cursor.execute("SELECT id_recurso FROM Recursos WHERE descripcion = ? AND tipo = 'MAQUINA'", (maq_nombre,))
row_maq = cursor.fetchone()
if row_maq:
rec_id = row_maq[0]
else:
cursor.execute("INSERT INTO Recursos (descripcion, tipo) OUTPUT INSERTED.id_recurso VALUES (?, 'MAQUINA')", (maq_nombre,))
rec_id = cursor.fetchone()[0]
# HM (Herramienta/Matriz)
hm_id = None
hm_nombre = None
if isinstance(relevamiento.hm, str):
hm_nombre = relevamiento.hm.strip()
elif relevamiento.hm and hasattr(relevamiento.hm, 'nombre'):
hm_nombre = relevamiento.hm.nombre.strip() if relevamiento.hm.nombre else None
if hm_nombre:
cursor.execute("SELECT id_recurso FROM Recursos WHERE descripcion = ? AND tipo = 'HM'", (hm_nombre,))
row_hm = cursor.fetchone()
if row_hm:
hm_id = row_hm[0]
else:
cursor.execute("INSERT INTO Recursos (descripcion, tipo) OUTPUT INSERTED.id_recurso VALUES (?, 'HM')", (hm_nombre,))
hm_id = cursor.fetchone()[0]
# Operario
op_id = None
op_nombre = None
if isinstance(relevamiento.operario, str):
op_nombre = relevamiento.operario.strip()
elif relevamiento.operario and hasattr(relevamiento.operario, 'nombre'):
op_nombre = relevamiento.operario.nombre.strip() if relevamiento.operario.nombre else None
if op_nombre:
cursor.execute("SELECT id_operario FROM Operarios WHERE nombre = ?", (op_nombre,))
row_op = cursor.fetchone()
if row_op:
op_id = row_op[0]
else:
cursor.execute("INSERT INTO Operarios (nombre) OUTPUT INSERTED.id_operario VALUES (?)", (op_nombre,))
op_id = cursor.fetchone()[0]
# --- Preparación de la cabecera ---
from datetime import date
fecha_str = relevamiento.fecha
if isinstance(fecha_str, date):
fecha_str = fecha_str.strftime("%d/%m/%Y")
# Sanitización estricta de Foreign Key id_tarea
id_tarea_db = relevamiento.id_tarea
if not id_tarea_db or str(id_tarea_db).strip() == "" or str(id_tarea_db) == "0" or str(id_tarea_db).lower() == "none":
id_tarea_db = None
else:
id_tarea_db = int(id_tarea_db)
id_rel = relevamiento.id_relevamiento
if id_rel is not None:
# UPDATE cabecera
cursor.execute(query_update, (
art_id, relevamiento.operacion, rec_id, hm_id, op_id,
fecha_str, relevamiento.postura,
relevamiento.piezas_por_golpe, relevamiento.articulos_por_golpe,
relevamiento.operarios_maquina, id_tarea_db,
id_rel
))
else:
# INSERT cabecera
cursor.execute(query_insert, (
art_id, relevamiento.operacion, rec_id, hm_id, op_id,
fecha_str, relevamiento.postura,
relevamiento.piezas_por_golpe, relevamiento.articulos_por_golpe,
relevamiento.operarios_maquina, id_tarea_db
))
id_rel = cursor.fetchone()[0]
relevamiento.id_relevamiento = id_rel
if not relevamiento.tiempos:
raise ValueError("La lista de tiempos está vacía. Abortando guardado para prevenir pérdida de datos (Regla 0).")
# DELETE tiempos anteriores antes del bulk insert
cursor.execute(query_delete_tiempos, (id_rel,))
# INSERT tiempos nuevos
for t in relevamiento.tiempos:
cursor.execute(query_tiempo, (
id_rel, t.elemento, t.valoracion, t.minutos, t.segundos,
t.elementos_medidos, t.lote_frecuencial, t.suplemento
))
conn.commit()
logger.info(f"Relevamiento {id_rel} guardado exitosamente en BD (Cabecera + Detalle).")
return True
except Exception as e:
conn.rollback()
logger.exception("Error crítico en transacción de guardado. Se ejecutó rollback.")
raise e
finally:
cursor.close()
@staticmethod
def obtener_por_id(id_relevamiento: int) -> Optional['Relevamiento']:
"""
Obtiene un relevamiento por su ID directamente, sin manejo silencioso de errores (Regla 0 y Regla 7).
"""
from modelos import Relevamiento, Articulo, Recurso, Operario, TiemposElemento
from database import get_db_cursor
query_cabecera = """
SELECT
r.id_relevamiento, r.operacion, r.fecha, r.postura,
r.piezas_por_golpe, r.articulos_por_golpe, r.operarios_maquina, r.id_tarea,
ISNULL(a.codigo, '') AS articulo_codigo,
ISNULL(a.descripcion, '') AS articulo_desc,
m.descripcion AS maquina_desc, m.tipo AS maquina_tipo,
h.descripcion AS hm_desc, h.tipo AS hm_tipo,
o.nombre AS operario_nombre
FROM Relevamientos r
LEFT JOIN Articulos a ON r.id_articulo = a.id_articulo
LEFT JOIN Recursos m ON r.id_recurso = m.id_recurso
LEFT JOIN Recursos h ON r.id_hm = h.id_recurso
LEFT JOIN Operarios o ON r.id_operario = o.id_operario
WHERE r.id_relevamiento = ?
"""
with get_db_cursor() as cursor:
cursor.execute(query_cabecera, (id_relevamiento,))
row = cursor.fetchone()
if not row:
return None
# Mapeo inmune a desfases utilizando nombres de columnas (Regla 0)
columns = [column[0] for column in cursor.description]
result_dict = dict(zip(columns, row))
id_rel = result_dict.get('id_relevamiento')
operacion = result_dict.get('operacion')
fecha_str = result_dict.get('fecha')
postura = result_dict.get('postura')
piezas = result_dict.get('piezas_por_golpe')
articulos_g = result_dict.get('articulos_por_golpe')
operarios_m = result_dict.get('operarios_maquina')
id_tarea = result_dict.get('id_tarea')
art_codigo = result_dict.get('articulo_codigo')
art_desc = result_dict.get('articulo_desc')
maq_desc = result_dict.get('maquina_desc')
maq_tipo = result_dict.get('maquina_tipo')
hm_desc = result_dict.get('hm_desc')
hm_tipo = result_dict.get('hm_tipo')
op_nombre = result_dict.get('operario_nombre')
articulo_obj = None
if art_codigo or art_desc:
articulo_obj = Articulo(
id_articulo="0",
codigo=art_codigo or "",
descripcion=art_desc or ""
)
maquina_obj = None
if maq_desc is not None:
maquina_obj = Recurso(id_recurso="0", nombre=maq_desc, tipo=maq_tipo)
hm_obj = None
if hm_desc is not None:
hm_obj = Recurso(id_recurso="0", nombre=hm_desc, tipo=hm_tipo)
operario_obj = None
if op_nombre is not None:
operario_obj = Operario(id_operario="0", nombre=op_nombre, legajo="")
query_tiempos = "SELECT elemento, valoracion, minutos, segundos, elementos_medidos, lote_frecuencial, suplemento FROM TiemposElemento WHERE id_relevamiento = ? ORDER BY id_tiempo ASC"
cursor.execute(query_tiempos, (id_rel,))
tiempos_db = cursor.fetchall()
tiempos = []
for t_row in tiempos_db:
tiempos.append(TiemposElemento(
elemento=t_row[0],
valoracion=float(t_row[1]),
minutos=float(t_row[2]),
segundos=float(t_row[3]),
elementos_medidos=int(t_row[4]),
lote_frecuencial=float(t_row[5]),
suplemento=float(t_row[6])
))
from datetime import datetime, date
parsed_date = date.today()
if fecha_str:
if isinstance(fecha_str, str):
if "-" in fecha_str:
parsed_date = datetime.strptime(fecha_str, "%Y-%m-%d").date()
else:
parsed_date = datetime.strptime(fecha_str, "%d/%m/%Y").date()
else:
parsed_date = fecha_str
return Relevamiento(
id_relevamiento=id_rel,
id_tarea=id_tarea,
articulo=articulo_obj,
operacion=operacion,
recurso=maquina_obj,
hm=hm_obj,
operario=operario_obj,
fecha=parsed_date,
postura=postura if postura else "pie",
piezas_por_golpe=int(piezas) if piezas else 1,
articulos_por_golpe=int(articulos_g) if articulos_g else 1,
operarios_maquina=int(operarios_m) if operarios_m else 1,
tiempos=tiempos
)
@staticmethod
def obtener_por_tarea(id_tarea: int) -> Optional[Relevamiento]:
"""
Obtiene un relevamiento asociado a una tarea.
"""
query = "SELECT id_relevamiento FROM Relevamientos WHERE id_tarea = ?"
try:
with get_db_cursor() as cursor:
cursor.execute(query, (id_tarea,))
row = cursor.fetchone()
if row:
return RelevamientoDAO.obtener_por_id(int(row[0]))
except Exception:
logger.exception(f"Error al obtener relevamiento por tarea: {id_tarea}")
return None
@staticmethod
def obtener_todos_resumen() -> List[tuple]:
"""
Devuelve un resumen de todos los relevamientos para listarlos en la interfaz.
Retorna lista de tuplas: (ID, Fecha, Artículo, Operación, Operario)
"""
query = """
SELECT
r.id_relevamiento,
r.fecha,
ISNULL(a.codigo, 'S/C') AS articulo_codigo,
r.operacion,
ISNULL(o.nombre, 'S/O') AS operario_nombre
FROM Relevamientos r
LEFT JOIN Articulos a ON r.id_articulo = a.id_articulo
LEFT JOIN Operarios o ON r.id_operario = o.id_operario
ORDER BY r.id_relevamiento DESC
"""
resumen = []
try:
with get_db_cursor() as cursor:
cursor.execute(query)
for row in cursor.fetchall():
resumen.append((row[0], row[1], row[2], row[3], row[4]))
except Exception:
logger.exception("Error al obtener resumen de relevamientos.")
return resumen
@staticmethod
def buscar_historico(filtros: Dict[str, str]) -> List[tuple]:
"""
Busca relevamientos históricos aplicando filtros dinámicos combinados.
Construye una consulta SQL con LEFT JOINs a Articulos, Recursos (máquina y HM)
y Operarios. Agrega cláusulas WHERE parametrizadas según los filtros provistos,
utilizando LIKE para búsqueda parcial (Regla 6: consultas parametrizadas estrictas).
Args:
filtros: Diccionario con claves opcionales:
- 'articulo': Filtra por código del artículo.
- 'operario': Filtra por nombre del operario.
- 'maquina': Filtra por descripción de la máquina.
- 'hm': Filtra por descripción de la herramienta/matriz.
- 'operacion': Filtra por nombre de la operación.
Returns:
Lista de tuplas con formato:
(id_relevamiento, fecha, codigo_articulo, operacion, maquina, hm, operario).
El id_relevamiento se usa internamente como iid del Treeview pero NO se
muestra al usuario. El código del artículo actúa como identificador visual.
Retorna lista vacía si ocurre un error.
Raises:
No lanza excepciones al exterior; los errores se registran
en el logger (Regla 7).
"""
query_base = """
SELECT
r.id_relevamiento,
r.fecha,
ISNULL(a.codigo, 'S/C') AS codigo_articulo,
ISNULL(r.operacion, '') AS operacion,
ISNULL(m.descripcion, 'S/M') AS maquina_desc,
ISNULL(h.descripcion, 'S/HM') AS hm_desc,
ISNULL(o.nombre, 'S/O') AS operario_nombre
FROM Relevamientos r
LEFT JOIN Articulos a ON r.id_articulo = a.id_articulo
LEFT JOIN Recursos m ON r.id_recurso = m.id_recurso
LEFT JOIN Recursos h ON r.id_hm = h.id_recurso
LEFT JOIN Operarios o ON r.id_operario = o.id_operario
"""
condiciones: List[str] = []
parametros: List[str] = []
# Mapeo filtro -> columna SQL para construcción dinámica segura
mapa_filtros: Dict[str, str] = {
"articulo": "a.codigo",
"operario": "o.nombre",
"maquina": "m.descripcion",
"hm": "h.descripcion",
"operacion": "r.operacion",
}
for clave, columna in mapa_filtros.items():
valor = filtros.get(clave, "").strip()
if valor:
condiciones.append(f"{columna} LIKE ?")
parametros.append(f"%{valor}%")
if condiciones:
query_base += " WHERE " + " AND ".join(condiciones)
query_base += " ORDER BY r.id_relevamiento DESC"
resultados: List[tuple] = []
try:
with get_db_cursor() as cursor:
cursor.execute(query_base, tuple(parametros))
for row in cursor.fetchall():
resultados.append(
(row[0], row[1], row[2], row[3], row[4], row[5], row[6])
)
except Exception:
logger.exception("Error al ejecutar búsqueda histórica de relevamientos.")
return resultados
@staticmethod
def borrar_por_id(id_relevamiento: int) -> bool:
"""
Elimina un relevamiento y sus tiempos asociados en una sola transacción.
Primero borra los TiemposElemento hijos y luego la cabecera del
relevamiento, garantizando integridad referencial (Regla 6).
Args:
id_relevamiento: ID del relevamiento a eliminar.
Returns:
True si la cabecera fue eliminada exitosamente, False en caso contrario.
Raises:
No lanza excepciones al exterior; registra en logger (Regla 7).
"""
from database import get_db_connection
with get_db_connection() as conn:
cursor = conn.cursor()
try:
cursor.execute(
"DELETE FROM TiemposElemento WHERE id_relevamiento = ?",
(id_relevamiento,)
)
cursor.execute(
"DELETE FROM Relevamientos WHERE id_relevamiento = ?",
(id_relevamiento,)
)
eliminado = cursor.rowcount > 0
conn.commit()
if eliminado:
logger.info(f"Relevamiento {id_relevamiento} eliminado (cabecera + tiempos).")
else:
logger.warning(f"No se encontró relevamiento {id_relevamiento} para eliminar.")
return eliminado
except Exception:
conn.rollback()
logger.exception(f"Error transaccional al eliminar relevamiento {id_relevamiento}. Rollback ejecutado.")
return False
finally:
cursor.close()
class UsuarioDAO:
@staticmethod
def obtener_por_username(username: str) -> Optional[Usuario]:
"""Obtiene un usuario (con su rol) mediante consulta parametrizada."""
query = """
SELECT u.id_usuario, u.username, u.password_hash, r.nombre AS rol, u.nombre
FROM Usuarios u
INNER JOIN Roles r ON u.id_rol = r.id_rol
WHERE u.username = ?
"""
try:
with get_db_cursor() as cursor:
cursor.execute(query, (username,))
row = cursor.fetchone()
if row:
# En modelos.py: id_usuario, username, password_hash, rol, nombre
from modelos import Usuario # Import local para evitar circularidad si la hubiera
return Usuario(id_usuario=row[0], username=row[1], password_hash=row[2], rol=row[3], nombre=row[4])
return None
except Exception:
logger.exception("Fallo al obtener usuario por username.")
return None
@staticmethod
def crear_usuario(username: str, password_hash: str, rol: str, nombre: str) -> bool:
"""Crea un nuevo usuario asignando el id_rol correspondiente."""
query = """
INSERT INTO Usuarios (username, password_hash, id_rol, nombre)
VALUES (?, ?, (SELECT id_rol FROM Roles WHERE nombre = ?), ?)
"""
try:
with get_db_cursor(commit=True) as cursor:
cursor.execute(query, (username, password_hash, rol, nombre))
return True
except Exception:
logger.exception(f"Fallo al crear el usuario: {username}")
return False
@staticmethod
def obtener_metodistas() -> List['Usuario']:
"""Obtiene la lista de usuarios con rol METODISTA."""
query = """
SELECT u.id_usuario, u.username, u.password_hash, r.nombre AS rol, u.nombre
FROM Usuarios u
INNER JOIN Roles r ON u.id_rol = r.id_rol
WHERE r.nombre = 'METODISTA'
"""
metodistas = []
try:
with get_db_cursor() as cursor:
cursor.execute(query)
from modelos import Usuario
for row in cursor.fetchall():
metodistas.append(Usuario(id_usuario=row[0], username=row[1], password_hash=row[2], rol=row[3], nombre=row[4]))
except Exception:
logger.exception("Fallo al obtener la lista de metodistas.")
return metodistas
@staticmethod
def obtener_por_nombre_completo(nombre: str) -> Optional[Usuario]:
"""
Obtiene un usuario buscando por su nombre completo (campo 'nombre').
Realiza un INNER JOIN con Roles para recuperar el nombre del rol
asociado, idéntico al patrón de obtener_por_username.
Utiliza consulta parametrizada (Regla 6).
Args:
nombre: Nombre completo del usuario a buscar (coincidencia exacta).
Returns:
Instancia de Usuario si se encuentra, None en caso contrario.
Raises:
No lanza excepciones al exterior; registra en logger (Regla 7).
"""
query = """
SELECT u.id_usuario, u.username, u.password_hash, r.nombre AS rol, u.nombre
FROM Usuarios u
INNER JOIN Roles r ON u.id_rol = r.id_rol
WHERE u.nombre = ?
"""
try:
with get_db_cursor() as cursor:
cursor.execute(query, (nombre,))
row = cursor.fetchone()
if row:
from modelos import Usuario
return Usuario(
id_usuario=row[0],
username=row[1],
password_hash=row[2],
rol=row[3],
nombre=row[4],
)
return None
except Exception:
logger.exception("Fallo al obtener usuario por nombre completo.")
return None
@staticmethod
def actualizar_password(id_usuario: int, nuevo_hash: str) -> bool:
"""
Actualiza el hash de contraseña de un usuario existente.
Ejecuta un UPDATE parametrizado con commit explícito (Regla 6).
Destinado al flujo de blanqueo/recuperación de contraseña.
Args:
id_usuario: ID del usuario cuya contraseña se actualiza.
nuevo_hash: Nuevo hash bcrypt de la contraseña.
Returns:
True si la actualización afectó al menos una fila, False en caso contrario.
Raises:
No lanza excepciones al exterior; registra en logger (Regla 7).
"""
query = "UPDATE Usuarios SET password_hash = ? WHERE id_usuario = ?"
try:
with get_db_cursor(commit=True) as cursor:
cursor.execute(query, (nuevo_hash, id_usuario))
actualizado: bool = cursor.rowcount > 0
if actualizado:
logger.info(f"Contraseña actualizada para id_usuario={id_usuario}.")
else:
logger.warning(f"No se encontró id_usuario={id_usuario} para actualizar contraseña.")
return actualizado
except Exception:
logger.exception(f"Fallo al actualizar contraseña para id_usuario={id_usuario}.")
return False
class TareaDAO:
@staticmethod
def obtener_todas() -> List['Tarea']:
"""Obtiene todas las tareas/solicitudes de la BD."""
# Se asume que id_usuario_asignado ya existe en BD tras el ALTER TABLE
query = "SELECT id_tarea, titulo, descripcion, estado, fecha, informe_url, id_usuario_asignado FROM Tareas"
tareas = []
try:
with get_db_cursor() as cursor:
cursor.execute(query)
from modelos import Tarea
for row in cursor.fetchall():
# Para prevenir crash si la columna aún no existe, capturamos el índice seguro
try:
id_asig = row[6]
except IndexError:
id_asig = None
tareas.append(Tarea(
id_tarea=row[0], titulo=row[1], descripcion=row[2],
estado=row[3], fecha=str(row[4]), informe_url=row[5] or "",
id_usuario_asignado=id_asig
))
except Exception:
logger.exception("Fallo al obtener tareas.")
return tareas
@staticmethod
def obtener_tareas_por_usuario(id_usuario: int) -> List['Tarea']:
"""Obtiene solo las tareas asignadas a un metodista."""
query = "SELECT id_tarea, titulo, descripcion, estado, fecha, informe_url, id_usuario_asignado FROM Tareas WHERE id_usuario_asignado = ?"
tareas = []
try:
with get_db_cursor() as cursor:
cursor.execute(query, (id_usuario,))
from modelos import Tarea
for row in cursor.fetchall():
tareas.append(Tarea(
id_tarea=row[0], titulo=row[1], descripcion=row[2],
estado=row[3], fecha=str(row[4]), informe_url=row[5] or "",
id_usuario_asignado=row[6]
))
except Exception:
logger.exception("Fallo al obtener tareas por usuario.")
return tareas
@staticmethod
def crear_tarea(titulo: str, descripcion: str, id_usuario_asignado: int) -> bool:
"""Crea una nueva solicitud de relevamiento (Tarea) en estado Pendiente."""
query = """
INSERT INTO Tareas (titulo, descripcion, id_usuario_asignado, estado)
VALUES (?, ?, ?, 'Pendiente')
"""
try:
with get_db_cursor(commit=True) as cursor:
cursor.execute(query, (titulo, descripcion, id_usuario_asignado))
return True
except Exception:
logger.exception("Fallo al crear la tarea.")
return False
@staticmethod
def borrar_tarea(id_tarea: int) -> bool:
"""Elimina una tarea permanentemente de la BD."""
query = "DELETE FROM Tareas WHERE id_tarea = ?"
try:
with get_db_cursor(commit=True) as cursor:
cursor.execute(query, (id_tarea,))
return cursor.rowcount > 0
except Exception:
logger.exception("Fallo al borrar la tarea.")
return False
@staticmethod
def actualizar_estado(id_tarea: int, nuevo_estado: str) -> bool:
"""Actualiza el estado de una tarea."""
query = "UPDATE Tareas SET estado = ? WHERE id_tarea = ?"
try:
with get_db_cursor(commit=True) as cursor:
cursor.execute(query, (nuevo_estado, id_tarea))
return cursor.rowcount > 0
except Exception:
logger.exception("Fallo al actualizar el estado de la tarea.")
return False
class AuditoriaDAO:
@staticmethod
def insertar_log(id_usuario: Optional[int], accion: str) -> bool:
"""Inserta un registro en LogsAuditoria de manera transaccional."""
query = "INSERT INTO LogsAuditoria (id_usuario, accion) VALUES (?, ?)"
try:
with get_db_cursor(commit=True) as cursor:
cursor.execute(query, (id_usuario, accion))
return True
except Exception:
logger.exception("Error crítico al guardar log de auditoría.")
return False
@staticmethod
def actualizar_estado(id_tarea: int, nuevo_estado: str) -> bool:
"""Actualiza el estado de una tarea."""
query = "UPDATE Tareas SET estado = ? WHERE id_tarea = ?"
try:
with get_db_cursor(commit=True) as cursor:
cursor.execute(query, (nuevo_estado, id_tarea))
return cursor.rowcount > 0
except Exception:
logger.exception("Fallo al actualizar el estado de la tarea.")
return False
class AuditoriaDAO:
@staticmethod
def insertar_log(id_usuario: Optional[int], accion: str) -> bool:
"""Inserta un registro en LogsAuditoria de manera transaccional."""
query = "INSERT INTO LogsAuditoria (id_usuario, accion) VALUES (?, ?)"
try:
with get_db_cursor(commit=True) as cursor:
cursor.execute(query, (id_usuario, accion))
return True
except Exception:
logger.exception("Error crítico al guardar log de auditoría.")
return False
+167
View File
@@ -0,0 +1,167 @@
# pyrefly: ignore [missing-import]
import pyodbc
from typing import Iterator, Any, Optional
from contextlib import contextmanager
import config
from logger import get_logger
logger = get_logger(__name__)
class DatabaseConnectionError(Exception):
"""Excepción personalizada para errores de conexión a la base de datos."""
pass
@contextmanager
def get_db_connection() -> Iterator[pyodbc.Connection]:
"""
Context Manager (Regla 3) para manejar de forma segura la conexión a SQL Server.
Garantiza que la conexión se cierre al salir del bloque.
Yields:
pyodbc.Connection: Objeto de conexión a la base de datos.
Raises:
DatabaseConnectionError: Si falla la conexión a la base de datos (Regla 7).
"""
conn_str = (
f"DRIVER={config.DB_DRIVER};"
f"SERVER={config.DB_SERVER};"
f"DATABASE={config.DB_NAME};"
f"Trusted_Connection=yes;"
)
conn = None
try:
conn = pyodbc.connect(conn_str)
logger.info("Conexión a base de datos establecida exitosamente.")
yield conn
except pyodbc.Error as e:
logger.exception("Error crítico al conectar a SQL Server.")
raise DatabaseConnectionError(f"No se pudo conectar a la base de datos: {e}")
finally:
if conn is not None:
conn.close()
logger.debug("Conexión a la base de datos cerrada.")
@contextmanager
def get_db_cursor(commit: bool = False) -> Iterator[pyodbc.Cursor]:
"""
Context Manager (Regla 3 y 6) para manejar un cursor de SQL Server,
con soporte para transacciones seguras (commit/rollback).
Args:
commit (bool): Indica si se debe ejecutar commit() al finalizar sin errores.
Yields:
pyodbc.Cursor: Cursor activo para ejecutar consultas.
"""
with get_db_connection() as conn:
cursor = conn.cursor()
try:
yield cursor
if commit:
conn.commit()
logger.info("Transacción confirmada (commit).")
except Exception as e:
conn.rollback()
logger.exception("Error en transacción SQL. Se ejecutó rollback().")
raise
finally:
cursor.close()
logger.debug("Cursor de base de datos cerrado.")
def ejecutar_backup(ruta_destino: str) -> bool:
"""
Ejecuta un backup nativo de SQL Server (BACKUP DATABASE) a la ruta indicada.
Utiliza una conexión independiente con autocommit=True, ya que el comando
BACKUP DATABASE de SQL Server no puede ejecutarse dentro de un bloque
de transacción explícito. Esta conexión se abre y cierra exclusivamente
para esta operación administrativa.
Args:
ruta_destino (str): Ruta absoluta del archivo .bak de destino en el
servidor de SQL Server. Ejemplo: ``'C:\\Backups\\mi_db.bak'``.
Returns:
bool: ``True`` si el backup se completó exitosamente,
``False`` si ocurrió cualquier error durante la operación.
Raises:
No levanta excepciones. Todos los errores se capturan, registran
en el logger y la función retorna ``False``.
Note:
El comando BACKUP DATABASE es una instrucción DDL administrativa que
no soporta consultas parametrizadas via ``cursor.execute(sql, params)``.
La interpolación directa de ``config.DB_NAME`` y ``ruta_destino`` en
el string SQL es una excepción técnica justificada a la Regla 6.
"""
conn_str: str = (
f"DRIVER={config.DB_DRIVER};"
f"SERVER={config.DB_SERVER};"
f"DATABASE={config.DB_NAME};"
f"Trusted_Connection=yes;"
)
conn: Optional[pyodbc.Connection] = None
cursor: Optional[pyodbc.Cursor] = None
try:
conn = pyodbc.connect(conn_str, autocommit=True)
cursor = conn.cursor()
logger.info(
"Conexión con autocommit establecida para backup de '%s'.",
config.DB_NAME,
)
sql_backup: str = (
f"BACKUP DATABASE [{config.DB_NAME}] "
f"TO DISK = '{ruta_destino}' "
f"WITH FORMAT, "
f"MEDIANAME = 'SQLServerBackups', "
f"NAME = 'Full Backup'"
)
cursor.execute(sql_backup)
# BACKUP DATABASE puede devolver múltiples result sets informativos.
# Consumirlos evita que la conexión quede en estado inconsistente.
while cursor.nextset():
pass
logger.info(
"Backup de la base de datos '%s' completado exitosamente en: %s",
config.DB_NAME,
ruta_destino,
)
return True
except pyodbc.Error as db_err:
logger.exception(
"Error de SQL Server al ejecutar backup de '%s': %s",
config.DB_NAME,
db_err,
)
return False
except OSError as os_err:
logger.exception(
"Error de sistema operativo durante el backup (posible ruta inválida '%s'): %s",
ruta_destino,
os_err,
)
return False
except Exception as unexpected_err:
logger.exception(
"Error inesperado durante el backup de '%s': %s",
config.DB_NAME,
unexpected_err,
)
return False
finally:
if cursor is not None:
cursor.close()
logger.debug("Cursor de backup cerrado.")
if conn is not None:
conn.close()
logger.debug("Conexión de backup cerrada.")