Compare commits

..

13 Commits

Author SHA1 Message Date
4af3a09b03 docs: add console system documentation and design docs
Console README with usage instructions, keybindings reference, architecture
overview, and test commands. Updated root README with console section, updated
architecture diagram, and installation instructions. Includes approved design
doc and implementation plan.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:03:22 +00:00
64503ca363 feat(console): add integration tests and polish error handling
Add MockRenderer-based integration tests that verify the full screen-to-
renderer pipeline without a real terminal. Update main.py with proper
--db flag handling, database existence check, startup banner, and
graceful error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:59:08 +00:00
7bf50a2c67 feat(console): add Rich-based modern renderer
Implement TextualRenderer in console/renderers/textual_renderer.py using
the Rich library for a modern dark-themed TUI with blue/cyan accents.
All 18 BaseRenderer methods are implemented: lifecycle (init_screen,
cleanup), primitives (clear, refresh, get_key, get_size), widgets
(draw_header, draw_footer, draw_menu, draw_table, draw_detail,
draw_form, draw_filter_list, draw_comparison, draw_text, draw_box),
and dialogs (show_message, show_input). Keyboard input uses raw
terminal mode via tty/termios with full escape sequence decoding
for arrow keys, F-keys, Page Up/Down, Home/End.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:57:25 +00:00
8194167c51 feat(console): add admin CRUD screens for parts, manufacturers, crossref, import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:53:36 +00:00
15f3c9c9fe feat(console): add part detail and comparator screens
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:52:46 +00:00
b042853408 feat(console): add catalog, search, and VIN decoder screens
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:49:15 +00:00
69fb26723d feat(console): add vehicle drill-down navigation screen
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:47:23 +00:00
e3ad101d56 feat(console): add app controller, main menu and statistics screen
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:45:03 +00:00
269bb9030b feat(console): add curses VT220 renderer with full widget set
Implements BaseRenderer abstract interface and CursesRenderer with
green-on-black VT220 aesthetic. Includes all 18 widget methods: header,
footer, menu, table, detail, form, filter list, comparison view, box
drawing, message dialogs, and input prompts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:41:51 +00:00
211883393e feat(console): add formatting utils and VIN API client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:38:28 +00:00
ceacab789b feat(console): add core framework - keybindings, navigation, screen base
Add the three core modules that all screens depend on:
- keybindings.py: Key constants (curses codes) and KeyBindings registry
- navigation.py: Stack-based screen navigation with breadcrumbs
- screens.py: Screen base class with on_enter/on_key/render lifecycle

Includes 31 tests covering all public APIs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:38:02 +00:00
3b884e24d3 feat(console): add database abstraction layer with tests
Implement console/db.py with Database class providing all data access
methods for the console application, plus 36 passing tests in
console/tests/test_db.py covering vehicle navigation, parts catalog,
search, VIN cache, stats, manufacturers, and admin CRUD operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:34:22 +00:00
7cf3ddc758 feat(console): scaffold project structure and config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:29:54 +00:00
40 changed files with 9476 additions and 4 deletions

View File

