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:
195
console/core/app.py
Normal file
195
console/core/app.py
Normal 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()
|
||||||
167
console/screens/estadisticas.py
Normal file
167
console/screens/estadisticas.py
Normal 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
|
||||||
137
console/screens/menu_principal.py
Normal file
137
console/screens/menu_principal.py
Normal 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
|
||||||
Reference in New Issue
Block a user