Homepage Redesign
This commit is contained in:
0
archive/platform-services/vehicles/api/__init__.py
Normal file
0
archive/platform-services/vehicles/api/__init__.py
Normal file
50
archive/platform-services/vehicles/api/config.py
Normal file
50
archive/platform-services/vehicles/api/config.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import os
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List
|
||||
|
||||
# Docker-first: load secrets from mounted files when env vars are absent
|
||||
_PG_SECRET_FILE = os.getenv("POSTGRES_PASSWORD_FILE", "/run/secrets/postgres-password")
|
||||
if not os.getenv("POSTGRES_PASSWORD"):
|
||||
try:
|
||||
with open(_PG_SECRET_FILE, 'r') as f:
|
||||
os.environ["POSTGRES_PASSWORD"] = f.read().strip()
|
||||
except Exception:
|
||||
# Leave as-is; connection will fail loudly if missing
|
||||
pass
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application configuration"""
|
||||
|
||||
# Database settings (shared mvp-postgres)
|
||||
POSTGRES_HOST: str = os.getenv("POSTGRES_HOST", "mvp-postgres")
|
||||
POSTGRES_PORT: int = int(os.getenv("POSTGRES_PORT", "5432"))
|
||||
POSTGRES_USER: str = os.getenv("POSTGRES_USER", "postgres")
|
||||
POSTGRES_PASSWORD: str = os.getenv("POSTGRES_PASSWORD", "")
|
||||
POSTGRES_DATABASE: str = os.getenv("POSTGRES_DATABASE", "motovaultpro")
|
||||
|
||||
# Redis settings (shared mvp-redis)
|
||||
REDIS_HOST: str = os.getenv("REDIS_HOST", "mvp-redis")
|
||||
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
|
||||
REDIS_DB: int = int(os.getenv("REDIS_DB", "1")) # Use DB 1 to separate from backend
|
||||
|
||||
# Database connection pool settings
|
||||
DATABASE_MIN_CONNECTIONS: int = int(os.getenv("DATABASE_MIN_CONNECTIONS", "5"))
|
||||
DATABASE_MAX_CONNECTIONS: int = int(os.getenv("DATABASE_MAX_CONNECTIONS", "20"))
|
||||
|
||||
# Cache settings
|
||||
CACHE_TTL: int = int(os.getenv("CACHE_TTL", "3600")) # 1 hour default
|
||||
|
||||
# Application settings
|
||||
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
|
||||
CORS_ORIGINS: List[str] = [
|
||||
"http://localhost:3000",
|
||||
"https://motovaultpro.com",
|
||||
"http://localhost:3001"
|
||||
]
|
||||
|
||||
class Config:
|
||||
case_sensitive = True
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Get application settings"""
|
||||
return Settings()
|
||||
40
archive/platform-services/vehicles/api/dependencies.py
Normal file
40
archive/platform-services/vehicles/api/dependencies.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import asyncpg
|
||||
import redis.asyncio as redis
|
||||
from fastapi import Request, Depends, HTTPException
|
||||
import logging
|
||||
from .config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
async def get_db_pool(request: Request) -> asyncpg.Pool:
|
||||
"""Get database pool from app state"""
|
||||
return request.app.state.db_pool
|
||||
|
||||
async def get_db(request: Request) -> asyncpg.Connection:
|
||||
"""Get database connection"""
|
||||
pool = await get_db_pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
yield conn
|
||||
|
||||
async def get_redis_client(request: Request) -> redis.Redis:
|
||||
"""Get Redis client from app state"""
|
||||
return request.app.state.redis_client
|
||||
|
||||
async def get_cache(request: Request):
|
||||
"""Get cache service from app state"""
|
||||
return request.app.state.cache_service
|
||||
|
||||
async def verify_bearer_token(request: Request) -> str:
|
||||
"""Verify Bearer token for service-to-service authentication
|
||||
|
||||
Expects header: Authorization: Bearer <token>
|
||||
Compares token to settings.API_KEY
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
if token != settings.API_KEY:
|
||||
raise HTTPException(status_code=401, detail="Invalid service token")
|
||||
return token
|
||||
202
archive/platform-services/vehicles/api/main.py
Normal file
202
archive/platform-services/vehicles/api/main.py
Normal file
@@ -0,0 +1,202 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request, HTTPException, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
import asyncpg
|
||||
import redis.asyncio as redis
|
||||
import time
|
||||
|
||||
from .config import get_settings
|
||||
from .dependencies import get_db_pool, get_redis_client, get_cache, verify_bearer_token
|
||||
from .routes import vehicles, vin
|
||||
from .models.responses import HealthResponse
|
||||
from .services.cache_service import CacheService
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan manager"""
|
||||
# Startup
|
||||
logger.info("Starting MVP Platform Vehicles API...")
|
||||
|
||||
# Initialize database pool
|
||||
try:
|
||||
app.state.db_pool = await asyncpg.create_pool(
|
||||
host=settings.POSTGRES_HOST,
|
||||
port=settings.POSTGRES_PORT,
|
||||
user=settings.POSTGRES_USER,
|
||||
password=settings.POSTGRES_PASSWORD,
|
||||
database=settings.POSTGRES_DATABASE,
|
||||
min_size=settings.DATABASE_MIN_CONNECTIONS,
|
||||
max_size=settings.DATABASE_MAX_CONNECTIONS,
|
||||
command_timeout=30
|
||||
)
|
||||
logger.info("Database pool initialized")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize database pool: {e}")
|
||||
raise
|
||||
|
||||
# Initialize Redis client
|
||||
try:
|
||||
app.state.redis_client = redis.Redis(
|
||||
host=settings.REDIS_HOST,
|
||||
port=settings.REDIS_PORT,
|
||||
db=settings.REDIS_DB,
|
||||
decode_responses=False,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5
|
||||
)
|
||||
# Test connection
|
||||
await app.state.redis_client.ping()
|
||||
logger.info("Redis client initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize Redis client: {e}")
|
||||
app.state.redis_client = None
|
||||
|
||||
# Initialize cache service
|
||||
app.state.cache_service = CacheService(
|
||||
app.state.redis_client,
|
||||
enabled=bool(app.state.redis_client),
|
||||
default_ttl=settings.CACHE_TTL
|
||||
)
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down MVP Platform Vehicles API...")
|
||||
|
||||
if hasattr(app.state, 'db_pool') and app.state.db_pool:
|
||||
await app.state.db_pool.close()
|
||||
logger.info("Database pool closed")
|
||||
|
||||
if hasattr(app.state, 'redis_client') and app.state.redis_client:
|
||||
await app.state.redis_client.aclose()
|
||||
logger.info("Redis client closed")
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="MVP Platform Vehicles API",
|
||||
description="Hierarchical Vehicle API with VIN decoding for MotoVaultPro platform services",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if settings.DEBUG else None,
|
||||
redoc_url="/redoc" if settings.DEBUG else None
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Request timing middleware
|
||||
@app.middleware("http")
|
||||
async def add_process_time_header(request: Request, call_next):
|
||||
start_time = time.time()
|
||||
response = await call_next(request)
|
||||
process_time = time.time() - start_time
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
return response
|
||||
|
||||
# Global exception handler
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
logger.error(f"Unhandled exception in {request.method} {request.url.path}: {exc}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"}
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(vehicles.router, prefix="/api/v1", dependencies=[Depends(verify_bearer_token)])
|
||||
app.include_router(vin.router, prefix="/api/v1", dependencies=[Depends(verify_bearer_token)])
|
||||
|
||||
# Health check endpoint
|
||||
@app.api_route("/health", methods=["GET", "HEAD"], response_model=HealthResponse)
|
||||
async def health_check(request: Request):
|
||||
"""Health check endpoint"""
|
||||
db_status = "ok"
|
||||
cache_status = "ok"
|
||||
|
||||
# Check database
|
||||
try:
|
||||
db_pool = request.app.state.db_pool
|
||||
async with db_pool.acquire() as conn:
|
||||
await conn.fetchval("SELECT 1")
|
||||
except Exception as e:
|
||||
logger.error(f"Database health check failed: {e}")
|
||||
db_status = "error"
|
||||
|
||||
# Check cache
|
||||
try:
|
||||
cache = request.app.state.cache_service
|
||||
if cache and cache.enabled:
|
||||
await cache.redis.ping()
|
||||
else:
|
||||
cache_status = "disabled"
|
||||
except Exception as e:
|
||||
logger.error(f"Cache health check failed: {e}")
|
||||
cache_status = "error"
|
||||
|
||||
overall_status = "ok" if db_status == "ok" else "degraded"
|
||||
|
||||
return HealthResponse(
|
||||
status=overall_status,
|
||||
database=db_status,
|
||||
cache=cache_status,
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint with API information"""
|
||||
return {
|
||||
"name": "MVP Platform Vehicles API",
|
||||
"version": "1.0.0",
|
||||
"description": "Hierarchical Vehicle API with VIN decoding",
|
||||
"docs_url": "/docs" if settings.DEBUG else "Contact administrator for documentation",
|
||||
"endpoints": {
|
||||
"health": "/health",
|
||||
"makes": "/api/v1/vehicles/makes?year=2024",
|
||||
"models": "/api/v1/vehicles/models?year=2024&make_id=1",
|
||||
"trims": "/api/v1/vehicles/trims?year=2024&make_id=1&model_id=1",
|
||||
"engines": "/api/v1/vehicles/engines?year=2024&make_id=1&model_id=1",
|
||||
"transmissions": "/api/v1/vehicles/transmissions?year=2024&make_id=1&model_id=1",
|
||||
"vin_decode": "/api/v1/vehicles/vindecode"
|
||||
}
|
||||
}
|
||||
|
||||
# Cache stats endpoint
|
||||
@app.get("/api/v1/cache/stats")
|
||||
async def cache_stats(request: Request, token: str = Depends(verify_bearer_token)):
|
||||
"""Get cache statistics"""
|
||||
try:
|
||||
cache = request.app.state.cache_service
|
||||
stats = await cache.get_stats()
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get cache stats: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve cache statistics")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"api.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=settings.DEBUG,
|
||||
log_level="info"
|
||||
)
|
||||
84
archive/platform-services/vehicles/api/models/responses.py
Normal file
84
archive/platform-services/vehicles/api/models/responses.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
class MakeItem(BaseModel):
|
||||
"""Make item response model"""
|
||||
id: int
|
||||
name: str
|
||||
|
||||
class ModelItem(BaseModel):
|
||||
"""Model item response model"""
|
||||
id: int
|
||||
name: str
|
||||
|
||||
class TrimItem(BaseModel):
|
||||
"""Trim item response model"""
|
||||
id: int
|
||||
name: str
|
||||
|
||||
class EngineItem(BaseModel):
|
||||
"""Engine item response model"""
|
||||
id: int
|
||||
name: str
|
||||
|
||||
class TransmissionItem(BaseModel):
|
||||
"""Transmission item response model"""
|
||||
name: str
|
||||
|
||||
class MakesResponse(BaseModel):
|
||||
"""Makes response model"""
|
||||
makes: List[MakeItem]
|
||||
|
||||
class YearsResponse(BaseModel):
|
||||
"""Years response model"""
|
||||
years: List[int]
|
||||
|
||||
class ModelsResponse(BaseModel):
|
||||
"""Models response model"""
|
||||
models: List[ModelItem]
|
||||
|
||||
class TrimsResponse(BaseModel):
|
||||
"""Trims response model"""
|
||||
trims: List[TrimItem]
|
||||
|
||||
class EnginesResponse(BaseModel):
|
||||
"""Engines response model"""
|
||||
engines: List[EngineItem]
|
||||
|
||||
class TransmissionsResponse(BaseModel):
|
||||
"""Transmissions response model"""
|
||||
transmissions: List[TransmissionItem]
|
||||
|
||||
class VINDecodeResult(BaseModel):
|
||||
"""VIN decode result model"""
|
||||
make: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
year: Optional[int] = None
|
||||
trim_name: Optional[str] = None
|
||||
engine_description: Optional[str] = None
|
||||
transmission_description: Optional[str] = None
|
||||
horsepower: Optional[float] = None
|
||||
torque: Optional[float] = None
|
||||
top_speed: Optional[float] = None
|
||||
fuel: Optional[str] = None
|
||||
confidence_score: Optional[float] = None
|
||||
vehicle_type: Optional[str] = None
|
||||
|
||||
class VINDecodeRequest(BaseModel):
|
||||
"""VIN decode request model"""
|
||||
vin: str
|
||||
|
||||
class VINDecodeResponse(BaseModel):
|
||||
"""VIN decode response model"""
|
||||
vin: str
|
||||
result: Optional[VINDecodeResult]
|
||||
success: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response model"""
|
||||
status: str
|
||||
database: str
|
||||
cache: str
|
||||
version: str
|
||||
etl_last_run: Optional[str] = None
|
||||
@@ -0,0 +1,79 @@
|
||||
import asyncpg
|
||||
from typing import List, Dict
|
||||
|
||||
|
||||
class VehiclesRepository:
|
||||
"""Repository for hierarchical vehicle queries against normalized schema"""
|
||||
|
||||
async def get_years(self, db: asyncpg.Connection) -> List[int]:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT DISTINCT year
|
||||
FROM vehicles.model_year
|
||||
ORDER BY year DESC
|
||||
"""
|
||||
)
|
||||
return [r["year"] for r in rows]
|
||||
|
||||
async def get_makes(self, db: asyncpg.Connection, year: int) -> List[Dict]:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ma.id, ma.name
|
||||
FROM vehicles.make ma
|
||||
JOIN vehicles.model mo ON mo.make_id = ma.id
|
||||
JOIN vehicles.model_year my ON my.model_id = mo.id AND my.year = $1
|
||||
ORDER BY ma.name
|
||||
""",
|
||||
year,
|
||||
)
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
|
||||
async def get_models(self, db: asyncpg.Connection, year: int, make_id: int) -> List[Dict]:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT DISTINCT mo.id, mo.name
|
||||
FROM vehicles.model mo
|
||||
JOIN vehicles.model_year my ON my.model_id = mo.id AND my.year = $1
|
||||
WHERE mo.make_id = $2
|
||||
ORDER BY mo.name
|
||||
""",
|
||||
year,
|
||||
make_id,
|
||||
)
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
|
||||
async def get_trims(self, db: asyncpg.Connection, year: int, model_id: int) -> List[Dict]:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT t.id, t.name
|
||||
FROM vehicles.trim t
|
||||
JOIN vehicles.model_year my ON my.id = t.model_year_id
|
||||
WHERE my.year = $1 AND my.model_id = $2
|
||||
ORDER BY t.name
|
||||
""",
|
||||
year,
|
||||
model_id,
|
||||
)
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
|
||||
async def get_engines(
|
||||
self, db: asyncpg.Connection, year: int, model_id: int, trim_id: int
|
||||
) -> List[Dict]:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT DISTINCT e.id, e.name
|
||||
FROM vehicles.engine e
|
||||
JOIN vehicles.trim_engine te ON te.engine_id = e.id
|
||||
JOIN vehicles.trim t ON t.id = te.trim_id
|
||||
JOIN vehicles.model_year my ON my.id = t.model_year_id
|
||||
WHERE my.year = $1
|
||||
AND my.model_id = $2
|
||||
AND t.id = $3
|
||||
ORDER BY e.name
|
||||
""",
|
||||
year,
|
||||
model_id,
|
||||
trim_id,
|
||||
)
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
|
||||
116
archive/platform-services/vehicles/api/routes/vehicles.py
Normal file
116
archive/platform-services/vehicles/api/routes/vehicles.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
import asyncpg
|
||||
from ..dependencies import get_db, get_cache
|
||||
# DropdownService deprecated; using normalized schema service
|
||||
from ..services.vehicles_service import VehiclesService
|
||||
from ..repositories.vehicles_repository import VehiclesRepository
|
||||
from ..services.cache_service import CacheService
|
||||
from ..models.responses import (
|
||||
MakesResponse, ModelsResponse, TrimsResponse,
|
||||
EnginesResponse,
|
||||
MakeItem, ModelItem, TrimItem, EngineItem
|
||||
)
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/vehicles", tags=["Vehicles"])
|
||||
|
||||
@router.get("/years", response_model=list[int])
|
||||
async def get_years(
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
cache: CacheService = Depends(get_cache),
|
||||
):
|
||||
"""Get available model years (distinct, desc)"""
|
||||
service = VehiclesService(cache, VehiclesRepository())
|
||||
return await service.get_years(db)
|
||||
|
||||
@router.get("/makes", response_model=MakesResponse)
|
||||
async def get_makes(
|
||||
year: int = Query(..., description="Model year", ge=1980, le=2050),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
cache: CacheService = Depends(get_cache)
|
||||
):
|
||||
"""Get makes for a specific year
|
||||
|
||||
Hierarchical API: First level - requires year parameter only
|
||||
"""
|
||||
try:
|
||||
service = VehiclesService(cache, VehiclesRepository())
|
||||
makes = await service.get_makes(db, year)
|
||||
return MakesResponse(makes=[MakeItem(**m) for m in makes])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get makes for year {year}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to retrieve makes for year {year}"
|
||||
)
|
||||
|
||||
@router.get("/models", response_model=ModelsResponse)
|
||||
async def get_models(
|
||||
year: int = Query(..., description="Model year", ge=1980, le=2050),
|
||||
make_id: int = Query(..., description="Make ID", ge=1),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
cache: CacheService = Depends(get_cache)
|
||||
):
|
||||
"""Get models for year and make
|
||||
|
||||
Hierarchical API: Second level - requires year and make_id parameters
|
||||
"""
|
||||
try:
|
||||
service = VehiclesService(cache, VehiclesRepository())
|
||||
models = await service.get_models(db, year, make_id)
|
||||
return ModelsResponse(models=[ModelItem(**m) for m in models])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get models for year {year}, make {make_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to retrieve models for year {year}, make {make_id}"
|
||||
)
|
||||
|
||||
@router.get("/trims", response_model=TrimsResponse)
|
||||
async def get_trims(
|
||||
year: int = Query(..., description="Model year", ge=1980, le=2050),
|
||||
make_id: int = Query(..., description="Make ID", ge=1),
|
||||
model_id: int = Query(..., description="Model ID", ge=1),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
cache: CacheService = Depends(get_cache)
|
||||
):
|
||||
"""Get trims for year, make, and model
|
||||
|
||||
Hierarchical API: Third level - requires year, make_id, and model_id parameters
|
||||
"""
|
||||
try:
|
||||
service = VehiclesService(cache, VehiclesRepository())
|
||||
trims = await service.get_trims(db, year, model_id)
|
||||
return TrimsResponse(trims=[TrimItem(**t) for t in trims])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get trims for year {year}, make {make_id}, model {model_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to retrieve trims for year {year}, make {make_id}, model {model_id}"
|
||||
)
|
||||
|
||||
@router.get("/engines", response_model=EnginesResponse)
|
||||
async def get_engines(
|
||||
year: int = Query(..., description="Model year", ge=1980, le=2050),
|
||||
make_id: int = Query(..., description="Make ID", ge=1),
|
||||
model_id: int = Query(..., description="Model ID", ge=1),
|
||||
trim_id: int = Query(..., description="Trim ID", ge=1),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
cache: CacheService = Depends(get_cache)
|
||||
):
|
||||
"""Get engines for year, make, model, and trim"""
|
||||
try:
|
||||
service = VehiclesService(cache, VehiclesRepository())
|
||||
engines = await service.get_engines(db, year, model_id, trim_id)
|
||||
return EnginesResponse(engines=[EngineItem(**e) for e in engines])
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to get engines for year {year}, make {make_id}, model {model_id}, trim {trim_id}: {e}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
f"Failed to retrieve engines for year {year}, make {make_id}, model {model_id}, trim {trim_id}"
|
||||
)
|
||||
)
|
||||
110
archive/platform-services/vehicles/api/routes/vin.py
Normal file
110
archive/platform-services/vehicles/api/routes/vin.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
import asyncpg
|
||||
from ..dependencies import get_db, get_cache
|
||||
from ..services.cache_service import CacheService
|
||||
from ..models.responses import VINDecodeRequest, VINDecodeResponse, VINDecodeResult
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/vehicles", tags=["VIN Decoding"])
|
||||
|
||||
def validate_vin(vin: str) -> bool:
|
||||
"""Validate VIN format"""
|
||||
if len(vin) != 17:
|
||||
return False
|
||||
|
||||
# VIN cannot contain I, O, Q
|
||||
if any(char in vin.upper() for char in ['I', 'O', 'Q']):
|
||||
return False
|
||||
|
||||
# Must be alphanumeric
|
||||
if not re.match(r'^[A-HJ-NPR-Z0-9]{17}$', vin.upper()):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@router.post("/vindecode", response_model=VINDecodeResponse)
|
||||
async def decode_vin(
|
||||
request: VINDecodeRequest,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
cache: CacheService = Depends(get_cache)
|
||||
):
|
||||
"""Decode VIN using PostgreSQL function with MSSQL parity
|
||||
|
||||
Uses the vehicles.f_decode_vin() function to decode VIN with confidence scoring
|
||||
"""
|
||||
vin = request.vin.upper().strip()
|
||||
|
||||
# Validate VIN format
|
||||
if not validate_vin(vin):
|
||||
return VINDecodeResponse(
|
||||
vin=vin,
|
||||
result=None,
|
||||
success=False,
|
||||
error="Invalid VIN format"
|
||||
)
|
||||
|
||||
# Check cache first
|
||||
cache_key = f"vin:decode:{vin}"
|
||||
cached_result = await cache.get(cache_key)
|
||||
if cached_result:
|
||||
logger.debug(f"VIN decode result for {vin} retrieved from cache")
|
||||
return VINDecodeResponse(**cached_result)
|
||||
|
||||
try:
|
||||
# Call PostgreSQL VIN decode function
|
||||
query = """
|
||||
SELECT * FROM vehicles.f_decode_vin($1)
|
||||
"""
|
||||
|
||||
row = await db.fetchrow(query, vin)
|
||||
|
||||
if row:
|
||||
result = VINDecodeResult(
|
||||
make=row['make'],
|
||||
model=row['model'],
|
||||
year=row['year'],
|
||||
trim_name=row['trim_name'],
|
||||
engine_description=row['engine_description'],
|
||||
transmission_description=row['transmission_description'],
|
||||
horsepower=row.get('horsepower'),
|
||||
torque=row.get('torque'),
|
||||
top_speed=row.get('top_speed'),
|
||||
fuel=row.get('fuel'),
|
||||
confidence_score=float(row['confidence_score']) if row['confidence_score'] else 0.0,
|
||||
vehicle_type=row.get('vehicle_type')
|
||||
)
|
||||
|
||||
response = VINDecodeResponse(
|
||||
vin=vin,
|
||||
result=result,
|
||||
success=True
|
||||
)
|
||||
|
||||
# Cache successful decode for 30 days
|
||||
await cache.set(cache_key, response.dict(), ttl=30*24*3600)
|
||||
|
||||
logger.info(f"Successfully decoded VIN {vin}: {result.make} {result.model} {result.year}")
|
||||
return response
|
||||
else:
|
||||
# No result found
|
||||
response = VINDecodeResponse(
|
||||
vin=vin,
|
||||
result=None,
|
||||
success=False,
|
||||
error="VIN not found in database"
|
||||
)
|
||||
|
||||
# Cache negative result for 1 hour
|
||||
await cache.set(cache_key, response.dict(), ttl=3600)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decode VIN {vin}: {e}")
|
||||
return VINDecodeResponse(
|
||||
vin=vin,
|
||||
result=None,
|
||||
success=False,
|
||||
error="Internal server error during VIN decoding"
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
import redis.asyncio as redis
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class CacheService:
|
||||
"""Redis cache service with JSON serialization"""
|
||||
|
||||
def __init__(self, redis_client: Optional[redis.Redis], enabled: bool = True, default_ttl: int = 3600):
|
||||
self.redis = redis_client
|
||||
self.enabled = enabled and redis_client is not None
|
||||
self.default_ttl = default_ttl
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
"""Get value from cache"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
value = await self.redis.get(key)
|
||||
if value:
|
||||
return json.loads(value)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Cache get error for key {key}: {e}")
|
||||
return None
|
||||
|
||||
async def set(self, key: str, value: Any, ttl: Optional[int] = None) -> bool:
|
||||
"""Set value in cache"""
|
||||
if not self.enabled:
|
||||
return False
|
||||
|
||||
try:
|
||||
ttl = ttl or self.default_ttl
|
||||
json_value = json.dumps(value, default=str) # Handle datetime objects
|
||||
await self.redis.setex(key, ttl, json_value)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Cache set error for key {key}: {e}")
|
||||
return False
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""Delete key from cache"""
|
||||
if not self.enabled:
|
||||
return False
|
||||
|
||||
try:
|
||||
deleted = await self.redis.delete(key)
|
||||
return deleted > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Cache delete error for key {key}: {e}")
|
||||
return False
|
||||
|
||||
async def invalidate_dropdown_cache(self) -> int:
|
||||
"""Invalidate all dropdown cache entries"""
|
||||
if not self.enabled:
|
||||
return 0
|
||||
|
||||
try:
|
||||
pattern = "dropdown:*"
|
||||
keys = await self.redis.keys(pattern)
|
||||
if keys:
|
||||
deleted = await self.redis.delete(*keys)
|
||||
logger.info(f"Invalidated {deleted} dropdown cache entries")
|
||||
return deleted
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Cache invalidation error: {e}")
|
||||
return 0
|
||||
|
||||
async def get_stats(self) -> dict:
|
||||
"""Get cache statistics"""
|
||||
if not self.enabled:
|
||||
return {"enabled": False}
|
||||
|
||||
try:
|
||||
info = await self.redis.info("memory")
|
||||
return {
|
||||
"enabled": True,
|
||||
"used_memory": info.get("used_memory_human"),
|
||||
"used_memory_peak": info.get("used_memory_peak_human"),
|
||||
"connected_clients": await self.redis.client_list()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Cache stats error: {e}")
|
||||
return {"enabled": True, "error": str(e)}
|
||||
@@ -0,0 +1,58 @@
|
||||
import asyncpg
|
||||
from typing import List, Dict
|
||||
from ..services.cache_service import CacheService
|
||||
from ..repositories.vehicles_repository import VehiclesRepository
|
||||
|
||||
|
||||
class VehiclesService:
|
||||
def __init__(self, cache: CacheService, repo: VehiclesRepository | None = None):
|
||||
self.cache = cache
|
||||
self.repo = repo or VehiclesRepository()
|
||||
|
||||
async def get_years(self, db: asyncpg.Connection) -> List[int]:
|
||||
cache_key = "dropdown:years"
|
||||
cached = await self.cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
years = await self.repo.get_years(db)
|
||||
await self.cache.set(cache_key, years, ttl=6 * 3600)
|
||||
return years
|
||||
|
||||
async def get_makes(self, db: asyncpg.Connection, year: int) -> List[Dict]:
|
||||
cache_key = f"dropdown:makes:{year}"
|
||||
cached = await self.cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
makes = await self.repo.get_makes(db, year)
|
||||
await self.cache.set(cache_key, makes, ttl=6 * 3600)
|
||||
return makes
|
||||
|
||||
async def get_models(self, db: asyncpg.Connection, year: int, make_id: int) -> List[Dict]:
|
||||
cache_key = f"dropdown:models:{year}:{make_id}"
|
||||
cached = await self.cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
models = await self.repo.get_models(db, year, make_id)
|
||||
await self.cache.set(cache_key, models, ttl=6 * 3600)
|
||||
return models
|
||||
|
||||
async def get_trims(self, db: asyncpg.Connection, year: int, model_id: int) -> List[Dict]:
|
||||
cache_key = f"dropdown:trims:{year}:{model_id}"
|
||||
cached = await self.cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
trims = await self.repo.get_trims(db, year, model_id)
|
||||
await self.cache.set(cache_key, trims, ttl=6 * 3600)
|
||||
return trims
|
||||
|
||||
async def get_engines(
|
||||
self, db: asyncpg.Connection, year: int, model_id: int, trim_id: int
|
||||
) -> List[Dict]:
|
||||
cache_key = f"dropdown:engines:{year}:{model_id}:{trim_id}"
|
||||
cached = await self.cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
engines = await self.repo.get_engines(db, year, model_id, trim_id)
|
||||
await self.cache.set(cache_key, engines, ttl=6 * 3600)
|
||||
return engines
|
||||
|
||||
Reference in New Issue
Block a user