feat(console): add catalog, search, and VIN decoder screens
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user