- Migrate from SQLite to PostgreSQL with normalized schema - Add 11 lookup tables (fuel_type, body_type, drivetrain, transmission, materials, position_part, manufacture_type, quality_tier, countries, reference_type, shapes) - Rewrite dashboard/server.py (76 routes) using SQLAlchemy text() queries - Rewrite console/db.py (27 methods) using SQLAlchemy ORM - Add models.py with 27 SQLAlchemy model definitions - Add config.py for centralized DB_URL configuration - Add migrate_to_postgres.py migration script - Add docs/METABASE_GUIDE.md with complete data entry guide - Rebrand from "AUTOPARTS DB" to "NEXUS AUTOPARTS" - Fill vehicle data gaps via NHTSA API + heuristics: engines (cylinders, power, torque), brands (country, founded_year), models (body_type, production years), MYE (drivetrain, transmission, trim) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
260 lines
8.1 KiB
Python
260 lines
8.1 KiB
Python
"""
|
|
VIN decoder screen for the NEXUS AUTOPARTS 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
|