feature/generador-instaladores-linux-windows
This commit is contained in:
@@ -16,6 +16,7 @@ from ..constants import (
|
||||
DIR_PROCESADOS,
|
||||
DIR_TEMP,
|
||||
ENV_PATH,
|
||||
IS_WINDOWS,
|
||||
LOGS_DIR,
|
||||
ODBC_DIR,
|
||||
SEVEN_ZIP_DIR,
|
||||
@@ -25,10 +26,32 @@ from .env_loader import render_env_template
|
||||
|
||||
|
||||
def _copy_tree_if_missing(src: Path, dest: Path) -> None:
|
||||
if not src.is_dir() or dest.exists():
|
||||
if not src.is_dir():
|
||||
return
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(src, dest)
|
||||
# dest puede existir pero VACÍO: ensure_runtime_layout crea ODBC_DIR/SEVEN_ZIP_DIR
|
||||
# antes de llamar aquí. Solo saltar si ya tiene contenido (evita re-copiar en cada
|
||||
# arranque). Antes se saltaba por dest.exists(), dejando 7-Zip/ODBC sin desplegar.
|
||||
if dest.exists() and any(dest.iterdir()):
|
||||
return
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(src, dest, dirs_exist_ok=True)
|
||||
|
||||
|
||||
def _ensure_seven_zip_executable() -> None:
|
||||
"""
|
||||
Asegura el bit de ejecución del 7-Zip embebido en Linux/macOS. shutil.copytree
|
||||
suele preservar los permisos, pero esto es una salvaguarda barata: si el binario
|
||||
quedó sin 'x', la extracción fallaría con PermissionError.
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
return
|
||||
for exe_name in ("7zz", "7za", "7z"):
|
||||
exe_path = SEVEN_ZIP_DIR / exe_name
|
||||
if exe_path.is_file():
|
||||
try:
|
||||
exe_path.chmod(0o755)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _write_env_if_missing() -> None:
|
||||
@@ -71,6 +94,7 @@ def ensure_runtime_layout() -> None:
|
||||
|
||||
_copy_tree_if_missing(BUNDLED_SOURCE_7ZIP, SEVEN_ZIP_DIR)
|
||||
_copy_tree_if_missing(BUNDLED_SOURCE_ODBC, ODBC_DIR)
|
||||
_ensure_seven_zip_executable()
|
||||
|
||||
from ..constants import DB_PATH
|
||||
|
||||
|
||||
@@ -27,11 +27,24 @@ Driver={driver_line}
|
||||
UsageCount=1
|
||||
"""
|
||||
else:
|
||||
# Los .so embebidos viven en config/odbc/lib (no en la raíz). Buscar ahí
|
||||
# primero y usar SIEMPRE la ruta absoluta del archivo versionado real
|
||||
# (p. ej. libmsodbcsql-18.5.so.1.1); un nombre suelto como
|
||||
# "libmsodbcsql-18.so" no resuelve porque ese symlink apunta a /opt.
|
||||
lib_dir = odbc_dir / "lib"
|
||||
search_dirs = [d for d in (lib_dir, odbc_dir) if d.is_dir()]
|
||||
so_path = None
|
||||
for pattern in ("libmsodbcsql-18*.so*", "msodbcsql-18*.so*"):
|
||||
matches = list(odbc_dir.glob(pattern))
|
||||
if matches:
|
||||
so_path = matches[0]
|
||||
for base in search_dirs:
|
||||
# Preferir el archivo real versionado sobre symlinks (que pueden estar rotos).
|
||||
candidates = sorted(
|
||||
(p for p in base.glob("libmsodbcsql-18*.so*") if p.is_file() and not p.is_symlink()),
|
||||
key=lambda p: len(p.name),
|
||||
reverse=True,
|
||||
)
|
||||
if not candidates:
|
||||
candidates = [p for p in base.glob("libmsodbcsql-18*.so*") if p.exists()]
|
||||
if candidates:
|
||||
so_path = candidates[0]
|
||||
break
|
||||
driver_line = str(so_path.resolve()) if so_path else "libmsodbcsql-18.so"
|
||||
content = f"""[ODBC Driver 18 for SQL Server]
|
||||
|
||||
@@ -378,6 +378,24 @@ class SQLServerManager:
|
||||
except Exception:
|
||||
app_logger.warning("No se pudo cerrar la conexión SQL tras el RESTORE")
|
||||
|
||||
@staticmethod
|
||||
def _sql_path_sep(data_folder: str) -> str:
|
||||
"""
|
||||
Separador de rutas que espera el SQL Server destino, deducido del formato
|
||||
de data_folder. Windows (C:\\..., o contiene '\\') usa '\\'; SQL Server
|
||||
sobre Linux (rutas POSIX '/var/opt/mssql/...') usa '/'. Las rutas del MOVE
|
||||
son del filesystem del SERVIDOR, no de donde corre la app.
|
||||
"""
|
||||
if "\\" in data_folder:
|
||||
return "\\"
|
||||
# Ruta POSIX explícita ("/...") o sin separador Windows → '/'.
|
||||
if data_folder.startswith("/"):
|
||||
return "/"
|
||||
# Heurística: "C:" u otra letra de unidad ⇒ Windows.
|
||||
if len(data_folder) >= 2 and data_folder[1] == ":":
|
||||
return "\\"
|
||||
return "/"
|
||||
|
||||
@staticmethod
|
||||
def _build_move_clauses(db_name, data_folder, logical_files) -> list:
|
||||
"""
|
||||
@@ -386,7 +404,13 @@ class SQLServerManager:
|
||||
- 1er data → {db}.mdf, siguientes → {db}_N.ndf
|
||||
- 1er log → {db}_log.ldf, siguientes → {db}_log_N.ldf
|
||||
- otros tipos (FILESTREAM/full-text) → {db}_{nombre_lógico_saneado}
|
||||
|
||||
El separador se adapta al SO del SQL Server destino (deducido de
|
||||
data_folder), de modo que funciona tanto con SQL Server en Windows
|
||||
(C:\\...\\db.mdf) como en Linux (/var/opt/mssql/data/db.mdf).
|
||||
"""
|
||||
sep = SQLServerManager._sql_path_sep(data_folder)
|
||||
base = data_folder.rstrip("\\/")
|
||||
clauses = []
|
||||
data_idx = 0
|
||||
log_idx = 0
|
||||
@@ -394,16 +418,16 @@ class SQLServerManager:
|
||||
if lf.type == 'D':
|
||||
suffix = "" if data_idx == 0 else f"_{data_idx}"
|
||||
ext = "mdf" if data_idx == 0 else "ndf"
|
||||
new_path = f"{data_folder}\\{db_name}{suffix}.{ext}"
|
||||
new_path = f"{base}{sep}{db_name}{suffix}.{ext}"
|
||||
data_idx += 1
|
||||
elif lf.type == 'L':
|
||||
suffix = "" if log_idx == 0 else f"_{log_idx}"
|
||||
new_path = f"{data_folder}\\{db_name}_log{suffix}.ldf"
|
||||
new_path = f"{base}{sep}{db_name}_log{suffix}.ldf"
|
||||
log_idx += 1
|
||||
else:
|
||||
# No descartar otros tipos: moverlos preservando el nombre lógico.
|
||||
safe = "".join(c if c.isalnum() else "_" for c in lf.logical_name)
|
||||
new_path = f"{data_folder}\\{db_name}_{safe}"
|
||||
new_path = f"{base}{sep}{db_name}_{safe}"
|
||||
|
||||
clauses.append(f"MOVE N'{lf.logical_name}' TO N'{new_path}'")
|
||||
return clauses
|
||||
|
||||
@@ -104,24 +104,29 @@ class ConfigTab(QWidget):
|
||||
|
||||
self.sql_windows_auth_checkbox = QCheckBox()
|
||||
self.sql_windows_auth_checkbox.setChecked(True)
|
||||
self.sql_windows_auth_checkbox.toggled.connect(self._on_auth_changed)
|
||||
sql_layout.addRow("Usar Windows Auth:", self.sql_windows_auth_checkbox)
|
||||
|
||||
self.sql_username_input = QLineEdit()
|
||||
self.sql_username_input.setEnabled(False)
|
||||
sql_layout.addRow("Usuario SQL:", self.sql_username_input)
|
||||
|
||||
self.sql_password_input = QLineEdit()
|
||||
self.sql_password_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.sql_password_input.setEnabled(False)
|
||||
sql_layout.addRow("Contraseña SQL:", self.sql_password_input)
|
||||
|
||||
# Conectar el handler DESPUÉS de crear usuario/contraseña: en Linux se
|
||||
# desmarca el checkbox (abajo), lo que dispararía _on_auth_changed antes de
|
||||
# que existan esos campos si se conectara arriba.
|
||||
self.sql_windows_auth_checkbox.toggled.connect(self._on_auth_changed)
|
||||
if sys.platform != "win32":
|
||||
# Windows Auth no aplica en Linux: forzar SQL Auth y habilitar campos.
|
||||
self.sql_windows_auth_checkbox.setChecked(False)
|
||||
self.sql_windows_auth_checkbox.setEnabled(False)
|
||||
self.sql_windows_auth_checkbox.setToolTip(
|
||||
"Windows Auth no está disponible en Linux; use SQL Auth."
|
||||
)
|
||||
|
||||
self.sql_username_input = QLineEdit()
|
||||
self.sql_username_input.setEnabled(False)
|
||||
sql_layout.addRow("Usuario SQL:", self.sql_username_input)
|
||||
|
||||
self.sql_password_input = QLineEdit()
|
||||
self.sql_password_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.sql_password_input.setEnabled(False)
|
||||
sql_layout.addRow("Contraseña SQL:", self.sql_password_input)
|
||||
|
||||
test_sql_btn = QPushButton("🔌 Probar Conexión")
|
||||
test_sql_btn.clicked.connect(self._test_sql_connection)
|
||||
sql_layout.addRow("", test_sql_btn)
|
||||
@@ -431,12 +436,17 @@ class ConfigTab(QWidget):
|
||||
line_edit.setText(folder)
|
||||
|
||||
def _browse_seven_zip(self):
|
||||
"""Abre el diálogo para seleccionar 7z.exe."""
|
||||
"""Abre el diálogo para seleccionar el ejecutable de 7-Zip."""
|
||||
if sys.platform == "win32":
|
||||
title = "Seleccionar 7z.exe"
|
||||
default_dir = "C:\\Program Files\\7-Zip"
|
||||
file_filter = "Ejecutables (*.exe)"
|
||||
else:
|
||||
title = "Seleccionar 7z / 7zz"
|
||||
default_dir = ""
|
||||
file_filter = "Ejecutables (*)"
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Seleccionar 7z.exe",
|
||||
"C:\\Program Files\\7-Zip",
|
||||
"Ejecutables (*.exe)"
|
||||
self, title, default_dir, file_filter
|
||||
)
|
||||
if file_path:
|
||||
self.seven_zip_input.setText(file_path)
|
||||
@@ -475,6 +485,9 @@ class ConfigTab(QWidget):
|
||||
|
||||
def _on_auth_changed(self, checked: bool):
|
||||
"""Maneja el cambio en el tipo de autenticación."""
|
||||
# Guard: la señal puede dispararse durante la construcción de la UI.
|
||||
if not hasattr(self, "sql_username_input"):
|
||||
return
|
||||
self.sql_username_input.setEnabled(not checked)
|
||||
self.sql_password_input.setEnabled(not checked)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tab de logs y soporte."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -91,10 +92,15 @@ class LogsTab(QWidget):
|
||||
self.logs_text.setPlainText("\n".join(log_lines))
|
||||
|
||||
def _open_logs_folder(self):
|
||||
"""Abre la carpeta de logs en el explorador."""
|
||||
"""Abre la carpeta de logs en el explorador de archivos del sistema."""
|
||||
try:
|
||||
if LOGS_DIR.exists():
|
||||
subprocess.run(["explorer", str(LOGS_DIR)])
|
||||
if sys.platform == "win32":
|
||||
subprocess.run(["explorer", str(LOGS_DIR)])
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.run(["open", str(LOGS_DIR)])
|
||||
else:
|
||||
subprocess.run(["xdg-open", str(LOGS_DIR)])
|
||||
else:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
|
||||
Reference in New Issue
Block a user