feat(console): add app controller, main menu and statistics screen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 01:45:03 +00:00
parent 269bb9030b
commit e3ad101d56
3 changed files with 499 additions and 0 deletions

195
console/core/app.py Normal file
View File

@@ -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()

View File

@@ -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

View File

@@ -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