Compare commits
13 Commits
140117a8e5
...
4af3a09b03
| Author | SHA1 | Date | |
|---|---|---|---|
| 4af3a09b03 | |||
| 64503ca363 | |||
| 7bf50a2c67 | |||
| 8194167c51 | |||
| 15f3c9c9fe | |||
| b042853408 | |||
| 69fb26723d | |||
| e3ad101d56 | |||
| 269bb9030b | |||
| 211883393e | |||
| ceacab789b | |||
| 3b884e24d3 | |||
| 7cf3ddc758 |
44
README.md
44
README.md
@@ -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
189
console/README.md
Normal 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
0
console/__init__.py
Normal file
7
console/__main__.py
Normal file
7
console/__main__.py
Normal 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
39
console/config.py
Normal 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
0
console/core/__init__.py
Normal file
195
console/core/app.py
Normal file
195
console/core/app.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Main application controller for the AUTOPARTES console application.
|
||||
|
||||
The :class:`App` class owns the screen lifecycle loop: it renders the
|
||||
current screen, reads a keypress, dispatches it, and follows any
|
||||
navigation instruction the screen returns.
|
||||
"""
|
||||
|
||||
from console.core.navigation import Navigation
|
||||
from console.core.keybindings import Key
|
||||
|
||||
|
||||
class App:
|
||||
"""Top-level application controller.
|
||||
|
||||
Parameters:
|
||||
renderer: A :class:`BaseRenderer` implementation (e.g. CursesRenderer).
|
||||
db: A :class:`Database` instance for data access.
|
||||
"""
|
||||
|
||||
def __init__(self, renderer, db):
|
||||
self.renderer = renderer
|
||||
self.db = db
|
||||
self.nav = Navigation()
|
||||
self.screens = {}
|
||||
self.running = False
|
||||
self._register_screens()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Screen registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _register_screens(self):
|
||||
"""Import and register all screen instances.
|
||||
|
||||
Each screen is wrapped in a try/except so that screens not yet
|
||||
implemented do not prevent the application from starting.
|
||||
"""
|
||||
# --- Required screens (Task 6) --------------------------------
|
||||
try:
|
||||
from console.screens.menu_principal import MenuPrincipalScreen
|
||||
s = MenuPrincipalScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.estadisticas import EstadisticasScreen
|
||||
s = EstadisticasScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# --- Optional screens (added by later tasks) -------------------
|
||||
try:
|
||||
from console.screens.vehiculo_nav import VehiculoNavScreen
|
||||
s = VehiculoNavScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.buscar_parte import BuscarParteScreen
|
||||
s = BuscarParteScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.buscar_texto import BuscarTextoScreen
|
||||
s = BuscarTextoScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.vin_decoder import VinDecoderScreen
|
||||
s = VinDecoderScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.catalogo import CatalogoScreen
|
||||
s = CatalogoScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.parte_detalle import ParteDetalleScreen
|
||||
s = ParteDetalleScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.comparador import ComparadorScreen
|
||||
s = ComparadorScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.admin_partes import AdminPartesScreen
|
||||
s = AdminPartesScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.admin_fabricantes import AdminFabricantesScreen
|
||||
s = AdminFabricantesScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.admin_crossref import AdminCrossrefScreen
|
||||
s = AdminCrossrefScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.admin_import import AdminImportScreen
|
||||
s = AdminImportScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(self):
|
||||
"""Enter the main event loop.
|
||||
|
||||
Initialises the renderer, pushes the main menu onto the
|
||||
navigation stack, and loops until the user quits or the stack
|
||||
empties.
|
||||
"""
|
||||
self.renderer.init_screen()
|
||||
self.running = True
|
||||
self.nav.push('menu', {}, label='Menu')
|
||||
|
||||
try:
|
||||
while self.running:
|
||||
current = self.nav.current()
|
||||
if current is None:
|
||||
break
|
||||
|
||||
screen_name, context = current
|
||||
screen = self.screens.get(screen_name)
|
||||
|
||||
if screen is None:
|
||||
self.renderer.show_message(
|
||||
f'Pantalla "{screen_name}" no disponible', 'error'
|
||||
)
|
||||
self.nav.pop()
|
||||
continue
|
||||
|
||||
# Render
|
||||
self.renderer.clear()
|
||||
screen.render(context, self.db, self.renderer)
|
||||
self.renderer.refresh()
|
||||
|
||||
# Input
|
||||
key = self.renderer.get_key()
|
||||
|
||||
# Global key: F10 = back to main menu
|
||||
if key == Key.F10:
|
||||
self.nav.clear()
|
||||
self.nav.push('menu', {}, label='Menu')
|
||||
continue
|
||||
|
||||
# Screen-specific key handling
|
||||
result = screen.on_key(
|
||||
key, context, self.db, self.renderer, self.nav
|
||||
)
|
||||
|
||||
if result == 'quit':
|
||||
self.running = False
|
||||
elif result == 'back':
|
||||
self.nav.pop()
|
||||
elif isinstance(result, tuple) and len(result) == 3:
|
||||
name, ctx, label = result
|
||||
self.nav.push(name, ctx, label=label)
|
||||
elif isinstance(result, str):
|
||||
self.nav.push(result, {}, label=result)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
self.renderer.cleanup()
|
||||
87
console/core/keybindings.py
Normal file
87
console/core/keybindings.py
Normal 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)
|
||||
60
console/core/navigation.py
Normal file
60
console/core/navigation.py
Normal 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
46
console/core/screens.py
Normal 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
770
console/db.py
Normal 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
119
console/main.py
Normal 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()
|
||||
0
console/renderers/__init__.py
Normal file
0
console/renderers/__init__.py
Normal file
152
console/renderers/base.py
Normal file
152
console/renderers/base.py
Normal 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
|
||||
537
console/renderers/curses_renderer.py
Normal file
537
console/renderers/curses_renderer.py
Normal 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))
|
||||
712
console/renderers/textual_renderer.py
Normal file
712
console/renderers/textual_renderer.py
Normal 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)
|
||||
0
console/screens/__init__.py
Normal file
0
console/screens/__init__.py
Normal file
302
console/screens/admin_crossref.py
Normal file
302
console/screens/admin_crossref.py
Normal 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
|
||||
277
console/screens/admin_fabricantes.py
Normal file
277
console/screens/admin_fabricantes.py
Normal 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
|
||||
325
console/screens/admin_import.py
Normal file
325
console/screens/admin_import.py
Normal 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 []
|
||||
321
console/screens/admin_partes.py
Normal file
321
console/screens/admin_partes.py
Normal 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
|
||||
153
console/screens/buscar_parte.py
Normal file
153
console/screens/buscar_parte.py
Normal 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
|
||||
174
console/screens/buscar_texto.py
Normal file
174
console/screens/buscar_texto.py
Normal 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
354
console/screens/catalogo.py
Normal 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
|
||||
282
console/screens/comparador.py
Normal file
282
console/screens/comparador.py
Normal 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
|
||||
167
console/screens/estadisticas.py
Normal file
167
console/screens/estadisticas.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Statistics dashboard screen for the AUTOPARTES console application.
|
||||
|
||||
Displays database table counts and coverage metrics retrieved via
|
||||
:meth:`Database.get_stats`.
|
||||
"""
|
||||
|
||||
from console.core.screens import Screen
|
||||
from console.core.keybindings import Key
|
||||
from console.config import APP_NAME, VERSION
|
||||
from console.utils.formatting import format_number
|
||||
|
||||
|
||||
# Human-readable labels for each database table counter.
|
||||
_TABLE_LABELS = [
|
||||
("brands", "Marcas"),
|
||||
("models", "Modelos"),
|
||||
("engines", "Motores"),
|
||||
("years", "Anos"),
|
||||
("part_categories", "Categorias"),
|
||||
("part_groups", "Grupos de Partes"),
|
||||
("parts", "Partes OEM"),
|
||||
("aftermarket_parts", "Partes Aftermarket"),
|
||||
("manufacturers", "Fabricantes"),
|
||||
("part_cross_references","Cross-References"),
|
||||
]
|
||||
|
||||
# Footer key labels
|
||||
_FOOTER = [
|
||||
("F5", "Refrescar"),
|
||||
("F10", "Menu"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
|
||||
class EstadisticasScreen(Screen):
|
||||
"""Read-only statistics dashboard showing database counters."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="estadisticas", title="Estadisticas del Sistema")
|
||||
self._stats = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_stats(self, db):
|
||||
"""Fetch fresh statistics from the database."""
|
||||
try:
|
||||
self._stats = db.get_stats()
|
||||
except Exception:
|
||||
self._stats = None
|
||||
|
||||
def _build_fields(self):
|
||||
"""Build the detail fields list from the cached stats dict."""
|
||||
if self._stats is None:
|
||||
return [("Error", "No se pudieron cargar las estadisticas")]
|
||||
|
||||
fields = []
|
||||
|
||||
# -- Section: BASE DE DATOS --
|
||||
for key, label in _TABLE_LABELS:
|
||||
value = self._stats.get(key, 0)
|
||||
fields.append((label, format_number(value)))
|
||||
|
||||
return fields
|
||||
|
||||
def _build_coverage_fields(self):
|
||||
"""Build coverage / summary fields."""
|
||||
if self._stats is None:
|
||||
return []
|
||||
|
||||
fields = []
|
||||
|
||||
# Vehicle-part fitments
|
||||
fitments = self._stats.get("vehicle_parts", 0)
|
||||
fields.append(("Fitments", format_number(fitments)))
|
||||
|
||||
# Top brands by fitment count
|
||||
top_brands = self._stats.get("top_brands", [])
|
||||
if top_brands:
|
||||
parts = []
|
||||
for b in top_brands[:5]:
|
||||
parts.append(f"{b['name']}({format_number(b['count'])})")
|
||||
fields.append(("Top marcas", " ".join(parts)))
|
||||
|
||||
return fields
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Screen interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self, context, db, renderer):
|
||||
# Load stats on first render (or after refresh)
|
||||
if self._stats is None:
|
||||
self._load_stats(db)
|
||||
|
||||
# Header
|
||||
renderer.draw_header(
|
||||
f" {APP_NAME} v{VERSION}",
|
||||
" Estadisticas ",
|
||||
)
|
||||
|
||||
h, w = renderer.get_size()
|
||||
|
||||
# -- Section title: BASE DE DATOS --
|
||||
section_title = " BASE DE DATOS "
|
||||
border_char = "\u2500" # ─
|
||||
pad_len = max(w - 4 - len(section_title), 0)
|
||||
section_line = border_char * 2 + section_title + border_char * pad_len
|
||||
renderer.draw_text(3, 2, section_line[:w - 4], "title")
|
||||
|
||||
# Database counters
|
||||
db_fields = self._build_fields()
|
||||
max_label = max((len(lbl) for lbl, _ in db_fields), default=10)
|
||||
dot_total = max_label + 4
|
||||
|
||||
row = 5
|
||||
for label, value in db_fields:
|
||||
if row >= h - 6:
|
||||
break
|
||||
dots = "." * (dot_total - len(label))
|
||||
label_part = f" {label}{dots}: "
|
||||
renderer.draw_text(row, 0, label_part, "field_label")
|
||||
renderer.draw_text(row, len(label_part), str(value), "field_value")
|
||||
row += 1
|
||||
|
||||
# -- Section title: COBERTURA --
|
||||
row += 1
|
||||
if row < h - 5:
|
||||
section_title2 = " COBERTURA "
|
||||
pad_len2 = max(w - 4 - len(section_title2), 0)
|
||||
section_line2 = border_char * 2 + section_title2 + border_char * pad_len2
|
||||
renderer.draw_text(row, 2, section_line2[:w - 4], "title")
|
||||
row += 2
|
||||
|
||||
coverage_fields = self._build_coverage_fields()
|
||||
cov_max_label = max(
|
||||
(len(lbl) for lbl, _ in coverage_fields), default=10
|
||||
)
|
||||
cov_dot_total = cov_max_label + 4
|
||||
|
||||
for label, value in coverage_fields:
|
||||
if row >= h - 3:
|
||||
break
|
||||
dots = "." * (cov_dot_total - len(label))
|
||||
label_part = f" {label}{dots}: "
|
||||
renderer.draw_text(row, 0, label_part, "field_label")
|
||||
renderer.draw_text(
|
||||
row, len(label_part), str(value), "field_value"
|
||||
)
|
||||
row += 1
|
||||
|
||||
# Footer
|
||||
renderer.draw_footer(_FOOTER)
|
||||
|
||||
def on_key(self, key, context, db, renderer, nav):
|
||||
# F5: refresh stats
|
||||
if key == Key.F5:
|
||||
self._stats = None # will reload on next render
|
||||
return None
|
||||
|
||||
# ESC or Backspace: go back
|
||||
if key in (Key.ESCAPE, Key.BACKSPACE):
|
||||
return "back"
|
||||
|
||||
return None
|
||||
137
console/screens/menu_principal.py
Normal file
137
console/screens/menu_principal.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Main menu screen for the AUTOPARTES console application.
|
||||
|
||||
Displays a numbered Pick-style menu with navigation options for all
|
||||
application sections. Number keys jump directly; arrow keys move the
|
||||
selection; ENTER activates.
|
||||
"""
|
||||
|
||||
from console.core.screens import Screen
|
||||
from console.core.keybindings import Key
|
||||
from console.config import APP_NAME, APP_SUBTITLE, VERSION
|
||||
|
||||
|
||||
# Menu items: list of (display_number, label, screen_name).
|
||||
# Separators use display_number None and screen_name None.
|
||||
_MENU_ITEMS = [
|
||||
("1", "Consulta por Vehiculo", "vehiculo_nav"),
|
||||
("2", "Busqueda por Numero de Parte", "buscar_parte"),
|
||||
("3", "Busqueda por Descripcion", "buscar_texto"),
|
||||
("4", "Decodificador VIN", "vin_decoder"),
|
||||
("5", "Catalogo de Categorias", "catalogo"),
|
||||
(None, None, None), # separator
|
||||
("6", "Administracion de Partes", "admin_partes"),
|
||||
("7", "Administracion de Fabricantes", "admin_fabricantes"),
|
||||
("8", "Cross-References", "admin_crossref"),
|
||||
("9", "Importar / Exportar Datos", "admin_import"),
|
||||
(None, None, None), # separator
|
||||
("0", "Estadisticas del Sistema", "estadisticas"),
|
||||
]
|
||||
|
||||
# Quick lookup: digit character -> screen name
|
||||
_KEY_MAP = {item[0]: item[2] for item in _MENU_ITEMS if item[0] is not None}
|
||||
|
||||
# Footer key labels
|
||||
_FOOTER = [
|
||||
("F1", "Ayuda"),
|
||||
("F3", "Buscar"),
|
||||
("F10", "Menu"),
|
||||
("ESC", "Salir"),
|
||||
]
|
||||
|
||||
|
||||
class MenuPrincipalScreen(Screen):
|
||||
"""Main menu screen with numbered items and arrow-key navigation."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="menu", title="Menu Principal")
|
||||
self._selected = 0 # index into _MENU_ITEMS (skipping separators)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _selectable_indices(self):
|
||||
"""Return list of indices in _MENU_ITEMS that are not separators."""
|
||||
return [i for i, item in enumerate(_MENU_ITEMS) if item[0] is not None]
|
||||
|
||||
def _move_selection(self, direction):
|
||||
"""Move selection up (-1) or down (+1), skipping separators."""
|
||||
indices = self._selectable_indices()
|
||||
if not indices:
|
||||
return
|
||||
try:
|
||||
pos = indices.index(self._selected)
|
||||
except ValueError:
|
||||
pos = 0
|
||||
pos = max(0, min(len(indices) - 1, pos + direction))
|
||||
self._selected = indices[pos]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Screen interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self, context, db, renderer):
|
||||
# Header
|
||||
renderer.draw_header(
|
||||
f" {APP_NAME} v{VERSION}",
|
||||
f"{APP_SUBTITLE} ",
|
||||
)
|
||||
|
||||
# Build items list for draw_menu.
|
||||
# Separators use the special "---" marker understood by the renderer.
|
||||
menu_items = []
|
||||
for num, label, _screen in _MENU_ITEMS:
|
||||
if num is None:
|
||||
menu_items.append(("---", ""))
|
||||
else:
|
||||
menu_items.append((num, label))
|
||||
|
||||
renderer.draw_menu(
|
||||
menu_items,
|
||||
selected_index=self._selected,
|
||||
title="MENU PRINCIPAL",
|
||||
)
|
||||
|
||||
# Footer
|
||||
renderer.draw_footer(_FOOTER)
|
||||
|
||||
def on_key(self, key, context, db, renderer, nav):
|
||||
# --- Number keys: direct navigation ---
|
||||
if 48 <= key <= 57: # ord('0') .. ord('9')
|
||||
digit = chr(key)
|
||||
screen_name = _KEY_MAP.get(digit)
|
||||
if screen_name:
|
||||
label = next(
|
||||
(lbl for num, lbl, _ in _MENU_ITEMS if num == digit),
|
||||
screen_name,
|
||||
)
|
||||
return (screen_name, {}, label)
|
||||
|
||||
# --- Arrow keys ---
|
||||
if key == Key.UP:
|
||||
self._move_selection(-1)
|
||||
return None
|
||||
|
||||
if key == Key.DOWN:
|
||||
self._move_selection(1)
|
||||
return None
|
||||
|
||||
# --- ENTER: activate selected ---
|
||||
if key == Key.ENTER:
|
||||
item = _MENU_ITEMS[self._selected]
|
||||
num, label, screen_name = item
|
||||
if screen_name is not None:
|
||||
return (screen_name, {}, label)
|
||||
return None
|
||||
|
||||
# --- ESC: quit confirmation ---
|
||||
if key == Key.ESCAPE:
|
||||
confirmed = renderer.show_message(
|
||||
"Desea salir de la aplicacion?", "confirm"
|
||||
)
|
||||
if confirmed:
|
||||
return "quit"
|
||||
return None
|
||||
|
||||
return None
|
||||
242
console/screens/parte_detalle.py
Normal file
242
console/screens/parte_detalle.py
Normal 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
|
||||
239
console/screens/vehiculo_nav.py
Normal file
239
console/screens/vehiculo_nav.py
Normal 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
|
||||
259
console/screens/vin_decoder.py
Normal file
259
console/screens/vin_decoder.py
Normal 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
|
||||
0
console/tests/__init__.py
Normal file
0
console/tests/__init__.py
Normal file
214
console/tests/test_core.py
Normal file
214
console/tests/test_core.py
Normal 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
273
console/tests/test_db.py
Normal 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
|
||||
277
console/tests/test_integration.py
Normal file
277
console/tests/test_integration.py
Normal 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
168
console/tests/test_utils.py
Normal 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)
|
||||
0
console/utils/__init__.py
Normal file
0
console/utils/__init__.py
Normal file
86
console/utils/formatting.py
Normal file
86
console/utils/formatting.py
Normal 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
93
console/utils/vin_api.py
Normal 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)}
|
||||
198
docs/plans/2026-02-14-pick-console-design.md
Normal file
198
docs/plans/2026-02-14-pick-console-design.md
Normal 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.
|
||||
1982
docs/plans/2026-02-14-pick-console-plan.md
Normal file
1982
docs/plans/2026-02-14-pick-console-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user