@@ -61,6 +61,15 @@ Autopartes/
│ ├── dashboard.js # Lógica JavaScript
│ └── start_dashboard.sh # Script de inicio
├── console/ # Consola Pick/VT220
│ ├── main.py # Punto de entrada
│ ├── db.py # Capa de datos abstracta
│ ├── core/ # Framework (app, screens, nav, keys)
│ ├── screens/ # 14 pantallas (menú, CRUD, búsqueda)
│ ├── renderers/ # VT220 (curses) y moderno (Rich)
│ ├── utils/ # Formato y API VIN
│ └── tests/ # 116 tests
├── vehicle_scraper/ # Herramientas de web scraping
│ ├── rockauto_scraper.py # Scraper RockAuto
│ ├── rockauto_scraper_v2.py # Scraper mejorado
@@ -73,6 +82,25 @@ Autopartes/
└── QUICK_START.sh # Guía rápida de inicio
```
## Consola Pick/VT220
Interfaz de terminal inspirada en los sistemas Pick/D3, 100% operada con teclado. Incluye dos modos de visualización:
- **VT220** (curses): Terminal clásica verde sobre negro con caracteres de caja
- **Modern** (Rich): Interfaz moderna con colores y estilos TUI
```bash
# Modo clásico VT220
python -m console
# Modo moderno
python -m console --mode modern
```
Funcionalidades: navegación por vehículo (marca→modelo→año→motor), búsqueda por número de parte, búsqueda full-text, decodificador VIN (NHTSA), catálogo por categorías, comparador OEM vs aftermarket, y administración CRUD completa.
116 tests automatizados. Ver [`console/README.md`](console/README.md) para documentación completa.
## Instalación
### Requisitos Previos
@@ -91,6 +119,7 @@ Autopartes/
2. **Instalar dependencias**
```bash
pip install flask requests beautifulsoup4 lxml
pip install rich # Opcional: para modo moderno de consola
```
3. **Inicializar la base de datos (opcional - ya incluye datos)**
@@ -110,7 +139,14 @@ python3 server.py
El dashboard estará disponible en: `http://localhost:5000`
### Usar la Interfaz CLI
### Iniciar la Consola Pick/VT220
```bash
python -m console # Modo VT220 (clásico)
python -m console --mode modern # Modo moderno (Rich)
```
### Usar la Interfaz CLI Legacy
```bash
cd vehicle_database/scripts
@@ -275,9 +311,9 @@ engines ─┴─────────────┘
│ │ │
v v v
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Flask API │ │ CLI Interface │ │ CSV Importer │
└────────┬────────┘ └──────────────────┘ └──────────────────┘
│ Flask API │ │ Pick Console │ │ CSV Importer │
└────────┬────────┘ │ (VT220/Rich) │ └──────────────────┘
└──────────────────┘
v
┌─────────────────┐
│ Web Dashboard │

189
console/README.md Normal file
View File

@@ -0,0 +1,189 @@
# AUTOPARTES Console - Sistema Pick/VT220
Interfaz de consola para el catálogo de autopartes, inspirada en los sistemas Pick/D3 con estética de terminal VT220. Funciona 100% con teclado.
## Requisitos
- Python 3.8+
- SQLite 3 (incluido con Python)
- Paquete `rich` (solo para modo moderno)
```bash
pip install rich # Opcional, solo para --mode modern
```
## Inicio Rápido
```bash
# Modo VT220 (clásico, verde sobre negro)
python -m console
# Modo moderno (Rich/TUI con colores)
python -m console --mode modern
# Especificar base de datos
python -m console --db /ruta/a/vehicle_database.db
# Ver versión
python -m console --version
```
## Modos de Visualización
### VT220 (por defecto)
- Terminal clásica verde sobre negro
- Caracteres de dibujo de cajas (box-drawing)
- Compatible con cualquier terminal
- Usa la librería `curses` (incluida en Python)
### Modern
- Interfaz moderna con colores y estilos Rich
- Tema azul/cian
- Requiere `pip install rich`
- Si `rich` no está instalado, cae automáticamente a modo VT220
## Menú Principal
```
╔══════════════════════════════════════╗
║ AUTOPARTES v1.0.0 ║
║ Sistema de Catalogo de Autopartes ║
╠══════════════════════════════════════╣
║ 1. Buscar por Vehiculo ║
║ 2. Buscar por Numero de Parte ║
║ 3. Buscar por Texto ║
║ 4. Decodificar VIN ║
║ 5. Catalogo por Categoria ║
║ ────────────────────────── ║
║ 6. Admin: Partes ║
║ 7. Admin: Fabricantes ║
║ 8. Admin: Referencias Cruzadas ║
║ 9. Import/Export ║
║ ────────────────────────── ║
║ S. Estadisticas del Sistema ║
║ 0. Salir ║
╚══════════════════════════════════════╝
```
## Teclas de Función
| Tecla | Acción |
|-------|--------|
| `0-9` | Seleccionar opción del menú / saltar a campo |
| `ENTER` | Confirmar selección |
| `ESC` | Regresar / Cancelar |
| `F1` | Ayuda / Lista de búsqueda |
| `F2` | Modo edición |
| `F3` | Buscar |
| `F4` | Referencias cruzadas |
| `F5` | Refrescar |
| `F6` | Vehículos relacionados |
| `F9` | Guardar |
| `F10` | Menú principal |
| `TAB` / `↓` | Siguiente campo |
| `↑` | Campo anterior |
| `PgUp/PgDn` | Navegación por páginas |
| `←→` | Scroll horizontal (comparador) |
## Pantallas
### 1. Búsqueda por Vehículo
Navegación jerárquica: Marca → Modelo → Año → Motor.
Cada nivel muestra una lista filtrable con búsqueda incremental.
### 2. Búsqueda por Número de Parte
Campo de entrada para número de parte. Busca en partes OEM, aftermarket y referencias cruzadas.
### 3. Búsqueda por Texto
Búsqueda full-text (FTS5) en nombres y descripciones de partes con resultados paginados.
### 4. Decodificador VIN
Ingresa un VIN de 17 caracteres. Consulta la API de NHTSA (con caché de 30 días) y muestra información del vehículo.
### 5. Catálogo por Categoría
Navega: Categorías → Grupos → Partes, independiente de la selección de vehículo.
### 6-9. Administración
- **Partes**: CRUD completo de partes OEM
- **Fabricantes**: CRUD de fabricantes aftermarket
- **Referencias Cruzadas**: CRUD de referencias cruzadas entre partes
- **Import/Export**: Importar CSV, exportar JSON
### Detalle de Parte
Vista completa de la parte con alternativas aftermarket. F4 para referencias cruzadas, F6 para vehículos compatibles.
### Comparador
Columnas lado a lado: OEM vs alternativas aftermarket con barras de calidad, porcentaje de ahorro y scroll horizontal.
### Estadísticas
Dashboard con contadores de la base de datos (marcas, modelos, partes, etc.) y métricas de cobertura.
## Arquitectura
```
console/
├── main.py # Punto de entrada, --mode vt220|modern
├── config.py # Configuración (DB, colores, paginación)
├── db.py # Capa de datos abstracta (SQLite)
├── core/
│ ├── app.py # Controlador principal
│ ├── screens.py # Clase base Screen
│ ├── navigation.py # Pila de navegación y breadcrumbs
│ └── keybindings.py # Constantes de teclas y registro
├── screens/
│ ├── menu_principal.py # Menú principal (12 opciones)
│ ├── vehiculo_nav.py # Drill-down: marca → modelo → año → motor
│ ├── buscar_parte.py # Búsqueda por número de parte
│ ├── buscar_texto.py # Búsqueda full-text (FTS)
│ ├── vin_decoder.py # Decodificador VIN (API NHTSA)
│ ├── catalogo.py # Categorías → grupos → partes
│ ├── parte_detalle.py # Detalle con alternativas
│ ├── comparador.py # Comparador OEM vs aftermarket
│ ├── estadisticas.py # Dashboard de estadísticas
│ ├── admin_partes.py # CRUD partes
│ ├── admin_fabricantes.py # CRUD fabricantes
│ ├── admin_crossref.py # CRUD referencias cruzadas
│ └── admin_import.py # Import/Export CSV/JSON
├── renderers/
│ ├── base.py # Interfaz abstracta BaseRenderer
│ ├── curses_renderer.py # Modo VT220 (curses)
│ └── textual_renderer.py # Modo moderno (Rich)
├── utils/
│ ├── formatting.py # Formato de tablas, moneda, números
│ └── vin_api.py # Cliente API NHTSA
└── tests/
├── test_db.py # 36 tests - capa de datos
├── test_core.py # 31 tests - keybindings, navigation, screens
├── test_utils.py # 30 tests - utilidades de formato
└── test_integration.py # 19 tests - integración con MockRenderer
```
## Tests
```bash
# Ejecutar todos los tests (116 total)
python -m pytest console/tests/ -v
# Ejecutar por módulo
python -m pytest console/tests/test_db.py -v
python -m pytest console/tests/test_core.py -v
python -m pytest console/tests/test_utils.py -v
python -m pytest console/tests/test_integration.py -v
```
## Capa de Datos
La clase `Database` en `db.py` abstrae todas las consultas SQL. Diseñada para migrar de SQLite a PostgreSQL cambiando solo la implementación interna.
Métodos principales:
- `get_brands()`, `get_models()`, `get_years()`, `get_engines()`
- `get_categories()`, `get_groups()`, `get_parts()`
- `get_part()`, `get_alternatives()`, `get_cross_references()`
- `search_parts()`, `search_part_number()`
- `decode_vin()`, `get_stats()`
- Métodos CRUD para administración

0
console/__init__.py Normal file
View File

7
console/__main__.py Normal file
View File

@@ -0,0 +1,7 @@
"""
Allow running the package with: python -m console
"""
from console.main import main
main()

39
console/config.py Normal file
View File

@@ -0,0 +1,39 @@
"""
Configuration settings for the AUTOPARTES console application.
"""
import os
# Application metadata
VERSION = "1.0.0"
APP_NAME = "AUTOPARTES"
APP_SUBTITLE = "Sistema de Catalogo de Autopartes"
# Database path (relative to the console/ directory, resolved to absolute)
_CONSOLE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(_CONSOLE_DIR, "..", "vehicle_database", "vehicle_database.db")
DB_PATH = os.path.normpath(DB_PATH)
# NHTSA VIN Decoder API
NHTSA_API_URL = "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVin"
VIN_CACHE_DAYS = 30
# Display defaults
DEFAULT_MODE = "vt220"
PAGE_SIZE = 15
# VT220 color pairs: (foreground, background)
# These map to curses color pair indices used by the renderer.
COLORS_VT220 = {
"header": ("green", "black"),
"footer": ("black", "green"),
"normal": ("green", "black"),
"highlight": ("black", "green"),
"border": ("green", "black"),
"title": ("white", "black"),
"error": ("red", "black"),
"info": ("cyan", "black"),
"field_label": ("green", "black"),
"field_value": ("white", "black"),
"field_active": ("black", "cyan"),
}

0
console/core/__init__.py Normal file
View File

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,87 @@
"""
Key constants and key-binding registry for the console UI.
Key provides named constants matching curses key codes so that screens
and renderers never need to import curses directly.
KeyBindings maps key codes to callable actions and tracks the footer
labels displayed at the bottom of the screen.
"""
import curses
class Key:
"""Key constants matching curses key codes."""
ESCAPE = 27
ENTER = 10
TAB = 9
BACKSPACE = 127
UP = curses.KEY_UP
DOWN = curses.KEY_DOWN
LEFT = curses.KEY_LEFT
RIGHT = curses.KEY_RIGHT
PGUP = curses.KEY_PPAGE
PGDN = curses.KEY_NPAGE
HOME = curses.KEY_HOME
END = curses.KEY_END
F1 = curses.KEY_F1
F2 = curses.KEY_F2
F3 = curses.KEY_F3
F4 = curses.KEY_F4
F5 = curses.KEY_F5
F6 = curses.KEY_F6
F7 = curses.KEY_F7
F8 = curses.KEY_F8
F9 = curses.KEY_F9
F10 = curses.KEY_F10
class KeyBindings:
"""Registry that maps key codes to callable actions.
Usage::
kb = KeyBindings()
kb.bind(Key.ENTER, lambda: do_something())
handled = kb.handle(Key.ENTER) # True, callback was invoked
"""
def __init__(self):
self._bindings: dict[int, callable] = {}
self._footer_labels: list[tuple[str, str]] = []
def bind(self, key: int, action: callable) -> None:
"""Register *action* as the callback for *key*.
If *key* already has a binding it is replaced.
"""
self._bindings[key] = action
def handle(self, key: int) -> bool:
"""Look up *key* and invoke its callback if one exists.
Returns ``True`` if a callback was found and executed,
``False`` otherwise.
"""
action = self._bindings.get(key)
if action is not None:
action()
return True
return False
def set_footer(self, labels: list[tuple[str, str]]) -> None:
"""Set the footer bar labels.
*labels* is a list of ``(key_label, description)`` tuples, e.g.
``[("F1", "Help"), ("F10", "Quit")]``.
"""
self._footer_labels = list(labels)
def get_footer_labels(self) -> list[tuple[str, str]]:
"""Return the current footer labels list."""
return list(self._footer_labels)

View File

@@ -0,0 +1,60 @@
"""
Screen-stack navigation for the console UI.
Navigation maintains a stack of ``(screen_name, context, label)`` entries.
Screens push onto the stack when the user drills into a sub-view and pop
when they press Escape / Backspace to go back.
"""
class Navigation:
"""A simple stack-based navigator.
Each entry is a tuple ``(screen_name, context, label)`` where
*screen_name* identifies which screen to display, *context* carries
any data the screen needs, and *label* is the human-readable text
shown in the breadcrumb trail.
"""
def __init__(self):
self._stack: list[tuple[str, object, str]] = []
def push(self, screen_name: str, context=None, label: str | None = None) -> None:
"""Push a new screen onto the stack.
If *label* is ``None`` the *screen_name* is used as fallback in
the breadcrumb.
"""
self._stack.append((screen_name, context, label if label is not None else screen_name))
def pop(self) -> tuple[str, object] | None:
"""Remove and return the top entry as ``(screen_name, context)``.
Returns ``None`` if the stack is empty.
"""
if not self._stack:
return None
screen_name, context, _label = self._stack.pop()
return (screen_name, context)
def current(self) -> tuple[str, object] | None:
"""Return the top entry as ``(screen_name, context)`` without removing it.
Returns ``None`` if the stack is empty.
"""
if not self._stack:
return None
screen_name, context, _label = self._stack[-1]
return (screen_name, context)
def breadcrumb(self) -> list[str]:
"""Return the list of labels from bottom to top of the stack."""
return [label for _name, _ctx, label in self._stack]
def clear(self) -> None:
"""Remove all entries from the stack."""
self._stack.clear()
def depth(self) -> int:
"""Return the number of entries on the stack."""
return len(self._stack)

46
console/core/screens.py Normal file
View File

@@ -0,0 +1,46 @@
"""
Base screen class for the console UI.
Every screen in the application inherits from :class:`Screen` and overrides
:meth:`on_enter`, :meth:`on_key`, and :meth:`render`.
"""
class Screen:
"""Abstract base for all console screens.
Subclasses must override the three lifecycle methods to provide real
behaviour. The base implementations are intentional no-ops so that
simple screens (e.g. a static splash page) need not implement every
method.
Attributes:
name: Machine-readable identifier used by :class:`Navigation`.
title: Human-readable heading displayed at the top of the screen.
"""
def __init__(self, name: str, title: str):
self.name = name
self.title = title
def on_enter(self, context, db, renderer) -> None:
"""Called once when this screen becomes the active screen.
Use this hook to load data, reset scroll positions, or set up
key bindings specific to the screen.
"""
def on_key(self, key: int, context, db, renderer, nav):
"""Handle a single keypress.
Returns a navigation instruction (e.g. a dict or tuple) when the
screen wants to push/pop, or ``None`` to stay on the current
screen.
"""
return None
def render(self, context, db, renderer) -> None:
"""Draw the screen contents using *renderer*.
Called after every keypress and on initial display.
"""

770
console/db.py Normal file
View File

@@ -0,0 +1,770 @@
"""
Database abstraction layer for the AUTOPARTES console application.
Provides all data access methods the console app needs, reading from the
same SQLite database used by the Flask web dashboard.
"""
import sqlite3
from datetime import datetime, timedelta
from typing import Optional
from console.config import DB_PATH
class Database:
"""Thin abstraction over the vehicle_database SQLite database."""
def __init__(self, db_path: Optional[str] = None):
self.db_path = db_path or DB_PATH
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _connect(self) -> sqlite3.Connection:
"""Open a connection with row_factory set to sqlite3.Row."""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def _query(self, sql: str, params: tuple = (), one: bool = False):
"""Execute a SELECT and return list[dict] (or a single dict if *one*)."""
conn = self._connect()
try:
cursor = conn.cursor()
cursor.execute(sql, params)
if one:
row = cursor.fetchone()
return dict(row) if row else None
return [dict(r) for r in cursor.fetchall()]
finally:
conn.close()
def _execute(self, sql: str, params: tuple = ()) -> int:
"""Execute an INSERT/UPDATE/DELETE and return lastrowid."""
conn = self._connect()
try:
cursor = conn.cursor()
cursor.execute(sql, params)
conn.commit()
return cursor.lastrowid
finally:
conn.close()
# ==================================================================
# Vehicle navigation
# ==================================================================
def get_brands(self) -> list[dict]:
"""Return all brands ordered by name: [{id, name, country}]."""
return self._query(
"SELECT id, name, country FROM brands ORDER BY name"
)
def get_models(self, brand: Optional[str] = None) -> list[dict]:
"""Return models, optionally filtered by brand name (case-insensitive)."""
if brand:
return self._query(
"""
SELECT DISTINCT m.id, m.name
FROM models m
JOIN brands b ON m.brand_id = b.id
WHERE UPPER(b.name) = UPPER(?)
ORDER BY m.name
""",
(brand,),
)
return self._query(
"SELECT id, name FROM models ORDER BY name"
)
def get_years(
self, brand: Optional[str] = None, model: Optional[str] = None
) -> list[dict]:
"""Return years, optionally filtered by brand and/or model."""
sql = """
SELECT DISTINCT y.id, y.year
FROM years y
JOIN model_year_engine mye ON y.id = mye.year_id
JOIN models m ON mye.model_id = m.id
JOIN brands b ON m.brand_id = b.id
WHERE 1=1
"""
params: list = []
if brand:
sql += " AND UPPER(b.name) = UPPER(?)"
params.append(brand)
if model:
sql += " AND UPPER(m.name) = UPPER(?)"
params.append(model)
sql += " ORDER BY y.year DESC"
return self._query(sql, tuple(params))
def get_engines(
self,
brand: Optional[str] = None,
model: Optional[str] = None,
year: Optional[int] = None,
) -> list[dict]:
"""Return engines, optionally filtered by brand/model/year."""
sql = """
SELECT DISTINCT e.id, e.name, e.displacement_cc, e.cylinders,
e.fuel_type, e.power_hp, e.torque_nm, e.engine_code
FROM engines e
JOIN model_year_engine mye ON e.id = mye.engine_id
JOIN models m ON mye.model_id = m.id
JOIN brands b ON m.brand_id = b.id
JOIN years y ON mye.year_id = y.id
WHERE 1=1
"""
params: list = []
if brand:
sql += " AND UPPER(b.name) = UPPER(?)"
params.append(brand)
if model:
sql += " AND UPPER(m.name) = UPPER(?)"
params.append(model)
if year:
sql += " AND y.year = ?"
params.append(int(year))
sql += " ORDER BY e.name"
return self._query(sql, tuple(params))
def get_model_year_engine(
self,
brand: str,
model: str,
year: int,
engine_id: Optional[int] = None,
) -> list[dict]:
"""Return model_year_engine records for a specific vehicle config."""
sql = """
SELECT
mye.id,
b.name AS brand,
m.name AS model,
y.year,
e.id AS engine_id,
e.name AS engine,
mye.trim_level,
mye.drivetrain,
mye.transmission
FROM model_year_engine mye
JOIN models m ON mye.model_id = m.id
JOIN brands b ON m.brand_id = b.id
JOIN years y ON mye.year_id = y.id
JOIN engines e ON mye.engine_id = e.id
WHERE UPPER(b.name) = UPPER(?)
AND UPPER(m.name) = UPPER(?)
AND y.year = ?
"""
params: list = [brand, model, int(year)]
if engine_id:
sql += " AND e.id = ?"
params.append(engine_id)
sql += " ORDER BY e.name, mye.trim_level"
return self._query(sql, tuple(params))
# ==================================================================
# Parts catalog
# ==================================================================
def get_categories(self) -> list[dict]:
"""Return all part categories ordered by display_order."""
return self._query(
"""
SELECT id, name, name_es, slug, icon_name, display_order
FROM part_categories
ORDER BY display_order, name
"""
)
def get_groups(self, category_id: int) -> list[dict]:
"""Return part groups for a given category."""
return self._query(
"""
SELECT id, name, name_es, slug, display_order
FROM part_groups
WHERE category_id = ?
ORDER BY display_order, name
""",
(category_id,),
)
def get_parts(
self,
group_id: Optional[int] = None,
mye_id: Optional[int] = None,
page: int = 1,
per_page: int = 15,
) -> list[dict]:
"""Return parts with optional group/vehicle filter and pagination."""
per_page = min(per_page, 100)
offset = (page - 1) * per_page
sql = """
SELECT
p.id,
p.oem_part_number,
p.name,
p.name_es,
p.group_id,
pg.name AS group_name,
pc.name AS category_name
FROM parts p
JOIN part_groups pg ON p.group_id = pg.id
JOIN part_categories pc ON pg.category_id = pc.id
"""
where_parts: list[str] = []
params: list = []
if group_id:
where_parts.append("p.group_id = ?")
params.append(group_id)
if mye_id:
where_parts.append(
"p.id IN (SELECT part_id FROM vehicle_parts WHERE model_year_engine_id = ?)"
)
params.append(mye_id)
if where_parts:
sql += " WHERE " + " AND ".join(where_parts)
sql += " ORDER BY p.name LIMIT ? OFFSET ?"
params.extend([per_page, offset])
return self._query(sql, tuple(params))
def get_part(self, part_id: int) -> Optional[dict]:
"""Return a single part with group/category info, or None."""
return self._query(
"""
SELECT
p.id,
p.oem_part_number,
p.name,
p.name_es,
p.description,
p.description_es,
p.weight_kg,
p.material,
p.is_discontinued,
p.superseded_by_id,
p.group_id,
pg.name AS group_name,
pg.name_es AS group_name_es,
pc.id AS category_id,
pc.name AS category_name,
pc.name_es AS category_name_es
FROM parts p
JOIN part_groups pg ON p.group_id = pg.id
JOIN part_categories pc ON pg.category_id = pc.id
WHERE p.id = ?
""",
(part_id,),
one=True,
)
def get_alternatives(self, part_id: int) -> list[dict]:
"""Return aftermarket alternatives for an OEM part."""
return self._query(
"""
SELECT
ap.id,
ap.part_number,
ap.name,
ap.name_es,
m.name AS manufacturer_name,
ap.manufacturer_id,
ap.quality_tier,
ap.price_usd,
ap.warranty_months,
ap.in_stock
FROM aftermarket_parts ap
JOIN manufacturers m ON ap.manufacturer_id = m.id
WHERE ap.oem_part_id = ?
ORDER BY ap.quality_tier DESC, ap.price_usd ASC
""",
(part_id,),
)
def get_cross_references(self, part_id: int) -> list[dict]:
"""Return cross-reference numbers for a part."""
return self._query(
"""
SELECT id, cross_reference_number, reference_type, source, notes
FROM part_cross_references
WHERE part_id = ?
ORDER BY reference_type, cross_reference_number
""",
(part_id,),
)
def get_vehicles_for_part(self, part_id: int) -> list[dict]:
"""Return vehicles that use a specific part."""
return self._query(
"""
SELECT
b.name AS brand,
m.name AS model,
y.year,
e.name AS engine,
mye.trim_level,
vp.quantity_required,
vp.position,
vp.fitment_notes
FROM vehicle_parts vp
JOIN model_year_engine mye ON vp.model_year_engine_id = mye.id
JOIN models m ON mye.model_id = m.id
JOIN brands b ON m.brand_id = b.id
JOIN years y ON mye.year_id = y.id
JOIN engines e ON mye.engine_id = e.id
WHERE vp.part_id = ?
ORDER BY b.name, m.name, y.year
""",
(part_id,),
)
# ==================================================================
# Search
# ==================================================================
def search_parts(
self, query: str, page: int = 1, per_page: int = 15
) -> list[dict]:
"""Full-text search using FTS5, with fallback to LIKE."""
per_page = min(per_page, 100)
offset = (page - 1) * per_page
conn = self._connect()
try:
cursor = conn.cursor()
# Check if FTS5 table exists
cursor.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name='parts_fts'"
)
fts_exists = cursor.fetchone() is not None
if fts_exists:
# Escape FTS5 special chars by quoting each term
terms = query.split()
quoted = ['"' + t.replace('"', '""') + '"' for t in terms]
fts_query = " ".join(quoted)
cursor.execute(
"""
SELECT
p.id,
p.oem_part_number,
p.name,
p.name_es,
p.description,
pg.name AS group_name,
pc.name AS category_name,
bm25(parts_fts) AS rank
FROM parts_fts
JOIN parts p ON parts_fts.rowid = p.id
JOIN part_groups pg ON p.group_id = pg.id
JOIN part_categories pc ON pg.category_id = pc.id
WHERE parts_fts MATCH ?
ORDER BY rank
LIMIT ? OFFSET ?
""",
(fts_query, per_page, offset),
)
else:
search_term = f"%{query}%"
cursor.execute(
"""
SELECT
p.id,
p.oem_part_number,
p.name,
p.name_es,
p.description,
pg.name AS group_name,
pc.name AS category_name,
0 AS rank
FROM parts p
JOIN part_groups pg ON p.group_id = pg.id
JOIN part_categories pc ON pg.category_id = pc.id
WHERE p.name LIKE ? OR p.name_es LIKE ?
OR p.oem_part_number LIKE ? OR p.description LIKE ?
ORDER BY p.name
LIMIT ? OFFSET ?
""",
(
search_term,
search_term,
search_term,
search_term,
per_page,
offset,
),
)
return [dict(r) for r in cursor.fetchall()]
finally:
conn.close()
def search_part_number(self, number: str) -> list[dict]:
"""Search OEM, aftermarket, and cross-reference part numbers."""
search_term = f"%{number}%"
results: list[dict] = []
conn = self._connect()
try:
cursor = conn.cursor()
# OEM parts
cursor.execute(
"""
SELECT id, oem_part_number, name, name_es
FROM parts
WHERE oem_part_number LIKE ?
""",
(search_term,),
)
for row in cursor.fetchall():
results.append(
{
**dict(row),
"match_type": "oem",
"matched_number": row["oem_part_number"],
}
)
# Aftermarket parts
cursor.execute(
"""
SELECT p.id, p.oem_part_number, p.name, p.name_es, ap.part_number
FROM aftermarket_parts ap
JOIN parts p ON ap.oem_part_id = p.id
WHERE ap.part_number LIKE ?
""",
(search_term,),
)
for row in cursor.fetchall():
results.append(
{
"id": row["id"],
"oem_part_number": row["oem_part_number"],
"name": row["name"],
"name_es": row["name_es"],
"match_type": "aftermarket",
"matched_number": row["part_number"],
}
)
# Cross-references
cursor.execute(
"""
SELECT p.id, p.oem_part_number, p.name, p.name_es,
pcr.cross_reference_number
FROM part_cross_references pcr
JOIN parts p ON pcr.part_id = p.id
WHERE pcr.cross_reference_number LIKE ?
""",
(search_term,),
)
for row in cursor.fetchall():
results.append(
{
"id": row["id"],
"oem_part_number": row["oem_part_number"],
"name": row["name"],
"name_es": row["name_es"],
"match_type": "cross_reference",
"matched_number": row["cross_reference_number"],
}
)
return results
finally:
conn.close()
# ==================================================================
# VIN cache
# ==================================================================
def get_vin_cache(self, vin: str) -> Optional[dict]:
"""Return cached VIN decode data if still valid, else None."""
return self._query(
"""
SELECT
vin, decoded_data, make, model, year,
engine_info, body_class, drive_type,
model_year_engine_id, created_at, expires_at
FROM vin_cache
WHERE vin = ? AND expires_at > datetime('now')
""",
(vin.upper().strip(),),
one=True,
)
def save_vin_cache(
self,
vin: str,
data: str,
make: str,
model: str,
year: int,
engine_info: str,
body_class: str,
drive_type: str,
) -> int:
"""Insert or replace a VIN cache entry (30-day expiry)."""
expires = datetime.utcnow() + timedelta(days=30)
conn = self._connect()
try:
cursor = conn.cursor()
cursor.execute(
"""
INSERT OR REPLACE INTO vin_cache
(vin, decoded_data, make, model, year,
engine_info, body_class, drive_type, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
vin.upper().strip(),
data,
make,
model,
year,
engine_info,
body_class,
drive_type,
expires.isoformat(),
),
)
conn.commit()
return cursor.lastrowid
finally:
conn.close()
# ==================================================================
# Stats
# ==================================================================
def get_stats(self) -> dict:
"""Return counts for all major tables plus top brands by fitment."""
conn = self._connect()
try:
cursor = conn.cursor()
stats: dict = {}
for table in [
"brands",
"models",
"years",
"engines",
"part_categories",
"part_groups",
"parts",
"aftermarket_parts",
"manufacturers",
"vehicle_parts",
"part_cross_references",
]:
cursor.execute(f"SELECT COUNT(*) FROM {table}")
stats[table] = cursor.fetchone()[0]
# Top brands by number of fitments
cursor.execute(
"""
SELECT b.name, COUNT(DISTINCT vp.id) AS cnt
FROM brands b
JOIN models m ON m.brand_id = b.id
JOIN model_year_engine mye ON mye.model_id = m.id
JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id
GROUP BY b.name
ORDER BY cnt DESC
LIMIT 10
"""
)
stats["top_brands"] = [
{"name": r["name"], "count": r["cnt"]} for r in cursor.fetchall()
]
return stats
finally:
conn.close()
# ==================================================================
# Admin — Manufacturers
# ==================================================================
def get_manufacturers(self) -> list[dict]:
"""Return all manufacturers ordered by name."""
return self._query(
"""
SELECT id, name, type, quality_tier, country, logo_url, website
FROM manufacturers
ORDER BY name
"""
)
def create_manufacturer(self, data: dict) -> int:
"""Insert a new manufacturer and return its id."""
return self._execute(
"""
INSERT INTO manufacturers (name, type, quality_tier, country, logo_url, website)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
data["name"],
data.get("type"),
data.get("quality_tier"),
data.get("country"),
data.get("logo_url"),
data.get("website"),
),
)
def update_manufacturer(self, mfr_id: int, data: dict) -> None:
"""Update an existing manufacturer."""
self._execute(
"""
UPDATE manufacturers
SET name = ?, type = ?, quality_tier = ?, country = ?, logo_url = ?, website = ?
WHERE id = ?
""",
(
data["name"],
data.get("type"),
data.get("quality_tier"),
data.get("country"),
data.get("logo_url"),
data.get("website"),
mfr_id,
),
)
def delete_manufacturer(self, mfr_id: int) -> None:
"""Delete a manufacturer by id."""
self._execute("DELETE FROM manufacturers WHERE id = ?", (mfr_id,))
# ==================================================================
# Admin — Parts
# ==================================================================
def create_part(self, data: dict) -> int:
"""Insert a new part and return its id."""
return self._execute(
"""
INSERT INTO parts
(oem_part_number, name, name_es, group_id,
description, description_es, weight_kg, material)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
data["oem_part_number"],
data["name"],
data.get("name_es"),
data.get("group_id"),
data.get("description"),
data.get("description_es"),
data.get("weight_kg"),
data.get("material"),
),
)
def update_part(self, part_id: int, data: dict) -> None:
"""Update an existing part."""
self._execute(
"""
UPDATE parts
SET oem_part_number = ?, name = ?, name_es = ?, group_id = ?,
description = ?, description_es = ?, weight_kg = ?, material = ?
WHERE id = ?
""",
(
data["oem_part_number"],
data["name"],
data.get("name_es"),
data.get("group_id"),
data.get("description"),
data.get("description_es"),
data.get("weight_kg"),
data.get("material"),
part_id,
),
)
def delete_part(self, part_id: int) -> None:
"""Delete a part by id."""
self._execute("DELETE FROM parts WHERE id = ?", (part_id,))
# ==================================================================
# Admin — Cross-references
# ==================================================================
def create_crossref(self, data: dict) -> int:
"""Insert a new cross-reference and return its id."""
return self._execute(
"""
INSERT INTO part_cross_references
(part_id, cross_reference_number, reference_type, source, notes)
VALUES (?, ?, ?, ?, ?)
""",
(
data["part_id"],
data["cross_reference_number"],
data.get("reference_type"),
data.get("source"),
data.get("notes"),
),
)
def update_crossref(self, xref_id: int, data: dict) -> None:
"""Update an existing cross-reference."""
self._execute(
"""
UPDATE part_cross_references
SET part_id = ?, cross_reference_number = ?,
reference_type = ?, source = ?, notes = ?
WHERE id = ?
""",
(
data["part_id"],
data["cross_reference_number"],
data.get("reference_type"),
data.get("source"),
data.get("notes"),
xref_id,
),
)
def delete_crossref(self, xref_id: int) -> None:
"""Delete a cross-reference by id."""
self._execute(
"DELETE FROM part_cross_references WHERE id = ?", (xref_id,)
)
def get_crossrefs_paginated(
self, page: int = 1, per_page: int = 15
) -> list[dict]:
"""Return paginated cross-references with part info."""
per_page = min(per_page, 100)
offset = (page - 1) * per_page
return self._query(
"""
SELECT
pcr.id,
pcr.part_id,
pcr.cross_reference_number,
pcr.reference_type,
pcr.source,
pcr.notes,
p.oem_part_number,
p.name AS part_name
FROM part_cross_references pcr
JOIN parts p ON pcr.part_id = p.id
ORDER BY pcr.id
LIMIT ? OFFSET ?
""",
(per_page, offset),
)

119
console/main.py Normal file
View File

@@ -0,0 +1,119 @@
"""
Entry point for the AUTOPARTES Pick/VT220-style console application.
Usage:
python -m console # via package
python -m console.main # via module
python console/main.py # direct
"""
import argparse
import os
import sys
from console.config import VERSION, APP_NAME, APP_SUBTITLE, DB_PATH, DEFAULT_MODE
def parse_args(argv=None):
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
prog=APP_NAME.lower(),
description=f"{APP_NAME} - {APP_SUBTITLE}",
)
parser.add_argument(
"--mode",
choices=["vt220", "modern"],
default=DEFAULT_MODE,
help=f"Display mode (default: {DEFAULT_MODE})",
)
parser.add_argument(
"--version",
action="version",
version=f"{APP_NAME} {VERSION}",
)
parser.add_argument(
"--db",
default=DB_PATH,
help="Path to the vehicle database (default: auto-detected)",
)
return parser.parse_args(argv)
def _print_banner(mode, db_path):
"""Print a startup banner before entering terminal mode."""
border = "=" * 58
print(border)
print(f" {APP_NAME} v{VERSION}")
print(f" {APP_SUBTITLE}")
print(border)
print(f" Mode : {mode}")
print(f" DB : {db_path}")
print(border)
print()
def main(argv=None):
"""Main entry point: parse args, set up renderer, DB, and launch the app."""
args = parse_args(argv)
db_path = args.db
mode = args.mode
# Verify the database file exists before proceeding
if not os.path.isfile(db_path):
print(
f"Error: Database not found at '{db_path}'.\n"
f"\n"
f"Make sure the vehicle database exists. You can specify a\n"
f"custom path with the --db flag:\n"
f"\n"
f" python -m console --db /path/to/vehicle_database.db\n",
file=sys.stderr,
)
sys.exit(1)
# Lazy imports so the module can be loaded without curses available
# (e.g. during tests or when just checking --version).
from console.db import Database
from console.renderers.curses_renderer import CursesRenderer
from console.core.app import App
# Print startup banner
_print_banner(mode, db_path)
db = Database(db_path)
# Select renderer based on mode
if mode == "modern":
try:
from console.renderers.textual_renderer import TextualRenderer
renderer = TextualRenderer()
except ImportError:
print(
"Warning: 'modern' mode requires the 'rich' package.\n"
"Falling back to vt220 mode.\n"
"Install with: pip install rich\n",
file=sys.stderr,
)
renderer = CursesRenderer()
else:
renderer = CursesRenderer()
app = App(renderer=renderer, db=db)
try:
app.run()
except KeyboardInterrupt:
pass
except Exception as e:
# Ensure terminal is restored before printing the traceback
try:
renderer.cleanup()
except Exception:
pass
print(f"\nError: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

152
console/renderers/base.py Normal file
View File

@@ -0,0 +1,152 @@
"""
Abstract base renderer interface for the AUTOPARTES console application.
Every renderer (curses VT220, Textual/Rich, etc.) must subclass
:class:`BaseRenderer` and implement all of its methods. Screens call
these methods without knowing which backend is active.
"""
class BaseRenderer:
"""Abstract interface that all renderers must implement.
Methods raise :exc:`NotImplementedError` so that missing overrides
are caught immediately at runtime.
"""
# ── Lifecycle ────────────────────────────────────────────────────
def init_screen(self):
"""Initialise the terminal / display backend."""
raise NotImplementedError
def cleanup(self):
"""Restore the terminal to its original state."""
raise NotImplementedError
# ── Screen queries ───────────────────────────────────────────────
def get_size(self) -> tuple:
"""Return ``(height, width)`` of the usable display area."""
raise NotImplementedError
# ── Primitive operations ─────────────────────────────────────────
def clear(self):
"""Clear the entire screen buffer."""
raise NotImplementedError
def refresh(self):
"""Flush the screen buffer to the terminal."""
raise NotImplementedError
def get_key(self) -> int:
"""Block until a key is pressed and return its key code."""
raise NotImplementedError
# ── High-level widgets ───────────────────────────────────────────
def draw_header(self, title, subtitle=''):
"""Draw the application header bar on the top two rows.
*title* is left-aligned; *subtitle* is right-aligned.
Row 1 is a horizontal separator.
"""
raise NotImplementedError
def draw_footer(self, key_labels):
"""Draw the footer bar on the bottom two rows.
*key_labels* is a list of ``(key, description)`` tuples,
e.g. ``[("F1", "Ayuda"), ("ESC", "Atras")]``.
"""
raise NotImplementedError
def draw_menu(self, items, selected_index=0, title=''):
"""Draw a numbered menu list starting at row 3.
*items* is a list of ``(number, label)`` tuples.
Separator items have ``number == '---'``.
The item at *selected_index* is highlighted.
"""
raise NotImplementedError
def draw_table(self, headers, rows, widths, page_info=None,
selected_row=-1):
"""Draw a columnar data table.
*headers*: list of column header strings.
*rows*: list of row tuples (each tuple matches *headers*).
*widths*: list of int column widths.
*page_info*: optional dict ``{page, total_pages, total_rows}``.
*selected_row*: index of the highlighted row (-1 = none).
"""
raise NotImplementedError
def draw_detail(self, fields, title=''):
"""Draw a detail view with label-value pairs.
*fields* is a list of ``(label, value)`` tuples displayed as
``Label........: Value``.
"""
raise NotImplementedError
def draw_form(self, fields, focused_index=0, title=''):
"""Draw an editable form.
*fields* is a list of dicts with keys:
``label``, ``value``, ``width``, ``type``, ``hint``.
The field at *focused_index* uses the active style.
"""
raise NotImplementedError
def draw_filter_list(self, items, filter_text, selected_index,
title=''):
"""Draw a filterable list with a text input at the top.
*items*: list of ``(number, label)`` tuples.
*filter_text*: current filter string.
*selected_index*: highlighted item index.
"""
raise NotImplementedError
def draw_comparison(self, columns, title=''):
"""Draw a side-by-side comparison view.
*columns* is a list of dicts, each with:
``header`` (str) and ``rows`` (list of ``(label, value)``).
"""
raise NotImplementedError
# ── Low-level drawing ────────────────────────────────────────────
def draw_text(self, row, col, text, style='normal'):
"""Draw *text* at ``(row, col)`` using the named *style*."""
raise NotImplementedError
def draw_box(self, top, left, height, width, title=''):
"""Draw a box with Unicode line-drawing characters.
Optional *title* is rendered in the top border.
"""
raise NotImplementedError
# ── Dialogs ──────────────────────────────────────────────────────
def show_message(self, text, msg_type='info') -> bool:
"""Show a centred message box.
*msg_type* is one of ``'info'``, ``'error'``, or ``'confirm'``.
For ``'confirm'`` the user must press S (si) or N (no);
returns ``True`` for S, ``False`` for N.
For other types, waits for any key and returns ``True``.
"""
raise NotImplementedError
def show_input(self, prompt, max_len=40):
"""Show a centred input dialog.
Returns the entered string, or ``None`` if the user pressed
Escape to cancel.
"""
raise NotImplementedError

View File

@@ -0,0 +1,537 @@
"""
Curses-based VT220 renderer for the AUTOPARTES console application.
Implements :class:`BaseRenderer` with a green-on-black aesthetic inspired
by classic Pick/UNIX VT220 terminals. All drawing is done through Python's
built-in :mod:`curses` library.
"""
import curses
from console.config import COLORS_VT220
from console.renderers.base import BaseRenderer
from console.utils.formatting import pad_right, truncate
# ── Colour-name-to-curses mapping ────────────────────────────────────
_CURSES_COLORS = {
"black": curses.COLOR_BLACK,
"red": curses.COLOR_RED,
"green": curses.COLOR_GREEN,
"yellow": curses.COLOR_YELLOW,
"blue": curses.COLOR_BLUE,
"magenta": curses.COLOR_MAGENTA,
"cyan": curses.COLOR_CYAN,
"white": curses.COLOR_WHITE,
}
# Box-drawing characters
_BOX_H = "\u2500" # ─
_BOX_V = "\u2502" # │
_BOX_TL = "\u250c" # ┌
_BOX_TR = "\u2510" # ┐
_BOX_BL = "\u2514" # └
_BOX_BR = "\u2518" # ┘
class CursesRenderer(BaseRenderer):
"""Full curses implementation of the VT220 green-on-black renderer."""
def __init__(self):
self._screen = None
self._color_pairs: dict[str, int] = {}
# ── Lifecycle ────────────────────────────────────────────────────
def init_screen(self):
"""Set up curses: raw mode, no echo, hidden cursor, colours."""
self._screen = curses.initscr()
curses.noecho()
curses.cbreak()
curses.curs_set(0)
self._screen.keypad(True)
self._init_colors()
def cleanup(self):
"""Restore the terminal to a usable state."""
if self._screen is None:
return
try:
curses.nocbreak()
self._screen.keypad(False)
curses.echo()
except curses.error:
pass
curses.endwin()
self._screen = None
# ── Screen queries ───────────────────────────────────────────────
def get_size(self) -> tuple:
"""Return ``(height, width)``."""
return self._screen.getmaxyx()
# ── Primitive operations ─────────────────────────────────────────
def clear(self):
self._screen.erase()
def refresh(self):
self._screen.refresh()
def get_key(self) -> int:
return self._screen.getch()
# ── Colour helpers ───────────────────────────────────────────────
def _init_colors(self):
"""Initialise curses colour pairs from ``COLORS_VT220``."""
curses.start_color()
curses.use_default_colors()
for idx, (name, (fg, bg)) in enumerate(COLORS_VT220.items(), start=1):
curses.init_pair(idx, _CURSES_COLORS[fg], _CURSES_COLORS[bg])
self._color_pairs[name] = idx
def _attr(self, style: str) -> int:
"""Return the curses attribute for a named style.
Falls back to the *normal* pair if *style* is unknown.
"""
pair_id = self._color_pairs.get(style,
self._color_pairs.get("normal", 1))
attr = curses.color_pair(pair_id)
if style in ("header", "title"):
attr |= curses.A_BOLD
return attr
# ── Safe drawing helpers ─────────────────────────────────────────
def _safe_addstr(self, row, col, text, attr=None):
"""Write *text* at (row, col), silently ignoring edge overflows."""
if attr is None:
attr = self._attr("normal")
h, w = self.get_size()
if row < 0 or row >= h or col >= w:
return
# Truncate to fit within the screen width
max_chars = w - col
if max_chars <= 0:
return
text = text[:max_chars]
try:
self._screen.addstr(row, col, text, attr)
except curses.error:
# Writing to the bottom-right corner raises an error after
# the character is actually drawn. Safe to ignore.
pass
def _hline(self, row, col, width, char=_BOX_H, style="border"):
"""Draw a horizontal line of *char* across *width* columns."""
self._safe_addstr(row, col, char * width, self._attr(style))
# ── High-level widgets ───────────────────────────────────────────
def draw_header(self, title, subtitle=''):
h, w = self.get_size()
attr = self._attr("header")
# Row 0: title (left) + subtitle (right)
header_line = pad_right(title, w)
if subtitle:
sub = subtitle[:w - len(title) - 1]
header_line = (title
+ " " * max(w - len(title) - len(sub), 0)
+ sub)
header_line = pad_right(header_line, w)
self._safe_addstr(0, 0, header_line, attr | curses.A_BOLD)
# Row 1: separator
self._hline(1, 0, w)
def draw_footer(self, key_labels):
h, w = self.get_size()
if h < 3:
return
# Row h-2: separator
self._hline(h - 2, 0, w)
# Row h-1: key labels
attr = self._attr("footer")
parts = [f"{k}={d}" for k, d in key_labels]
line = " ".join(parts)
self._safe_addstr(h - 1, 0, pad_right(line, w), attr)
def draw_menu(self, items, selected_index=0, title=''):
h, w = self.get_size()
start_row = 3
if title:
self._safe_addstr(start_row, 2, title, self._attr("title"))
start_row += 2
visible = h - start_row - 3 # leave room for footer
if visible < 1:
return
# Scrolling offset
offset = 0
if selected_index >= visible:
offset = selected_index - visible + 1
drawn = 0
for idx, (num, label) in enumerate(items):
if drawn >= visible:
break
if idx < offset:
continue
row = start_row + drawn
# Separator
if num == "\u2500" or num == "---":
self._hline(row, 2, w - 4)
drawn += 1
continue
marker = "\u25b8 " if idx == selected_index else " "
text = f"{marker}{num}. {label}"
style = "highlight" if idx == selected_index else "normal"
self._safe_addstr(row, 2, pad_right(text, w - 4),
self._attr(style))
drawn += 1
def draw_table(self, headers, rows, widths, page_info=None,
selected_row=-1):
h, w = self.get_size()
start_row = 3
# Header row
header_cells = [pad_right(hdr, wd) for hdr, wd in zip(headers, widths)]
header_text = " # " + " \u2502 ".join(header_cells)
self._safe_addstr(start_row, 0, pad_right(header_text, w),
self._attr("title"))
# Separator
self._hline(start_row + 1, 0, w)
visible = h - start_row - 5 # room for header, sep, footer
if visible < 1:
return
for i, row_data in enumerate(rows):
if i >= visible:
break
row_num = start_row + 2 + i
row_idx_str = pad_right(str(i + 1), 3)
cells = [pad_right(str(v), wd) for v, wd in zip(row_data, widths)]
line = f" {row_idx_str}\u2502 " + " \u2502 ".join(cells)
style = "highlight" if i == selected_row else "normal"
self._safe_addstr(row_num, 0, pad_right(line, w),
self._attr(style))
# Page info
if page_info:
info_row = start_row + 2 + min(len(rows), visible)
page = page_info.get("page", 1)
total = page_info.get("total_pages", 1)
total_rows = page_info.get("total_rows", len(rows))
info_text = (f" Pagina {page}/{total}"
f" ({total_rows} registros)")
self._safe_addstr(info_row, 0, info_text, self._attr("info"))
def draw_detail(self, fields, title=''):
h, w = self.get_size()
start_row = 3
if title:
self._safe_addstr(start_row, 2, title, self._attr("title"))
self._hline(start_row + 1, 2, w - 4)
start_row += 3
# Determine max label width for alignment
max_label = max((len(lbl) for lbl, _ in fields), default=10)
dot_total = max_label + 4 # label + dots
for i, (label, value) in enumerate(fields):
row = start_row + i
if row >= h - 3:
break
dots = "." * (dot_total - len(label))
label_part = f" {label}{dots}: "
self._safe_addstr(row, 0, label_part,
self._attr("field_label"))
self._safe_addstr(row, len(label_part), str(value),
self._attr("field_value"))
def draw_form(self, fields, focused_index=0, title=''):
h, w = self.get_size()
start_row = 3
if title:
self._safe_addstr(start_row, 2, title, self._attr("title"))
self._hline(start_row + 1, 2, w - 4)
start_row += 3
max_label = max((len(f.get("label", "")) for f in fields),
default=10)
dot_total = max_label + 4
for i, field in enumerate(fields):
row = start_row + i * 2 # space between fields
if row >= h - 3:
break
label = field.get("label", "")
value = field.get("value", "")
fw = field.get("width", 20)
hint = field.get("hint", "")
dots = "." * (dot_total - len(label))
num_str = f"{i + 1}. "
label_part = f" {num_str}{label}{dots}: "
self._safe_addstr(row, 0, label_part,
self._attr("field_label"))
# Editable field value in brackets
style = "field_active" if i == focused_index else "field_value"
display_val = pad_right(str(value), fw)
field_text = f"[{display_val}]"
self._safe_addstr(row, len(label_part), field_text,
self._attr(style))
# Optional hint
if hint:
hint_col = len(label_part) + len(field_text) + 2
self._safe_addstr(row, hint_col, hint,
self._attr("info"))
def draw_filter_list(self, items, filter_text, selected_index,
title=''):
h, w = self.get_size()
start_row = 3
if title:
self._safe_addstr(start_row, 2, title, self._attr("title"))
start_row += 1
# Separator
self._hline(start_row, 2, w - 4)
start_row += 1
# Filter input
prompt = "Filtro: "
self._safe_addstr(start_row, 2, prompt,
self._attr("field_label"))
self._safe_addstr(start_row, 2 + len(prompt),
filter_text + "_",
self._attr("field_active"))
start_row += 1
# Separator
self._hline(start_row, 2, w - 4)
start_row += 1
# Scrollable list
visible = h - start_row - 4
if visible < 1:
return
offset = 0
if selected_index >= visible:
offset = selected_index - visible + 1
drawn = 0
for idx, (num, label) in enumerate(items):
if drawn >= visible:
break
if idx < offset:
continue
row = start_row + drawn
marker = "\u25b8 " if idx == selected_index else " "
text = f"{marker}{num}. {label}"
style = "highlight" if idx == selected_index else "normal"
self._safe_addstr(row, 2, pad_right(text, w - 4),
self._attr(style))
drawn += 1
# Count at bottom
count_row = start_row + min(drawn, visible)
count_text = f" {len(items)} elementos"
self._safe_addstr(count_row, 2, count_text, self._attr("info"))
def draw_comparison(self, columns, title=''):
h, w = self.get_size()
start_row = 3
if title:
self._safe_addstr(start_row, 2, title, self._attr("title"))
self._hline(start_row + 1, 2, w - 4)
start_row += 3
n_cols = len(columns)
if n_cols == 0:
return
# Determine label width from the first column's row labels
all_labels = []
for col in columns:
for lbl, _ in col.get("rows", []):
all_labels.append(lbl)
label_w = max((len(l) for l in all_labels), default=8) + 2
# Available width for data columns
avail = w - label_w - 4
col_w = max(avail // n_cols, 10)
# Header row
header_line = pad_right("", label_w)
for col in columns:
header_line += " \u2502 " + pad_right(col.get("header", ""), col_w)
self._safe_addstr(start_row, 2, header_line, self._attr("title"))
# Separator
self._hline(start_row + 1, 2, w - 4)
# Data rows — use the first column's labels as the canonical set
if not columns[0].get("rows"):
return
n_rows = len(columns[0]["rows"])
for i in range(n_rows):
row = start_row + 2 + i
if row >= h - 3:
break
lbl = columns[0]["rows"][i][0] if i < len(columns[0]["rows"]) else ""
line = pad_right(lbl, label_w)
for col in columns:
rows_data = col.get("rows", [])
val = rows_data[i][1] if i < len(rows_data) else ""
line += " \u2502 " + pad_right(str(val), col_w)
self._safe_addstr(row, 2, line, self._attr("normal"))
# ── Low-level drawing ────────────────────────────────────────────
def draw_text(self, row, col, text, style='normal'):
self._safe_addstr(row, col, text, self._attr(style))
def draw_box(self, top, left, height, width, title=''):
if height < 2 or width < 2:
return
attr = self._attr("border")
# Top border
top_line = _BOX_TL + _BOX_H * (width - 2) + _BOX_TR
if title:
t = truncate(title, width - 4)
top_line = (_BOX_TL + _BOX_H + t
+ _BOX_H * (width - 3 - len(t)) + _BOX_TR)
self._safe_addstr(top, left, top_line, attr)
# Side borders
for r in range(1, height - 1):
self._safe_addstr(top + r, left, _BOX_V, attr)
self._safe_addstr(top + r, left + width - 1, _BOX_V, attr)
# Bottom border
bottom_line = _BOX_BL + _BOX_H * (width - 2) + _BOX_BR
self._safe_addstr(top + height - 1, left, bottom_line, attr)
# ── Dialogs ──────────────────────────────────────────────────────
def show_message(self, text, msg_type='info') -> bool:
h, w = self.get_size()
lines = text.split("\n")
box_w = max(max((len(l) for l in lines), default=20) + 6, 30)
box_h = len(lines) + 4
top = max((h - box_h) // 2, 0)
left = max((w - box_w) // 2, 0)
style = "error" if msg_type == "error" else "info"
self.draw_box(top, left, box_h, box_w)
# Fill interior with spaces
interior_attr = self._attr(style)
for r in range(1, box_h - 1):
self._safe_addstr(top + r, left + 1,
" " * (box_w - 2), interior_attr)
# Draw message lines
for i, line in enumerate(lines):
x = left + max((box_w - len(line)) // 2, 2)
self._safe_addstr(top + 1 + i, x, line, interior_attr)
# Prompt line
if msg_type == "confirm":
prompt = "[S]i / [N]o"
else:
prompt = "Presione cualquier tecla..."
px = left + max((box_w - len(prompt)) // 2, 2)
self._safe_addstr(top + box_h - 2, px, prompt,
self._attr("highlight"))
self.refresh()
# Wait for key
if msg_type == "confirm":
while True:
key = self.get_key()
if key in (ord("s"), ord("S")):
return True
if key in (ord("n"), ord("N"), 27): # 27 = ESC
return False
else:
self.get_key()
return True
def show_input(self, prompt, max_len=40):
h, w = self.get_size()
box_w = max(len(prompt) + max_len + 8, 30)
box_h = 5
top = max((h - box_h) // 2, 0)
left = max((w - box_w) // 2, 0)
buf = []
try:
curses.curs_set(1) # show cursor during input
except curses.error:
pass
while True:
self.draw_box(top, left, box_h, box_w)
# Fill interior
interior_attr = self._attr("normal")
for r in range(1, box_h - 1):
self._safe_addstr(top + r, left + 1,
" " * (box_w - 2), interior_attr)
# Prompt
self._safe_addstr(top + 1, left + 2, prompt,
self._attr("field_label"))
# Input field
val = "".join(buf)
field_text = "[" + pad_right(val, max_len) + "]"
self._safe_addstr(top + 2, left + 2, field_text,
self._attr("field_active"))
# Hint
hint = "ENTER=Aceptar ESC=Cancelar"
hx = left + max((box_w - len(hint)) // 2, 2)
self._safe_addstr(top + 3, hx, hint, self._attr("info"))
self.refresh()
key = self.get_key()
if key == 27: # ESC
try:
curses.curs_set(0)
except curses.error:
pass
return None
elif key in (10, curses.KEY_ENTER): # ENTER
try:
curses.curs_set(0)
except curses.error:
pass
return "".join(buf)
elif key in (127, curses.KEY_BACKSPACE, 8): # BACKSPACE
if buf:
buf.pop()
elif 32 <= key <= 126: # printable ASCII
if len(buf) < max_len:
buf.append(chr(key))

View File

@@ -0,0 +1,712 @@
"""
Rich-based modern renderer for the AUTOPARTES console application.
Implements :class:`BaseRenderer` using the ``rich`` library for a modern
dark-themed TUI with blue/cyan accents. Keyboard input is handled via
raw terminal mode using :mod:`tty` / :mod:`termios` since Rich is a
display-only library.
NOTE: Despite the module name (historical), this uses **Rich** only --
not the Textual framework.
"""
import sys
import tty
import termios
import select
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.text import Text
from rich import box as rich_box
from console.core.keybindings import Key
from console.renderers.base import BaseRenderer
from console.utils.formatting import pad_right, truncate
class TextualRenderer(BaseRenderer):
"""Rich-based modern renderer with blue/cyan colour scheme."""
def __init__(self):
self._console = None
self._old_term_settings = None
# ── Lifecycle ────────────────────────────────────────────────────
def init_screen(self):
"""Create a Rich Console and put the terminal into raw mode."""
self._console = Console(highlight=False, force_terminal=True)
# Save terminal state *before* entering raw mode
try:
fd = sys.stdin.fileno()
self._old_term_settings = termios.tcgetattr(fd)
except (termios.error, ValueError, OSError):
self._old_term_settings = None
# Hide cursor
sys.stdout.write("\033[?25l")
sys.stdout.flush()
def cleanup(self):
"""Restore the terminal to its original state."""
# Show cursor
sys.stdout.write("\033[?25h")
sys.stdout.flush()
# Restore original terminal attributes
if self._old_term_settings is not None:
try:
fd = sys.stdin.fileno()
termios.tcsetattr(fd, termios.TCSADRAIN,
self._old_term_settings)
except (termios.error, ValueError, OSError):
pass
self._old_term_settings = None
self._console = None
# ── Screen queries ───────────────────────────────────────────────
def get_size(self) -> tuple:
"""Return ``(height, width)`` of the terminal."""
size = self._console.size
return (size.height, size.width)
# ── Primitive operations ─────────────────────────────────────────
def clear(self):
"""Clear the screen."""
self._console.clear()
def refresh(self):
"""No-op -- Rich prints immediately to stdout."""
pass
def get_key(self) -> int:
"""Read a single key from stdin using raw terminal mode.
Escape sequences (arrows, F-keys, etc.) are decoded and mapped
to the same integer constants used by :class:`Key` (which mirror
curses key codes).
"""
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch = sys.stdin.read(1)
if ch == "\x1b":
# Check if more bytes are available (escape sequence)
if _has_data(fd):
ch2 = sys.stdin.read(1)
if ch2 == "[":
ch3 = sys.stdin.read(1)
# Arrow keys
if ch3 == "A":
return Key.UP
if ch3 == "B":
return Key.DOWN
if ch3 == "C":
return Key.RIGHT
if ch3 == "D":
return Key.LEFT
if ch3 == "H":
return Key.HOME
if ch3 == "F":
return Key.END
# Page Up / Page Down / Home / End / Insert / Delete
if ch3 == "5":
sys.stdin.read(1) # consume '~'
return Key.PGUP
if ch3 == "6":
sys.stdin.read(1) # consume '~'
return Key.PGDN
if ch3 == "1":
# Could be: Home (1~), F5-F8 (15~,17~,18~,19~)
ch4 = sys.stdin.read(1)
if ch4 == "~":
return Key.HOME
if ch4 == "5":
sys.stdin.read(1) # ~
return Key.F5
if ch4 == "7":
sys.stdin.read(1) # ~
return Key.F6
if ch4 == "8":
sys.stdin.read(1) # ~
return Key.F7
if ch4 == "9":
sys.stdin.read(1) # ~
return Key.F8
# Consume trailing ~ if present
if _has_data(fd):
sys.stdin.read(1)
return Key.HOME
if ch3 == "2":
ch4 = sys.stdin.read(1)
if ch4 == "0":
sys.stdin.read(1) # ~
return Key.F9
if ch4 == "1":
sys.stdin.read(1) # ~
return Key.F10
# 2~ = Insert -- map to escape for now
return Key.ESCAPE
if ch3 == "3":
# Delete key: 3~
if _has_data(fd):
sys.stdin.read(1) # ~
return Key.BACKSPACE
if ch3 == "4":
if _has_data(fd):
sys.stdin.read(1) # ~
return Key.END
# Drain any remaining escape sequence bytes
while _has_data(fd):
sys.stdin.read(1)
return Key.ESCAPE
elif ch2 == "O":
# SS3 sequences (F1-F4, sometimes Home/End)
ch3 = sys.stdin.read(1)
if ch3 == "P":
return Key.F1
if ch3 == "Q":
return Key.F2
if ch3 == "R":
return Key.F3
if ch3 == "S":
return Key.F4
if ch3 == "H":
return Key.HOME
if ch3 == "F":
return Key.END
return Key.ESCAPE
# Unknown escape -- drain and return ESC
while _has_data(fd):
sys.stdin.read(1)
return Key.ESCAPE
# Bare ESC (no further bytes)
return Key.ESCAPE
if ch == "\r" or ch == "\n":
return Key.ENTER
if ch == "\t":
return Key.TAB
if ch == "\x7f" or ch == "\x08":
return Key.BACKSPACE
if ch == "\x03":
# Ctrl-C -- treat as escape so the app can exit gracefully
return Key.ESCAPE
return ord(ch)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
# ── High-level widgets ───────────────────────────────────────────
def draw_header(self, title, subtitle=''):
h, w = self.get_size()
header = Text()
header.append(title, style="bold cyan")
if subtitle:
padding = w - len(title) - len(subtitle)
if padding > 0:
header.append(" " * padding)
header.append(subtitle, style="dim white")
# Pad to full width
if header.cell_len < w:
header.append(" " * (w - header.cell_len))
header.stylize("on rgb(20,40,80)")
self._console.print(header, end="")
# Separator line
sep = Text("" * w, style="blue")
self._console.print(sep, end="")
def draw_footer(self, key_labels):
h, w = self.get_size()
# Separator
sep = Text("" * w, style="blue")
self._console.print(sep, end="")
# Key labels
footer = Text()
for i, (key, desc) in enumerate(key_labels):
if i > 0:
footer.append(" ", style="dim white on rgb(20,40,80)")
footer.append(f" {key} ", style="bold white on rgb(40,80,120)")
footer.append(f" {desc}", style="white on rgb(20,40,80)")
# Pad to full width
if footer.cell_len < w:
footer.append(
" " * (w - footer.cell_len),
style="on rgb(20,40,80)",
)
self._console.print(footer, end="")
def draw_menu(self, items, selected_index=0, title=''):
h, w = self.get_size()
visible_lines = h - 6 # header(2) + footer(2) + margins
if title:
title_text = Text()
title_text.append(f" {title}", style="bold white")
self._console.print(title_text, end="")
self._console.print("", end="") # blank line
visible_lines -= 2
if visible_lines < 1:
return
# Scrolling offset
offset = 0
if selected_index >= visible_lines:
offset = selected_index - visible_lines + 1
drawn = 0
for idx, (num, label) in enumerate(items):
if drawn >= visible_lines:
break
if idx < offset:
continue
# Separator
if num == "\u2500" or num == "---":
sep = Text(" " + "" * (w - 4), style="dim blue")
self._console.print(sep, end="")
drawn += 1
continue
line = Text()
marker = "\u25b8 " if idx == selected_index else " "
if idx == selected_index:
entry = f"{marker}{num}. {label}"
entry = pad_right(entry, w - 4)
line.append(f" {entry}", style="bold white on rgb(30,60,120)")
else:
line.append(f" {marker}{num}. {label}", style="white")
self._console.print(line, end="")
drawn += 1
def draw_table(self, headers, rows, widths, page_info=None,
selected_row=-1):
h, w = self.get_size()
table = Table(
box=rich_box.SIMPLE_HEAD,
show_edge=False,
pad_edge=False,
expand=True,
style="white",
header_style="bold cyan",
row_styles=["white", "dim white"],
)
# Row number column
table.add_column("#", style="dim cyan", width=4, justify="right")
for hdr, wd in zip(headers, widths):
table.add_column(hdr, width=wd, no_wrap=True, overflow="ellipsis")
visible = h - 8 # header, table header, separator, footer, page info
if visible < 1:
visible = 1
for i, row_data in enumerate(rows):
if i >= visible:
break
cells = [str(v) for v in row_data]
row_style = ("bold white on rgb(30,60,120)"
if i == selected_row else None)
table.add_row(str(i + 1), *cells, style=row_style)
self._console.print(table, end="")
if page_info:
page = page_info.get("page", 1)
total = page_info.get("total_pages", 1)
total_rows = page_info.get("total_rows", len(rows))
info = Text()
info.append(
f" Pagina {page}/{total} ({total_rows} registros)",
style="dim cyan",
)
self._console.print(info, end="")
def draw_detail(self, fields, title=''):
h, w = self.get_size()
if title:
title_text = Text()
title_text.append(f" {title}", style="bold white")
self._console.print(title_text, end="")
sep = Text(" " + "" * (w - 4), style="blue")
self._console.print(sep, end="")
self._console.print("", end="") # blank line
max_label = max((len(lbl) for lbl, _ in fields), default=10)
dot_total = max_label + 4
lines_available = h - 6
if title:
lines_available -= 3
for i, (label, value) in enumerate(fields):
if i >= lines_available:
break
dots = "." * (dot_total - len(label))
line = Text()
line.append(f" {label}{dots}: ", style="cyan")
line.append(str(value), style="bold white")
self._console.print(line, end="")
def draw_form(self, fields, focused_index=0, title=''):
h, w = self.get_size()
if title:
title_text = Text()
title_text.append(f" {title}", style="bold white")
self._console.print(title_text, end="")
sep = Text(" " + "" * (w - 4), style="blue")
self._console.print(sep, end="")
self._console.print("", end="") # blank line
max_label = max((len(f.get("label", "")) for f in fields), default=10)
dot_total = max_label + 4
for i, field in enumerate(fields):
label = field.get("label", "")
value = field.get("value", "")
fw = field.get("width", 20)
hint = field.get("hint", "")
dots = "." * (dot_total - len(label))
num_str = f"{i + 1}. "
line = Text()
line.append(f" {num_str}{label}{dots}: ", style="cyan")
display_val = pad_right(str(value), fw)
if i == focused_index:
line.append(f"[{display_val}]",
style="bold white on rgb(0,100,140)")
else:
line.append(f"[{display_val}]", style="white")
if hint:
line.append(f" {hint}", style="dim cyan")
self._console.print(line, end="")
# Blank line between fields for spacing
self._console.print("", end="")
def draw_filter_list(self, items, filter_text, selected_index,
title=''):
h, w = self.get_size()
if title:
title_text = Text()
title_text.append(f" {title}", style="bold white")
self._console.print(title_text, end="")
# Separator
sep = Text(" " + "" * (w - 4), style="blue")
self._console.print(sep, end="")
# Filter input
filter_line = Text()
filter_line.append(" Filtro: ", style="cyan")
filter_line.append(filter_text, style="bold white on rgb(0,100,140)")
filter_line.append("_", style="bold white on rgb(0,100,140)")
self._console.print(filter_line, end="")
# Separator
self._console.print(sep, end="")
# Scrollable list
visible = h - 10 # header, title, filter, separators, footer, count
if visible < 1:
visible = 1
offset = 0
if selected_index >= visible:
offset = selected_index - visible + 1
drawn = 0
for idx, (num, label) in enumerate(items):
if drawn >= visible:
break
if idx < offset:
continue
marker = "\u25b8 " if idx == selected_index else " "
line = Text()
if idx == selected_index:
entry = f"{marker}{num}. {label}"
entry = pad_right(entry, w - 4)
line.append(f" {entry}",
style="bold white on rgb(30,60,120)")
else:
line.append(f" {marker}{num}. {label}", style="white")
self._console.print(line, end="")
drawn += 1
# Count at bottom
count_line = Text()
count_line.append(f" {len(items)} elementos", style="dim cyan")
self._console.print(count_line, end="")
def draw_comparison(self, columns, title=''):
h, w = self.get_size()
if title:
title_text = Text()
title_text.append(f" {title}", style="bold white")
self._console.print(title_text, end="")
sep = Text(" " + "" * (w - 4), style="blue")
self._console.print(sep, end="")
self._console.print("", end="") # blank line
n_cols = len(columns)
if n_cols == 0:
return
# Build a Rich Table for the comparison
table = Table(
box=rich_box.SIMPLE_HEAD,
show_edge=False,
pad_edge=True,
expand=True,
style="white",
header_style="bold cyan",
)
# Label column
table.add_column("", style="cyan", no_wrap=True)
for col in columns:
table.add_column(
col.get("header", ""),
style="white",
no_wrap=True,
)
# Data rows -- use the first column's labels as the canonical set
if not columns[0].get("rows"):
self._console.print(table, end="")
return
n_rows = len(columns[0]["rows"])
max_rows = h - 8
for i in range(min(n_rows, max_rows)):
lbl = (columns[0]["rows"][i][0]
if i < len(columns[0]["rows"]) else "")
vals = []
for col in columns:
rows_data = col.get("rows", [])
val = rows_data[i][1] if i < len(rows_data) else ""
vals.append(str(val))
table.add_row(lbl, *vals)
self._console.print(table, end="")
# ── Low-level drawing ────────────────────────────────────────────
def draw_text(self, row, col, text, style='normal'):
"""Draw text using Rich styling.
Since Rich does not support absolute cursor positioning the way
curses does, we approximate by printing the text preceded by
ANSI escape codes that move the cursor to the requested row/col.
"""
style_map = {
"normal": "white",
"header": "bold cyan",
"footer": "white on rgb(20,40,80)",
"highlight": "bold white on rgb(30,60,120)",
"border": "blue",
"title": "bold white",
"error": "bold red",
"info": "dim cyan",
"field_label": "cyan",
"field_value": "bold white",
"field_active": "bold white on rgb(0,100,140)",
}
rich_style = style_map.get(style, "white")
styled = Text(text, style=rich_style)
# Use ANSI escape to position cursor
sys.stdout.write(f"\033[{row + 1};{col + 1}H")
sys.stdout.flush()
self._console.print(styled, end="")
def draw_box(self, top, left, height, width, title=''):
"""Draw a box using Rich's Panel.
Since Rich Panel does not support absolute positioning, we build
the box manually with Unicode line-drawing characters and ANSI
cursor movement for precise placement.
"""
BOX_H = "\u2500"
BOX_V = "\u2502"
BOX_TL = "\u256d" # rounded corners for modern look
BOX_TR = "\u256e"
BOX_BL = "\u2570"
BOX_BR = "\u256f"
if height < 2 or width < 2:
return
# Top border
if title:
t = truncate(title, width - 4)
top_line = (BOX_TL + BOX_H + t
+ BOX_H * (width - 3 - len(t)) + BOX_TR)
else:
top_line = BOX_TL + BOX_H * (width - 2) + BOX_TR
sys.stdout.write(f"\033[{top + 1};{left + 1}H")
styled = Text(top_line, style="blue")
self._console.print(styled, end="")
# Side borders
for r in range(1, height - 1):
sys.stdout.write(f"\033[{top + r + 1};{left + 1}H")
styled = Text(BOX_V, style="blue")
self._console.print(styled, end="")
sys.stdout.write(f"\033[{top + r + 1};{left + width}H")
self._console.print(styled, end="")
# Bottom border
bottom_line = BOX_BL + BOX_H * (width - 2) + BOX_BR
sys.stdout.write(f"\033[{top + height};{left + 1}H")
styled = Text(bottom_line, style="blue")
self._console.print(styled, end="")
# ── Dialogs ──────────────────────────────────────────────────────
def show_message(self, text, msg_type='info') -> bool:
h, w = self.get_size()
lines = text.split("\n")
box_w = max(max((len(l) for l in lines), default=20) + 6, 30)
box_h = len(lines) + 4
top = max((h - box_h) // 2, 0)
left = max((w - box_w) // 2, 0)
# Determine style
if msg_type == "error":
border_style = "bold red"
text_style = "bold red"
title_label = " Error "
elif msg_type == "confirm":
border_style = "bold yellow"
text_style = "white"
title_label = " Confirmar "
else:
border_style = "bold cyan"
text_style = "white"
title_label = " Info "
# Draw box
self.draw_box(top, left, box_h, box_w, title_label)
# Fill interior and draw message lines
interior_style = text_style
for r in range(1, box_h - 1):
sys.stdout.write(f"\033[{top + r + 1};{left + 2}H")
fill = Text(" " * (box_w - 2), style=interior_style)
self._console.print(fill, end="")
for i, line in enumerate(lines):
x = left + max((box_w - len(line)) // 2, 2)
sys.stdout.write(f"\033[{top + 2 + i};{x + 1}H")
styled = Text(line, style=text_style)
self._console.print(styled, end="")
# Prompt line
if msg_type == "confirm":
prompt = "[S]i / [N]o"
else:
prompt = "Presione cualquier tecla..."
px = left + max((box_w - len(prompt)) // 2, 2)
sys.stdout.write(f"\033[{top + box_h};{px + 1}H")
prompt_styled = Text(prompt, style="bold cyan")
self._console.print(prompt_styled, end="")
sys.stdout.flush()
# Wait for key
if msg_type == "confirm":
while True:
key = self.get_key()
if key in (ord("s"), ord("S")):
return True
if key in (ord("n"), ord("N"), Key.ESCAPE):
return False
else:
self.get_key()
return True
def show_input(self, prompt, max_len=40):
h, w = self.get_size()
box_w = max(len(prompt) + max_len + 8, 30)
box_h = 5
top = max((h - box_h) // 2, 0)
left = max((w - box_w) // 2, 0)
buf = []
# Show cursor during input
sys.stdout.write("\033[?25h")
sys.stdout.flush()
while True:
self.draw_box(top, left, box_h, box_w, " Entrada ")
# Fill interior
for r in range(1, box_h - 1):
sys.stdout.write(f"\033[{top + r + 1};{left + 2}H")
fill = Text(" " * (box_w - 2))
self._console.print(fill, end="")
# Prompt label
sys.stdout.write(f"\033[{top + 2};{left + 3}H")
label = Text(prompt, style="cyan")
self._console.print(label, end="")
# Input field
val = "".join(buf)
display = pad_right(val, max_len)
sys.stdout.write(f"\033[{top + 3};{left + 3}H")
field = Text(f"[{display}]",
style="bold white on rgb(0,100,140)")
self._console.print(field, end="")
# Hint
hint = "ENTER=Aceptar ESC=Cancelar"
hx = left + max((box_w - len(hint)) // 2, 2)
sys.stdout.write(f"\033[{top + 4};{hx + 1}H")
hint_styled = Text(hint, style="dim cyan")
self._console.print(hint_styled, end="")
sys.stdout.flush()
key = self.get_key()
if key == Key.ESCAPE:
sys.stdout.write("\033[?25l")
sys.stdout.flush()
return None
elif key == Key.ENTER:
sys.stdout.write("\033[?25l")
sys.stdout.flush()
return "".join(buf)
elif key == Key.BACKSPACE:
if buf:
buf.pop()
elif 32 <= key <= 126:
if len(buf) < max_len:
buf.append(chr(key))
# ── Module-level helpers ─────────────────────────────────────────────
def _has_data(fd, timeout=0.05):
"""Return True if there is data waiting on file descriptor *fd*."""
r, _, _ = select.select([fd], [], [], timeout)
return bool(r)

View File

View File

@@ -0,0 +1,302 @@
"""
Admin CRUD screen for Cross-References in the AUTOPARTES console application.
Provides a paginated list view with create (F3), edit (ENTER), and
delete (F8/Del) operations for the part_cross_references table.
"""
from console.core.screens import Screen
from console.core.keybindings import Key
from console.config import APP_NAME, VERSION
from console.utils.formatting import truncate
# Form field definitions for create/edit
_FIELDS = [
{'label': 'Part ID', 'key': 'part_id', 'width': 8, 'hint': 'F1=Buscar parte'},
{'label': 'Numero cruzado', 'key': 'cross_reference_number', 'width': 25},
{'label': 'Tipo', 'key': 'reference_type', 'width': 15, 'hint': 'supersession/interchange/competitor'},
{'label': 'Fuente', 'key': 'source', 'width': 20},
{'label': 'Notas', 'key': 'notes', 'width': 40},
]
# Footer labels per mode
_FOOTER_LIST = [
("F3", "Nuevo"),
("ENTER", "Editar"),
("F8", "Eliminar"),
("PgUp/Dn", "Paginar"),
("ESC", "Atras"),
]
_FOOTER_FORM = [
("TAB/Down", "Siguiente"),
("Up", "Anterior"),
("F9", "Guardar"),
("ESC", "Cancelar"),
]
class AdminCrossrefScreen(Screen):
"""Admin CRUD screen for the part_cross_references table."""
def __init__(self):
super().__init__(name="admin_crossref", title="Cross-References")
self._mode = 'list' # 'list' or 'form'
self._page = 1
self._per_page = 15
self._selected = 0
self._crossrefs = []
self._editing_id = None # None = creating, int = editing
self._focused_field = 0
self._form_data = {}
self._dirty = False
# ------------------------------------------------------------------
# Data loading
# ------------------------------------------------------------------
def _load_crossrefs(self, db):
"""Load the current page of cross-references."""
self._crossrefs = db.get_crossrefs_paginated(
page=self._page, per_page=self._per_page
)
def _init_form(self, xref=None):
"""Initialise form_data from an existing cross-reference or blank."""
self._form_data = {}
if xref:
for f in _FIELDS:
val = xref.get(f['key'], '')
self._form_data[f['key']] = str(val) if val is not None else ''
else:
for f in _FIELDS:
self._form_data[f['key']] = ''
self._focused_field = 0
self._dirty = False
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self, context, db, renderer):
renderer.draw_header(
f" {APP_NAME} v{VERSION}",
" CROSS-REFERENCES ",
)
if self._mode == 'list':
self._render_list(db, renderer)
else:
self._render_form(renderer)
def _render_list(self, db, renderer):
"""Render the paginated cross-references list."""
self._load_crossrefs(db)
headers = ["PARTE OEM", "NUMERO CRUZADO", "TIPO", "FUENTE"]
widths = [18, 22, 14, 16]
rows = []
for x in self._crossrefs:
rows.append((
truncate(x.get("oem_part_number", ""), 18),
truncate(x.get("cross_reference_number", ""), 22),
truncate(x.get("reference_type", "") or "", 14),
truncate(x.get("source", "") or "", 16),
))
renderer.draw_table(
headers,
rows,
widths,
page_info={
"page": self._page,
"total_pages": self._page,
"total_rows": len(rows),
},
selected_row=self._selected,
)
renderer.draw_footer(_FOOTER_LIST)
def _render_form(self, renderer):
"""Render the create/edit form."""
title = "EDITAR CROSS-REFERENCE" if self._editing_id else "NUEVA CROSS-REFERENCE"
fields = []
for f in _FIELDS:
fields.append({
'label': f['label'],
'value': self._form_data.get(f['key'], ''),
'width': f['width'],
'hint': f.get('hint', ''),
})
renderer.draw_form(fields, focused_index=self._focused_field, title=title)
renderer.draw_footer(_FOOTER_FORM)
# ------------------------------------------------------------------
# Key handling
# ------------------------------------------------------------------
def on_key(self, key, context, db, renderer, nav):
if self._mode == 'list':
return self._handle_list_key(key, db, renderer)
else:
return self._handle_form_key(key, db, renderer)
def _handle_list_key(self, key, db, renderer):
"""Handle keys in list mode."""
# ESC: go back
if key == Key.ESCAPE:
return "back"
# Arrow navigation
if key == Key.UP:
if self._selected > 0:
self._selected -= 1
return None
if key == Key.DOWN:
if self._crossrefs and self._selected < len(self._crossrefs) - 1:
self._selected += 1
return None
# PgDn: next page
if key == Key.PGDN:
if len(self._crossrefs) == self._per_page:
self._page += 1
self._selected = 0
return None
# PgUp: previous page
if key == Key.PGUP:
if self._page > 1:
self._page -= 1
self._selected = 0
return None
# F3: create new cross-reference
if key == Key.F3:
self._mode = 'form'
self._editing_id = None
self._init_form()
return None
# ENTER: edit selected cross-reference
if key == Key.ENTER:
if self._crossrefs and 0 <= self._selected < len(self._crossrefs):
xref = self._crossrefs[self._selected]
self._editing_id = xref["id"]
self._mode = 'form'
self._init_form(xref)
return None
# Number keys 1-9: edit cross-reference at that row index
if 49 <= key <= 57:
idx = key - 49
if 0 <= idx < len(self._crossrefs):
xref = self._crossrefs[idx]
self._editing_id = xref["id"]
self._mode = 'form'
self._init_form(xref)
return None
# F8 or DEL: delete selected cross-reference
if key in (Key.F8, 330): # 330 = KEY_DC (Delete)
if self._crossrefs and 0 <= self._selected < len(self._crossrefs):
xref = self._crossrefs[self._selected]
oem = xref.get("oem_part_number", "")
xnum = xref.get("cross_reference_number", "")
confirmed = renderer.show_message(
f"Eliminar cross-reference?\n{oem} -> {xnum}",
"confirm",
)
if confirmed:
try:
db.delete_crossref(xref["id"])
except Exception as exc:
renderer.show_message(f"Error:\n{exc}", "error")
return None
if self._selected >= len(self._crossrefs) - 1:
self._selected = max(0, self._selected - 1)
return None
return None
def _handle_form_key(self, key, db, renderer):
"""Handle keys in form mode."""
# ESC: cancel form (with dirty check)
if key == Key.ESCAPE:
if self._dirty:
confirmed = renderer.show_message(
"Descartar cambios?", "confirm"
)
if not confirmed:
return None
self._mode = 'list'
return None
# TAB / Down: next field
if key in (Key.TAB, Key.DOWN):
if self._focused_field < len(_FIELDS) - 1:
self._focused_field += 1
return None
# Up: previous field
if key == Key.UP:
if self._focused_field > 0:
self._focused_field -= 1
return None
# F9: save
if key == Key.F9:
return self._save(db, renderer)
# Backspace: delete last char from current field value
if key in (Key.BACKSPACE, 8):
field_key = _FIELDS[self._focused_field]['key']
val = self._form_data.get(field_key, '')
if val:
self._form_data[field_key] = val[:-1]
self._dirty = True
return None
# Printable characters: append to current field
if 32 <= key <= 126:
field_def = _FIELDS[self._focused_field]
field_key = field_def['key']
val = self._form_data.get(field_key, '')
if len(val) < field_def['width']:
self._form_data[field_key] = val + chr(key)
self._dirty = True
return None
return None
def _save(self, db, renderer):
"""Validate and save the form data."""
data = dict(self._form_data)
# Validate required fields
pid = data.get('part_id', '').strip()
if not pid or not pid.isdigit():
renderer.show_message("Part ID debe ser un numero valido", "error")
return None
data['part_id'] = int(pid)
if not data.get('cross_reference_number', '').strip():
renderer.show_message("Numero cruzado es requerido", "error")
return None
try:
if self._editing_id:
db.update_crossref(self._editing_id, data)
renderer.show_message("Cross-reference actualizada correctamente", "info")
else:
db.create_crossref(data)
renderer.show_message("Cross-reference creada correctamente", "info")
except Exception as exc:
renderer.show_message(f"Error al guardar:\n{exc}", "error")
return None
self._mode = 'list'
self._dirty = False
return None

View File

@@ -0,0 +1,277 @@
"""
Admin CRUD screen for Manufacturers in the AUTOPARTES console application.
Provides a list view with create (F3), edit (ENTER), and delete (F8/Del)
operations for the manufacturers table.
"""
from console.core.screens import Screen
from console.core.keybindings import Key
from console.config import APP_NAME, VERSION
from console.utils.formatting import truncate
# Form field definitions for create/edit
_FIELDS = [
{'label': 'Nombre', 'key': 'name', 'width': 30},
{'label': 'Tipo', 'key': 'type', 'width': 15, 'hint': 'oem/aftermarket/remanufactured'},
{'label': 'Calidad', 'key': 'quality_tier', 'width': 10, 'hint': 'premium/standard/economy'},
{'label': 'Pais', 'key': 'country', 'width': 20},
{'label': 'Website', 'key': 'website', 'width': 40},
]
# Footer labels per mode
_FOOTER_LIST = [
("F3", "Nuevo"),
("ENTER", "Editar"),
("F8", "Eliminar"),
("ESC", "Atras"),
]
_FOOTER_FORM = [
("TAB/Down", "Siguiente"),
("Up", "Anterior"),
("F9", "Guardar"),
("ESC", "Cancelar"),
]
class AdminFabricantesScreen(Screen):
"""Admin CRUD screen for the manufacturers table."""
def __init__(self):
super().__init__(name="admin_fabricantes", title="Administracion de Fabricantes")
self._mode = 'list' # 'list' or 'form'
self._selected = 0
self._manufacturers = []
self._editing_id = None # None = creating, int = editing
self._focused_field = 0
self._form_data = {}
self._dirty = False
# ------------------------------------------------------------------
# Data loading
# ------------------------------------------------------------------
def _load_manufacturers(self, db):
"""Load all manufacturers."""
self._manufacturers = db.get_manufacturers()
def _init_form(self, mfr=None):
"""Initialise form_data from an existing manufacturer or blank."""
self._form_data = {}
if mfr:
for f in _FIELDS:
val = mfr.get(f['key'], '')
self._form_data[f['key']] = str(val) if val is not None else ''
else:
for f in _FIELDS:
self._form_data[f['key']] = ''
self._focused_field = 0
self._dirty = False
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self, context, db, renderer):
renderer.draw_header(
f" {APP_NAME} v{VERSION}",
" ADMINISTRACION DE FABRICANTES ",
)
if self._mode == 'list':
self._render_list(db, renderer)
else:
self._render_form(renderer)
def _render_list(self, db, renderer):
"""Render the manufacturers list."""
self._load_manufacturers(db)
headers = ["NOMBRE", "TIPO", "CALIDAD", "PAIS", "WEBSITE"]
widths = [20, 14, 10, 14, 20]
rows = []
for m in self._manufacturers:
rows.append((
truncate(m.get("name", ""), 20),
truncate(m.get("type", "") or "", 14),
truncate(m.get("quality_tier", "") or "", 10),
truncate(m.get("country", "") or "", 14),
truncate(m.get("website", "") or "", 20),
))
renderer.draw_table(
headers,
rows,
widths,
page_info={
"page": 1,
"total_pages": 1,
"total_rows": len(rows),
},
selected_row=self._selected,
)
renderer.draw_footer(_FOOTER_LIST)
def _render_form(self, renderer):
"""Render the create/edit form."""
title = "EDITAR FABRICANTE" if self._editing_id else "NUEVO FABRICANTE"
fields = []
for f in _FIELDS:
fields.append({
'label': f['label'],
'value': self._form_data.get(f['key'], ''),
'width': f['width'],
'hint': f.get('hint', ''),
})
renderer.draw_form(fields, focused_index=self._focused_field, title=title)
renderer.draw_footer(_FOOTER_FORM)
# ------------------------------------------------------------------
# Key handling
# ------------------------------------------------------------------
def on_key(self, key, context, db, renderer, nav):
if self._mode == 'list':
return self._handle_list_key(key, db, renderer)
else:
return self._handle_form_key(key, db, renderer)
def _handle_list_key(self, key, db, renderer):
"""Handle keys in list mode."""
# ESC: go back
if key == Key.ESCAPE:
return "back"
# Arrow navigation
if key == Key.UP:
if self._selected > 0:
self._selected -= 1
return None
if key == Key.DOWN:
if self._manufacturers and self._selected < len(self._manufacturers) - 1:
self._selected += 1
return None
# F3: create new manufacturer
if key == Key.F3:
self._mode = 'form'
self._editing_id = None
self._init_form()
return None
# ENTER: edit selected manufacturer
if key == Key.ENTER:
if self._manufacturers and 0 <= self._selected < len(self._manufacturers):
mfr = self._manufacturers[self._selected]
self._editing_id = mfr["id"]
self._mode = 'form'
self._init_form(mfr)
return None
# Number keys 1-9: edit manufacturer at that row index
if 49 <= key <= 57:
idx = key - 49
if 0 <= idx < len(self._manufacturers):
mfr = self._manufacturers[idx]
self._editing_id = mfr["id"]
self._mode = 'form'
self._init_form(mfr)
return None
# F8 or DEL: delete selected manufacturer
if key in (Key.F8, 330): # 330 = KEY_DC (Delete)
if self._manufacturers and 0 <= self._selected < len(self._manufacturers):
mfr = self._manufacturers[self._selected]
name = mfr.get("name", "")
confirmed = renderer.show_message(
f"Eliminar fabricante?\n{name}",
"confirm",
)
if confirmed:
try:
db.delete_manufacturer(mfr["id"])
except Exception as exc:
renderer.show_message(f"Error:\n{exc}", "error")
return None
if self._selected >= len(self._manufacturers) - 1:
self._selected = max(0, self._selected - 1)
return None
return None
def _handle_form_key(self, key, db, renderer):
"""Handle keys in form mode."""
# ESC: cancel form (with dirty check)
if key == Key.ESCAPE:
if self._dirty:
confirmed = renderer.show_message(
"Descartar cambios?", "confirm"
)
if not confirmed:
return None
self._mode = 'list'
return None
# TAB / Down: next field
if key in (Key.TAB, Key.DOWN):
if self._focused_field < len(_FIELDS) - 1:
self._focused_field += 1
return None
# Up: previous field
if key == Key.UP:
if self._focused_field > 0:
self._focused_field -= 1
return None
# F9: save
if key == Key.F9:
return self._save(db, renderer)
# Backspace: delete last char from current field value
if key in (Key.BACKSPACE, 8):
field_key = _FIELDS[self._focused_field]['key']
val = self._form_data.get(field_key, '')
if val:
self._form_data[field_key] = val[:-1]
self._dirty = True
return None
# Printable characters: append to current field
if 32 <= key <= 126:
field_def = _FIELDS[self._focused_field]
field_key = field_def['key']
val = self._form_data.get(field_key, '')
if len(val) < field_def['width']:
self._form_data[field_key] = val + chr(key)
self._dirty = True
return None
return None
def _save(self, db, renderer):
"""Validate and save the form data."""
data = dict(self._form_data)
# Validate required fields
if not data.get('name', '').strip():
renderer.show_message("Nombre es requerido", "error")
return None
try:
if self._editing_id:
db.update_manufacturer(self._editing_id, data)
renderer.show_message("Fabricante actualizado correctamente", "info")
else:
db.create_manufacturer(data)
renderer.show_message("Fabricante creado correctamente", "info")
except Exception as exc:
renderer.show_message(f"Error al guardar:\n{exc}", "error")
return None
self._mode = 'list'
self._dirty = False
return None

View File

@@ -0,0 +1,325 @@
"""
Import/Export screen for the AUTOPARTES console application.
Provides a simple menu flow to import CSV files into the database or
export data to JSON files. Uses the renderer's show_input and
show_message dialogs for all user interaction.
"""
import csv
import json
import os
from console.core.screens import Screen
from console.core.keybindings import Key
from console.config import APP_NAME, VERSION
# Import type mapping: menu choice -> (label, table hint)
_IMPORT_TYPES = {
'1': ('Categorias', 'categories'),
'2': ('Grupos', 'groups'),
'3': ('Partes', 'parts'),
'4': ('Fabricantes', 'manufacturers'),
'5': ('Aftermarket', 'aftermarket'),
'6': ('CrossRef', 'crossref'),
'7': ('Fitment', 'fitment'),
}
# Export type mapping
_EXPORT_TYPES = {
'1': ('Categorias', 'categories'),
'2': ('Grupos', 'groups'),
'3': ('Partes', 'parts'),
'4': ('Fabricantes', 'manufacturers'),
'5': ('Cross-References', 'crossref'),
}
# Footer for the main menu
_FOOTER_MENU = [
("1-3", "Seleccionar"),
("ESC", "Atras"),
]
class AdminImportScreen(Screen):
"""Import/Export data screen with simple menu flow."""
def __init__(self):
super().__init__(name="admin_import", title="Importar / Exportar Datos")
self._selected = 0
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self, context, db, renderer):
renderer.draw_header(
f" {APP_NAME} v{VERSION}",
" IMPORTAR / EXPORTAR DATOS ",
)
menu_items = [
("1", "Importar CSV"),
("2", "Exportar datos a JSON"),
("3", "Volver"),
]
renderer.draw_menu(
menu_items,
selected_index=self._selected,
title="IMPORTAR / EXPORTAR DATOS",
)
renderer.draw_footer(_FOOTER_MENU)
# ------------------------------------------------------------------
# Key handling
# ------------------------------------------------------------------
def on_key(self, key, context, db, renderer, nav):
# ESC or '3': go back
if key == Key.ESCAPE or key == ord('3'):
return "back"
# Arrow navigation
if key == Key.UP:
if self._selected > 0:
self._selected -= 1
return None
if key == Key.DOWN:
if self._selected < 2:
self._selected += 1
return None
# ENTER: activate selected
if key == Key.ENTER:
if self._selected == 0:
self._do_import(db, renderer)
elif self._selected == 1:
self._do_export(db, renderer)
else:
return "back"
return None
# Direct number keys
if key == ord('1'):
self._do_import(db, renderer)
return None
if key == ord('2'):
self._do_export(db, renderer)
return None
return None
# ------------------------------------------------------------------
# Import flow
# ------------------------------------------------------------------
def _do_import(self, db, renderer):
"""Run the CSV import flow using dialogs."""
# Ask for import type
type_prompt = (
"Tipo de datos:\n"
"1=Categorias 2=Grupos 3=Partes\n"
"4=Fabricantes 5=Aftermarket\n"
"6=CrossRef 7=Fitment"
)
renderer.show_message(type_prompt, "info")
type_choice = renderer.show_input("Tipo (1-7)", max_len=1)
if type_choice is None or type_choice not in _IMPORT_TYPES:
renderer.show_message("Tipo no valido o cancelado", "error")
return
type_label, type_key = _IMPORT_TYPES[type_choice]
# Ask for file path
file_path = renderer.show_input("Ruta del archivo CSV", max_len=60)
if file_path is None or not file_path.strip():
renderer.show_message("Importacion cancelada", "info")
return
file_path = file_path.strip()
if not os.path.isfile(file_path):
renderer.show_message(f"Archivo no encontrado:\n{file_path}", "error")
return
# Confirm
confirmed = renderer.show_message(
f"Importar {type_label} desde:\n{file_path}",
"confirm",
)
if not confirmed:
return
# Process the CSV
try:
count = self._process_csv(db, type_key, file_path)
renderer.show_message(
f"Importacion completada\n{count} registros procesados",
"info",
)
except Exception as exc:
renderer.show_message(f"Error en importacion:\n{exc}", "error")
def _process_csv(self, db, type_key, file_path):
"""Read a CSV file and insert records into the database.
Returns the number of records processed.
"""
count = 0
with open(file_path, newline='', encoding='utf-8') as fh:
reader = csv.DictReader(fh)
for row in reader:
self._insert_row(db, type_key, row)
count += 1
return count
def _insert_row(self, db, type_key, row):
"""Insert a single CSV row into the appropriate table."""
if type_key == 'categories':
db._execute(
"INSERT INTO part_categories (name, name_es, slug, icon_name, display_order) "
"VALUES (?, ?, ?, ?, ?)",
(
row.get('name', ''),
row.get('name_es', ''),
row.get('slug', ''),
row.get('icon_name', ''),
int(row.get('display_order', 0) or 0),
),
)
elif type_key == 'groups':
db._execute(
"INSERT INTO part_groups (category_id, name, name_es, slug, display_order) "
"VALUES (?, ?, ?, ?, ?)",
(
int(row.get('category_id', 0) or 0),
row.get('name', ''),
row.get('name_es', ''),
row.get('slug', ''),
int(row.get('display_order', 0) or 0),
),
)
elif type_key == 'parts':
db.create_part({
'oem_part_number': row.get('oem_part_number', ''),
'name': row.get('name', ''),
'name_es': row.get('name_es', ''),
'group_id': int(row['group_id']) if row.get('group_id') else None,
'description': row.get('description', ''),
'description_es': row.get('description_es', ''),
'weight_kg': float(row['weight_kg']) if row.get('weight_kg') else None,
'material': row.get('material', ''),
})
elif type_key == 'manufacturers':
db.create_manufacturer({
'name': row.get('name', ''),
'type': row.get('type', ''),
'quality_tier': row.get('quality_tier', ''),
'country': row.get('country', ''),
'logo_url': row.get('logo_url', ''),
'website': row.get('website', ''),
})
elif type_key == 'aftermarket':
db._execute(
"INSERT INTO aftermarket_parts "
"(oem_part_id, manufacturer_id, part_number, name, name_es, "
" quality_tier, price_usd, warranty_months, in_stock) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
int(row.get('oem_part_id', 0) or 0),
int(row.get('manufacturer_id', 0) or 0),
row.get('part_number', ''),
row.get('name', ''),
row.get('name_es', ''),
row.get('quality_tier', ''),
float(row['price_usd']) if row.get('price_usd') else None,
int(row['warranty_months']) if row.get('warranty_months') else None,
int(row.get('in_stock', 1) or 1),
),
)
elif type_key == 'crossref':
db.create_crossref({
'part_id': int(row.get('part_id', 0) or 0),
'cross_reference_number': row.get('cross_reference_number', ''),
'reference_type': row.get('reference_type', ''),
'source': row.get('source', ''),
'notes': row.get('notes', ''),
})
elif type_key == 'fitment':
db._execute(
"INSERT INTO vehicle_parts "
"(part_id, model_year_engine_id, quantity_required, position, fitment_notes) "
"VALUES (?, ?, ?, ?, ?)",
(
int(row.get('part_id', 0) or 0),
int(row.get('model_year_engine_id', 0) or 0),
int(row.get('quantity_required', 1) or 1),
row.get('position', ''),
row.get('fitment_notes', ''),
),
)
# ------------------------------------------------------------------
# Export flow
# ------------------------------------------------------------------
def _do_export(self, db, renderer):
"""Run the JSON export flow using dialogs."""
# Ask for export type
type_prompt = (
"Tipo de datos:\n"
"1=Categorias 2=Grupos 3=Partes\n"
"4=Fabricantes 5=CrossRef"
)
renderer.show_message(type_prompt, "info")
type_choice = renderer.show_input("Tipo (1-5)", max_len=1)
if type_choice is None or type_choice not in _EXPORT_TYPES:
renderer.show_message("Tipo no valido o cancelado", "error")
return
type_label, type_key = _EXPORT_TYPES[type_choice]
# Ask for output path
default_name = f"{type_key}_export.json"
out_path = renderer.show_input(
f"Archivo de salida [{default_name}]", max_len=60
)
if out_path is None:
renderer.show_message("Exportacion cancelada", "info")
return
if not out_path.strip():
out_path = default_name
# Fetch data and write
try:
data = self._fetch_export_data(db, type_key)
with open(out_path.strip(), 'w', encoding='utf-8') as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
renderer.show_message(
f"Exportacion completada\n{len(data)} registros -> {out_path.strip()}",
"info",
)
except Exception as exc:
renderer.show_message(f"Error en exportacion:\n{exc}", "error")
def _fetch_export_data(self, db, type_key):
"""Fetch the data to export based on the type key."""
if type_key == 'categories':
return db.get_categories()
elif type_key == 'groups':
# Export all groups across all categories
categories = db.get_categories()
groups = []
for cat in categories:
groups.extend(db.get_groups(cat['id']))
return groups
elif type_key == 'parts':
return db.get_parts(page=1, per_page=100)
elif type_key == 'manufacturers':
return db.get_manufacturers()
elif type_key == 'crossref':
return db.get_crossrefs_paginated(page=1, per_page=100)
return []

View File

@@ -0,0 +1,321 @@
"""
Admin CRUD screen for Parts in the AUTOPARTES console application.
Provides a paginated list view with create (F3), edit (ENTER), and
delete (F8/Del) operations. Form editing is handled inline with
field-by-field navigation.
"""
from console.core.screens import Screen
from console.core.keybindings import Key
from console.config import APP_NAME, VERSION
from console.utils.formatting import truncate, pad_right
# Form field definitions for create/edit
_FIELDS = [
{'label': 'Numero OEM', 'key': 'oem_part_number', 'width': 20},
{'label': 'Nombre', 'key': 'name', 'width': 40},
{'label': 'Nombre (ES)', 'key': 'name_es', 'width': 40},
{'label': 'Grupo ID', 'key': 'group_id', 'width': 5, 'hint': 'F1=Lista'},
{'label': 'Descripcion', 'key': 'description', 'width': 50},
{'label': 'Descripcion ES', 'key': 'description_es', 'width': 50},
{'label': 'Material', 'key': 'material', 'width': 20},
{'label': 'Peso (kg)', 'key': 'weight_kg', 'width': 8},
{'label': 'Descontinuada', 'key': 'is_discontinued', 'width': 1, 'hint': 'S/N'},
]
# Footer labels per mode
_FOOTER_LIST = [
("F3", "Nuevo"),
("ENTER", "Editar"),
("F8", "Eliminar"),
("PgUp/Dn", "Paginar"),
("ESC", "Atras"),
]
_FOOTER_FORM = [
("TAB/Down", "Siguiente"),
("Up", "Anterior"),
("F9", "Guardar"),
("ESC", "Cancelar"),
]
class AdminPartesScreen(Screen):
"""Admin CRUD screen for the parts table."""
def __init__(self):
super().__init__(name="admin_partes", title="Administracion de Partes")
self._mode = 'list' # 'list' or 'form'
self._page = 1
self._per_page = 15
self._selected = 0
self._parts = []
self._editing_id = None # None = creating, int = editing
self._focused_field = 0
self._form_data = {}
self._dirty = False
# ------------------------------------------------------------------
# Data loading
# ------------------------------------------------------------------
def _load_parts(self, db):
"""Load the current page of parts."""
self._parts = db.get_parts(page=self._page, per_page=self._per_page)
def _init_form(self, part=None):
"""Initialise form_data from an existing part or blank."""
self._form_data = {}
if part:
for f in _FIELDS:
key = f['key']
val = part.get(key, '')
if key == 'is_discontinued':
val = 'S' if val else 'N'
self._form_data[key] = str(val) if val is not None else ''
else:
for f in _FIELDS:
self._form_data[f['key']] = ''
self._focused_field = 0
self._dirty = False
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self, context, db, renderer):
renderer.draw_header(
f" {APP_NAME} v{VERSION}",
" ADMINISTRACION DE PARTES ",
)
if self._mode == 'list':
self._render_list(db, renderer)
else:
self._render_form(renderer)
def _render_list(self, db, renderer):
"""Render the paginated parts list."""
self._load_parts(db)
headers = ["NUMERO OEM", "NOMBRE", "GRUPO", "MATERIAL", "DISCONT"]
widths = [18, 25, 15, 12, 7]
rows = []
for p in self._parts:
disc = "Si" if p.get("is_discontinued") else ""
rows.append((
truncate(p.get("oem_part_number", ""), 18),
truncate(p.get("name_es") or p.get("name", ""), 25),
truncate(p.get("group_name", ""), 15),
truncate(p.get("material", "") or "", 12),
disc,
))
renderer.draw_table(
headers,
rows,
widths,
page_info={
"page": self._page,
"total_pages": self._page,
"total_rows": len(rows),
},
selected_row=self._selected,
)
renderer.draw_footer(_FOOTER_LIST)
def _render_form(self, renderer):
"""Render the create/edit form."""
title = "EDITAR PARTE" if self._editing_id else "NUEVA PARTE"
fields = []
for f in _FIELDS:
fields.append({
'label': f['label'],
'value': self._form_data.get(f['key'], ''),
'width': f['width'],
'hint': f.get('hint', ''),
})
renderer.draw_form(fields, focused_index=self._focused_field, title=title)
renderer.draw_footer(_FOOTER_FORM)
# ------------------------------------------------------------------
# Key handling
# ------------------------------------------------------------------
def on_key(self, key, context, db, renderer, nav):
if self._mode == 'list':
return self._handle_list_key(key, context, db, renderer)
else:
return self._handle_form_key(key, db, renderer)
def _handle_list_key(self, key, context, db, renderer):
"""Handle keys in list mode."""
# ESC: go back
if key == Key.ESCAPE:
return "back"
# Arrow navigation
if key == Key.UP:
if self._selected > 0:
self._selected -= 1
return None
if key == Key.DOWN:
if self._parts and self._selected < len(self._parts) - 1:
self._selected += 1
return None
# PgDn: next page
if key == Key.PGDN:
if len(self._parts) == self._per_page:
self._page += 1
self._selected = 0
return None
# PgUp: previous page
if key == Key.PGUP:
if self._page > 1:
self._page -= 1
self._selected = 0
return None
# F3: create new part
if key == Key.F3:
self._mode = 'form'
self._editing_id = None
self._init_form()
return None
# ENTER: edit selected part
if key == Key.ENTER:
if self._parts and 0 <= self._selected < len(self._parts):
part_row = self._parts[self._selected]
part = db.get_part(part_row["id"])
if part:
self._editing_id = part["id"]
self._mode = 'form'
self._init_form(part)
return None
# Number keys 1-9: edit part at that row index
if 49 <= key <= 57:
idx = key - 49
if 0 <= idx < len(self._parts):
part_row = self._parts[idx]
part = db.get_part(part_row["id"])
if part:
self._editing_id = part["id"]
self._mode = 'form'
self._init_form(part)
return None
# F8 or DEL: delete selected part
if key in (Key.F8, 330): # 330 = KEY_DC (Delete)
if self._parts and 0 <= self._selected < len(self._parts):
part = self._parts[self._selected]
name = part.get("name_es") or part.get("name", "")
oem = part.get("oem_part_number", "")
confirmed = renderer.show_message(
f"Eliminar parte?\n{oem} - {name}",
"confirm",
)
if confirmed:
db.delete_part(part["id"])
# Adjust selection
if self._selected >= len(self._parts) - 1:
self._selected = max(0, self._selected - 1)
return None
return None
def _handle_form_key(self, key, db, renderer):
"""Handle keys in form mode."""
# ESC: cancel form (with dirty check)
if key == Key.ESCAPE:
if self._dirty:
confirmed = renderer.show_message(
"Descartar cambios?", "confirm"
)
if not confirmed:
return None
self._mode = 'list'
return None
# TAB / Down: next field
if key in (Key.TAB, Key.DOWN):
if self._focused_field < len(_FIELDS) - 1:
self._focused_field += 1
return None
# Up: previous field
if key == Key.UP:
if self._focused_field > 0:
self._focused_field -= 1
return None
# F9: save
if key == Key.F9:
return self._save(db, renderer)
# Backspace: delete last char from current field value
if key in (Key.BACKSPACE, 8):
field_key = _FIELDS[self._focused_field]['key']
val = self._form_data.get(field_key, '')
if val:
self._form_data[field_key] = val[:-1]
self._dirty = True
return None
# Printable characters: append to current field
if 32 <= key <= 126:
field_def = _FIELDS[self._focused_field]
field_key = field_def['key']
val = self._form_data.get(field_key, '')
if len(val) < field_def['width']:
self._form_data[field_key] = val + chr(key)
self._dirty = True
return None
return None
def _save(self, db, renderer):
"""Validate and save the form data."""
data = dict(self._form_data)
# Validate required fields
if not data.get('oem_part_number', '').strip():
renderer.show_message("Numero OEM es requerido", "error")
return None
if not data.get('name', '').strip():
renderer.show_message("Nombre es requerido", "error")
return None
# Convert types
gid = data.get('group_id', '').strip()
data['group_id'] = int(gid) if gid.isdigit() else None
wkg = data.get('weight_kg', '').strip()
try:
data['weight_kg'] = float(wkg) if wkg else None
except ValueError:
data['weight_kg'] = None
disc = data.get('is_discontinued', '').strip().upper()
data['is_discontinued'] = 1 if disc == 'S' else 0
try:
if self._editing_id:
db.update_part(self._editing_id, data)
renderer.show_message("Parte actualizada correctamente", "info")
else:
db.create_part(data)
renderer.show_message("Parte creada correctamente", "info")
except Exception as exc:
renderer.show_message(f"Error al guardar:\n{exc}", "error")
return None
self._mode = 'list'
self._dirty = False
return None

View File

@@ -0,0 +1,153 @@
"""
Part number search screen for the AUTOPARTES console application.
Prompts the user for a part number (OEM, aftermarket, or cross-reference)
and displays matching results in a table. Selecting a result navigates
to the part detail screen.
"""
from console.core.screens import Screen
from console.core.keybindings import Key
from console.config import APP_NAME, VERSION
from console.utils.formatting import truncate
# Match type labels in Spanish
_TYPE_LABELS = {
"oem": "OEM",
"aftermarket": "Aftermarket",
"cross_reference": "X-Ref",
}
# Footer labels
_FOOTER_INPUT = [
("ENTER", "Buscar"),
("ESC", "Atras"),
]
_FOOTER_RESULTS = [
("1-9", "Ver parte"),
("ENTER", "Ver parte"),
("F3", "Nueva busqueda"),
("ESC", "Atras"),
]
class BuscarParteScreen(Screen):
"""Search by part number (OEM, aftermarket, cross-reference)."""
def __init__(self):
super().__init__(name="buscar_parte", title="Buscar por Numero de Parte")
self._results = None
self._search_term = None
self._selected = 0
self._needs_input = True
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self, context, db, renderer):
# Header
renderer.draw_header(
f" {APP_NAME} v{VERSION}",
" BUSCAR POR NUMERO DE PARTE ",
)
if self._needs_input:
# Show the input dialog (handled in on_key via on_enter-like flow)
# Just draw footer; the input dialog will overlay
renderer.draw_footer(_FOOTER_INPUT)
return
if self._results is None:
renderer.draw_text(5, 4, "Presione F3 para buscar", "info")
renderer.draw_footer(_FOOTER_RESULTS)
return
if not self._results:
renderer.draw_text(
5, 4,
f'No se encontraron resultados para "{self._search_term}"',
"info",
)
renderer.draw_footer(_FOOTER_RESULTS)
return
# Display results table
headers = ["TIPO", "NUMERO", "DESCRIPCION", "FUENTE"]
widths = [12, 20, 30, 20]
rows = []
for r in self._results:
rows.append((
_TYPE_LABELS.get(r.get("match_type", ""), r.get("match_type", "")),
truncate(r.get("matched_number", ""), 20),
truncate(r.get("name_es") or r.get("name", ""), 30),
truncate(r.get("oem_part_number", ""), 20),
))
renderer.draw_table(
headers,
rows,
widths,
selected_row=self._selected,
)
renderer.draw_footer(_FOOTER_RESULTS)
# ------------------------------------------------------------------
# Key handling
# ------------------------------------------------------------------
def on_key(self, key, context, db, renderer, nav):
# If we need input, show the input dialog
if self._needs_input:
self._needs_input = False
value = renderer.show_input("Numero de parte", max_len=30)
if value is None:
# User pressed ESC in input dialog
if self._results is not None:
# Go back to results view
return None
return "back"
if value.strip():
self._search_term = value.strip()
self._results = db.search_part_number(self._search_term)
self._selected = 0
return None
# ESC: go back
if key == Key.ESCAPE:
return "back"
# F3: new search
if key == Key.F3:
self._needs_input = True
return None
# Arrow navigation
if key == Key.UP:
if self._selected > 0:
self._selected -= 1
return None
if key == Key.DOWN:
if self._results and self._selected < len(self._results) - 1:
self._selected += 1
return None
# ENTER: view selected part
if key == Key.ENTER:
if self._results and 0 <= self._selected < len(self._results):
part = self._results[self._selected]
return ("parte_detalle", {"part_id": part["id"]}, "Parte")
return None
# Number keys: direct selection (1-9)
if 49 <= key <= 57: # '1'..'9'
idx = key - 49 # 0-based
if self._results and 0 <= idx < len(self._results):
part = self._results[idx]
return ("parte_detalle", {"part_id": part["id"]}, "Parte")
return None
return None

View File

@@ -0,0 +1,174 @@
"""
Full-text search screen for the AUTOPARTES console application.
Prompts the user for a search query and displays matching parts using
the FTS5 full-text search engine (with LIKE fallback). Results are
paginated and selecting a row navigates to the part detail screen.
"""
from console.core.screens import Screen
from console.core.keybindings import Key
from console.config import APP_NAME, VERSION
from console.utils.formatting import truncate
# Footer labels
_FOOTER_INPUT = [
("ENTER", "Buscar"),
("ESC", "Atras"),
]
_FOOTER_RESULTS = [
("1-9", "Ver parte"),
("ENTER", "Ver parte"),
("PgUp/Dn", "Paginar"),
("F3", "Nueva busqueda"),
("ESC", "Atras"),
]
class BuscarTextoScreen(Screen):
"""Full-text search by description / name."""
def __init__(self):
super().__init__(name="buscar_texto", title="Buscar por Descripcion")
self._results = None
self._search_term = None
self._selected = 0
self._page = 1
self._per_page = 15
self._needs_input = True
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self, context, db, renderer):
# Header
renderer.draw_header(
f" {APP_NAME} v{VERSION}",
" BUSCAR POR DESCRIPCION ",
)
if self._needs_input:
renderer.draw_footer(_FOOTER_INPUT)
return
if self._results is None:
renderer.draw_text(5, 4, "Presione F3 para buscar", "info")
renderer.draw_footer(_FOOTER_RESULTS)
return
if not self._results:
renderer.draw_text(
5, 4,
f'No se encontraron resultados para "{self._search_term}"',
"info",
)
renderer.draw_footer(_FOOTER_RESULTS)
return
# Display results table
headers = ["NUMERO OEM", "NOMBRE", "CATEGORIA", "GRUPO"]
widths = [18, 28, 18, 18]
rows = []
for r in self._results:
rows.append((
truncate(r.get("oem_part_number", ""), 18),
truncate(r.get("name_es") or r.get("name", ""), 28),
truncate(r.get("category_name", ""), 18),
truncate(r.get("group_name", ""), 18),
))
renderer.draw_table(
headers,
rows,
widths,
page_info={
"page": self._page,
"total_pages": self._page,
"total_rows": len(rows),
},
selected_row=self._selected,
)
renderer.draw_footer(_FOOTER_RESULTS)
# ------------------------------------------------------------------
# Key handling
# ------------------------------------------------------------------
def _do_search(self, db):
"""Execute the full-text search with current parameters."""
self._results = db.search_parts(
self._search_term,
page=self._page,
per_page=self._per_page,
)
self._selected = 0
def on_key(self, key, context, db, renderer, nav):
# If we need input, show the input dialog
if self._needs_input:
self._needs_input = False
value = renderer.show_input("Buscar", max_len=40)
if value is None:
# User pressed ESC in input dialog
if self._results is not None:
return None
return "back"
if value.strip():
self._search_term = value.strip()
self._page = 1
self._do_search(db)
return None
# ESC: go back
if key == Key.ESCAPE:
return "back"
# F3: new search
if key == Key.F3:
self._needs_input = True
return None
# Arrow navigation
if key == Key.UP:
if self._selected > 0:
self._selected -= 1
return None
if key == Key.DOWN:
if self._results and self._selected < len(self._results) - 1:
self._selected += 1
return None
# ENTER: view selected part
if key == Key.ENTER:
if self._results and 0 <= self._selected < len(self._results):
part = self._results[self._selected]
return ("parte_detalle", {"part_id": part["id"]}, "Parte")
return None
# Number keys: direct selection (1-9)
if 49 <= key <= 57: # '1'..'9'
idx = key - 49 # 0-based
if self._results and 0 <= idx < len(self._results):
part = self._results[idx]
return ("parte_detalle", {"part_id": part["id"]}, "Parte")
return None
# PgDn: next page
if key == Key.PGDN:
if self._results and len(self._results) >= self._per_page:
self._page += 1
self._do_search(db)
return None
# PgUp: previous page
if key == Key.PGUP:
if self._page > 1:
self._page -= 1
self._do_search(db)
return None
return None

354
console/screens/catalogo.py Normal file
View File

@@ -0,0 +1,354 @@
"""
Catalog navigation screen for the AUTOPARTES console application.
Provides a three-level drill-down through the parts hierarchy:
Categories -> Groups -> Parts. An optional vehicle filter (mye_id)
restricts the parts list to those that fit a specific vehicle
configuration.
"""
from console.core.screens import Screen
from console.core.keybindings import Key
from console.config import APP_NAME, VERSION
from console.utils.formatting import truncate
# Footer labels for each navigation level
_FOOTER_CATEGORIES = [
("1-9", "Seleccionar"),
("Filtro", "Teclear"),
("F10", "Menu"),
("ESC", "Atras"),
]
_FOOTER_GROUPS = [
("1-9", "Seleccionar"),
("Filtro", "Teclear"),
("F10", "Menu"),
("ESC", "Atras"),
]
_FOOTER_PARTS = [
("1-9", "Ver parte"),
("ENTER", "Ver parte"),
("PgUp/Dn", "Paginar"),
("ESC", "Atras"),
]
class CatalogoScreen(Screen):
"""Hierarchical catalog browser: Categories -> Groups -> Parts."""
def __init__(self):
super().__init__(name="catalogo", title="Catalogo")
self._filter_text = ""
self._selected = 0
self._items = []
self._parts_data = []
self._selected_part = 0
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _ensure_defaults(self, context):
"""Set default context values if missing."""
context.setdefault("level", "categories")
context.setdefault("mye_id", None)
context.setdefault("brand", "")
context.setdefault("model", "")
context.setdefault("year", "")
context.setdefault("engine", "")
context.setdefault("category_id", None)
context.setdefault("category_name", "")
context.setdefault("group_id", None)
context.setdefault("group_name", "")
context.setdefault("page", 1)
context.setdefault("per_page", 15)
def _build_header_title(self, context):
"""Build the header title based on context."""
level = context["level"]
parts = []
# Vehicle info if available
if context.get("brand"):
vehicle = " ".join(
filter(None, [
context["brand"],
context["model"],
str(context["year"]) if context["year"] else "",
])
)
parts.append(vehicle)
if level == "categories":
parts.append("Categorias")
elif level == "groups":
parts.append(context.get("category_name", "Grupos"))
elif level == "parts":
cat = context.get("category_name", "")
grp = context.get("group_name", "")
if cat and grp:
parts.append(f"{cat} > {grp}")
elif grp:
parts.append(grp)
return "".join(parts) if parts else "CATALOGO DE CATEGORIAS"
def _load_categories(self, db):
"""Load and filter categories."""
categories = db.get_categories()
if self._filter_text:
ft = self._filter_text.upper()
categories = [
c for c in categories
if ft in (c.get("name_es") or c.get("name") or "").upper()
or ft in (c.get("name") or "").upper()
]
self._items = [
(str(i + 1), c.get("name_es") or c.get("name", ""), c["id"])
for i, c in enumerate(categories)
]
def _load_groups(self, db, category_id):
"""Load and filter groups for a category."""
groups = db.get_groups(category_id)
if self._filter_text:
ft = self._filter_text.upper()
groups = [
g for g in groups
if ft in (g.get("name_es") or g.get("name") or "").upper()
or ft in (g.get("name") or "").upper()
]
self._items = [
(str(i + 1), g.get("name_es") or g.get("name", ""), g["id"])
for i, g in enumerate(groups)
]
def _load_parts(self, db, context):
"""Load parts for the current group/vehicle with pagination."""
self._parts_data = db.get_parts(
group_id=context.get("group_id"),
mye_id=context.get("mye_id"),
page=context.get("page", 1),
per_page=context.get("per_page", 15),
)
def _reset_filter(self):
"""Reset filter text and selection."""
self._filter_text = ""
self._selected = 0
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self, context, db, renderer):
self._ensure_defaults(context)
level = context["level"]
# Header
header_title = self._build_header_title(context)
renderer.draw_header(
f" {APP_NAME} v{VERSION}",
f" {header_title} ",
)
if level == "categories":
self._load_categories(db)
display_items = [(num, label) for num, label, _id in self._items]
renderer.draw_filter_list(
display_items,
self._filter_text,
self._selected,
title="CATALOGO DE CATEGORIAS",
)
renderer.draw_footer(_FOOTER_CATEGORIES)
elif level == "groups":
self._load_groups(db, context["category_id"])
display_items = [(num, label) for num, label, _id in self._items]
renderer.draw_filter_list(
display_items,
self._filter_text,
self._selected,
title=context.get("category_name", "GRUPOS"),
)
renderer.draw_footer(_FOOTER_GROUPS)
elif level == "parts":
self._load_parts(db, context)
headers = ["NUMERO OEM", "DESCRIPCION", "GRUPO", "ALT"]
widths = [18, 30, 18, 5]
rows = []
for p in self._parts_data:
alts = len(db.get_alternatives(p["id"]))
rows.append((
truncate(p.get("oem_part_number", ""), 18),
truncate(
p.get("name_es") or p.get("name", ""), 30
),
truncate(p.get("group_name", ""), 18),
str(alts) if alts > 0 else "",
))
page = context.get("page", 1)
renderer.draw_table(
headers,
rows,
widths,
page_info={"page": page, "total_pages": page, "total_rows": len(rows)},
selected_row=self._selected_part,
)
renderer.draw_footer(_FOOTER_PARTS)
# ------------------------------------------------------------------
# Key handling
# ------------------------------------------------------------------
def on_key(self, key, context, db, renderer, nav):
self._ensure_defaults(context)
level = context["level"]
if level in ("categories", "groups"):
return self._handle_filter_level(key, context)
elif level == "parts":
return self._handle_parts_level(key, context)
return None
def _handle_filter_level(self, key, context):
"""Handle keys for categories and groups levels (filter list)."""
level = context["level"]
# ESC: go back
if key == Key.ESCAPE:
if level == "groups":
context["level"] = "categories"
context["category_id"] = None
context["category_name"] = ""
self._reset_filter()
return None
return "back"
# Arrow navigation
if key == Key.UP:
if self._selected > 0:
self._selected -= 1
return None
if key == Key.DOWN:
if self._items and self._selected < len(self._items) - 1:
self._selected += 1
return None
# ENTER: select current item
if key == Key.ENTER:
if self._items and 0 <= self._selected < len(self._items):
return self._select_item(context, self._selected)
return None
# Number keys: direct selection (1-9)
if 49 <= key <= 57: # '1'..'9'
idx = key - 49 # 0-based
if 0 <= idx < len(self._items):
return self._select_item(context, idx)
return None
# Backspace: remove last filter character
if key in (Key.BACKSPACE, 8):
if self._filter_text:
self._filter_text = self._filter_text[:-1]
self._selected = 0
elif context["level"] == "groups":
context["level"] = "categories"
context["category_id"] = None
context["category_name"] = ""
self._reset_filter()
else:
return "back"
return None
# Printable characters: add to filter
if 32 <= key <= 126:
self._filter_text += chr(key)
self._selected = 0
return None
return None
def _select_item(self, context, idx):
"""Handle selection of an item at the given index."""
_num, label, item_id = self._items[idx]
level = context["level"]
if level == "categories":
context["level"] = "groups"
context["category_id"] = item_id
context["category_name"] = label
self._reset_filter()
return None
elif level == "groups":
context["level"] = "parts"
context["group_id"] = item_id
context["group_name"] = label
context["page"] = 1
self._selected_part = 0
self._reset_filter()
return None
return None
def _handle_parts_level(self, key, context):
"""Handle keys for the parts table level."""
# ESC: go back to groups
if key == Key.ESCAPE:
context["level"] = "groups"
context["group_id"] = None
context["group_name"] = ""
self._selected_part = 0
self._reset_filter()
return None
# Arrow navigation
if key == Key.UP:
if self._selected_part > 0:
self._selected_part -= 1
return None
if key == Key.DOWN:
if self._parts_data and self._selected_part < len(self._parts_data) - 1:
self._selected_part += 1
return None
# ENTER: view selected part detail
if key == Key.ENTER:
if self._parts_data and 0 <= self._selected_part < len(self._parts_data):
part = self._parts_data[self._selected_part]
return ("parte_detalle", {"part_id": part["id"]}, "Parte")
return None
# Number keys: direct selection (1-9)
if 49 <= key <= 57: # '1'..'9'
idx = key - 49 # 0-based
if 0 <= idx < len(self._parts_data):
part = self._parts_data[idx]
return ("parte_detalle", {"part_id": part["id"]}, "Parte")
return None
# PgDn: next page
if key == Key.PGDN:
context["page"] = context.get("page", 1) + 1
self._selected_part = 0
return None
# PgUp: previous page
if key == Key.PGUP:
if context.get("page", 1) > 1:
context["page"] = context["page"] - 1
self._selected_part = 0
return None
return None

View File

@@ -0,0 +1,282 @@
"""
Part comparator screen for the AUTOPARTES console application.
Displays a side-by-side comparison of an OEM part against its aftermarket
alternatives. The first column is always the OEM part; subsequent columns
are aftermarket options. Below the comparison table, cross-reference
numbers are shown grouped by type.
"""
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_currency, quality_bar
# Footer labels
_FOOTER = [
("\u2190\u2192", "Scroll"),
("#", "Ver detalle"),
("F3", "Otra parte"),
("ESC", "Atras"),
]
class ComparadorScreen(Screen):
"""Side-by-side OEM vs aftermarket comparison."""
def __init__(self):
super().__init__(name="comparador", title="Comparador")
self._part = None
self._alternatives = []
self._cross_refs = []
self._manufacturers = {} # id -> dict
self._col_offset = 0 # horizontal scroll offset
self._selected_alt = 0 # currently highlighted alternative
# ------------------------------------------------------------------
# Data loading
# ------------------------------------------------------------------
def _load(self, context, db):
"""Load OEM part, alternatives, cross-refs, and manufacturer info."""
part_id = context.get("part_id")
if part_id is None:
self._part = None
self._alternatives = []
self._cross_refs = []
return
self._part = db.get_part(part_id)
self._alternatives = db.get_alternatives(part_id) if self._part else []
self._cross_refs = db.get_cross_references(part_id) if self._part else []
# Build manufacturer lookup for country info
try:
mfrs = db.get_manufacturers()
self._manufacturers = {m["id"]: m for m in mfrs}
except Exception:
self._manufacturers = {}
# Set initial column offset to show the selected alternative
selected = context.get("selected_alt_index", 0)
if 0 <= selected < len(self._alternatives):
self._selected_alt = selected
else:
self._selected_alt = 0
self._col_offset = 0
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _format_warranty(months):
"""Format warranty months as 'X meses' or '──' if missing."""
if months is None:
return "\u2500\u2500"
return f"{months} meses"
@staticmethod
def _calc_savings(oem_price, alt_price):
"""Calculate percentage savings of alt vs OEM.
Returns a formatted string like '-28%' or '──' when prices are
unavailable.
"""
if oem_price is None or alt_price is None or oem_price == 0:
return "\u2500\u2500"
pct = ((oem_price - alt_price) / oem_price) * 100
if pct > 0:
return f"-{pct:.0f}%"
elif pct < 0:
return f"+{abs(pct):.0f}%"
return "0%"
@staticmethod
def _format_stock(in_stock):
"""Format boolean in_stock as Si/No."""
if in_stock is None:
return "\u2500\u2500"
return "Si" if in_stock else "No"
def _build_columns(self):
"""Build the column list for draw_comparison.
First column is the OEM part, followed by each aftermarket
alternative. Returns a list of dicts with 'header' and 'rows'.
"""
if self._part is None:
return []
p = self._part
oem_price = None # OEM parts don't have price_usd in our schema
# ── OEM column ──
oem_col = {
"header": "OEM",
"rows": [
("Numero", p.get("oem_part_number", "")),
("Calidad", quality_bar("oem")),
("Tier", "OEM"),
("Precio USD", "\u2500\u2500"),
("Ahorro", "\u2500\u2500"),
("Garantia", "\u2500\u2500"),
("En stock", "\u2500\u2500"),
("Fabricante", p.get("category_name", "")),
],
}
columns = [oem_col]
# ── Aftermarket columns ──
for alt in self._alternatives:
tier = alt.get("quality_tier", "") or ""
price = alt.get("price_usd")
mfr_id = alt.get("manufacturer_id")
mfr_name = alt.get("manufacturer_name", "")
mfr_country = ""
if mfr_id and mfr_id in self._manufacturers:
mfr_country = self._manufacturers[mfr_id].get("country", "") or ""
alt_col = {
"header": mfr_name,
"rows": [
("Numero", alt.get("part_number", "")),
("Calidad", quality_bar(tier.lower()) if tier else "\u2500\u2500"),
("Tier", tier.capitalize()),
("Precio USD", format_currency(price)),
("Ahorro", self._calc_savings(oem_price, price)),
("Garantia", self._format_warranty(alt.get("warranty_months"))),
("En stock", self._format_stock(alt.get("in_stock"))),
("Fabricante", mfr_country if mfr_country else mfr_name),
],
}
columns.append(alt_col)
return columns
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self, context, db, renderer):
self._load(context, db)
# Header
renderer.draw_header(
f" {APP_NAME} v{VERSION}",
" COMPARADOR OEM vs AFTERMARKET ",
)
if self._part is None:
renderer.draw_text(5, 4, "Parte no encontrada", "error")
renderer.draw_footer([("ESC", "Atras")])
return
# Build comparison columns
all_columns = self._build_columns()
# Apply horizontal scroll: always show OEM (col 0) + offset slice
if len(all_columns) <= 1:
visible_columns = all_columns
else:
# Determine how many alt columns we can show.
# The renderer will auto-size, but let's allow scrolling
# through alternatives.
h, w = renderer.get_size()
# Rough estimate: label_w ~12, each col ~15-20 chars
# We keep it simple: show OEM + up to 3 alternatives at a time
max_visible_alts = max((w - 20) // 18, 1)
alt_cols = all_columns[1:] # all aftermarket columns
end = min(self._col_offset + max_visible_alts, len(alt_cols))
visible_columns = [all_columns[0]] + alt_cols[self._col_offset:end]
part_name = self._part.get("name_es") or self._part.get("name", "")
oem_number = self._part.get("oem_part_number", "")
title = f"COMPARACION: {oem_number} - {part_name}"
renderer.draw_comparison(visible_columns, title=title)
# ── Cross-references below the comparison ──
h, w = renderer.get_size()
# Estimate row where comparison ends:
# title(3) + header(1) + sep(1) + 8 data rows + 1 gap = 14
xref_row = 3 + 3 + 1 + 8 + 2
if self._cross_refs and xref_row < h - 4:
section_title = (
"\u2500\u2500 CROSS-REFERENCES "
+ "\u2500" * max(w - 24, 4)
)
renderer.draw_text(xref_row, 2, section_title, "title")
xref_row += 1
# Group by reference type
by_type = {}
for xr in self._cross_refs:
rtype = xr.get("reference_type", "other") or "other"
by_type.setdefault(rtype, []).append(
xr.get("cross_reference_number", "")
)
for rtype, numbers in by_type.items():
if xref_row >= h - 3:
break
line = f"{rtype.capitalize()}: {', '.join(numbers)}"
renderer.draw_text(xref_row, 4, line, "normal")
xref_row += 1
# Scroll indicator
if len(all_columns) > 1:
alt_count = len(all_columns) - 1
indicator = (
f" Mostrando alternativas "
f"{self._col_offset + 1}-"
f"{min(self._col_offset + len(visible_columns) - 1, alt_count)}"
f" de {alt_count}"
)
indicator_row = min(xref_row + 1, h - 4)
if indicator_row > 0:
renderer.draw_text(indicator_row, 2, indicator, "info")
renderer.draw_footer(_FOOTER)
# ------------------------------------------------------------------
# Key handling
# ------------------------------------------------------------------
def on_key(self, key, context, db, renderer, nav):
# ESC: back to part detail
if key == Key.ESCAPE:
return "back"
# Left arrow: scroll columns left
if key == Key.LEFT:
if self._col_offset > 0:
self._col_offset -= 1
return None
# Right arrow: scroll columns right
if key == Key.RIGHT:
max_offset = max(len(self._alternatives) - 1, 0)
if self._col_offset < max_offset:
self._col_offset += 1
return None
# Number keys (1-9): view alternative detail
if 49 <= key <= 57: # '1'..'9'
idx = key - 49 # 0-based
if self._alternatives and 0 <= idx < len(self._alternatives):
part_id = context.get("part_id")
return (
"comparador",
{"part_id": part_id, "selected_alt_index": idx},
"Comparador",
)
return None
# F3: search for another part (go to buscar_parte)
if key == Key.F3:
return ("buscar_parte", {}, "Buscar Parte")
return None

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

View File

@@ -0,0 +1,242 @@
"""
Part detail screen for the AUTOPARTES console application.
Shows full part information (OEM number, name, group, category, etc.)
with a table of aftermarket alternatives. Number keys navigate to
the comparator screen; F4 shows cross-references; F6 lists compatible
vehicles.
"""
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_currency, truncate, quality_bar
# Footer labels
_FOOTER = [
("#", "Comparar"),
("F4", "Cross-Ref"),
("F6", "Vehiculos"),
("ESC", "Atras"),
]
class ParteDetalleScreen(Screen):
"""Detail view for a single OEM part with aftermarket alternatives."""
def __init__(self):
super().__init__(name="parte_detalle", title="Detalle de Parte")
self._part = None
self._alternatives = []
self._selected_alt = 0
# ------------------------------------------------------------------
# Data loading
# ------------------------------------------------------------------
def _load(self, context, db):
"""Load part and alternatives from context['part_id']."""
part_id = context.get("part_id")
if part_id is None:
self._part = None
self._alternatives = []
return
self._part = db.get_part(part_id)
self._alternatives = db.get_alternatives(part_id) if self._part else []
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _format_warranty(months):
"""Format warranty months as 'X meses' or '──' if missing."""
if months is None:
return "──"
return f"{months} meses"
@staticmethod
def _format_weight(kg):
"""Format weight in kilograms or '──' if missing."""
if kg is None:
return "──"
return f"{kg} kg"
@staticmethod
def _format_discontinued(flag):
"""Format the is_discontinued flag as Si/No."""
if flag:
return "Si"
return "No"
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self, context, db, renderer):
self._load(context, db)
# Header
renderer.draw_header(
f" {APP_NAME} v{VERSION}",
" DETALLE DE PARTE ",
)
if self._part is None:
renderer.draw_text(5, 4, "Parte no encontrada", "error")
renderer.draw_footer([("ESC", "Atras")])
return
p = self._part
# ── Top section: part detail fields ──
fields = [
("Numero OEM", p.get("oem_part_number", "")),
("Nombre", p.get("name", "")),
("Nombre (ES)", p.get("name_es", "") or ""),
("Grupo", p.get("group_name_es") or p.get("group_name", "")),
("Categoria", p.get("category_name_es") or p.get("category_name", "")),
("Descripcion", p.get("description_es") or p.get("description", "") or ""),
("Material", p.get("material", "") or "──"),
("Peso", self._format_weight(p.get("weight_kg"))),
("Descontinuada", self._format_discontinued(p.get("is_discontinued"))),
]
renderer.draw_detail(fields, title="INFORMACION DE LA PARTE")
# ── Bottom section: alternatives table ──
h, w = renderer.get_size()
# Calculate where the detail section ends (title=3 rows + fields + 1 gap)
table_start_row = 3 + 3 + len(fields) + 1
if self._alternatives:
# Draw section title
section_title = "\u2500\u2500 ALTERNATIVAS AFTERMARKET " + "\u2500" * max(w - 32, 4)
renderer.draw_text(table_start_row, 2, section_title, "title")
table_start_row += 1
headers = ["FABRICANTE", "NUMERO", "CALIDAD", "PRECIO", "GARANTIA"]
widths = [14, 16, 10, 10, 10]
rows = []
for alt in self._alternatives:
rows.append((
truncate(alt.get("manufacturer_name", ""), 14),
truncate(alt.get("part_number", ""), 16),
(alt.get("quality_tier", "") or "").capitalize(),
format_currency(alt.get("price_usd")),
self._format_warranty(alt.get("warranty_months")),
))
renderer.draw_table(
headers,
rows,
widths,
selected_row=self._selected_alt,
)
else:
renderer.draw_text(
table_start_row, 4,
"No hay alternativas aftermarket registradas",
"info",
)
renderer.draw_footer(_FOOTER)
# ------------------------------------------------------------------
# Key handling
# ------------------------------------------------------------------
def on_key(self, key, context, db, renderer, nav):
# ESC: go back
if key == Key.ESCAPE:
return "back"
# Arrow navigation for alternatives
if key == Key.UP:
if self._selected_alt > 0:
self._selected_alt -= 1
return None
if key == Key.DOWN:
if self._alternatives and self._selected_alt < len(self._alternatives) - 1:
self._selected_alt += 1
return None
# Number keys (1-9): navigate to comparador for the selected alternative
if 49 <= key <= 57: # '1'..'9'
idx = key - 49 # 0-based
if self._alternatives and 0 <= idx < len(self._alternatives):
part_id = context.get("part_id")
return (
"comparador",
{"part_id": part_id, "selected_alt_index": idx},
"Comparador",
)
return None
# ENTER: navigate to comparador for the currently highlighted alternative
if key == Key.ENTER:
if self._alternatives and 0 <= self._selected_alt < len(self._alternatives):
part_id = context.get("part_id")
return (
"comparador",
{"part_id": part_id, "selected_alt_index": self._selected_alt},
"Comparador",
)
return None
# F4: show cross-references
if key == Key.F4:
part_id = context.get("part_id")
if part_id is None:
return None
xrefs = db.get_cross_references(part_id)
if not xrefs:
renderer.show_message("No hay cross-references para esta parte", "info")
return None
# Build message text grouped by reference type
lines = []
by_type = {}
for xr in xrefs:
rtype = xr.get("reference_type", "other") or "other"
by_type.setdefault(rtype, []).append(
xr.get("cross_reference_number", "")
)
for rtype, numbers in by_type.items():
lines.append(f"{rtype.capitalize()}: {', '.join(numbers)}")
msg = "CROSS-REFERENCES\n" + "\n".join(lines)
renderer.show_message(msg, "info")
return None
# F6: show vehicles that use this part
if key == Key.F6:
part_id = context.get("part_id")
if part_id is None:
return None
vehicles = db.get_vehicles_for_part(part_id)
if not vehicles:
renderer.show_message(
"No hay vehiculos registrados para esta parte", "info"
)
return None
# Build message with vehicle list (limit to avoid overflow)
lines = []
for v in vehicles[:10]:
brand = v.get("brand", "")
model = v.get("model", "")
year = v.get("year", "")
engine = v.get("engine", "")
line = f"{brand} {model} {year}"
if engine:
line += f" ({engine})"
position = v.get("position", "")
if position:
line += f" - {position}"
lines.append(line)
if len(vehicles) > 10:
lines.append(f"... y {len(vehicles) - 10} mas")
msg = "VEHICULOS COMPATIBLES\n" + "\n".join(lines)
renderer.show_message(msg, "info")
return None
return None

View File

@@ -0,0 +1,239 @@
"""
Vehicle drill-down navigation screen for the AUTOPARTES console application.
Guides the user through a four-level hierarchy:
Brand -> Model -> Year -> Engine
Each level presents a filterable list. After engine selection the screen
navigates to the catalogue (``catalogo``) with the resolved
``model_year_engine`` id.
"""
from console.core.screens import Screen
from console.core.keybindings import Key
# Ordered sequence of drill-down levels.
_LEVELS = ("brand", "model", "year", "engine")
# Human-readable titles for each level (Spanish).
_LEVEL_TITLES = {
"brand": "Seleccione Marca",
"model": "Seleccione Modelo",
"year": "Seleccione Ano",
"engine": "Seleccione Motor",
}
class VehiculoNavScreen(Screen):
"""Four-level vehicle drill-down: Brand -> Model -> Year -> Engine."""
def __init__(self):
super().__init__("vehiculo_nav", "Consulta por Vehiculo")
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _get_title_for_level(self, context):
"""Return the title string for the current drill-down level."""
level = context.get("level", "brand")
return _LEVEL_TITLES.get(level, "Seleccione")
def _get_subtitle(self, context):
"""Build a breadcrumb subtitle from selections made so far.
Example: ``"TOYOTA > CAMRY > 2023 > Seleccione motor"``
"""
parts = []
if context.get("brand"):
parts.append(context["brand"])
if context.get("model"):
parts.append(context["model"])
if context.get("year") is not None:
parts.append(str(context["year"]))
level = context.get("level", "brand")
parts.append(_LEVEL_TITLES.get(level, ""))
return " > ".join(parts)
def _load_items(self, context, db):
"""Fetch the item list from the database for the current level."""
level = context.get("level", "brand")
if level == "brand":
context["all_items"] = db.get_brands()
elif level == "model":
context["all_items"] = db.get_models(brand=context.get("brand"))
elif level == "year":
context["all_items"] = db.get_years(
brand=context.get("brand"),
model=context.get("model"),
)
elif level == "engine":
context["all_items"] = db.get_engines(
brand=context.get("brand"),
model=context.get("model"),
year=context.get("year"),
)
else:
context["all_items"] = []
self._apply_filter(context)
def _apply_filter(self, context):
"""Reduce ``all_items`` to those matching ``filter_text``.
Matching is a case-insensitive substring test on the display name.
"""
level = context.get("level", "brand")
ft = context.get("filter_text", "").lower()
all_items = context.get("all_items", [])
if ft:
context["filtered_items"] = [
item for item in all_items
if ft in self._get_display_name(item, level).lower()
]
else:
context["filtered_items"] = list(all_items)
@staticmethod
def _get_display_name(item, level):
"""Extract the human-readable display string from an item dict."""
if level == "year":
return str(item.get("year", ""))
return item.get("name", "")
# ------------------------------------------------------------------
# Screen interface
# ------------------------------------------------------------------
def render(self, context, db, renderer):
# First-render initialisation
if "level" not in context:
context["level"] = "brand"
context["filter_text"] = ""
context["selected_index"] = 0
self._load_items(context, db)
level = context["level"]
title = self._get_title_for_level(context)
subtitle = self._get_subtitle(context)
renderer.draw_header(title, subtitle)
# Build the (number, label) tuples expected by draw_filter_list.
filtered = context.get("filtered_items", [])
display_items = [
(str(idx + 1), self._get_display_name(item, level))
for idx, item in enumerate(filtered)
]
renderer.draw_filter_list(
display_items,
context.get("filter_text", ""),
context.get("selected_index", 0),
title=f"SELECCIONAR {level.upper()}",
)
renderer.draw_footer([
("Escriba", "Filtrar"),
("ENTER", "Seleccionar"),
("\u2191\u2193", "Mover"),
("ESC", "Atras"),
])
def on_key(self, key, context, db, renderer, nav):
filtered = context.get("filtered_items", [])
level = context.get("level", "brand")
# -- ESC: go back one level, or return to menu ----------------
if key == Key.ESCAPE:
if level == "brand":
return "back"
prev = _LEVELS[_LEVELS.index(level) - 1]
context["level"] = prev
context["filter_text"] = ""
context["selected_index"] = 0
self._load_items(context, db)
return None
# -- ENTER: select item and advance ---------------------------
if key == Key.ENTER and filtered:
idx = context.get("selected_index", 0)
if idx >= len(filtered):
return None
selected = filtered[idx]
if level == "brand":
context["brand"] = selected["name"]
context["level"] = "model"
elif level == "model":
context["model"] = selected["name"]
context["level"] = "year"
elif level == "year":
context["year"] = selected["year"]
context["level"] = "engine"
elif level == "engine":
context["engine_id"] = selected["id"]
context["engine_name"] = selected["name"]
# Resolve the model_year_engine row
mye_list = db.get_model_year_engine(
context["brand"],
context["model"],
context["year"],
context["engine_id"],
)
if mye_list:
mye_id = mye_list[0]["id"]
return (
"catalogo",
{
"mye_id": mye_id,
"brand": context["brand"],
"model": context["model"],
"year": context["year"],
"engine": context["engine_name"],
},
f"{context['brand']} {context['model']} {context['year']}",
)
else:
renderer.show_message(
"No se encontro configuracion para este vehiculo",
"error",
)
return None
# Reset filter for the new level
context["filter_text"] = ""
context["selected_index"] = 0
self._load_items(context, db)
return None
# -- Arrow keys: move selection cursor ------------------------
if key == Key.UP:
if context.get("selected_index", 0) > 0:
context["selected_index"] -= 1
return None
if key == Key.DOWN:
if context.get("selected_index", 0) < len(filtered) - 1:
context["selected_index"] += 1
return None
# -- Backspace: trim filter text ------------------------------
if key in (Key.BACKSPACE, 8, 263):
if context.get("filter_text"):
context["filter_text"] = context["filter_text"][:-1]
self._apply_filter(context)
context["selected_index"] = 0
return None
# -- Printable characters: append to filter -------------------
if isinstance(key, int) and 32 <= key <= 126:
context["filter_text"] = context.get("filter_text", "") + chr(key)
self._apply_filter(context)
context["selected_index"] = 0
return None
return None

View File

@@ -0,0 +1,259 @@
"""
VIN decoder screen for the AUTOPARTES console application.
Prompts for a 17-character Vehicle Identification Number, decodes it
via the NHTSA vPIC API (with local caching), and displays the decoded
vehicle information. The user can then navigate to the parts catalog
filtered by the matched vehicle.
"""
import json
from console.core.screens import Screen
from console.core.keybindings import Key
from console.config import APP_NAME, VERSION
from console.utils.vin_api import decode_vin_nhtsa
# Footer labels
_FOOTER_INPUT = [
("ENTER", "Decodificar"),
("ESC", "Atras"),
]
_FOOTER_RESULT = [
("1", "Ver partes"),
("2/F3", "Nuevo VIN"),
("ESC", "Atras"),
]
_FOOTER_ERROR = [
("F3", "Nuevo VIN"),
("ESC", "Atras"),
]
class VinDecoderScreen(Screen):
"""VIN decoder with NHTSA API integration and local cache."""
def __init__(self):
super().__init__(name="vin_decoder", title="Decodificador VIN")
self._vin = None
self._decoded = None
self._error = None
self._needs_input = True
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _decode(self, vin, db):
"""Decode a VIN using cache first, then NHTSA API."""
self._vin = vin.upper().strip()
self._decoded = None
self._error = None
# Check cache
cached = db.get_vin_cache(self._vin)
if cached:
self._decoded = {
"make": cached.get("make", ""),
"model": cached.get("model", ""),
"year": cached.get("year", ""),
"engine_info": cached.get("engine_info", ""),
"body_class": cached.get("body_class", ""),
"drive_type": cached.get("drive_type", ""),
}
return
# Call NHTSA API
result = decode_vin_nhtsa(self._vin)
if "error" in result:
self._error = result["error"]
return
# Extract fields
make = result.get("make", "")
model = result.get("model", "")
year = result.get("year", "")
body_class = result.get("body_class", "")
drive_type = result.get("drive_type", "")
# Build engine info string
engine_info_dict = result.get("engine_info", {})
engine_parts = []
if engine_info_dict.get("displacement_l"):
engine_parts.append(f"{engine_info_dict['displacement_l']}L")
if engine_info_dict.get("cylinders"):
engine_parts.append(f"{engine_info_dict['cylinders']}cil")
if engine_info_dict.get("fuel_type"):
engine_parts.append(engine_info_dict["fuel_type"])
if engine_info_dict.get("power_hp"):
engine_parts.append(f"{engine_info_dict['power_hp']}hp")
engine_info = " ".join(engine_parts)
self._decoded = {
"make": make,
"model": model,
"year": year,
"engine_info": engine_info,
"body_class": body_class,
"drive_type": drive_type,
}
# Cache the result
try:
year_int = int(year) if year else 0
except (ValueError, TypeError):
year_int = 0
try:
db.save_vin_cache(
vin=self._vin,
data=json.dumps(result),
make=make,
model=model,
year=year_int,
engine_info=engine_info,
body_class=body_class,
drive_type=drive_type,
)
except Exception:
pass # Non-critical: caching failure should not break the flow
def _try_match_vehicle(self, db):
"""Try to match the decoded VIN to a vehicle in the database.
Returns a context dict for the catalogo screen if a match is
found, or None if no match exists.
"""
if not self._decoded:
return None
make = self._decoded.get("make", "")
model = self._decoded.get("model", "")
year = self._decoded.get("year", "")
if not make or not model:
return None
try:
year_int = int(year) if year else None
except (ValueError, TypeError):
year_int = None
# Try to find matching model_year_engine records
if year_int:
mye_records = db.get_model_year_engine(make, model, year_int)
else:
mye_records = []
ctx = {
"level": "categories",
"brand": make,
"model": model,
"year": year,
"engine": self._decoded.get("engine_info", ""),
}
if mye_records:
# Use the first match
ctx["mye_id"] = mye_records[0]["id"]
return ctx
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self, context, db, renderer):
# Header
renderer.draw_header(
f" {APP_NAME} v{VERSION}",
" DECODIFICADOR VIN ",
)
if self._needs_input:
renderer.draw_footer(_FOOTER_INPUT)
return
if self._error:
renderer.draw_text(5, 4, f"Error: {self._error}", "error")
renderer.draw_footer(_FOOTER_ERROR)
return
if self._decoded is None:
renderer.draw_text(5, 4, "Presione F3 para ingresar un VIN", "info")
renderer.draw_footer(_FOOTER_ERROR)
return
# Display decoded VIN info
fields = [
("VIN", self._vin or ""),
("Marca", self._decoded.get("make", "")),
("Modelo", self._decoded.get("model", "")),
("Ano", str(self._decoded.get("year", ""))),
("Motor", self._decoded.get("engine_info", "")),
("Carroceria", self._decoded.get("body_class", "")),
("Traccion", self._decoded.get("drive_type", "")),
]
renderer.draw_detail(fields, title="INFORMACION DEL VEHICULO")
# Action menu below detail
h, _w = renderer.get_size()
action_row = 5 + len(fields) + 3
if action_row < h - 4:
renderer.draw_text(action_row, 4, "1. Ver partes compatibles", "normal")
renderer.draw_text(action_row + 1, 4, "2. Nueva consulta VIN", "normal")
renderer.draw_footer(_FOOTER_RESULT)
# ------------------------------------------------------------------
# Key handling
# ------------------------------------------------------------------
def on_key(self, key, context, db, renderer, nav):
# If we need input, show the input dialog
if self._needs_input:
self._needs_input = False
value = renderer.show_input("VIN (17 caracteres)", max_len=17)
if value is None:
# User pressed ESC in input dialog
if self._decoded is not None:
return None
return "back"
value = value.strip()
if len(value) != 17:
self._error = "El VIN debe tener exactamente 17 caracteres"
self._decoded = None
return None
self._decode(value, db)
return None
# ESC: go back
if key == Key.ESCAPE:
return "back"
# F3 or '2': new VIN input
if key == Key.F3 or key == ord("2"):
self._needs_input = True
self._error = None
return None
# '1': view compatible parts
if key == ord("1"):
if self._decoded:
cat_context = self._try_match_vehicle(db)
if cat_context:
return ("catalogo", cat_context, "Catalogo")
else:
renderer.show_message(
"No se encontro el vehiculo en la base de datos.\n"
"Se mostrara el catalogo completo.",
"info",
)
return ("catalogo", {"level": "categories"}, "Catalogo")
return None
return None

View File

214
console/tests/test_core.py Normal file
View File

@@ -0,0 +1,214 @@
"""
Tests for the core framework: keybindings, navigation, and screen base class.
"""
import pytest
from console.core.keybindings import Key, KeyBindings
from console.core.navigation import Navigation
from console.core.screens import Screen
# =========================================================================
# Key constants
# =========================================================================
class TestKeyConstants:
def test_escape_is_27(self):
assert Key.ESCAPE == 27
def test_enter_is_10(self):
assert Key.ENTER == 10
def test_tab_is_9(self):
assert Key.TAB == 9
def test_backspace_is_127(self):
assert Key.BACKSPACE == 127
def test_arrow_keys_are_not_none(self):
assert Key.UP is not None
assert Key.DOWN is not None
assert Key.LEFT is not None
assert Key.RIGHT is not None
def test_page_keys_are_not_none(self):
assert Key.PGUP is not None
assert Key.PGDN is not None
def test_home_end_are_not_none(self):
assert Key.HOME is not None
assert Key.END is not None
def test_f1_is_not_none(self):
assert Key.F1 is not None
def test_f10_is_not_none(self):
assert Key.F10 is not None
def test_f_keys_are_sequential(self):
"""F1 through F10 should be sequential curses key codes."""
for i in range(1, 10):
f_current = getattr(Key, f"F{i}")
f_next = getattr(Key, f"F{i + 1}")
assert f_next == f_current + 1
# =========================================================================
# KeyBindings
# =========================================================================
class TestKeyBindings:
def test_bind_and_handle_calls_callback(self):
kb = KeyBindings()
called = []
kb.bind(Key.ENTER, lambda: called.append(True))
result = kb.handle(Key.ENTER)
assert result is True
assert len(called) == 1
def test_handle_returns_false_for_unbound_key(self):
kb = KeyBindings()
result = kb.handle(Key.ESCAPE)
assert result is False
def test_bind_overwrites_previous(self):
kb = KeyBindings()
called_a = []
called_b = []
kb.bind(Key.ENTER, lambda: called_a.append(True))
kb.bind(Key.ENTER, lambda: called_b.append(True))
kb.handle(Key.ENTER)
assert len(called_a) == 0
assert len(called_b) == 1
def test_multiple_bindings(self):
kb = KeyBindings()
results = {}
kb.bind(Key.ENTER, lambda: results.update(enter=True))
kb.bind(Key.ESCAPE, lambda: results.update(escape=True))
kb.handle(Key.ENTER)
kb.handle(Key.ESCAPE)
assert results == {"enter": True, "escape": True}
def test_set_footer_and_get_footer_labels(self):
kb = KeyBindings()
labels = [("F1", "Help"), ("F10", "Quit")]
kb.set_footer(labels)
assert kb.get_footer_labels() == labels
def test_get_footer_labels_empty_by_default(self):
kb = KeyBindings()
assert kb.get_footer_labels() == []
# =========================================================================
# Navigation
# =========================================================================
class TestNavigation:
def test_initial_state_is_empty(self):
nav = Navigation()
assert nav.current() is None
assert nav.depth() == 0
def test_push_and_current(self):
nav = Navigation()
nav.push("brands", context={"page": 1}, label="Brands")
result = nav.current()
assert result is not None
screen_name, context = result
assert screen_name == "brands"
assert context == {"page": 1}
def test_push_increases_depth(self):
nav = Navigation()
nav.push("brands", label="Brands")
assert nav.depth() == 1
nav.push("models", label="Models")
assert nav.depth() == 2
def test_pop_returns_previous_screen(self):
nav = Navigation()
nav.push("brands", context={"page": 1}, label="Brands")
nav.push("models", context={"brand": "TOYOTA"}, label="Models")
popped = nav.pop()
assert popped is not None
screen_name, context = popped
assert screen_name == "models"
assert context == {"brand": "TOYOTA"}
# Current should now be brands
current = nav.current()
assert current[0] == "brands"
def test_pop_on_empty_returns_none(self):
nav = Navigation()
result = nav.pop()
assert result is None
def test_pop_on_single_item_returns_it_and_empties(self):
nav = Navigation()
nav.push("home", label="Home")
popped = nav.pop()
assert popped is not None
assert popped[0] == "home"
assert nav.current() is None
assert nav.depth() == 0
def test_breadcrumb_returns_label_list(self):
nav = Navigation()
nav.push("brands", label="Brands")
nav.push("models", label="Toyota Models")
nav.push("years", label="2020")
assert nav.breadcrumb() == ["Brands", "Toyota Models", "2020"]
def test_breadcrumb_empty_when_no_items(self):
nav = Navigation()
assert nav.breadcrumb() == []
def test_breadcrumb_uses_screen_name_as_fallback(self):
nav = Navigation()
nav.push("brands")
assert nav.breadcrumb() == ["brands"]
def test_clear_empties_stack(self):
nav = Navigation()
nav.push("brands", label="Brands")
nav.push("models", label="Models")
nav.clear()
assert nav.depth() == 0
assert nav.current() is None
assert nav.breadcrumb() == []
def test_context_defaults_to_none(self):
nav = Navigation()
nav.push("home", label="Home")
screen_name, context = nav.current()
assert context is None
# =========================================================================
# Screen base class
# =========================================================================
class TestScreen:
def test_has_name_and_title(self):
screen = Screen("brands", "Select Brand")
assert screen.name == "brands"
assert screen.title == "Select Brand"
def test_on_enter_is_callable(self):
screen = Screen("test", "Test")
# Should not raise
screen.on_enter(context=None, db=None, renderer=None)
def test_on_key_is_callable(self):
screen = Screen("test", "Test")
# Should not raise, returns None by default
result = screen.on_key(key=10, context=None, db=None, renderer=None, nav=None)
assert result is None
def test_render_is_callable(self):
screen = Screen("test", "Test")
# Should not raise
screen.render(context=None, db=None, renderer=None)

273
console/tests/test_db.py Normal file
View File

@@ -0,0 +1,273 @@
"""
Tests for the Database abstraction layer.
All tests run against the real SQLite database at vehicle_database/vehicle_database.db.
"""
import pytest
from console.db import Database
@pytest.fixture(scope="module")
def db():
"""Provide a shared Database instance for all tests in this module."""
return Database()
# =========================================================================
# Vehicle navigation
# =========================================================================
class TestGetBrands:
def test_returns_nonempty_list(self, db):
brands = db.get_brands()
assert isinstance(brands, list)
assert len(brands) > 0
def test_each_brand_has_name_key(self, db):
brands = db.get_brands()
for b in brands:
assert "name" in b
def test_each_brand_has_id_and_country(self, db):
brands = db.get_brands()
for b in brands:
assert "id" in b
assert "country" in b
class TestGetModels:
def test_no_filter_returns_nonempty(self, db):
models = db.get_models()
assert isinstance(models, list)
assert len(models) > 0
def test_filter_by_uppercase_brand(self, db):
models = db.get_models(brand="TOYOTA")
assert isinstance(models, list)
assert len(models) > 0
def test_filter_by_lowercase_brand(self, db):
"""Brand filtering must be case-insensitive."""
models = db.get_models(brand="toyota")
assert isinstance(models, list)
assert len(models) > 0
def test_each_model_has_id_and_name(self, db):
models = db.get_models()
for m in models[:5]:
assert "id" in m
assert "name" in m
class TestGetYears:
def test_returns_list(self, db):
years = db.get_years()
assert isinstance(years, list)
assert len(years) > 0
def test_filter_by_brand(self, db):
years = db.get_years(brand="TOYOTA")
assert isinstance(years, list)
assert len(years) > 0
def test_each_year_has_id_and_year(self, db):
years = db.get_years()
for y in years[:5]:
assert "id" in y
assert "year" in y
class TestGetEngines:
def test_returns_list(self, db):
engines = db.get_engines()
assert isinstance(engines, list)
assert len(engines) > 0
def test_filter_by_brand(self, db):
engines = db.get_engines(brand="TOYOTA")
assert isinstance(engines, list)
assert len(engines) > 0
class TestGetModelYearEngine:
def test_returns_list(self, db):
result = db.get_model_year_engine(
brand="TOYOTA", model="Corolla", year=2020, engine_id=None
)
assert isinstance(result, list)
# =========================================================================
# Parts catalog
# =========================================================================
class TestGetCategories:
def test_returns_exactly_12(self, db):
categories = db.get_categories()
assert isinstance(categories, list)
assert len(categories) == 12
def test_each_has_expected_keys(self, db):
categories = db.get_categories()
for c in categories:
assert "id" in c
assert "name" in c
class TestGetGroups:
def test_returns_nonempty_for_known_category(self, db):
groups = db.get_groups(category_id=2)
assert isinstance(groups, list)
assert len(groups) > 0
def test_each_group_has_name(self, db):
groups = db.get_groups(category_id=2)
for g in groups:
assert "name" in g
class TestGetParts:
def test_returns_list(self, db):
parts = db.get_parts()
assert isinstance(parts, list)
assert len(parts) > 0
def test_pagination(self, db):
page1 = db.get_parts(page=1, per_page=5)
page2 = db.get_parts(page=2, per_page=5)
assert len(page1) <= 5
assert len(page2) <= 5
# Pages should contain different items (if enough data)
if page1 and page2:
ids1 = {p["id"] for p in page1}
ids2 = {p["id"] for p in page2}
assert ids1.isdisjoint(ids2)
class TestGetPart:
def test_returns_dict_with_oem_part_number(self, db):
part = db.get_part(1)
assert isinstance(part, dict)
assert "oem_part_number" in part
def test_includes_group_and_category_info(self, db):
part = db.get_part(1)
assert "group_name" in part
assert "category_name" in part
def test_nonexistent_returns_none(self, db):
part = db.get_part(999999)
assert part is None
class TestGetAlternatives:
def test_returns_list(self, db):
alts = db.get_alternatives(1)
assert isinstance(alts, list)
class TestGetCrossReferences:
def test_returns_list(self, db):
refs = db.get_cross_references(1)
assert isinstance(refs, list)
class TestGetVehiclesForPart:
def test_returns_list(self, db):
vehicles = db.get_vehicles_for_part(1)
assert isinstance(vehicles, list)
assert len(vehicles) > 0
# =========================================================================
# Search
# =========================================================================
class TestSearchParts:
def test_returns_results_for_brake(self, db):
results = db.search_parts("brake")
assert isinstance(results, list)
assert len(results) > 0
def test_each_result_has_expected_keys(self, db):
results = db.search_parts("brake")
for r in results[:3]:
assert "id" in r
assert "name" in r
assert "oem_part_number" in r
class TestSearchPartNumber:
def test_returns_results_for_04465(self, db):
results = db.search_part_number("04465")
assert isinstance(results, list)
assert len(results) > 0
def test_each_result_has_match_type(self, db):
results = db.search_part_number("04465")
for r in results:
assert "match_type" in r
# =========================================================================
# VIN cache
# =========================================================================
class TestVinCache:
def test_get_nonexistent_vin_returns_none(self, db):
result = db.get_vin_cache("00000000000000000")
assert result is None
# =========================================================================
# Stats
# =========================================================================
class TestGetStats:
def test_returns_dict_with_required_keys(self, db):
stats = db.get_stats()
assert isinstance(stats, dict)
assert "brands" in stats
assert "models" in stats
assert "parts" in stats
def test_counts_are_positive(self, db):
stats = db.get_stats()
assert stats["brands"] > 0
assert stats["models"] > 0
assert stats["parts"] > 0
def test_includes_top_brands(self, db):
stats = db.get_stats()
assert "top_brands" in stats
assert isinstance(stats["top_brands"], list)
# =========================================================================
# Manufacturers
# =========================================================================
class TestGetManufacturers:
def test_returns_nonempty_list(self, db):
manufacturers = db.get_manufacturers()
assert isinstance(manufacturers, list)
assert len(manufacturers) > 0
def test_each_has_name(self, db):
manufacturers = db.get_manufacturers()
for m in manufacturers:
assert "name" in m
assert "id" in m
# =========================================================================
# Admin CRUD — smoke tests
# =========================================================================
class TestCrossrefsPaginated:
def test_returns_list(self, db):
refs = db.get_crossrefs_paginated(page=1, per_page=5)
assert isinstance(refs, list)
assert len(refs) <= 5

View File

@@ -0,0 +1,277 @@
"""
Integration tests for the AUTOPARTES console application.
Uses a MockRenderer that records draw calls instead of painting to a real
terminal, allowing end-to-end testing of the screen -> renderer pipeline
without curses.
"""
import pytest
from console.renderers.base import BaseRenderer
from console.core.navigation import Navigation
from console.db import Database
# =========================================================================
# MockRenderer
# =========================================================================
class MockRenderer(BaseRenderer):
"""A renderer that records all draw calls for later assertion.
Pre-load ``self.keys`` with a sequence of key codes; ``get_key()``
pops from the front and returns ESC (27) when the list is exhausted.
"""
def __init__(self):
self.calls = [] # list of (method_name, args, kwargs)
self.keys = [] # pre-loaded key presses
self._size = (24, 80) # rows, cols
# -- Lifecycle ----------------------------------------------------------
def init_screen(self):
self.calls.append(('init_screen', (), {}))
def cleanup(self):
self.calls.append(('cleanup', (), {}))
# -- Screen queries -----------------------------------------------------
def get_size(self):
return self._size
# -- Primitive operations -----------------------------------------------
def clear(self):
self.calls.append(('clear', (), {}))
def refresh(self):
self.calls.append(('refresh', (), {}))
def get_key(self):
if self.keys:
return self.keys.pop(0)
return 27 # ESC to exit
# -- High-level widgets -------------------------------------------------
def draw_header(self, title, subtitle=''):
self.calls.append(('draw_header', (title, subtitle), {}))
def draw_footer(self, key_labels):
self.calls.append(('draw_footer', (key_labels,), {}))
def draw_menu(self, items, selected_index=0, title=''):
self.calls.append(('draw_menu', (items, selected_index), {}))
def draw_table(self, headers, rows, widths, page_info=None,
selected_row=-1):
self.calls.append(('draw_table', (headers, rows, widths), {}))
def draw_detail(self, fields, title=''):
self.calls.append(('draw_detail', (fields,), {}))
def draw_form(self, fields, focused_index=0, title=''):
self.calls.append(('draw_form', (fields, focused_index), {}))
def draw_filter_list(self, items, filter_text, selected_index, title=''):
self.calls.append(('draw_filter_list', (items, filter_text, selected_index), {}))
def draw_comparison(self, columns, title=''):
self.calls.append(('draw_comparison', (columns,), {}))
# -- Low-level drawing --------------------------------------------------
def draw_text(self, row, col, text, style='normal'):
self.calls.append(('draw_text', (row, col, text, style), {}))
def draw_box(self, top, left, height, width, title=''):
self.calls.append(('draw_box', (top, left, height, width), {}))
# -- Dialogs ------------------------------------------------------------
def show_message(self, text, msg_type='info'):
self.calls.append(('show_message', (text, msg_type), {}))
if msg_type == 'confirm':
return True # auto-confirm
return True
def show_input(self, prompt, max_len=40):
self.calls.append(('show_input', (prompt, max_len), {}))
return None # cancel by default
# -- Helpers for assertions ---------------------------------------------
def method_names(self):
"""Return a list of just the method names from recorded calls."""
return [name for name, _args, _kwargs in self.calls]
def calls_for(self, method_name):
"""Return only the calls matching *method_name*."""
return [(args, kwargs) for name, args, kwargs in self.calls
if name == method_name]
# =========================================================================
# Fixtures
# =========================================================================
@pytest.fixture
def mock_renderer():
"""Provide a fresh MockRenderer for each test."""
return MockRenderer()
@pytest.fixture(scope="module")
def db():
"""Provide a shared Database instance for integration tests."""
return Database()
# =========================================================================
# Test 1: App creates with screens
# =========================================================================
class TestAppCreatesWithScreens:
def test_app_creates_with_screens(self, mock_renderer, db):
"""App should register at least 'menu' and 'estadisticas' screens."""
from console.core.app import App
app = App(renderer=mock_renderer, db=db)
assert 'menu' in app.screens
assert 'estadisticas' in app.screens
assert len(app.screens) >= 2
# =========================================================================
# Test 2: App runs and quits
# =========================================================================
class TestAppRunsAndQuits:
def test_app_runs_and_quits(self, mock_renderer, db):
"""Pre-load ESC + confirm-yes (ord('s')). App should exit cleanly."""
from console.core.app import App
# ESC triggers quit dialog, show_message auto-confirms True
mock_renderer.keys = [27] # ESC
app = App(renderer=mock_renderer, db=db)
app.run() # should not raise
# Verify init_screen and cleanup were both called
names = mock_renderer.method_names()
assert 'init_screen' in names
assert 'cleanup' in names
# =========================================================================
# Test 3: Menu renders
# =========================================================================
class TestMenuRenders:
def test_menu_renders(self, mock_renderer, db):
"""MenuPrincipalScreen.render() should call draw_header and draw_menu."""
from console.screens.menu_principal import MenuPrincipalScreen
screen = MenuPrincipalScreen()
screen.render(context={}, db=db, renderer=mock_renderer)
names = mock_renderer.method_names()
assert 'draw_header' in names
assert 'draw_menu' in names
# =========================================================================
# Test 4: Estadisticas renders
# =========================================================================
class TestEstadisticasRenders:
def test_estadisticas_renders(self, mock_renderer, db):
"""EstadisticasScreen.render() should call draw_header and draw_text."""
from console.screens.estadisticas import EstadisticasScreen
screen = EstadisticasScreen()
screen.render(context={}, db=db, renderer=mock_renderer)
names = mock_renderer.method_names()
assert 'draw_header' in names
# EstadisticasScreen uses draw_text for its detail fields
assert 'draw_text' in names
# =========================================================================
# Test 5: Navigation integration
# =========================================================================
class TestNavigationIntegration:
def test_navigation_push_pop_breadcrumb(self):
"""Push menu, push estadisticas, verify breadcrumb, pop, verify current."""
nav = Navigation()
nav.push('menu', {}, label='Menu')
nav.push('estadisticas', {}, label='Estadisticas')
# Breadcrumb should show both
assert nav.breadcrumb() == ['Menu', 'Estadisticas']
assert nav.depth() == 2
# Current should be estadisticas
current = nav.current()
assert current is not None
assert current[0] == 'estadisticas'
# Pop estadisticas
popped = nav.pop()
assert popped[0] == 'estadisticas'
# Now current should be menu
current = nav.current()
assert current is not None
assert current[0] == 'menu'
assert nav.breadcrumb() == ['Menu']
# =========================================================================
# Test 6: All screens instantiate
# =========================================================================
class TestAllScreensInstantiate:
"""Import and instantiate all 13 screen classes, verifying each has
name and title attributes."""
# (module_path, class_name)
_SCREEN_CLASSES = [
("console.screens.menu_principal", "MenuPrincipalScreen"),
("console.screens.estadisticas", "EstadisticasScreen"),
("console.screens.vehiculo_nav", "VehiculoNavScreen"),
("console.screens.buscar_parte", "BuscarParteScreen"),
("console.screens.buscar_texto", "BuscarTextoScreen"),
("console.screens.vin_decoder", "VinDecoderScreen"),
("console.screens.catalogo", "CatalogoScreen"),
("console.screens.parte_detalle", "ParteDetalleScreen"),
("console.screens.comparador", "ComparadorScreen"),
("console.screens.admin_partes", "AdminPartesScreen"),
("console.screens.admin_fabricantes", "AdminFabricantesScreen"),
("console.screens.admin_crossref", "AdminCrossrefScreen"),
("console.screens.admin_import", "AdminImportScreen"),
]
def test_all_13_screens_exist(self):
"""All 13 screen modules should be importable."""
assert len(self._SCREEN_CLASSES) == 13
@pytest.mark.parametrize("module_path,class_name", _SCREEN_CLASSES)
def test_screen_instantiates(self, module_path, class_name):
"""Each screen class should instantiate and have name + title."""
import importlib
mod = importlib.import_module(module_path)
cls = getattr(mod, class_name)
instance = cls()
assert hasattr(instance, 'name')
assert hasattr(instance, 'title')
assert isinstance(instance.name, str)
assert isinstance(instance.title, str)
assert len(instance.name) > 0
assert len(instance.title) > 0

168
console/tests/test_utils.py Normal file
View File

@@ -0,0 +1,168 @@
"""
Tests for the formatting utility functions.
VIN API tests are excluded because they require network access.
"""
import pytest
from console.utils.formatting import (
format_currency,
format_number,
truncate,
pad_right,
format_table_row,
quality_bar,
)
# =========================================================================
# format_currency
# =========================================================================
class TestFormatCurrency:
def test_none_returns_dash(self):
assert format_currency(None) == "──"
def test_zero_returns_zero_dollars(self):
assert format_currency(0) == "$0.00"
def test_positive_value(self):
assert format_currency(45.99) == "$45.99"
def test_integer_value(self):
assert format_currency(100) == "$100.00"
def test_large_value_with_commas(self):
assert format_currency(1234.56) == "$1,234.56"
def test_small_decimal(self):
assert format_currency(0.5) == "$0.50"
# =========================================================================
# format_number
# =========================================================================
class TestFormatNumber:
def test_none_returns_zero(self):
assert format_number(None) == "0"
def test_zero(self):
assert format_number(0) == "0"
def test_thousands_separator(self):
assert format_number(13685) == "13,685"
def test_small_number(self):
assert format_number(42) == "42"
def test_million(self):
assert format_number(1000000) == "1,000,000"
# =========================================================================
# truncate
# =========================================================================
class TestTruncate:
def test_none_returns_empty(self):
assert truncate(None, 10) == ""
def test_short_string_unchanged(self):
assert truncate("hello", 10) == "hello"
def test_exact_length_unchanged(self):
assert truncate("hello", 5) == "hello"
def test_long_string_truncated_with_ellipsis(self):
assert truncate("hello world!", 8) == "hello..."
def test_very_short_max_len(self):
result = truncate("hello world", 3)
assert result == "..."
# =========================================================================
# pad_right
# =========================================================================
class TestPadRight:
def test_none_returns_empty(self):
assert pad_right(None, 10) == ""
def test_short_string_padded(self):
result = pad_right("hi", 5)
assert result == "hi "
assert len(result) == 5
def test_exact_length_unchanged(self):
result = pad_right("hello", 5)
assert result == "hello"
def test_long_string_truncated(self):
result = pad_right("hello world", 5)
assert result == "hello"
assert len(result) == 5
# =========================================================================
# format_table_row
# =========================================================================
class TestFormatTableRow:
def test_basic_row(self):
result = format_table_row(["A", "B", "C"], [5, 5, 5])
assert "" in result
assert len(result.split("")) == 3
def test_values_padded_to_widths(self):
result = format_table_row(["hi", "there"], [5, 7])
parts = result.split("")
assert len(parts[0]) == 5
assert len(parts[1]) == 7
def test_custom_separator(self):
result = format_table_row(["A", "B"], [3, 3], separator=" | ")
assert " | " in result
def test_truncation_when_value_exceeds_width(self):
result = format_table_row(["toolongvalue", "ok"], [5, 5])
parts = result.split("")
assert len(parts[0]) == 5
# =========================================================================
# quality_bar
# =========================================================================
class TestQualityBar:
def test_oem(self):
result = quality_bar("oem")
assert "" in result
assert len(result) > 0
def test_premium(self):
result = quality_bar("premium")
assert "" in result
def test_standard(self):
result = quality_bar("standard")
assert "" in result
assert "" in result
def test_economy(self):
result = quality_bar("economy")
assert "" in result
assert "" in result
def test_oem_longer_than_economy(self):
oem = quality_bar("oem")
economy = quality_bar("economy")
oem_blocks = oem.count("")
economy_blocks = economy.count("")
assert oem_blocks > economy_blocks
def test_unknown_tier_returns_string(self):
result = quality_bar("unknown")
assert isinstance(result, str)

View File

View File

@@ -0,0 +1,86 @@
"""
Display formatting utilities for the AUTOPARTES console application.
Functions for currency, numbers, text truncation, table layout, and
quality-tier visual bars.
"""
def format_currency(value) -> str:
"""Format a numeric value as USD currency.
None -> '──'
0 -> '$0.00'
45.99 -> '$45.99'
"""
if value is None:
return "──"
return f"${value:,.2f}"
def format_number(value) -> str:
"""Format an integer with thousands separators.
None -> '0'
13685 -> '13,685'
"""
if value is None:
return "0"
return f"{value:,}"
def truncate(text, max_len) -> str:
"""Truncate text to *max_len* characters, appending '...' if trimmed.
None -> ''
fits -> text unchanged
too long -> text[:max_len-3] + '...'
"""
if text is None:
return ""
if len(text) <= max_len:
return text
return text[: max_len - 3] + "..."
def pad_right(text, width) -> str:
"""Pad *text* to *width* with spaces on the right, or truncate if longer.
None -> ''
fits -> ljust(width)
too long -> text[:width]
"""
if text is None:
return ""
if len(text) > width:
return text[:width]
return text.ljust(width)
def format_table_row(values, widths, separator="") -> str:
"""Join *values* padded to corresponding *widths* with *separator*.
Each value is passed through :func:`pad_right` to ensure uniform column
widths, then all columns are joined by the separator string.
"""
cells = [pad_right(str(v), w) for v, w in zip(values, widths)]
return separator.join(cells)
# ── Quality-tier bars ──────────────────────────────────────────────────
_QUALITY_BARS = {
"oem": "███████████",
"premium": "██████████░",
"standard": "███████░░░░",
"economy": "█████░░░░░░",
}
def quality_bar(tier) -> str:
"""Return a Unicode block-bar representing a quality tier.
Recognised tiers: oem, premium, standard, economy.
Unknown tiers fall back to a minimal bar.
"""
return _QUALITY_BARS.get(tier, "░░░░░░░░░░░")

93
console/utils/vin_api.py Normal file
View File

@@ -0,0 +1,93 @@
"""
NHTSA VIN Decoder API client for the AUTOPARTES console application.
Wraps the National Highway Traffic Safety Administration (NHTSA) Vehicle
Product Information Catalog (vPIC) DecodeVin endpoint to retrieve vehicle
specifications from a 17-character VIN.
"""
import requests
from console.config import NHTSA_API_URL
# NHTSA result variables we care about, mapped to our internal keys.
_FIELD_MAP = {
"Make": "make",
"Model": "model",
"Model Year": "year",
"Body Class": "body_class",
"Drive Type": "drive_type",
"Displacement (L)": "displacement_l",
"Engine Number of Cylinders": "cylinders",
"Fuel Type - Primary": "fuel_type",
"Engine Brake (hp) From": "power_hp",
}
def decode_vin_nhtsa(vin: str) -> dict:
"""Decode a VIN using the NHTSA vPIC API.
Parameters
----------
vin : str
A 17-character Vehicle Identification Number.
Returns
-------
dict
On success::
{
"make": "TOYOTA",
"model": "Corolla",
"year": "2020",
"body_class": "Sedan/Saloon",
"drive_type": "FWD",
"engine_info": {
"displacement_l": "2.0",
"cylinders": "4",
"fuel_type": "Gasoline",
"power_hp": "169",
"raw": { ... full variable->value mapping ... },
},
}
On error::
{"error": "<description>"}
"""
try:
url = f"{NHTSA_API_URL}/{vin}"
response = requests.get(url, params={"format": "json"}, timeout=15)
response.raise_for_status()
data = response.json()
results = data.get("Results", [])
# Build a flat lookup: variable name -> value (skip empty/None)
raw: dict[str, str] = {}
for item in results:
var = item.get("Variable", "")
val = item.get("Value")
if val and str(val).strip():
raw[var] = str(val).strip()
# Extract top-level vehicle fields
vehicle: dict = {}
engine_info: dict = {"raw": raw}
engine_keys = {"displacement_l", "cylinders", "fuel_type", "power_hp"}
for nhtsa_var, our_key in _FIELD_MAP.items():
value = raw.get(nhtsa_var, "")
if our_key in engine_keys:
engine_info[our_key] = value
else:
vehicle[our_key] = value
vehicle["engine_info"] = engine_info
return vehicle
except Exception as e:
return {"error": str(e)}

View File

@@ -0,0 +1,198 @@
# Pick-Style Console System - Design Document
**Date:** 2026-02-14
**Status:** Approved
## Overview
Console-based autoparts catalog system inspired by Pick/D3 operating systems with VT220 terminal aesthetics. Runs entirely from keyboard in a real terminal (CLI), with two selectable rendering modes: classic VT220 (curses) and modern TUI (textual).
## Requirements
- **Platform:** Real CLI terminal (Python), no web browser
- **Users:** Sales counter staff AND warehouse/admin personnel
- **Style:** Pick-inspired with ANSI colors, box drawing, formatted tables
- **Data:** Abstract DB layer (SQLite today, PostgreSQL migration planned)
- **Renderers:** Two modes selectable via `--mode vt220|modern`
- **Input:** 100% keyboard-driven with F-keys, menus, and incremental search
## Architecture
```
┌─────────────────────────────────────┐
│ Capa de Presentación │
│ ┌──────────┐ ┌───────────────┐ │
│ │ curses │ │ textual │ │
│ │ (VT220) │ │ (moderno) │ │
│ └─────┬─────┘ └──────┬────────┘ │
│ └────────┬────────┘ │
│ Interface común │
├─────────────────────────────────────┤
│ Capa de Lógica / Screens │
│ Menús, Navegación, Formularios, │
│ Búsqueda, CRUD │
├─────────────────────────────────────┤
│ Capa de Datos (DB) │
│ SQLite hoy → PostgreSQL mañana │
└─────────────────────────────────────┘
```
## File Structure
```
console/
├── main.py # Entry point, --mode vt220|modern
├── config.py # DB path, colors, key mappings
├── db.py # Abstract DB layer (SQLite/PostgreSQL)
├── core/
│ ├── screens.py # Screen base class
│ ├── widgets.py # Lista, Formulario, Tabla, Barra
│ ├── navigation.py # Screen stack, breadcrumb, history
│ └── keybindings.py # F-keys, ESC, TAB mappings
├── screens/
│ ├── menu_principal.py # Main menu (9 options + exit)
│ ├── vehiculo_nav.py # Drill-down: brand → model → year → engine
│ ├── buscar_parte.py # Search by part number
│ ├── buscar_texto.py # Full-text search (FTS)
│ ├── vin_decoder.py # VIN decoder (NHTSA API)
│ ├── catalogo.py # Categories → groups → parts
│ ├── parte_detalle.py # Part detail with alternatives
│ ├── comparador.py # OEM vs aftermarket comparison
│ ├── estadisticas.py # System statistics dashboard
│ ├── admin_partes.py # Parts CRUD
│ ├── admin_fabricantes.py # Manufacturers CRUD
│ ├── admin_crossref.py # Cross-references CRUD
│ └── admin_import.py # Import/Export CSV
├── renderers/
│ ├── curses_renderer.py # VT220 mode (curses)
│ └── textual_renderer.py # Modern mode (textual/rich)
└── utils/
├── formatting.py # Table formatting, numbers, currency
└── vin_api.py # NHTSA VIN API client
```
## Screens
### Main Menu
- 9 numbered options + 0 to exit
- F-key bar at bottom
- Header with system name and version
### 1. Vehicle Navigation (Drill-Down)
- Sequential selection: Brand → Model → Year → Engine
- Each step shows filterable list with incremental search
- Arrow keys + ENTER to select, ESC to go back
- Leads to categories/groups/parts for selected vehicle
### 2. Part Number Search
- Single input field for part number
- Searches OEM, aftermarket, and cross-references
- Results show type, number, description, source
- Select result to see full detail
### 3. Text Search (FTS)
- Uses SQLite FTS5 full-text search
- Searches part names and descriptions
- Paginated results with relevance ranking
### 4. VIN Decoder
- Input 17-character VIN
- Calls NHTSA API (with cache)
- Shows decoded vehicle info
- Option to view compatible parts
### 5. Category Catalog
- Browse: Categories → Groups → Parts
- Independent of vehicle selection
### 6-9. Administration
- CRUD screens with Pick-style positional forms
- Numbered fields, TAB/arrow navigation
- F1 for lookup lists on foreign key fields
- F9 to save, ESC to cancel (with dirty check)
- Import/Export CSV with file path input
### 10. Part Detail
- Full part info in form layout (label.....: value)
- Aftermarket alternatives table below
- F4 for cross-references, F6 for vehicles
### 11. Part Comparator
- Side-by-side columns: OEM vs aftermarket alternatives
- Visual quality bars, savings percentage
- Cross-reference numbers at bottom
- Horizontal scroll if more than 3 columns
### 12. Statistics Dashboard
- Database counters (brands, models, parts, etc.)
- Coverage metrics (vehicles with parts, top brands)
- VIN cache status
## Key Bindings
| Key | Action |
|-----|--------|
| 0-9 | Select menu option / jump to field |
| ENTER | Confirm selection |
| ESC | Go back / Cancel |
| F1 | Help / Lookup list |
| F2 | Edit mode |
| F3 | Search |
| F4 | Cross-references |
| F5 | Refresh |
| F6 | Related vehicles |
| F9 | Save |
| F10 | Main menu |
| TAB / ↓ | Next field |
| ↑ | Previous field |
| PgUp/PgDn | Page navigation |
| ←→ | Scroll columns (comparator) |
## Data Layer
Abstract interface with two implementations:
```python
class Database:
def get_brands() -> list
def get_models(brand=None) -> list
def get_vehicles(brand, model, year, engine) -> list
def get_categories() -> list
def get_groups(category_id) -> list
def get_parts(group_id=None, mye_id=None) -> list
def get_part(part_id) -> dict
def get_alternatives(part_id) -> list
def get_cross_references(part_id) -> list
def search_parts(query) -> list
def search_part_number(number) -> list
def decode_vin(vin) -> dict
def get_stats() -> dict
# CRUD methods for admin...
```
SQLite implementation reads directly from `vehicle_database.db`. PostgreSQL implementation will use psycopg2 with same interface.
## Renderer Interface
```python
class Renderer:
def init_screen()
def clear()
def draw_header(title, subtitle)
def draw_footer(keys)
def draw_menu(items, selected)
def draw_table(headers, rows, page_info)
def draw_form(fields, focused_field)
def draw_detail(labels_values)
def draw_comparison(columns)
def draw_filter_list(items, filter_text, selected)
def draw_stats(data)
def get_key() -> key_event
def show_message(text, type) # info/error/confirm
```
Curses implementation uses box drawing chars, ANSI colors (green/amber on black). Textual implementation uses Rich widgets with modern styling.

File diff suppressed because it is too large Load Diff