Homepage Redesign

This commit is contained in:
Eric Gullickson
2025-11-03 14:06:54 -06:00
parent 54d97a98b5
commit eeb20543fa
71 changed files with 3925 additions and 1340 deletions

View File

@@ -0,0 +1,44 @@
# MVP Platform Vehicles Service
For full platform architecture and integration patterns, see `docs/PLATFORM-SERVICES.md`.
## Schema Bootstrapping (Docker-First)
- Database: PostgreSQL, service `mvp-platform-vehicles-db`.
- On first start, schema files from `mvp-platform-services/vehicles/sql/schema` are executed automatically because the folder is mounted to `/docker-entrypoint-initdb.d` in `docker-compose.yml`.
- Files run in lexicographic order:
- `001_schema.sql` creates `vehicles` schema and tables
- `002_constraints_indexes.sql` adds uniques and indexes
- `003_seed_minimal.sql` seeds minimal dropdown data for sanity checks
## When Do Files Run?
- Only on the initial database initialization (i.e., when the Postgres data volume is empty).
- Subsequent `make start` runs will not reapply these files unless you reset the volume.
## Applying Schema Changes
- Option 1 (fresh reset):
1. `make clean` to remove volumes
2. `make start` (the `.sql` files will be reapplied)
- Option 2 (manual apply to existing DB):
- Exec into the DB container and run the SQL files in order:
```bash
docker compose exec mvp-platform-vehicles-db bash -lc "psql -U mvp_platform_user -d vehicles -f /docker-entrypoint-initdb.d/001_schema.sql"
docker compose exec mvp-platform-vehicles-db bash -lc "psql -U mvp_platform_user -d vehicles -f /docker-entrypoint-initdb.d/002_constraints_indexes.sql"
docker compose exec mvp-platform-vehicles-db bash -lc "psql -U mvp_platform_user -d vehicles -f /docker-entrypoint-initdb.d/003_seed_minimal.sql"
```
## Quick Start
```bash
make start
make logs-platform-vehicles # View API + DB logs
```
## Endpoint Summary (Auth Required: Authorization: Bearer <API_KEY>)
- `GET /api/v1/vehicles/years` → `[number]`
- `GET /api/v1/vehicles/makes?year=YYYY` → `{ makes: [{id,name}] }`
- `GET /api/v1/vehicles/models?year=YYYY&make_id=ID` → `{ models: [...] }`
- `GET /api/v1/vehicles/trims?year=YYYY&make_id=ID&model_id=ID` → `{ trims: [...] }`
- `GET /api/v1/vehicles/engines?year=YYYY&make_id=ID&model_id=ID&trim_id=ID` → `{ engines: [...] }`
## Notes
- Transmissions and performance tables exist for future use; no endpoints yet.
- VIN decode endpoints are pending rebuild and not documented here.

View 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()

View 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

View 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"
)

View 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

View File

@@ -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]

View 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}"
)
)

View 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"
)

View File

@@ -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)}

View File

@@ -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

View File

