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
|
│ ├── dashboard.js # Lógica JavaScript
|
||||||
│ └── start_dashboard.sh # Script de inicio
|
│ └── 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
|
├── vehicle_scraper/ # Herramientas de web scraping
|
||||||
│ ├── rockauto_scraper.py # Scraper RockAuto
|
│ ├── rockauto_scraper.py # Scraper RockAuto
|
||||||
│ ├── rockauto_scraper_v2.py # Scraper mejorado
|
│ ├── rockauto_scraper_v2.py # Scraper mejorado
|
||||||
@@ -73,6 +82,25 @@ Autopartes/
|
|||||||
└── QUICK_START.sh # Guía rápida de inicio
|
└── 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
|
## Instalación
|
||||||
|
|
||||||
### Requisitos Previos
|
### Requisitos Previos
|
||||||
@@ -91,6 +119,7 @@ Autopartes/
|
|||||||
2. **Instalar dependencias**
|
2. **Instalar dependencias**
|
||||||
```bash
|
```bash
|
||||||
pip install flask requests beautifulsoup4 lxml
|
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)**
|
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`
|
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
|
```bash
|
||||||
cd vehicle_database/scripts
|
cd vehicle_database/scripts
|
||||||
@@ -275,9 +311,9 @@ engines ─┴─────────────┘
|
|||||||
│ │ │
|
│ │ │
|
||||||
v v v
|
v v v
|
||||||
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||||
│ Flask API │ │ CLI Interface │ │ CSV Importer │
|
│ Flask API │ │ Pick Console │ │ CSV Importer │
|
||||||
└────────┬────────┘ └──────────────────┘ └──────────────────┘
|
└────────┬────────┘ │ (VT220/Rich) │ └──────────────────┘
|
||||||
│
|
│ └──────────────────┘
|
||||||
v
|
v
|
||||||
┌─────────────────┐
|
┌─────────────────┐
|
||||||
│ Web Dashboard │
|
│ 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