- 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>
303 lines
10 KiB
Python
303 lines
10 KiB
Python
"""
|
|
Admin CRUD screen for Cross-References in the NEXUS AUTOPARTS 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
|