@@ -0,0 +1,30 @@
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
wget \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements-api.txt .
RUN pip install --no-cache-dir -r requirements-api.txt
# Copy application code
COPY api/ ./api/
# Set Python path
ENV PYTHONPATH=/app
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:8000/health || exit 1
# Run application
CMD ["python", "-m", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,69 @@
{
"manufacturers": [
"Acura",
"Alfa Romeo",
"Aston Martin",
"Audi",
"BMW",
"Bentley",
"Buick",
"Cadillac",
"Chevrolet",
"Chrysler",
"Daewoo",
"Dodge",
"Eagle",
"Ferrari",
"Fiat",
"Fisker",
"Ford",
"GMC",
"Genesis",
"Geo",
"Honda",
"Hummer",
"Hyundai",
"Infiniti",
"Isuzu",
"Jaguar",
"Jeep",
"Kia",
"Lamborghini",
"Land Rover",
"Lexus",
"Lincoln",
"Lotus",
"Mazda",
"Maserati",
"Maybach",
"McLaren",
"Mercedes-Benz",
"Mercury",
"Mini",
"Mitsubishi",
"Nissan",
"Oldsmobile",
"Panoz",
"Plymouth",
"Polestar",
"Pontiac",
"Porsche",
"Ram",
"Rivian",
"Rolls Royce",
"Saab",
"Saturn",
"Scion",
"Smart",
"Subaru",
"Suzuki",
"Tesla",
"Toyota",
"Volkswagen",
"Volvo",
"Karma",
"Pagani",
"Koenigsegg",
"Lucid"
]
}

View File

@@ -0,0 +1,6 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
asyncpg==0.29.0
redis==5.0.1
pydantic==2.5.0
pydantic-settings==2.1.0

View File

@@ -0,0 +1,73 @@
-- Vehicles Platform Service Schema (baseline)
CREATE SCHEMA IF NOT EXISTS vehicles;
-- Makes
CREATE TABLE IF NOT EXISTS vehicles.make (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL
);
-- Models
CREATE TABLE IF NOT EXISTS vehicles.model (
id BIGSERIAL PRIMARY KEY,
make_id BIGINT NOT NULL REFERENCES vehicles.make(id) ON DELETE RESTRICT,
name TEXT NOT NULL
);
-- Model availability by year
CREATE TABLE IF NOT EXISTS vehicles.model_year (
id BIGSERIAL PRIMARY KEY,
model_id BIGINT NOT NULL REFERENCES vehicles.model(id) ON DELETE RESTRICT,
year INTEGER NOT NULL CHECK (year BETWEEN 1950 AND 2100)
);
-- Trims (year-specific)
CREATE TABLE IF NOT EXISTS vehicles.trim (
id BIGSERIAL PRIMARY KEY,
model_year_id BIGINT NOT NULL REFERENCES vehicles.model_year(id) ON DELETE RESTRICT,
name TEXT NOT NULL
);
-- Engines (canonical)
CREATE TABLE IF NOT EXISTS vehicles.engine (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
code TEXT NULL,
displacement_l NUMERIC(3,1) NULL,
cylinders SMALLINT NULL,
fuel_type TEXT NULL,
aspiration TEXT NULL
);
-- Trim to Engine mapping (many-to-many)
CREATE TABLE IF NOT EXISTS vehicles.trim_engine (
trim_id BIGINT NOT NULL REFERENCES vehicles.trim(id) ON DELETE RESTRICT,
engine_id BIGINT NOT NULL REFERENCES vehicles.engine(id) ON DELETE RESTRICT,
PRIMARY KEY (trim_id, engine_id)
);
-- Optional: Transmissions (reserved for future)
CREATE TABLE IF NOT EXISTS vehicles.transmission (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NULL,
gears SMALLINT NULL
);
CREATE TABLE IF NOT EXISTS vehicles.trim_transmission (
trim_id BIGINT NOT NULL REFERENCES vehicles.trim(id) ON DELETE RESTRICT,
transmission_id BIGINT NOT NULL REFERENCES vehicles.transmission(id) ON DELETE RESTRICT,
PRIMARY KEY (trim_id, transmission_id)
);
-- Optional: Performance (reserved for future)
CREATE TABLE IF NOT EXISTS vehicles.performance (
id BIGSERIAL PRIMARY KEY,
engine_id BIGINT NULL REFERENCES vehicles.engine(id) ON DELETE SET NULL,
trim_id BIGINT NULL REFERENCES vehicles.trim(id) ON DELETE SET NULL,
horsepower NUMERIC(6,2) NULL,
torque NUMERIC(6,2) NULL,
top_speed NUMERIC(6,2) NULL,
zero_to_sixty NUMERIC(4,2) NULL
);

View File

@@ -0,0 +1,23 @@
-- Uniques and indexes to enforce data integrity and performance
-- Unique, case-insensitive names
CREATE UNIQUE INDEX IF NOT EXISTS ux_make_name ON vehicles.make (lower(name));
CREATE UNIQUE INDEX IF NOT EXISTS ux_model_make_name ON vehicles.model (make_id, lower(name));
-- Model/Year availability
ALTER TABLE vehicles.model_year
ADD CONSTRAINT ux_model_year UNIQUE (model_id, year);
CREATE INDEX IF NOT EXISTS ix_model_year_year_model ON vehicles.model_year (year, model_id);
-- Trims are unique per model_year by name (case-insensitive)
CREATE UNIQUE INDEX IF NOT EXISTS ux_trim_modelyear_name ON vehicles.trim (model_year_id, lower(name));
CREATE INDEX IF NOT EXISTS ix_trim_modelyear_name ON vehicles.trim (model_year_id, name);
-- Engine uniqueness (prefer code when present)
CREATE UNIQUE INDEX IF NOT EXISTS ux_engine_code_not_null ON vehicles.engine (code) WHERE code IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS ux_engine_name ON vehicles.engine (lower(name));
-- Bridge indexes
CREATE INDEX IF NOT EXISTS ix_trim_engine_trim ON vehicles.trim_engine (trim_id);
CREATE INDEX IF NOT EXISTS ix_trim_engine_engine ON vehicles.trim_engine (engine_id);

View File

@@ -0,0 +1,70 @@
-- Minimal seed data for testing dropdown hierarchy
INSERT INTO vehicles.make (name) VALUES ('Honda') ON CONFLICT DO NOTHING;
INSERT INTO vehicles.make (name) VALUES ('Toyota') ON CONFLICT DO NOTHING;
-- Resolve make ids
WITH m AS (
SELECT id FROM vehicles.make WHERE lower(name) = lower('Honda')
)
INSERT INTO vehicles.model (make_id, name)
SELECT m.id, 'Civic' FROM m
ON CONFLICT DO NOTHING;
WITH m AS (
SELECT id FROM vehicles.make WHERE lower(name) = lower('Toyota')
)
INSERT INTO vehicles.model (make_id, name)
SELECT m.id, 'Corolla' FROM m
ON CONFLICT DO NOTHING;
-- Model years
WITH mo AS (
SELECT id FROM vehicles.model WHERE lower(name) = lower('Civic')
)
INSERT INTO vehicles.model_year (model_id, year)
SELECT mo.id, 2024 FROM mo ON CONFLICT DO NOTHING;
WITH mo AS (
SELECT id FROM vehicles.model WHERE lower(name) = lower('Corolla')
)
INSERT INTO vehicles.model_year (model_id, year)
SELECT mo.id, 2024 FROM mo ON CONFLICT DO NOTHING;
-- Trims
WITH my AS (
SELECT my.id FROM vehicles.model_year my
JOIN vehicles.model mo ON mo.id = my.model_id
WHERE lower(mo.name) = lower('Civic') AND my.year = 2024
)
INSERT INTO vehicles.trim (model_year_id, name)
SELECT my.id, 'LX' FROM my ON CONFLICT DO NOTHING;
WITH my AS (
SELECT my.id FROM vehicles.model_year my
JOIN vehicles.model mo ON mo.id = my.model_id
WHERE lower(mo.name) = lower('Corolla') AND my.year = 2024
)
INSERT INTO vehicles.trim (model_year_id, name)
SELECT my.id, 'LE' FROM my ON CONFLICT DO NOTHING;
-- Engines
INSERT INTO vehicles.engine (name, code, displacement_l, cylinders, fuel_type, aspiration)
VALUES ('2.0L I4', 'K20', 2.0, 4, 'Gasoline', 'NA')
ON CONFLICT DO NOTHING;
INSERT INTO vehicles.engine (name, code, displacement_l, cylinders, fuel_type, aspiration)
VALUES ('2.0L I4', 'M20', 2.0, 4, 'Gasoline', 'NA')
ON CONFLICT DO NOTHING;
-- Map engines to trims
WITH t AS (
SELECT t.id AS trim_id, e.id AS engine_id
FROM vehicles.trim t
JOIN vehicles.model_year my ON my.id = t.model_year_id AND my.year = 2024
JOIN vehicles.model mo ON mo.id = my.model_id
JOIN vehicles.make ma ON ma.id = mo.make_id
JOIN vehicles.engine e ON e.code IN ('K20','M20')
WHERE lower(ma.name) = lower('Honda') AND lower(mo.name) = lower('Civic') AND lower(t.name) = lower('LX')
)
INSERT INTO vehicles.trim_engine (trim_id, engine_id)
SELECT trim_id, engine_id FROM t ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,105 @@
-- Seed sample data based on ETL source filter (etl/sources/makes.json)
-- Focus on Chevrolet (Corvette) and GMC (Sierra 1500)
-- Makes
INSERT INTO vehicles.make (name) VALUES ('Chevrolet') ON CONFLICT DO NOTHING;
INSERT INTO vehicles.make (name) VALUES ('GMC') ON CONFLICT DO NOTHING;
-- Chevrolet Corvette
WITH chevy AS (
SELECT id FROM vehicles.make WHERE lower(name) = lower('Chevrolet')
)
INSERT INTO vehicles.model (make_id, name)
SELECT chevy.id, 'Corvette' FROM chevy
ON CONFLICT DO NOTHING;
WITH corvette AS (
SELECT id FROM vehicles.model WHERE lower(name) = lower('Corvette')
)
INSERT INTO vehicles.model_year (model_id, year)
SELECT corvette.id, 2024 FROM corvette
ON CONFLICT DO NOTHING;
-- Corvette trims (2024)
WITH my AS (
SELECT my.id
FROM vehicles.model_year my
JOIN vehicles.model mo ON mo.id = my.model_id
WHERE lower(mo.name) = lower('Corvette') AND my.year = 2024
)
INSERT INTO vehicles.trim (model_year_id, name)
SELECT my.id, t.name
FROM my, (VALUES ('Stingray'), ('Z06')) AS t(name)
ON CONFLICT DO NOTHING;
-- Corvette engines
INSERT INTO vehicles.engine (name, code, displacement_l, cylinders, fuel_type, aspiration)
VALUES
('6.2L V8 LT2', 'LT2', 6.2, 8, 'Gasoline', 'NA'),
('5.5L V8 LT6', 'LT6', 5.5, 8, 'Gasoline', 'NA')
ON CONFLICT DO NOTHING;
-- Map Corvette engines to trims
WITH t AS (
SELECT t.id AS trim_id, t.name AS trim_name
FROM vehicles.trim t
JOIN vehicles.model_year my ON my.id = t.model_year_id AND my.year = 2024
JOIN vehicles.model mo ON mo.id = my.model_id AND lower(mo.name) = lower('Corvette')
)
INSERT INTO vehicles.trim_engine (trim_id, engine_id)
SELECT t.trim_id, e.id
FROM t
JOIN vehicles.engine e
ON (t.trim_name = 'Stingray' AND e.code = 'LT2')
OR (t.trim_name = 'Z06' AND e.code = 'LT6')
ON CONFLICT DO NOTHING;
-- GMC Sierra 1500
WITH gmc AS (
SELECT id FROM vehicles.make WHERE lower(name) = lower('GMC')
)
INSERT INTO vehicles.model (make_id, name)
SELECT gmc.id, 'Sierra 1500' FROM gmc
ON CONFLICT DO NOTHING;
WITH sierra AS (
SELECT id FROM vehicles.model WHERE lower(name) = lower('Sierra 1500')
)
INSERT INTO vehicles.model_year (model_id, year)
SELECT sierra.id, 2024 FROM sierra
ON CONFLICT DO NOTHING;
-- Sierra trims (2024)
WITH my AS (
SELECT my.id
FROM vehicles.model_year my
JOIN vehicles.model mo ON mo.id = my.model_id
WHERE lower(mo.name) = lower('Sierra 1500') AND my.year = 2024
)
INSERT INTO vehicles.trim (model_year_id, name)
SELECT my.id, t.name
FROM my, (VALUES ('SLE'), ('Denali')) AS t(name)
ON CONFLICT DO NOTHING;
-- Sierra engines
INSERT INTO vehicles.engine (name, code, displacement_l, cylinders, fuel_type, aspiration)
VALUES
('5.3L V8 L84', 'L84', 5.3, 8, 'Gasoline', 'NA'),
('6.2L V8 L87', 'L87', 6.2, 8, 'Gasoline', 'NA')
ON CONFLICT DO NOTHING;
-- Map Sierra engines to trims
WITH t AS (
SELECT t.id AS trim_id, t.name AS trim_name
FROM vehicles.trim t
JOIN vehicles.model_year my ON my.id = t.model_year_id AND my.year = 2024
JOIN vehicles.model mo ON mo.id = my.model_id AND lower(mo.name) = lower('Sierra 1500')
)
INSERT INTO vehicles.trim_engine (trim_id, engine_id)
SELECT t.trim_id, e.id
FROM t
JOIN vehicles.engine e
ON (t.trim_name = 'SLE' AND e.code = 'L84')
OR (t.trim_name = 'Denali' AND e.code = 'L87')
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,75 @@
-- Seed specific vehicle combinations requested
-- Ensure makes exist
INSERT INTO vehicles.make (name) VALUES ('GMC') ON CONFLICT DO NOTHING;
INSERT INTO vehicles.make (name) VALUES ('Chevrolet') ON CONFLICT DO NOTHING;
-- Ensure models exist under their makes
WITH m AS (
SELECT id FROM vehicles.make WHERE lower(name) = lower('GMC')
)
INSERT INTO vehicles.model (make_id, name)
SELECT m.id, 'Sierra 1500' FROM m
ON CONFLICT DO NOTHING;
WITH m AS (
SELECT id FROM vehicles.make WHERE lower(name) = lower('Chevrolet')
)
INSERT INTO vehicles.model (make_id, name)
SELECT m.id, 'Corvette' FROM m
ON CONFLICT DO NOTHING;
-- Model years
WITH mo AS (
SELECT id FROM vehicles.model WHERE lower(name) = lower('Sierra 1500')
)
INSERT INTO vehicles.model_year (model_id, year)
SELECT mo.id, 2023 FROM mo ON CONFLICT DO NOTHING;
WITH mo AS (
SELECT id FROM vehicles.model WHERE lower(name) = lower('Corvette')
)
INSERT INTO vehicles.model_year (model_id, year)
SELECT mo.id, 2017 FROM mo ON CONFLICT DO NOTHING;
-- Trims
WITH my AS (
SELECT my.id FROM vehicles.model_year my
JOIN vehicles.model mo ON mo.id = my.model_id
WHERE lower(mo.name) = lower('Sierra 1500') AND my.year = 2023
)
INSERT INTO vehicles.trim (model_year_id, name)
SELECT my.id, 'AT4x' FROM my ON CONFLICT DO NOTHING;
WITH my AS (
SELECT my.id FROM vehicles.model_year my
JOIN vehicles.model mo ON mo.id = my.model_id
WHERE lower(mo.name) = lower('Corvette') AND my.year = 2017
)
INSERT INTO vehicles.trim (model_year_id, name)
SELECT my.id, 'Z06 Convertible' FROM my ON CONFLICT DO NOTHING;
-- Engines (ensure canonical engines exist)
INSERT INTO vehicles.engine (name, code, displacement_l, cylinders, fuel_type, aspiration)
VALUES ('6.2L V8 L87', 'L87', 6.2, 8, 'Gasoline', 'NA')
ON CONFLICT DO NOTHING;
INSERT INTO vehicles.engine (name, code, displacement_l, cylinders, fuel_type, aspiration)
VALUES ('6.2L V8 LT4', 'LT4', 6.2, 8, 'Gasoline', 'SC')
ON CONFLICT DO NOTHING;
-- Map engines to trims
WITH t AS (
SELECT t.id AS trim_id, t.name AS trim_name, my.year, mo.name AS model_name
FROM vehicles.trim t
JOIN vehicles.model_year my ON my.id = t.model_year_id
JOIN vehicles.model mo ON mo.id = my.model_id
)
INSERT INTO vehicles.trim_engine (trim_id, engine_id)
SELECT t.trim_id, e.id
FROM t
JOIN vehicles.engine e
ON (t.model_name = 'Sierra 1500' AND t.year = 2023 AND t.trim_name = 'AT4x' AND e.code = 'L87')
OR (t.model_name = 'Corvette' AND t.year = 2017 AND t.trim_name = 'Z06 Convertible' AND e.code = 'LT4')
ON CONFLICT DO NOTHING;