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