Files
IPSExtractor/gui/field_selector.py
T

387 lines
14 KiB
Python

"""Diálogo de selección de campos basado en `customtkinter`.
Permite buscar, seleccionar/deseleccionar y revisar una muestra de la primera
factura para facilitar la elección de paths del schema.
"""
from __future__ import annotations
import json
from typing import Dict, List, Callable, Optional
import customtkinter as ctk
import threading
import tkinter.messagebox as mb
from application.app_prefs import get_allowed_document_types
DOCUMENT_TYPE_HINT_ALIASES = {
"CC", "TI", "CE", "RC", "PA", "PPT", "PEP", "EPT", "NUIP",
"PT", "PAS", "PASS",
}
class FieldSelectorDialog(ctk.CTkToplevel):
"""Ventana modal para elegir campos del schema a extraer."""
def __init__(self, master, schema: Dict[str, str], sample: Optional[dict] = None, initial_selected: Optional[List[str]] = None):
"""Crea el diálogo con lista scrolleable y búsqueda.
Args:
master: Ventana padre.
schema: Mapa path->tipo (ayuda visual en etiqueta del checkbox).
sample: JSON de muestra para vista previa.
initial_selected: Paths seleccionados inicialmente.
"""
super().__init__(master)
self.title("Seleccionar campos del schema")
self.geometry("800x600")
self.transient(master)
self.grab_set()
self._schema = schema
self._sample = sample
self._on_submit: Optional[Callable[[List[str], Dict[str, str]], None]] = None
self._on_reprocess: Optional[Callable[[], tuple[Dict[str, str], dict]]] = None
self.search_entry = ctk.CTkEntry(self, placeholder_text="Buscar campo...")
self.search_entry.grid(row=0, column=0, columnspan=3, sticky="we", padx=10, pady=(10, 5))
self.search_entry.bind("<KeyRelease>", self._on_search)
self.scroll = ctk.CTkScrollableFrame(self)
self.scroll.grid(row=1, column=0, columnspan=3, sticky="nsew", padx=10, pady=5)
self.select_all_btn = ctk.CTkButton(self, text="Seleccionar todo", command=self._select_all)
self.select_all_btn.grid(row=2, column=0, padx=10, pady=10, sticky="w")
self.clear_btn = ctk.CTkButton(self, text="Limpiar", command=self._clear_all)
self.clear_btn.grid(row=2, column=1, padx=10, pady=10, sticky="w")
self._right_btns = ctk.CTkFrame(self)
self._right_btns.grid(row=2, column=2, padx=10, pady=10, sticky="e")
self.sample_btn = ctk.CTkButton(self._right_btns, text="Ver muestra", command=self._show_sample)
self.sample_btn.pack(side="left", padx=(0, 8))
self.reprocess_btn = ctk.CTkButton(self._right_btns, text="Reprocesar", command=self._reprocess)
self.reprocess_btn.pack(side="left")
self.cancel_btn = ctk.CTkButton(self, text="Cancelar", command=self._cancel)
self.cancel_btn.grid(row=3, column=0, padx=10, pady=(0, 10), sticky="w")
self.ok_btn = ctk.CTkButton(self, text="Aceptar", command=self._accept)
self.ok_btn.grid(row=3, column=2, padx=10, pady=(0, 10), sticky="e")
self.grid_columnconfigure(0, weight=1)
self.grid_columnconfigure(1, weight=0)
self.grid_columnconfigure(2, weight=1)
self.grid_rowconfigure(1, weight=1)
self._vars: Dict[str, ctk.BooleanVar] = {}
self._rows: Dict[str, ctk.CTkFrame] = {}
self._alias_entries: Dict[str, ctk.CTkEntry] = {}
self._group_labels: Dict[str, ctk.CTkLabel] = {}
self._group_paths: Dict[str, list[str]] = {}
initial_set = set(initial_selected or [])
# Construir grupos por prefijo (Tipo) cuando el path contiene '/'
grouped: Dict[str, list[tuple[str, str]]] = {}
for path, typ in sorted(self._schema.items(), key=lambda x: x[0]):
if '/' in path:
group, sub = path.split('/', 1)
else:
group, sub = "General", path
grouped.setdefault(group, []).append((path, typ))
# Crear filas agrupadas
rows_container = self.scroll
for group_name, items in grouped.items():
# Encabezado del grupo
glabel = ctk.CTkLabel(rows_container, text=group_name, anchor="w")
glabel.pack(anchor="w", fill="x", padx=8, pady=(6, 2))
self._group_labels[group_name] = glabel
self._group_paths[group_name] = []
# Filas del grupo
for path, typ in items:
var = ctk.BooleanVar(value=(path in initial_set))
row = ctk.CTkFrame(rows_container)
row.pack(anchor="w", fill="x", padx=8, pady=2)
cb = ctk.CTkCheckBox(row, text=f"{path} ({typ})", variable=var)
cb.grid(row=0, column=0, sticky="w")
alias_entry = ctk.CTkEntry(row, width=260, placeholder_text="Nombre en CSV")
try:
alias_entry.insert(0, path)
except Exception:
pass
alias_entry.grid(row=0, column=1, padx=(12, 0))
self._add_document_type_hint(row, path)
try:
alias_entry.bind("<KeyRelease>", lambda _e: self._validate_aliases())
except Exception:
pass
def _toggle(ent: ctk.CTkEntry, v: ctk.BooleanVar = var, p: str = path):
try:
if p == 'radicado':
ent.grid_remove()
else:
if v.get():
ent.grid(row=0, column=1, padx=(12, 0))
else:
ent.grid_remove()
except Exception:
pass
self._validate_aliases()
_toggle(alias_entry)
cb.configure(command=lambda e=alias_entry, v=var, p=path: _toggle(e, v, p))
self._vars[path] = var
self._rows[path] = row
self._alias_entries[path] = alias_entry
self._group_paths[group_name].append(path)
self._validate_aliases()
def set_on_submit(self, callback: Callable[[List[str], Dict[str, str]], None]) -> None:
"""Registra el callback a invocar con (selected_fields, alias_map)."""
self._on_submit = callback
def set_on_reprocess(self, callback: Callable[[], tuple[Dict[str, str], dict]]) -> None:
"""Registra el callback a invocar para reprocesar y actualizar (schema, sample)."""
self._on_reprocess = callback
def _on_search(self, _event=None):
"""Filtra filas visibles por coincidencia de texto en el path."""
query = self.search_entry.get().strip().lower()
# Lista ordenada de grupos según se crearon
ordered_groups = list(self._group_labels.keys())
for group_name in ordered_groups:
paths = self._group_paths.get(group_name, [])
# Determinar qué filas de este grupo coinciden
visible_paths = []
for p in paths:
if query in p.lower():
visible_paths.append(p)
# Si hay filas visibles, mostrar grupo y esas filas; si no, ocultar todo el grupo
lbl = self._group_labels.get(group_name)
if visible_paths:
if lbl:
lbl.pack(anchor="w", fill="x", padx=8, pady=(6, 2))
for p in paths:
row = self._rows.get(p)
if not row: continue
if p in visible_paths:
row.pack(anchor="w", fill="x", padx=8, pady=2)
else:
row.pack_forget()
else:
if lbl:
lbl.pack_forget()
for p in paths:
row = self._rows.get(p)
if row:
row.pack_forget()
def _select_all(self):
"""Marca todos los campos y muestra las entradas de alias (excepto radicado)."""
for path, var in self._vars.items():
var.set(True)
try:
ent = self._alias_entries.get(path)
if ent is not None:
if path == 'radicado':
ent.grid_remove()
else:
ent.grid(row=0, column=1, padx=(12, 0))
except Exception:
pass
self._validate_aliases()
def _clear_all(self):
"""Desmarca todos los campos y oculta entradas de alias."""
for path, var in self._vars.items():
var.set(False)
try:
ent = self._alias_entries.get(path)
if ent is not None:
ent.grid_remove()
except Exception:
pass
self._validate_aliases()
def _show_sample(self):
"""Muestra la muestra JSON en un toplevel de solo lectura (si existe)."""
if self._sample is None:
return
win = ctk.CTkToplevel(self)
win.title("Muestra de la primera factura")
win.geometry("700x500")
win.transient(self)
win.lift()
try:
win.attributes("-topmost", True)
# mantener al frente un instante y luego permitir comportamiento normal
win.after(300, lambda: win.attributes("-topmost", False))
except Exception:
pass
win.focus_force()
txt = ctk.CTkTextbox(win)
txt.pack(expand=True, fill="both", padx=10, pady=10)
txt.insert("end", json.dumps(self._sample, ensure_ascii=False, indent=2))
txt.configure(state="disabled")
def _accept(self):
"""Construye selección y alias y cierra devolviendo datos al callback."""
selected = [p for p, v in self._vars.items() if v.get()]
alias_map: Dict[str, str] = {}
for p in selected:
ent = self._alias_entries.get(p)
alias = None
try:
alias = (ent.get().strip() if ent is not None else "")
except Exception:
alias = ""
if not alias:
alias = p
# 'radicado' permanece fijo si aparece
if p == 'radicado':
alias = 'radicado'
alias_map[p] = alias
if self._on_submit:
self._on_submit(selected, alias_map)
self.destroy()
def _cancel(self):
"""Cancela la selección (devuelve listas vacías) y cierra el diálogo."""
if self._on_submit:
self._on_submit([], {})
self.destroy()
def _validate_aliases(self):
"""Valida alias. (Validación de duplicados eliminada para permitir fusión de columnas)."""
# Siempre permitir aceptar, ya que los duplicados se fusionarán en el backend.
try:
self.ok_btn.configure(state="normal")
except Exception:
pass
# --- Reprocesar (reextraer primer documento y refrescar lista) ---
def _set_busy(self, busy: bool) -> None:
"""Habilita/deshabilita controles y cambia texto del botón Reprocesar."""
try:
state = "disabled" if busy else "normal"
self.select_all_btn.configure(state=state)
self.clear_btn.configure(state=state)
self.sample_btn.configure(state=state)
self.ok_btn.configure(state=state)
self.cancel_btn.configure(state=state)
self.search_entry.configure(state=state)
self.reprocess_btn.configure(text=("Reprocesando…" if busy else "Reprocesar"), state=("disabled" if busy else "normal"))
except Exception:
pass
def _reprocess(self):
"""Ejecuta reproceso en hilo de fondo y actualiza schema/muestra preservando selección y alias existentes."""
if self._on_reprocess is None:
return
self._set_busy(True)
def worker():
try:
new_schema, new_sample = self._on_reprocess()
def apply_updates():
self._apply_new_schema_sample(new_schema, new_sample)
self._set_busy(False)
self.after(0, apply_updates)
except Exception as e:
def on_err():
try:
mb.showerror("Error de reproceso", f"No se pudo reprocesar: {e}")
except Exception:
pass
self._set_busy(False)
self.after(0, on_err)
threading.Thread(target=worker, daemon=True).start()
def _apply_new_schema_sample(self, new_schema: Dict[str, str], new_sample: dict) -> None:
"""Reconstruye la lista de campos preservando selección y alias cuando sea posible."""
# Guardar selección y alias actuales
prev_selected = {p for p, v in self._vars.items() if v.get()}
prev_alias: Dict[str, str] = {}
for p, ent in self._alias_entries.items():
try:
val = (ent.get().strip() if ent is not None else "") or p
except Exception:
val = p
if p == 'radicado':
val = 'radicado'
prev_alias[p] = val
# Limpiar filas actuales
try:
for _, frame in self._rows.items():
frame.destroy()
except Exception:
pass
self._vars.clear()
self._rows.clear()
self._alias_entries.clear()
# Asignar nuevos datos
self._schema = new_schema or {}
self._sample = new_sample
rows_container = self.scroll
for path in sorted(self._schema.keys()):
var = ctk.BooleanVar(value=(path in prev_selected))
row = ctk.CTkFrame(rows_container)
row.pack(anchor="w", fill="x", padx=8, pady=2)
cb = ctk.CTkCheckBox(row, text=f"{path} ({self._schema[path]})", variable=var)
cb.grid(row=0, column=0, sticky="w")
alias_entry = ctk.CTkEntry(row, width=260, placeholder_text="Nombre en CSV")
# Establecer alias previo si existe, si no por defecto al path
alias_value = prev_alias.get(path, path)
try:
alias_entry.insert(0, alias_value)
except Exception:
pass
alias_entry.grid(row=0, column=1, padx=(12, 0))
self._add_document_type_hint(row, path)
try:
alias_entry.bind("<KeyRelease>", lambda _e: self._validate_aliases())
except Exception:
pass
def _toggle(ent: ctk.CTkEntry, v: ctk.BooleanVar = var, p: str = path):
try:
if p == 'radicado':
ent.grid_remove()
else:
if v.get():
ent.grid(row=0, column=1, padx=(12, 0))
else:
ent.grid_remove()
except Exception:
pass
self._validate_aliases()
_toggle(alias_entry)
cb.configure(command=lambda e=alias_entry, v=var, p=path: _toggle(e, v, p))
self._vars[path] = var
self._rows[path] = row
self._alias_entries[path] = alias_entry
# Revalidar y re-aplicar filtro de búsqueda actual
self._validate_aliases()
try:
self._on_search()
except Exception:
pass
def _add_document_type_hint(self, row: ctk.CTkFrame, path: str) -> None:
"""Muestra una ayuda breve bajo claves tipo CC, TI, CE, PAS, etc."""
key_tail = str(path or "").split("/")[-1].strip().upper()
valid_hint_keys = set(get_allowed_document_types()) | DOCUMENT_TYPE_HINT_ALIASES
if key_tail not in valid_hint_keys:
return
hint = ctk.CTkLabel(
row,
text="Si a este campo le pones el alias 'documento_forzate', se guardará con la sigla exacta detectada, por ejemplo 'CC 123456789' o 'EPT 123456789'.",
anchor="w",
justify="left",
)
hint.grid(row=1, column=0, columnspan=2, sticky="w", padx=(28, 0), pady=(2, 0))