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