From e3ad101d566d8bba9fc27b701b478877ebba1ef5 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sun, 15 Feb 2026 01:45:03 +0000 Subject: [PATCH] feat(console): add app controller, main menu and statistics screen Co-Authored-By: Claude Opus 4.6 --- console/core/app.py | 195 ++++++++++++++++++++++++++++++ console/screens/estadisticas.py | 167 +++++++++++++++++++++++++ console/screens/menu_principal.py | 137 +++++++++++++++++++++ 3 files changed, 499 insertions(+) create mode 100644 console/core/app.py create mode 100644 console/screens/estadisticas.py create mode 100644 console/screens/menu_principal.py diff --git a/console/core/app.py b/console/core/app.py new file mode 100644 index 0000000..b43d5d8 --- /dev/null +++ b/console/core/app.py @@ -0,0 +1,195 @@ +""" +Main application controller for the AUTOPARTES console application. + +The :class:`App` class owns the screen lifecycle loop: it renders the +current screen, reads a keypress, dispatches it, and follows any +navigation instruction the screen returns. +""" + +from console.core.navigation import Navigation +from console.core.keybindings import Key + + +class App: + """Top-level application controller. + + Parameters: + renderer: A :class:`BaseRenderer` implementation (e.g. CursesRenderer). + db: A :class:`Database` instance for data access. + """ + + def __init__(self, renderer, db): + self.renderer = renderer + self.db = db + self.nav = Navigation() + self.screens = {} + self.running = False + self._register_screens() + + # ------------------------------------------------------------------ + # Screen registration + # ------------------------------------------------------------------ + + def _register_screens(self): + """Import and register all screen instances. + + Each screen is wrapped in a try/except so that screens not yet + implemented do not prevent the application from starting. + """ + # --- Required screens (Task 6) -------------------------------- + try: + from console.screens.menu_principal import MenuPrincipalScreen + s = MenuPrincipalScreen() + self.screens[s.name] = s + except ImportError: + pass + + try: + from console.screens.estadisticas import EstadisticasScreen + s = EstadisticasScreen() + self.screens[s.name] = s + except ImportError: + pass + + # --- Optional screens (added by later tasks) ------------------- + try: + from console.screens.vehiculo_nav import VehiculoNavScreen + s = VehiculoNavScreen() + self.screens[s.name] = s + except ImportError: + pass + + try: + from console.screens.buscar_parte import BuscarParteScreen + s = BuscarParteScreen() + self.screens[s.name] = s + except ImportError: + pass + + try: + from console.screens.buscar_texto import BuscarTextoScreen + s = BuscarTextoScreen() + self.screens[s.name] = s + except ImportError: + pass + + try: + from console.screens.vin_decoder import VinDecoderScreen + s = VinDecoderScreen() + self.screens[s.name] = s + except ImportError: + pass + + try: + from console.screens.catalogo import CatalogoScreen + s = CatalogoScreen() + self.screens[s.name] = s + except ImportError: + pass + + try: + from console.screens.parte_detalle import ParteDetalleScreen + s = ParteDetalleScreen() + self.screens[s.name] = s + except ImportError: + pass + + try: + from console.screens.comparador import ComparadorScreen + s = ComparadorScreen() + self.screens[s.name] = s + except ImportError: + pass + + try: + from console.screens.admin_partes import AdminPartesScreen + s = AdminPartesScreen() + self.screens[s.name] = s + except ImportError: + pass + + try: + from console.screens.admin_fabricantes import AdminFabricantesScreen + s = AdminFabricantesScreen() + self.screens[s.name] = s + except ImportError: + pass + + try: + from console.screens.admin_crossref import AdminCrossrefScreen + s = AdminCrossrefScreen() + self.screens[s.name] = s + except ImportError: + pass + + try: + from console.screens.admin_import import AdminImportScreen + s = AdminImportScreen() + self.screens[s.name] = s + except ImportError: + pass + + # ------------------------------------------------------------------ + # Main loop + # ------------------------------------------------------------------ + + def run(self): + """Enter the main event loop. + + Initialises the renderer, pushes the main menu onto the + navigation stack, and loops until the user quits or the stack + empties. + """ + self.renderer.init_screen() + self.running = True + self.nav.push('menu', {}, label='Menu') + + try: + while self.running: + current = self.nav.current() + if current is None: + break + + screen_name, context = current + screen = self.screens.get(screen_name) + + if screen is None: + self.renderer.show_message( + f'Pantalla "{screen_name}" no disponible', 'error' + ) + self.nav.pop() + continue + + # Render + self.renderer.clear() + screen.render(context, self.db, self.renderer) + self.renderer.refresh() + + # Input + key = self.renderer.get_key() + + # Global key: F10 = back to main menu + if key == Key.F10: + self.nav.clear() + self.nav.push('menu', {}, label='Menu') + continue + + # Screen-specific key handling + result = screen.on_key( + key, context, self.db, self.renderer, self.nav + ) + + if result == 'quit': + self.running = False + elif result == 'back': + self.nav.pop() + elif isinstance(result, tuple) and len(result) == 3: + name, ctx, label = result + self.nav.push(name, ctx, label=label) + elif isinstance(result, str): + self.nav.push(result, {}, label=result) + + except KeyboardInterrupt: + pass + finally: + self.renderer.cleanup() diff --git a/console/screens/estadisticas.py b/console/screens/estadisticas.py new file mode 100644 index 0000000..c8dd997 --- /dev/null +++ b/console/screens/estadisticas.py @@ -0,0 +1,167 @@ +""" +Statistics dashboard screen for the AUTOPARTES console application. + +Displays database table counts and coverage metrics retrieved via +:meth:`Database.get_stats`. +""" + +from console.core.screens import Screen +from console.core.keybindings import Key +from console.config import APP_NAME, VERSION +from console.utils.formatting import format_number + + +# Human-readable labels for each database table counter. +_TABLE_LABELS = [ + ("brands", "Marcas"), + ("models", "Modelos"), + ("engines", "Motores"), + ("years", "Anos"), + ("part_categories", "Categorias"), + ("part_groups", "Grupos de Partes"), + ("parts", "Partes OEM"), + ("aftermarket_parts", "Partes Aftermarket"), + ("manufacturers", "Fabricantes"), + ("part_cross_references","Cross-References"), +] + +# Footer key labels +_FOOTER = [ + ("F5", "Refrescar"), + ("F10", "Menu"), + ("ESC", "Atras"), +] + + +class EstadisticasScreen(Screen): + """Read-only statistics dashboard showing database counters.""" + + def __init__(self): + super().__init__(name="estadisticas", title="Estadisticas del Sistema") + self._stats = None + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _load_stats(self, db): + """Fetch fresh statistics from the database.""" + try: + self._stats = db.get_stats() + except Exception: + self._stats = None + + def _build_fields(self): + """Build the detail fields list from the cached stats dict.""" + if self._stats is None: + return [("Error", "No se pudieron cargar las estadisticas")] + + fields = [] + + # -- Section: BASE DE DATOS -- + for key, label in _TABLE_LABELS: + value = self._stats.get(key, 0) + fields.append((label, format_number(value))) + + return fields + + def _build_coverage_fields(self): + """Build coverage / summary fields.""" + if self._stats is None: + return [] + + fields = [] + + # Vehicle-part fitments + fitments = self._stats.get("vehicle_parts", 0) + fields.append(("Fitments", format_number(fitments))) + + # Top brands by fitment count + top_brands = self._stats.get("top_brands", []) + if top_brands: + parts = [] + for b in top_brands[:5]: + parts.append(f"{b['name']}({format_number(b['count'])})") + fields.append(("Top marcas", " ".join(parts))) + + return fields + + # ------------------------------------------------------------------ + # Screen interface + # ------------------------------------------------------------------ + + def render(self, context, db, renderer): + # Load stats on first render (or after refresh) + if self._stats is None: + self._load_stats(db) + + # Header + renderer.draw_header( + f" {APP_NAME} v{VERSION}", + " Estadisticas ", + ) + + h, w = renderer.get_size() + + # -- Section title: BASE DE DATOS -- + section_title = " BASE DE DATOS " + border_char = "\u2500" # ─ + pad_len = max(w - 4 - len(section_title), 0) + section_line = border_char * 2 + section_title + border_char * pad_len + renderer.draw_text(3, 2, section_line[:w - 4], "title") + + # Database counters + db_fields = self._build_fields() + max_label = max((len(lbl) for lbl, _ in db_fields), default=10) + dot_total = max_label + 4 + + row = 5 + for label, value in db_fields: + if row >= h - 6: + break + dots = "." * (dot_total - len(label)) + label_part = f" {label}{dots}: " + renderer.draw_text(row, 0, label_part, "field_label") + renderer.draw_text(row, len(label_part), str(value), "field_value") + row += 1 + + # -- Section title: COBERTURA -- + row += 1 + if row < h - 5: + section_title2 = " COBERTURA " + pad_len2 = max(w - 4 - len(section_title2), 0) + section_line2 = border_char * 2 + section_title2 + border_char * pad_len2 + renderer.draw_text(row, 2, section_line2[:w - 4], "title") + row += 2 + + coverage_fields = self._build_coverage_fields() + cov_max_label = max( + (len(lbl) for lbl, _ in coverage_fields), default=10 + ) + cov_dot_total = cov_max_label + 4 + + for label, value in coverage_fields: + if row >= h - 3: + break + dots = "." * (cov_dot_total - len(label)) + label_part = f" {label}{dots}: " + renderer.draw_text(row, 0, label_part, "field_label") + renderer.draw_text( + row, len(label_part), str(value), "field_value" + ) + row += 1 + + # Footer + renderer.draw_footer(_FOOTER) + + def on_key(self, key, context, db, renderer, nav): + # F5: refresh stats + if key == Key.F5: + self._stats = None # will reload on next render + return None + + # ESC or Backspace: go back + if key in (Key.ESCAPE, Key.BACKSPACE): + return "back" + + return None diff --git a/console/screens/menu_principal.py b/console/screens/menu_principal.py new file mode 100644 index 0000000..7c89ccd --- /dev/null +++ b/console/screens/menu_principal.py @@ -0,0 +1,137 @@ +""" +Main menu screen for the AUTOPARTES console application. + +Displays a numbered Pick-style menu with navigation options for all +application sections. Number keys jump directly; arrow keys move the +selection; ENTER activates. +""" + +from console.core.screens import Screen +from console.core.keybindings import Key +from console.config import APP_NAME, APP_SUBTITLE, VERSION + + +# Menu items: list of (display_number, label, screen_name). +# Separators use display_number None and screen_name None. +_MENU_ITEMS = [ + ("1", "Consulta por Vehiculo", "vehiculo_nav"), + ("2", "Busqueda por Numero de Parte", "buscar_parte"), + ("3", "Busqueda por Descripcion", "buscar_texto"), + ("4", "Decodificador VIN", "vin_decoder"), + ("5", "Catalogo de Categorias", "catalogo"), + (None, None, None), # separator + ("6", "Administracion de Partes", "admin_partes"), + ("7", "Administracion de Fabricantes", "admin_fabricantes"), + ("8", "Cross-References", "admin_crossref"), + ("9", "Importar / Exportar Datos", "admin_import"), + (None, None, None), # separator + ("0", "Estadisticas del Sistema", "estadisticas"), +] + +# Quick lookup: digit character -> screen name +_KEY_MAP = {item[0]: item[2] for item in _MENU_ITEMS if item[0] is not None} + +# Footer key labels +_FOOTER = [ + ("F1", "Ayuda"), + ("F3", "Buscar"), + ("F10", "Menu"), + ("ESC", "Salir"), +] + + +class MenuPrincipalScreen(Screen): + """Main menu screen with numbered items and arrow-key navigation.""" + + def __init__(self): + super().__init__(name="menu", title="Menu Principal") + self._selected = 0 # index into _MENU_ITEMS (skipping separators) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _selectable_indices(self): + """Return list of indices in _MENU_ITEMS that are not separators.""" + return [i for i, item in enumerate(_MENU_ITEMS) if item[0] is not None] + + def _move_selection(self, direction): + """Move selection up (-1) or down (+1), skipping separators.""" + indices = self._selectable_indices() + if not indices: + return + try: + pos = indices.index(self._selected) + except ValueError: + pos = 0 + pos = max(0, min(len(indices) - 1, pos + direction)) + self._selected = indices[pos] + + # ------------------------------------------------------------------ + # Screen interface + # ------------------------------------------------------------------ + + def render(self, context, db, renderer): + # Header + renderer.draw_header( + f" {APP_NAME} v{VERSION}", + f"{APP_SUBTITLE} ", + ) + + # Build items list for draw_menu. + # Separators use the special "---" marker understood by the renderer. + menu_items = [] + for num, label, _screen in _MENU_ITEMS: + if num is None: + menu_items.append(("---", "")) + else: + menu_items.append((num, label)) + + renderer.draw_menu( + menu_items, + selected_index=self._selected, + title="MENU PRINCIPAL", + ) + + # Footer + renderer.draw_footer(_FOOTER) + + def on_key(self, key, context, db, renderer, nav): + # --- Number keys: direct navigation --- + if 48 <= key <= 57: # ord('0') .. ord('9') + digit = chr(key) + screen_name = _KEY_MAP.get(digit) + if screen_name: + label = next( + (lbl for num, lbl, _ in _MENU_ITEMS if num == digit), + screen_name, + ) + return (screen_name, {}, label) + + # --- Arrow keys --- + if key == Key.UP: + self._move_selection(-1) + return None + + if key == Key.DOWN: + self._move_selection(1) + return None + + # --- ENTER: activate selected --- + if key == Key.ENTER: + item = _MENU_ITEMS[self._selected] + num, label, screen_name = item + if screen_name is not None: + return (screen_name, {}, label) + return None + + # --- ESC: quit confirmation --- + if key == Key.ESCAPE: + confirmed = renderer.show_message( + "Desea salir de la aplicacion?", "confirm" + ) + if confirmed: + return "quit" + return None + + return None