diff --git a/README.md b/README.md index 5d97073..180e085 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# Autoparts DB +# Nexus Autoparts -Sistema completo de gestión de base de datos de vehículos y autopartes con dashboard web, herramientas de web scraping y múltiples interfaces de consulta. +Sistema completo de gestión de base de datos de vehículos y nexus-autoparts con dashboard web, herramientas de web scraping y múltiples interfaces de consulta. ## Descripción -**Autoparts DB** es una solución integral para la gestión de información de vehículos que incluye: +**Nexus Autoparts** es una solución integral para la gestión de información de vehículos que incluye: - Base de datos SQLite normalizada con información de marcas, modelos, motores y años - Dashboard web moderno y responsivo para consultar y explorar datos @@ -105,8 +105,8 @@ Funcionalidades: navegación por vehículo (marca→modelo→año→motor), bús 1. **Clonar el repositorio** ```bash - git clone https://git.consultoria-as.com/[usuario]/Autoparts-DB.git - cd Autoparts-DB + git clone https://git.consultoria-as.com/[usuario]/Nexus-Autoparts.git + cd Nexus-Autoparts ``` 2. **Instalar dependencias** @@ -330,4 +330,4 @@ Para más información, contactar al equipo de desarrollo. --- -**Autoparts DB** - Sistema de Gestión de Base de Datos de Vehículos +**Nexus Autoparts** - Sistema de Gestión de Base de Datos de Vehículos diff --git a/config.py b/config.py new file mode 100644 index 0000000..498d847 --- /dev/null +++ b/config.py @@ -0,0 +1,20 @@ +""" +Central configuration for Nexus Autoparts. +""" +import os + +# Database +DB_URL = os.environ.get( + "DATABASE_URL", + "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts" +) + +# Legacy SQLite path (used only by migration script) +SQLITE_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "vehicle_database", "vehicle_database.db" +) + +# Application identity +APP_NAME = "NEXUS AUTOPARTS" +APP_SLOGAN = "Tu conexión directa con las partes que necesitas" diff --git a/console/README.md b/console/README.md index 76ae019..ecca030 100644 --- a/console/README.md +++ b/console/README.md @@ -1,6 +1,6 @@ -# AUTOPARTES Console - Sistema Pick/VT220 +# NEXUS AUTOPARTS 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, verde sobre negro. +Interfaz de consola para el catálogo de nexus-autoparts, inspirada en los sistemas Pick/D3 con estética de terminal VT220. Funciona 100% con teclado, verde sobre negro. ## Requisitos diff --git a/console/config.py b/console/config.py index 875361f..f17c409 100644 --- a/console/config.py +++ b/console/config.py @@ -1,18 +1,18 @@ """ -Configuration settings for the AUTOPARTES console application. +Configuration settings for the NEXUS AUTOPARTS console application. """ import os +import sys # Application metadata -VERSION = "1.0.0" -APP_NAME = "AUTOPARTES" -APP_SUBTITLE = "Sistema de Catalogo de Autopartes" +VERSION = "2.0.0" +APP_NAME = "NEXUS AUTOPARTS" +APP_SUBTITLE = "Tu conexión directa con las partes que necesitas" -# 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) +# Database URL (PostgreSQL) +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) +from config import DB_URL # NHTSA VIN Decoder API NHTSA_API_URL = "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVin" diff --git a/console/core/app.py b/console/core/app.py index 784adae..75eece9 100644 --- a/console/core/app.py +++ b/console/core/app.py @@ -1,5 +1,5 @@ """ -Main application controller for the AUTOPARTES console application. +Main application controller for the NEXUS AUTOPARTS console application. The :class:`App` class owns the screen lifecycle loop: it renders the current screen, reads a keypress, dispatches it, and follows any diff --git a/console/db.py b/console/db.py index d691a4c..84b3cf1 100644 --- a/console/db.py +++ b/console/db.py @@ -1,56 +1,64 @@ """ -Database abstraction layer for the AUTOPARTES console application. +Database abstraction layer for the NEXUS AUTOPARTS console application. Provides all data access methods the console app needs, reading from the -same SQLite database used by the Flask web dashboard. +PostgreSQL database used by the Flask web dashboard. """ -import sqlite3 +import json as json_module from datetime import datetime, timedelta from typing import Optional -from console.config import DB_PATH +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +import sys, os +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) +from config import DB_URL class Database: - """Thin abstraction over the vehicle_database SQLite database.""" + """Thin abstraction over the nexus_autoparts PostgreSQL database.""" - def __init__(self, db_path: Optional[str] = None): - self.db_path = db_path or DB_PATH - self._conn: Optional[sqlite3.Connection] = None + def __init__(self, db_url: Optional[str] = None): + self.db_url = db_url or DB_URL + self._engine = None + self._Session = None self._cache: dict = {} # ------------------------------------------------------------------ # Private helpers # ------------------------------------------------------------------ - def _connect(self) -> sqlite3.Connection: - """Return persistent connection (created once, reused).""" - if self._conn is None: - self._conn = sqlite3.connect(self.db_path) - self._conn.row_factory = sqlite3.Row - self._conn.execute("PRAGMA journal_mode=WAL") - self._conn.execute("PRAGMA cache_size=-8000") # 8MB cache - self._conn.execute("PRAGMA mmap_size=67108864") # 64MB mmap - return self._conn + def _get_engine(self): + if self._engine is None: + self._engine = create_engine(self.db_url, pool_pre_ping=True) + self._Session = sessionmaker(bind=self._engine) + return self._engine + + def _session(self): + self._get_engine() + return self._Session() def close(self): - """Close the persistent connection.""" - if self._conn is not None: - self._conn.close() - self._conn = None + """Dispose the engine connection pool.""" + if self._engine is not None: + self._engine.dispose() + self._engine = None + self._Session = None - def _query(self, sql: str, params: tuple = (), one: bool = False): + def _query(self, sql: str, params: dict = None, one: bool = False): """Execute a SELECT and return list[dict] (or a single dict if *one*).""" - conn = self._connect() - 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()] + session = self._session() + try: + rows = session.execute(text(sql), params or {}).mappings().all() + if one: + return dict(rows[0]) if rows else None + return [dict(r) for r in rows] + finally: + session.close() - def _query_cached(self, cache_key: str, sql: str, params: tuple = ()): + def _query_cached(self, cache_key: str, sql: str, params: dict = None): """Execute a SELECT with in-memory caching for repeated queries.""" if cache_key in self._cache: return self._cache[cache_key] @@ -58,14 +66,20 @@ class Database: self._cache[cache_key] = result return result - def _execute(self, sql: str, params: tuple = ()) -> int: - """Execute an INSERT/UPDATE/DELETE and return lastrowid.""" - conn = self._connect() - cursor = conn.cursor() - cursor.execute(sql, params) - conn.commit() - self._cache.clear() # invalidate cache on writes - return cursor.lastrowid + def _execute(self, sql: str, params: dict = None) -> Optional[int]: + """Execute an INSERT/UPDATE/DELETE. Returns scalar result if RETURNING used.""" + session = self._session() + try: + result = session.execute(text(sql), params or {}) + session.commit() + self._cache.clear() + # If the query has RETURNING, get the scalar + try: + return result.scalar() + except Exception: + return None + finally: + session.close() # ================================================================== # Vehicle navigation @@ -75,7 +89,7 @@ class Database: """Return all brands ordered by name: [{id, name, country}].""" return self._query_cached( "brands", - "SELECT id, name, country FROM brands ORDER BY name", + "SELECT id_brand AS id, name_brand AS name, country FROM brands ORDER BY name_brand", ) def get_models(self, brand: Optional[str] = None) -> list[dict]: @@ -85,18 +99,18 @@ class Database: return self._query_cached( key, """ - SELECT MIN(m.id) AS id, m.name + SELECT MIN(m.id_model) AS id, m.name_model AS name FROM models m - JOIN brands b ON m.brand_id = b.id - WHERE UPPER(b.name) = UPPER(?) - GROUP BY UPPER(m.name) - ORDER BY m.name + JOIN brands b ON m.brand_id = b.id_brand + WHERE b.name_brand ILIKE :brand + GROUP BY UPPER(m.name_model), m.name_model + ORDER BY m.name_model """, - (brand,), + {"brand": brand}, ) return self._query_cached( "models:all", - "SELECT MIN(id) AS id, name FROM models GROUP BY UPPER(name) ORDER BY name", + "SELECT MIN(id_model) AS id, name_model AS name FROM models GROUP BY UPPER(name_model), name_model ORDER BY name_model", ) def get_years( @@ -104,22 +118,22 @@ class Database: ) -> list[dict]: """Return years, optionally filtered by brand and/or model.""" sql = """ - SELECT DISTINCT y.id, y.year + SELECT DISTINCT y.id_year AS id, y.year_car AS 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 + JOIN model_year_engine mye ON y.id_year = mye.year_id + JOIN models m ON mye.model_id = m.id_model + JOIN brands b ON m.brand_id = b.id_brand WHERE 1=1 """ - params: list = [] + params: dict = {} if brand: - sql += " AND UPPER(b.name) = UPPER(?)" - params.append(brand) + sql += " AND b.name_brand ILIKE :brand" + params["brand"] = brand if model: - sql += " AND UPPER(m.name) = UPPER(?)" - params.append(model) - sql += " ORDER BY y.year DESC" - return self._query(sql, tuple(params)) + sql += " AND m.name_model ILIKE :model" + params["model"] = model + sql += " ORDER BY y.year_car DESC" + return self._query(sql, params) def get_engines( self, @@ -129,32 +143,33 @@ class Database: ) -> list[dict]: """Return engines, optionally filtered by brand/model/year.""" sql = """ - SELECT MIN(e.id) AS id, e.name, + SELECT MIN(e.id_engine) AS id, e.name_engine AS name, MAX(e.displacement_cc) AS displacement_cc, MAX(e.cylinders) AS cylinders, - MAX(e.fuel_type) AS fuel_type, + MAX(ft.name_fuel) AS fuel_type, MAX(e.power_hp) AS power_hp, MAX(e.torque_nm) AS torque_nm, MAX(e.engine_code) AS 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 + LEFT JOIN fuel_type ft ON e.id_fuel = ft.id_fuel + JOIN model_year_engine mye ON e.id_engine = mye.engine_id + JOIN models m ON mye.model_id = m.id_model + JOIN brands b ON m.brand_id = b.id_brand + JOIN years y ON mye.year_id = y.id_year WHERE 1=1 """ - params: list = [] + params: dict = {} if brand: - sql += " AND UPPER(b.name) = UPPER(?)" - params.append(brand) + sql += " AND b.name_brand ILIKE :brand" + params["brand"] = brand if model: - sql += " AND UPPER(m.name) = UPPER(?)" - params.append(model) + sql += " AND m.name_model ILIKE :model" + params["model"] = model if year: - sql += " AND y.year = ?" - params.append(int(year)) - sql += " GROUP BY UPPER(e.name) ORDER BY e.name" - return self._query(sql, tuple(params)) + sql += " AND y.year_car = :year" + params["year"] = int(year) + sql += " GROUP BY UPPER(e.name_engine), e.name_engine ORDER BY e.name_engine" + return self._query(sql, params) def get_model_year_engine( self, @@ -166,30 +181,32 @@ class Database: """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.id_mye AS id, + b.name_brand AS brand, + m.name_model AS model, + y.year_car AS year, + e.id_engine AS engine_id, + e.name_engine AS engine, mye.trim_level, - mye.drivetrain, - mye.transmission + dt.name_drivetrain AS drivetrain, + tr.name_transmission AS 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 = ? + JOIN models m ON mye.model_id = m.id_model + JOIN brands b ON m.brand_id = b.id_brand + JOIN years y ON mye.year_id = y.id_year + JOIN engines e ON mye.engine_id = e.id_engine + LEFT JOIN drivetrain dt ON mye.id_drivetrain = dt.id_drivetrain + LEFT JOIN transmission tr ON mye.id_transmission = tr.id_transmission + WHERE b.name_brand ILIKE :brand + AND m.name_model ILIKE :model + AND y.year_car = :year """ - params: list = [brand, model, int(year)] + params: dict = {"brand": brand, "model": model, "year": 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)) + sql += " AND e.id_engine = :engine_id" + params["engine_id"] = engine_id + sql += " ORDER BY e.name_engine, mye.trim_level" + return self._query(sql, params) # ================================================================== # Parts catalog @@ -200,9 +217,10 @@ class Database: return self._query_cached( "categories", """ - SELECT id, name, name_es, slug, icon_name, display_order + SELECT id_part_category AS id, name_part_category AS name, + name_es, slug, icon_name, display_order FROM part_categories - ORDER BY display_order, name + ORDER BY display_order, name_part_category """, ) @@ -210,12 +228,13 @@ class Database: """Return part groups for a given category.""" return self._query( """ - SELECT id, name, name_es, slug, display_order + SELECT id_part_group AS id, name_part_group AS name, + name_es, slug, display_order FROM part_groups - WHERE category_id = ? - ORDER BY display_order, name + WHERE category_id = :cat_id + ORDER BY display_order, name_part_group """, - (category_id,), + {"cat_id": category_id}, ) def get_parts( @@ -231,64 +250,64 @@ class Database: sql = """ SELECT - p.id, + p.id_part AS id, p.oem_part_number, - p.name, + p.name_part AS name, p.name_es, p.group_id, - pg.name AS group_name, - pc.name AS category_name + pg.name_part_group AS group_name, + pc.name_part_category 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 + JOIN part_groups pg ON p.group_id = pg.id_part_group + JOIN part_categories pc ON pg.category_id = pc.id_part_category """ where_parts: list[str] = [] - params: list = [] + params: dict = {} if group_id: - where_parts.append("p.group_id = ?") - params.append(group_id) + where_parts.append("p.group_id = :group_id") + params["group_id"] = group_id if mye_id: where_parts.append( - "p.id IN (SELECT part_id FROM vehicle_parts WHERE model_year_engine_id = ?)" + "p.id_part IN (SELECT part_id FROM vehicle_parts WHERE model_year_engine_id = :mye_id)" ) - params.append(mye_id) + params["mye_id"] = mye_id if where_parts: sql += " WHERE " + " AND ".join(where_parts) - sql += " ORDER BY p.name LIMIT ? OFFSET ?" - params.extend([per_page, offset]) + sql += " ORDER BY p.name_part LIMIT :limit OFFSET :offset" + params["limit"] = per_page + params["offset"] = offset - return self._query(sql, tuple(params)) + return self._query(sql, 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.id_part AS id, p.oem_part_number, - p.name, + p.name_part AS name, p.name_es, p.description, p.description_es, p.weight_kg, - p.material, - p.is_discontinued, - p.superseded_by_id, + mat.name_material AS material, p.group_id, - pg.name AS group_name, + pg.name_part_group AS group_name, pg.name_es AS group_name_es, - pc.id AS category_id, - pc.name AS category_name, + pc.id_part_category AS category_id, + pc.name_part_category 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 = ? + JOIN part_groups pg ON p.group_id = pg.id_part_group + JOIN part_categories pc ON pg.category_id = pc.id_part_category + LEFT JOIN materials mat ON p.id_material = mat.id_material + WHERE p.id_part = :part_id """, - (part_id,), + {"part_id": part_id}, one=True, ) @@ -297,34 +316,37 @@ class Database: return self._query( """ SELECT - ap.id, + ap.id_aftermarket_parts AS id, ap.part_number, - ap.name, + ap.name_aftermarket_parts AS name, ap.name_es, - m.name AS manufacturer_name, + m.name_manufacture AS manufacturer_name, ap.manufacturer_id, - ap.quality_tier, + qt.name_quality AS quality_tier, ap.price_usd, - ap.warranty_months, - ap.in_stock + ap.warranty_months 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 + JOIN manufacturers m ON ap.manufacturer_id = m.id_manufacture + LEFT JOIN quality_tier qt ON ap.id_quality_tier = qt.id_quality_tier + WHERE ap.oem_part_id = :part_id + ORDER BY qt.name_quality DESC, ap.price_usd ASC """, - (part_id,), + {"part_id": 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 + SELECT id_part_cross_ref AS id, cross_reference_number, + rt.name_ref_type AS reference_type, + source_ref AS source, notes + FROM part_cross_references pcr + LEFT JOIN reference_type rt ON pcr.id_ref_type = rt.id_ref_type + WHERE pcr.part_id = :part_id + ORDER BY rt.name_ref_type, pcr.cross_reference_number """, - (part_id,), + {"part_id": part_id}, ) def get_vehicles_for_part(self, part_id: int) -> list[dict]: @@ -332,24 +354,25 @@ class Database: return self._query( """ SELECT - b.name AS brand, - m.name AS model, - y.year, - e.name AS engine, + b.name_brand AS brand, + m.name_model AS model, + y.year_car AS year, + e.name_engine AS engine, mye.trim_level, vp.quantity_required, - vp.position, + pp.name_position_part AS 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 + JOIN model_year_engine mye ON vp.model_year_engine_id = mye.id_mye + JOIN models m ON mye.model_id = m.id_model + JOIN brands b ON m.brand_id = b.id_brand + JOIN years y ON mye.year_id = y.id_year + JOIN engines e ON mye.engine_id = e.id_engine + LEFT JOIN position_part pp ON vp.id_position_part = pp.id_position_part + WHERE vp.part_id = :part_id + ORDER BY b.name_brand, m.name_model, y.year_car """, - (part_id,), + {"part_id": part_id}, ) # ================================================================== @@ -359,150 +382,93 @@ class Database: def search_parts( self, query: str, page: int = 1, per_page: int = 15 ) -> list[dict]: - """Full-text search using FTS5, with fallback to LIKE.""" + """Full-text search using PostgreSQL tsvector.""" per_page = min(per_page, 100) offset = (page - 1) * per_page - conn = self._connect() - cursor = conn.cursor() - - # Check if FTS5 table exists - cursor.execute( - "SELECT name FROM sqlite_master " - "WHERE type='table' AND name='parts_fts'" + return self._query( + """ + SELECT + p.id_part AS id, + p.oem_part_number, + p.name_part AS name, + p.name_es, + p.description, + pg.name_part_group AS group_name, + pc.name_part_category AS category_name, + ts_rank(p.search_vector, plainto_tsquery('spanish', :q)) AS rank + FROM parts p + JOIN part_groups pg ON p.group_id = pg.id_part_group + JOIN part_categories pc ON pg.category_id = pc.id_part_category + WHERE p.search_vector @@ plainto_tsquery('spanish', :q) + ORDER BY rank DESC + LIMIT :limit OFFSET :offset + """, + {"q": query, "limit": per_page, "offset": offset}, ) - 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()] 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() - cursor = conn.cursor() + search_term = f"%{number}%" # OEM parts - cursor.execute( + rows = self._query( """ - SELECT id, oem_part_number, name, name_es + SELECT id_part AS id, oem_part_number, name_part AS name, name_es FROM parts - WHERE oem_part_number LIKE ? + WHERE oem_part_number ILIKE :term """, - (search_term,), + {"term": search_term}, ) - for row in cursor.fetchall(): - results.append( - { - **dict(row), - "match_type": "oem", - "matched_number": row["oem_part_number"], - } - ) + for row in rows: + results.append({ + **row, + "match_type": "oem", + "matched_number": row["oem_part_number"], + }) # Aftermarket parts - cursor.execute( + rows = self._query( """ - SELECT p.id, p.oem_part_number, p.name, p.name_es, ap.part_number + SELECT p.id_part AS id, p.oem_part_number, p.name_part AS 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 ? + JOIN parts p ON ap.oem_part_id = p.id_part + WHERE ap.part_number ILIKE :term """, - (search_term,), + {"term": 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"], - } - ) + for row in rows: + 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( + rows = self._query( """ - SELECT p.id, p.oem_part_number, p.name, p.name_es, - pcr.cross_reference_number + SELECT p.id_part AS id, p.oem_part_number, p.name_part AS 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 ? + JOIN parts p ON pcr.part_id = p.id_part + WHERE pcr.cross_reference_number ILIKE :term """, - (search_term,), + {"term": 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"], - } - ) + for row in rows: + 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 @@ -519,9 +485,9 @@ class Database: engine_info, body_class, drive_type, model_year_engine_id, created_at, expires_at FROM vin_cache - WHERE vin = ? AND expires_at > datetime('now') + WHERE vin = :vin AND expires_at > NOW() """, - (vin.upper().strip(),), + {"vin": vin.upper().strip()}, one=True, ) @@ -535,33 +501,40 @@ class Database: engine_info: str, body_class: str, drive_type: str, - ) -> int: - """Insert or replace a VIN cache entry (30-day expiry).""" + ) -> Optional[int]: + """Insert or update a VIN cache entry (30-day expiry).""" expires = datetime.utcnow() + timedelta(days=30) - conn = self._connect() - cursor = conn.cursor() - cursor.execute( + decoded = json_module.loads(data) if isinstance(data, str) else data + return self._execute( """ - INSERT OR REPLACE INTO vin_cache + INSERT INTO vin_cache (vin, decoded_data, make, model, year, engine_info, body_class, drive_type, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (:vin, :decoded_data, :make, :model, :year, + :engine_info, :body_class, :drive_type, :expires_at) + ON CONFLICT (vin) DO UPDATE SET + decoded_data = EXCLUDED.decoded_data, + make = EXCLUDED.make, + model = EXCLUDED.model, + year = EXCLUDED.year, + engine_info = EXCLUDED.engine_info, + body_class = EXCLUDED.body_class, + drive_type = EXCLUDED.drive_type, + expires_at = EXCLUDED.expires_at + RETURNING id """, - ( - vin.upper().strip(), - data, - make, - model, - year, - engine_info, - body_class, - drive_type, - expires.isoformat(), - ), + { + "vin": vin.upper().strip(), + "decoded_data": json_module.dumps(decoded), + "make": make, + "model": model, + "year": year, + "engine_info": engine_info, + "body_class": body_class, + "drive_type": drive_type, + "expires_at": expires.isoformat(), + }, ) - conn.commit() - self._cache.clear() - return cursor.lastrowid # ================================================================== # Stats @@ -569,44 +542,43 @@ class Database: def get_stats(self) -> dict: """Return counts for all major tables plus top brands by fitment.""" - conn = self._connect() - cursor = conn.cursor() - stats: dict = {} + session = self._session() + try: + stats: dict = {} + table_map = { + "brands": "brands", + "models": "models", + "years": "years", + "engines": "engines", + "part_categories": "part_categories", + "part_groups": "part_groups", + "parts": "parts", + "aftermarket_parts": "aftermarket_parts", + "manufacturers": "manufacturers", + "vehicle_parts": "vehicle_parts", + "part_cross_references": "part_cross_references", + } + for key, table in table_map.items(): + row = session.execute(text(f"SELECT COUNT(*) AS cnt FROM {table}")).mappings().one() + stats[key] = row["cnt"] - 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 + # Top brands by number of fitments + rows = session.execute(text(""" + SELECT b.name_brand AS name, COUNT(DISTINCT vp.id_vehicle_part) AS cnt + FROM brands b + JOIN models m ON m.brand_id = b.id_brand + JOIN model_year_engine mye ON mye.model_id = m.id_model + JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id_mye + GROUP BY b.name_brand + ORDER BY cnt DESC + LIMIT 10 + """)).mappings().all() + stats["top_brands"] = [ + {"name": r["name"], "count": r["cnt"]} for r in rows + ] + return stats + finally: + session.close() # ================================================================== # Admin — Manufacturers @@ -616,27 +588,40 @@ class Database: """Return all manufacturers ordered by name.""" return self._query( """ - SELECT id, name, type, quality_tier, country, logo_url, website - FROM manufacturers - ORDER BY name + SELECT m.id_manufacture AS id, m.name_manufacture AS name, + mt.name_type_manu AS type, + qt.name_quality AS quality_tier, + c.name_country AS country, + m.logo_url, m.website + FROM manufacturers m + LEFT JOIN manufacture_type mt ON m.id_type_manu = mt.id_type_manu + LEFT JOIN quality_tier qt ON m.id_quality_tier = qt.id_quality_tier + LEFT JOIN countries c ON m.id_country = c.id_country + ORDER BY m.name_manufacture """ ) - def create_manufacturer(self, data: dict) -> int: + def create_manufacturer(self, data: dict) -> Optional[int]: """Insert a new manufacturer and return its id.""" return self._execute( """ - INSERT INTO manufacturers (name, type, quality_tier, country, logo_url, website) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO manufacturers (name_manufacture, id_type_manu, id_quality_tier, + id_country, logo_url, website) + VALUES (:name, + (SELECT id_type_manu FROM manufacture_type WHERE name_type_manu = :type), + (SELECT id_quality_tier FROM quality_tier WHERE name_quality = :quality_tier), + (SELECT id_country FROM countries WHERE name_country = :country), + :logo_url, :website) + RETURNING id_manufacture """, - ( - data["name"], - data.get("type"), - data.get("quality_tier"), - data.get("country"), - data.get("logo_url"), - data.get("website"), - ), + { + "name": data["name"], + "type": data.get("type"), + "quality_tier": data.get("quality_tier"), + "country": data.get("country"), + "logo_url": data.get("logo_url"), + "website": data.get("website"), + }, ) def update_manufacturer(self, mfr_id: int, data: dict) -> None: @@ -644,47 +629,55 @@ class Database: self._execute( """ UPDATE manufacturers - SET name = ?, type = ?, quality_tier = ?, country = ?, logo_url = ?, website = ? - WHERE id = ? + SET name_manufacture = :name, + id_type_manu = (SELECT id_type_manu FROM manufacture_type WHERE name_type_manu = :type), + id_quality_tier = (SELECT id_quality_tier FROM quality_tier WHERE name_quality = :quality_tier), + id_country = (SELECT id_country FROM countries WHERE name_country = :country), + logo_url = :logo_url, website = :website + WHERE id_manufacture = :mfr_id """, - ( - data["name"], - data.get("type"), - data.get("quality_tier"), - data.get("country"), - data.get("logo_url"), - data.get("website"), - mfr_id, - ), + { + "name": data["name"], + "type": data.get("type"), + "quality_tier": data.get("quality_tier"), + "country": data.get("country"), + "logo_url": data.get("logo_url"), + "website": data.get("website"), + "mfr_id": mfr_id, + }, ) def delete_manufacturer(self, mfr_id: int) -> None: """Delete a manufacturer by id.""" - self._execute("DELETE FROM manufacturers WHERE id = ?", (mfr_id,)) + self._execute("DELETE FROM manufacturers WHERE id_manufacture = :id", {"id": mfr_id}) # ================================================================== # Admin — Parts # ================================================================== - def create_part(self, data: dict) -> int: + def create_part(self, data: dict) -> Optional[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 (?, ?, ?, ?, ?, ?, ?, ?) + (oem_part_number, name_part, name_es, group_id, + description, description_es, weight_kg, + id_material) + VALUES (:oem_part_number, :name, :name_es, :group_id, + :description, :description_es, :weight_kg, + (SELECT id_material FROM materials WHERE name_material = :material)) + RETURNING id_part """, - ( - 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"), - ), + { + "oem_part_number": data["oem_part_number"], + "name": data["name"], + "name_es": data.get("name_es"), + "group_id": data.get("group_id"), + "description": data.get("description"), + "description_es": data.get("description_es"), + "weight_kg": data.get("weight_kg"), + "material": data.get("material"), + }, ) def update_part(self, part_id: int, data: dict) -> None: @@ -692,46 +685,52 @@ class Database: self._execute( """ UPDATE parts - SET oem_part_number = ?, name = ?, name_es = ?, group_id = ?, - description = ?, description_es = ?, weight_kg = ?, material = ? - WHERE id = ? + SET oem_part_number = :oem_part_number, name_part = :name, + name_es = :name_es, group_id = :group_id, + description = :description, description_es = :description_es, + weight_kg = :weight_kg, + id_material = (SELECT id_material FROM materials WHERE name_material = :material) + WHERE id_part = :part_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, - ), + { + "oem_part_number": data["oem_part_number"], + "name": data["name"], + "name_es": data.get("name_es"), + "group_id": data.get("group_id"), + "description": data.get("description"), + "description_es": data.get("description_es"), + "weight_kg": data.get("weight_kg"), + "material": data.get("material"), + "part_id": part_id, + }, ) def delete_part(self, part_id: int) -> None: """Delete a part by id.""" - self._execute("DELETE FROM parts WHERE id = ?", (part_id,)) + self._execute("DELETE FROM parts WHERE id_part = :id", {"id": part_id}) # ================================================================== # Admin — Cross-references # ================================================================== - def create_crossref(self, data: dict) -> int: + def create_crossref(self, data: dict) -> Optional[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 (?, ?, ?, ?, ?) + (part_id, cross_reference_number, id_ref_type, source_ref, notes) + VALUES (:part_id, :cross_reference_number, + (SELECT id_ref_type FROM reference_type WHERE name_ref_type = :reference_type), + :source, :notes) + RETURNING id_part_cross_ref """, - ( - data["part_id"], - data["cross_reference_number"], - data.get("reference_type"), - data.get("source"), - data.get("notes"), - ), + { + "part_id": data["part_id"], + "cross_reference_number": data["cross_reference_number"], + "reference_type": data.get("reference_type"), + "source": data.get("source"), + "notes": data.get("notes"), + }, ) def update_crossref(self, xref_id: int, data: dict) -> None: @@ -739,24 +738,25 @@ class Database: self._execute( """ UPDATE part_cross_references - SET part_id = ?, cross_reference_number = ?, - reference_type = ?, source = ?, notes = ? - WHERE id = ? + SET part_id = :part_id, cross_reference_number = :cross_reference_number, + id_ref_type = (SELECT id_ref_type FROM reference_type WHERE name_ref_type = :reference_type), + source_ref = :source, notes = :notes + WHERE id_part_cross_ref = :xref_id """, - ( - data["part_id"], - data["cross_reference_number"], - data.get("reference_type"), - data.get("source"), - data.get("notes"), - xref_id, - ), + { + "part_id": data["part_id"], + "cross_reference_number": data["cross_reference_number"], + "reference_type": data.get("reference_type"), + "source": data.get("source"), + "notes": data.get("notes"), + "xref_id": 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,) + "DELETE FROM part_cross_references WHERE id_part_cross_ref = :id", {"id": xref_id} ) def get_crossrefs_paginated( @@ -768,18 +768,19 @@ class Database: return self._query( """ SELECT - pcr.id, + pcr.id_part_cross_ref AS id, pcr.part_id, pcr.cross_reference_number, - pcr.reference_type, - pcr.source, + rt.name_ref_type AS reference_type, + pcr.source_ref AS source, pcr.notes, p.oem_part_number, - p.name AS part_name + p.name_part AS part_name FROM part_cross_references pcr - JOIN parts p ON pcr.part_id = p.id - ORDER BY pcr.id - LIMIT ? OFFSET ? + JOIN parts p ON pcr.part_id = p.id_part + LEFT JOIN reference_type rt ON pcr.id_ref_type = rt.id_ref_type + ORDER BY pcr.id_part_cross_ref + LIMIT :limit OFFSET :offset """, - (per_page, offset), + {"limit": per_page, "offset": offset}, ) diff --git a/console/main.py b/console/main.py index 055a6c0..eb2b58f 100644 --- a/console/main.py +++ b/console/main.py @@ -1,5 +1,5 @@ """ -Entry point for the AUTOPARTES Pick/VT220-style console application. +Entry point for the NEXUS AUTOPARTS Pick/VT220-style console application. Usage: python -m console # via package diff --git a/console/renderers/base.py b/console/renderers/base.py index c879776..bc1e6c0 100644 --- a/console/renderers/base.py +++ b/console/renderers/base.py @@ -1,5 +1,5 @@ """ -Abstract base renderer interface for the AUTOPARTES console application. +Abstract base renderer interface for the NEXUS AUTOPARTS console application. Every renderer (curses VT220, Textual/Rich, etc.) must subclass :class:`BaseRenderer` and implement all of its methods. Screens call diff --git a/console/renderers/curses_renderer.py b/console/renderers/curses_renderer.py index d62699b..ea4fcc7 100644 --- a/console/renderers/curses_renderer.py +++ b/console/renderers/curses_renderer.py @@ -1,5 +1,5 @@ """ -Curses-based VT220 renderer for the AUTOPARTES console application. +Curses-based VT220 renderer for the NEXUS AUTOPARTS 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 diff --git a/console/screens/admin_crossref.py b/console/screens/admin_crossref.py index f3e801e..bcde6be 100644 --- a/console/screens/admin_crossref.py +++ b/console/screens/admin_crossref.py @@ -1,5 +1,5 @@ """ -Admin CRUD screen for Cross-References in the AUTOPARTES console application. +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. diff --git a/console/screens/admin_fabricantes.py b/console/screens/admin_fabricantes.py index 2c3167d..8fd039f 100644 --- a/console/screens/admin_fabricantes.py +++ b/console/screens/admin_fabricantes.py @@ -1,5 +1,5 @@ """ -Admin CRUD screen for Manufacturers in the AUTOPARTES console application. +Admin CRUD screen for Manufacturers in the NEXUS AUTOPARTS console application. Provides a list view with create (F3), edit (ENTER), and delete (F8/Del) operations for the manufacturers table. diff --git a/console/screens/admin_import.py b/console/screens/admin_import.py index 4326613..1562fad 100644 --- a/console/screens/admin_import.py +++ b/console/screens/admin_import.py @@ -1,5 +1,5 @@ """ -Import/Export screen for the AUTOPARTES console application. +Import/Export screen for the NEXUS AUTOPARTS 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 diff --git a/console/screens/admin_partes.py b/console/screens/admin_partes.py index 512fdaf..f34cdd3 100644 --- a/console/screens/admin_partes.py +++ b/console/screens/admin_partes.py @@ -1,5 +1,5 @@ """ -Admin CRUD screen for Parts in the AUTOPARTES console application. +Admin CRUD screen for Parts in the NEXUS AUTOPARTS console application. Provides a paginated list view with create (F3), edit (ENTER), and delete (F8/Del) operations. Form editing is handled inline with diff --git a/console/screens/buscar_parte.py b/console/screens/buscar_parte.py index 782a5b3..5aa8e2a 100644 --- a/console/screens/buscar_parte.py +++ b/console/screens/buscar_parte.py @@ -1,5 +1,5 @@ """ -Part number search screen for the AUTOPARTES console application. +Part number search screen for the NEXUS AUTOPARTS 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 diff --git a/console/screens/buscar_texto.py b/console/screens/buscar_texto.py index 181c104..a32e903 100644 --- a/console/screens/buscar_texto.py +++ b/console/screens/buscar_texto.py @@ -1,5 +1,5 @@ """ -Full-text search screen for the AUTOPARTES console application. +Full-text search screen for the NEXUS AUTOPARTS 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 diff --git a/console/screens/catalogo.py b/console/screens/catalogo.py index 93fa923..7f327fe 100644 --- a/console/screens/catalogo.py +++ b/console/screens/catalogo.py @@ -1,5 +1,5 @@ """ -Catalog navigation screen for the AUTOPARTES console application. +Catalog navigation screen for the NEXUS AUTOPARTS console application. Provides a three-level drill-down through the parts hierarchy: Categories -> Groups -> Parts. An optional vehicle filter (mye_id) diff --git a/console/screens/comparador.py b/console/screens/comparador.py index 7a004fb..2fea444 100644 --- a/console/screens/comparador.py +++ b/console/screens/comparador.py @@ -1,5 +1,5 @@ """ -Part comparator screen for the AUTOPARTES console application. +Part comparator screen for the NEXUS AUTOPARTS 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 diff --git a/console/screens/estadisticas.py b/console/screens/estadisticas.py index c8dd997..13ad9ef 100644 --- a/console/screens/estadisticas.py +++ b/console/screens/estadisticas.py @@ -1,5 +1,5 @@ """ -Statistics dashboard screen for the AUTOPARTES console application. +Statistics dashboard screen for the NEXUS AUTOPARTS console application. Displays database table counts and coverage metrics retrieved via :meth:`Database.get_stats`. diff --git a/console/screens/menu_principal.py b/console/screens/menu_principal.py index 7c89ccd..750ac58 100644 --- a/console/screens/menu_principal.py +++ b/console/screens/menu_principal.py @@ -1,5 +1,5 @@ """ -Main menu screen for the AUTOPARTES console application. +Main menu screen for the NEXUS AUTOPARTS console application. Displays a numbered Pick-style menu with navigation options for all application sections. Number keys jump directly; arrow keys move the diff --git a/console/screens/parte_detalle.py b/console/screens/parte_detalle.py index 3f84802..b0c1457 100644 --- a/console/screens/parte_detalle.py +++ b/console/screens/parte_detalle.py @@ -1,5 +1,5 @@ """ -Part detail screen for the AUTOPARTES console application. +Part detail screen for the NEXUS AUTOPARTS console application. Shows full part information (OEM number, name, group, category, etc.) with a table of aftermarket alternatives. Number keys navigate to diff --git a/console/screens/vehiculo_nav.py b/console/screens/vehiculo_nav.py index b46bea8..0700c1e 100644 --- a/console/screens/vehiculo_nav.py +++ b/console/screens/vehiculo_nav.py @@ -1,5 +1,5 @@ """ -Vehicle drill-down navigation screen for the AUTOPARTES console application. +Vehicle drill-down navigation screen for the NEXUS AUTOPARTS console application. Guides the user through a four-level hierarchy: diff --git a/console/screens/vin_decoder.py b/console/screens/vin_decoder.py index 3fe882a..acaf22f 100644 --- a/console/screens/vin_decoder.py +++ b/console/screens/vin_decoder.py @@ -1,5 +1,5 @@ """ -VIN decoder screen for the AUTOPARTES console application. +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 diff --git a/console/tests/test_integration.py b/console/tests/test_integration.py index 51206c0..f6bb92b 100644 --- a/console/tests/test_integration.py +++ b/console/tests/test_integration.py @@ -1,5 +1,5 @@ """ -Integration tests for the AUTOPARTES console application. +Integration tests for the NEXUS AUTOPARTS 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 diff --git a/console/utils/formatting.py b/console/utils/formatting.py index b14bdb4..1482532 100644 --- a/console/utils/formatting.py +++ b/console/utils/formatting.py @@ -1,5 +1,5 @@ """ -Display formatting utilities for the AUTOPARTES console application. +Display formatting utilities for the NEXUS AUTOPARTS console application. Functions for currency, numbers, text truncation, table layout, and quality-tier visual bars. diff --git a/console/utils/vin_api.py b/console/utils/vin_api.py index afefb8e..7f92709 100644 --- a/console/utils/vin_api.py +++ b/console/utils/vin_api.py @@ -1,5 +1,5 @@ """ -NHTSA VIN Decoder API client for the AUTOPARTES console application. +NHTSA VIN Decoder API client for the NEXUS AUTOPARTS console application. Wraps the National Highway Traffic Safety Administration (NHTSA) Vehicle Product Information Catalog (vPIC) DecodeVin endpoint to retrieve vehicle diff --git a/dashboard/admin.html b/dashboard/admin.html index ea991bc..61f98eb 100644 --- a/dashboard/admin.html +++ b/dashboard/admin.html @@ -3,7 +3,7 @@
-