feat: Introduce bot-digitador service for automated data entry in Integración Total. Add CLI and GUI interfaces for CSV processing, implement auto-start functionality on Windows, and enhance README with setup instructions and usage details. Include necessary configurations in docker-compose for service integration.
This commit is contained in:
@@ -37,6 +37,15 @@ python run_gui.py
|
||||
|
||||
Configura URL API (ej. `https://pys1.builker.xyz/bot-descarga` o `http://127.0.0.1:8189/bot-descarga`), Worker ID y token. Pulsa **Conectar agent**. Queda idle hasta que el desktop asigne un job.
|
||||
|
||||
### Autoarranque en Windows
|
||||
|
||||
En la misma GUI:
|
||||
|
||||
1. Marca **Iniciar con Windows** (crea un `.bat` en la carpeta Startup del usuario).
|
||||
2. Deja **Conectar al abrir** activo para que tras el login el agent quede online solo.
|
||||
|
||||
Tras reiniciar el PC e iniciar sesión, se abre la GUI y se registra el worker automáticamente.
|
||||
|
||||
## Arranque solo agent (CLI)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import customtkinter as ctk
|
||||
@@ -11,6 +12,11 @@ from bot_descarga.ui.settings_store import (
|
||||
normalize_api_url,
|
||||
save_gui_settings,
|
||||
)
|
||||
from bot_descarga.ui.windows_autostart import (
|
||||
disable_autostart,
|
||||
enable_autostart,
|
||||
is_autostart_enabled,
|
||||
)
|
||||
from bot_descarga.worker.agent import WorkerAgent
|
||||
|
||||
ctk.set_appearance_mode("dark")
|
||||
@@ -24,7 +30,7 @@ class BotDashboard(ctk.CTk):
|
||||
super().__init__()
|
||||
|
||||
self.title("Bot de Descargas PDF — Agent")
|
||||
self.geometry("920x680")
|
||||
self.geometry("920x720")
|
||||
self.resizable(False, False)
|
||||
|
||||
self._agent: WorkerAgent | None = None
|
||||
@@ -36,6 +42,10 @@ class BotDashboard(ctk.CTk):
|
||||
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
# Tras login de Windows: si está marcado, conectar solo
|
||||
if bool(load_gui_settings().get("auto_connect")):
|
||||
self.after(800, self._maybe_auto_connect)
|
||||
|
||||
def _construir_ui(self):
|
||||
root = ctk.CTkFrame(self, corner_radius=18)
|
||||
root.pack(fill="both", expand=True, padx=18, pady=18)
|
||||
@@ -81,7 +91,7 @@ class BotDashboard(ctk.CTk):
|
||||
self.entry_worker.pack(side="left", fill="x", expand=True)
|
||||
|
||||
row_token = ctk.CTkFrame(config, fg_color="transparent")
|
||||
row_token.pack(fill="x", padx=18, pady=(0, 14))
|
||||
row_token.pack(fill="x", padx=18, pady=(0, 8))
|
||||
ctk.CTkLabel(row_token, text="Token", width=90, anchor="w").pack(side="left")
|
||||
self.entry_token = ctk.CTkEntry(
|
||||
row_token,
|
||||
@@ -97,6 +107,23 @@ class BotDashboard(ctk.CTk):
|
||||
command=self._toggle_token,
|
||||
).pack(side="left")
|
||||
|
||||
row_auto = ctk.CTkFrame(config, fg_color="transparent")
|
||||
row_auto.pack(fill="x", padx=18, pady=(0, 14))
|
||||
self.chk_autostart = ctk.CTkCheckBox(
|
||||
row_auto,
|
||||
text="Iniciar con Windows",
|
||||
command=self._on_autostart_toggle,
|
||||
)
|
||||
self.chk_autostart.pack(side="left", padx=(0, 18))
|
||||
self.chk_auto_connect = ctk.CTkCheckBox(
|
||||
row_auto,
|
||||
text="Conectar al abrir",
|
||||
command=self._on_auto_connect_toggle,
|
||||
)
|
||||
self.chk_auto_connect.pack(side="left")
|
||||
if sys.platform != "win32":
|
||||
self.chk_autostart.configure(state="disabled")
|
||||
|
||||
status = ctk.CTkFrame(root, corner_radius=14)
|
||||
status.pack(fill="x", padx=24, pady=(0, 14))
|
||||
|
||||
@@ -158,19 +185,77 @@ class BotDashboard(ctk.CTk):
|
||||
)
|
||||
self.text_logs.pack(fill="both", expand=True, padx=18, pady=(0, 16))
|
||||
|
||||
def _persist_flags(self):
|
||||
save_gui_settings(
|
||||
normalize_api_url(self.entry_api.get()),
|
||||
self.entry_worker.get(),
|
||||
self.entry_token.get(),
|
||||
autostart_windows=bool(self.chk_autostart.get()),
|
||||
auto_connect=bool(self.chk_auto_connect.get()),
|
||||
)
|
||||
|
||||
def _cargar_settings(self):
|
||||
s = load_gui_settings()
|
||||
self.entry_api.insert(0, s.get("api_url") or DEFAULT_API_URL)
|
||||
self.entry_worker.insert(0, s.get("worker_id") or socket.gethostname())
|
||||
self.entry_token.insert(0, s.get("token") or "")
|
||||
|
||||
want_autostart = bool(s.get("autostart_windows"))
|
||||
if want_autostart and sys.platform == "win32":
|
||||
try:
|
||||
if not is_autostart_enabled():
|
||||
enable_autostart()
|
||||
self.chk_autostart.select()
|
||||
except Exception as e:
|
||||
self._log(f"No se pudo restaurar autoarranque: {e}")
|
||||
elif is_autostart_enabled():
|
||||
# Script presente aunque el flag esté off → marcar checkbox
|
||||
self.chk_autostart.select()
|
||||
|
||||
if bool(s.get("auto_connect")):
|
||||
self.chk_auto_connect.select()
|
||||
|
||||
def _guardar_settings(self):
|
||||
api = normalize_api_url(self.entry_api.get())
|
||||
self.entry_api.delete(0, "end")
|
||||
self.entry_api.insert(0, api)
|
||||
save_gui_settings(api, self.entry_worker.get(), self.entry_token.get())
|
||||
self._persist_flags()
|
||||
self._log("Configuración guardada en data/gui_settings.json")
|
||||
|
||||
def _on_autostart_toggle(self):
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
enabled = bool(self.chk_autostart.get())
|
||||
try:
|
||||
if enabled:
|
||||
path = enable_autostart()
|
||||
self._log(f"Autoarranque activado: {path}")
|
||||
# Tiene sentido conectar al abrir si arranca con Windows
|
||||
if not self.chk_auto_connect.get():
|
||||
self.chk_auto_connect.select()
|
||||
else:
|
||||
disable_autostart()
|
||||
self._log("Autoarranque desactivado.")
|
||||
self._persist_flags()
|
||||
except Exception as e:
|
||||
self._log(f"Error configurando autoarranque: {e}")
|
||||
if enabled:
|
||||
self.chk_autostart.deselect()
|
||||
else:
|
||||
self.chk_autostart.select()
|
||||
|
||||
def _on_auto_connect_toggle(self):
|
||||
self._persist_flags()
|
||||
|
||||
def _maybe_auto_connect(self):
|
||||
if self._connected:
|
||||
return
|
||||
if not self.entry_worker.get().strip() or not self.entry_token.get().strip():
|
||||
self._log("Auto-conectar omitido: falta Worker ID o token.")
|
||||
return
|
||||
self._log("Conectando automáticamente…")
|
||||
self._conectar()
|
||||
|
||||
def _toggle_token(self):
|
||||
self._token_visible = not self._token_visible
|
||||
self.entry_token.configure(show="" if self._token_visible else "•")
|
||||
@@ -195,7 +280,7 @@ class BotDashboard(ctk.CTk):
|
||||
|
||||
self.entry_api.delete(0, "end")
|
||||
self.entry_api.insert(0, api)
|
||||
save_gui_settings(api, worker_id, token)
|
||||
self._persist_flags()
|
||||
|
||||
self._agent = WorkerAgent(
|
||||
api_url=api,
|
||||
@@ -251,6 +336,9 @@ class BotDashboard(ctk.CTk):
|
||||
self.entry_api.configure(state=state)
|
||||
self.entry_worker.configure(state=state)
|
||||
self.entry_token.configure(state=state)
|
||||
if sys.platform == "win32":
|
||||
self.chk_autostart.configure(state="normal")
|
||||
self.chk_auto_connect.configure(state="normal")
|
||||
|
||||
def _limpiar_logs(self):
|
||||
self.text_logs.delete("1.0", "end")
|
||||
|
||||
@@ -21,6 +21,8 @@ def load_gui_settings() -> Dict[str, Any]:
|
||||
"api_url": DEFAULT_API_URL,
|
||||
"worker_id": "",
|
||||
"token": DEFAULT_TOKEN,
|
||||
"autostart_windows": False,
|
||||
"auto_connect": False,
|
||||
}
|
||||
if not _SETTINGS_PATH.exists():
|
||||
return defaults
|
||||
@@ -33,13 +35,26 @@ def load_gui_settings() -> Dict[str, Any]:
|
||||
return defaults
|
||||
|
||||
|
||||
def save_gui_settings(api_url: str, worker_id: str, token: str) -> None:
|
||||
def save_gui_settings(
|
||||
api_url: str,
|
||||
worker_id: str,
|
||||
token: str,
|
||||
*,
|
||||
autostart_windows: bool | None = None,
|
||||
auto_connect: bool | None = None,
|
||||
) -> None:
|
||||
_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
current = load_gui_settings()
|
||||
payload = {
|
||||
**current,
|
||||
"api_url": api_url.strip(),
|
||||
"worker_id": worker_id.strip(),
|
||||
"token": token.strip(),
|
||||
}
|
||||
if autostart_windows is not None:
|
||||
payload["autostart_windows"] = bool(autostart_windows)
|
||||
if auto_connect is not None:
|
||||
payload["auto_connect"] = bool(auto_connect)
|
||||
_SETTINGS_PATH.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Autoarranque con Windows (carpeta Startup del usuario)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TASK_BASENAME = "IPSExtractor-BotDescarga"
|
||||
|
||||
|
||||
def bot_root() -> Path:
|
||||
"""Raíz del paquete bots/bot_descarga (donde está run_gui.py)."""
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def startup_dir() -> Path:
|
||||
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
||||
return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
|
||||
|
||||
|
||||
def startup_script_path() -> Path:
|
||||
return startup_dir() / f"{TASK_BASENAME}.bat"
|
||||
|
||||
|
||||
def resolve_python(root: Path | None = None) -> Path:
|
||||
root = root or bot_root()
|
||||
for candidate in (
|
||||
root / ".venv" / "Scripts" / "pythonw.exe",
|
||||
root / ".venv" / "Scripts" / "python.exe",
|
||||
root / ".venv" / "bin" / "python",
|
||||
):
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return Path(sys.executable)
|
||||
|
||||
|
||||
def is_autostart_enabled() -> bool:
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
return startup_script_path().exists()
|
||||
|
||||
|
||||
def enable_autostart(root: Path | None = None) -> Path:
|
||||
"""Crea un .bat en Startup que abre la GUI del bot tras el login."""
|
||||
if sys.platform != "win32":
|
||||
raise RuntimeError("El autoarranque solo está disponible en Windows.")
|
||||
|
||||
root = (root or bot_root()).resolve()
|
||||
gui = root / "run_gui.py"
|
||||
if not gui.exists():
|
||||
raise FileNotFoundError(f"No se encontró run_gui.py en {root}")
|
||||
|
||||
py = resolve_python(root)
|
||||
dest = startup_script_path()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# start "" deja que el .bat termine; la GUI queda como proceso independiente.
|
||||
content = (
|
||||
"@echo off\r\n"
|
||||
f'cd /d "{root}"\r\n'
|
||||
f'start "" "{py}" "{gui}"\r\n'
|
||||
)
|
||||
dest.write_text(content, encoding="utf-8")
|
||||
return dest
|
||||
|
||||
|
||||
def disable_autostart() -> bool:
|
||||
"""Elimina el .bat de Startup. True si había algo que borrar."""
|
||||
path = startup_script_path()
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return True
|
||||
return False
|
||||
@@ -131,13 +131,14 @@ class WorkerAgent:
|
||||
|
||||
def _run_job(self, job: dict) -> None:
|
||||
job_id = job["id"]
|
||||
downloads_prefix = (job.get("downloads_prefix") or "").strip() or f"downloads/{job_id}/"
|
||||
self._uploaded_keys = []
|
||||
work_dir = self.staging_root / job_id
|
||||
out_dir = work_dir / "out"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
excel_path = work_dir / (Path(job.get("excel_key") or "input.xlsx").name)
|
||||
|
||||
self._log(f"[worker {self.worker_id}] job {job_id} iniciado")
|
||||
self._log(f"[worker {self.worker_id}] job {job_id} iniciado (prefix={downloads_prefix})")
|
||||
|
||||
try:
|
||||
self.api.download_excel(job_id, excel_path)
|
||||
@@ -168,7 +169,14 @@ class WorkerAgent:
|
||||
self._log(f"[worker] no se pudo enviar evento: {e}")
|
||||
|
||||
def on_pdf_saved(nit: str, radicado: str, local_path: str) -> None:
|
||||
key = self.minio.upload_pdf(job_id, nit, radicado, local_path)
|
||||
key = self.minio.upload_pdf(
|
||||
job_id,
|
||||
nit,
|
||||
radicado,
|
||||
local_path,
|
||||
downloads_prefix=downloads_prefix,
|
||||
skip_if_exists=True,
|
||||
)
|
||||
self._uploaded_keys.append(key)
|
||||
|
||||
service = DownloadService(
|
||||
@@ -181,7 +189,11 @@ class WorkerAgent:
|
||||
summary = service.run()
|
||||
|
||||
try:
|
||||
self.minio.write_manifest(job_id, self._uploaded_keys)
|
||||
self.minio.write_manifest(
|
||||
job_id,
|
||||
self._uploaded_keys,
|
||||
downloads_prefix=downloads_prefix,
|
||||
)
|
||||
except Exception as e:
|
||||
self._log(f"[worker] manifest no escrito: {e}")
|
||||
|
||||
@@ -194,7 +206,7 @@ class WorkerAgent:
|
||||
"exitosos": summary.exitosos,
|
||||
"fallidos": fallidos_count,
|
||||
"cancelled": summary.cancelled,
|
||||
"downloads_prefix": job.get("downloads_prefix"),
|
||||
"downloads_prefix": downloads_prefix,
|
||||
"uploaded_count": len(self._uploaded_keys),
|
||||
}
|
||||
|
||||
|
||||
@@ -40,10 +40,22 @@ class MinioSettings:
|
||||
)
|
||||
|
||||
|
||||
def pdf_key_from_prefix(downloads_prefix: str, nit: str, radicado: str) -> str:
|
||||
prefix = (downloads_prefix or "").strip().rstrip("/")
|
||||
if not prefix:
|
||||
raise ValueError("downloads_prefix vacío")
|
||||
return f"{prefix}/{nit}/{radicado}.pdf"
|
||||
|
||||
|
||||
def pdf_key(job_id: str, nit: str, radicado: str) -> str:
|
||||
return f"downloads/{job_id}/{nit}/{radicado}.pdf"
|
||||
|
||||
|
||||
def manifest_key_from_prefix(downloads_prefix: str) -> str:
|
||||
prefix = (downloads_prefix or "").strip().rstrip("/")
|
||||
return f"{prefix}/_manifest.json"
|
||||
|
||||
|
||||
def manifest_key(job_id: str) -> str:
|
||||
return f"downloads/{job_id}/_manifest.json"
|
||||
|
||||
@@ -89,8 +101,30 @@ class WorkerMinioUploader:
|
||||
self._client = _s3_client(self.settings)
|
||||
return self._client
|
||||
|
||||
def upload_pdf(self, job_id: str, nit: str, radicado: str, local_path: str | Path) -> str:
|
||||
key = pdf_key(job_id, nit, radicado)
|
||||
def object_exists(self, key: str) -> bool:
|
||||
try:
|
||||
self.client.head_object(Bucket=self.settings.bucket, Key=key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def upload_pdf(
|
||||
self,
|
||||
job_id: str,
|
||||
nit: str,
|
||||
radicado: str,
|
||||
local_path: str | Path,
|
||||
*,
|
||||
downloads_prefix: str | None = None,
|
||||
skip_if_exists: bool = True,
|
||||
) -> str:
|
||||
"""Sube PDF bajo downloads_prefix (padre) o downloads/{job_id}/."""
|
||||
if downloads_prefix:
|
||||
key = pdf_key_from_prefix(downloads_prefix, nit, radicado)
|
||||
else:
|
||||
key = pdf_key(job_id, nit, radicado)
|
||||
if skip_if_exists and self.object_exists(key):
|
||||
return key
|
||||
path = Path(local_path)
|
||||
# put_object (no multipart) es más fiable en buckets anónimos.
|
||||
self.client.put_object(
|
||||
@@ -101,9 +135,23 @@ class WorkerMinioUploader:
|
||||
)
|
||||
return key
|
||||
|
||||
def write_manifest(self, job_id: str, keys: List[str]) -> str:
|
||||
key = manifest_key(job_id)
|
||||
payload = {"job_id": job_id, "files": keys, "count": len(keys)}
|
||||
def write_manifest(
|
||||
self,
|
||||
job_id: str,
|
||||
keys: List[str],
|
||||
*,
|
||||
downloads_prefix: str | None = None,
|
||||
) -> str:
|
||||
if downloads_prefix:
|
||||
key = manifest_key_from_prefix(downloads_prefix)
|
||||
else:
|
||||
key = manifest_key(job_id)
|
||||
payload = {
|
||||
"job_id": job_id,
|
||||
"downloads_prefix": downloads_prefix or f"downloads/{job_id}/",
|
||||
"files": keys,
|
||||
"count": len(keys),
|
||||
}
|
||||
self.client.put_object(
|
||||
Bucket=self.settings.bucket,
|
||||
Key=key,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from pathlib import Path
|
||||
|
||||
from bot_descarga.ui.windows_autostart import TASK_BASENAME, bot_root, resolve_python
|
||||
|
||||
|
||||
def test_bot_root_has_run_gui():
|
||||
root = bot_root()
|
||||
assert (root / "run_gui.py").exists()
|
||||
|
||||
|
||||
def test_resolve_python_returns_path():
|
||||
py = resolve_python()
|
||||
assert isinstance(py, Path)
|
||||
assert str(py)
|
||||
|
||||
|
||||
def test_task_basename():
|
||||
assert "BotDescarga" in TASK_BASENAME
|
||||
Reference in New Issue
Block a user