""" Position rating service with Redis caching. Provides cached access to position ratings with automatic expiration and fallback to API. Author: Claude Date: 2025-11-03 Phase: 3E-Final """ import json import logging from typing import List, Optional from redis.exceptions import RedisError from app.models.player_models import PositionRating from app.services.pd_api_client import pd_api_client logger = logging.getLogger(f'{__name__}.PositionRatingService') # Redis key pattern: "position_ratings:{card_id}" # TTL: 86400 seconds (24 hours) REDIS_KEY_PREFIX = "position_ratings" REDIS_TTL_SECONDS = 86400 # 24 hours class PositionRatingService: """Service for position rating lookup with caching.""" def __init__(self, use_cache: bool = True): """ Initialize position rating service. Args: use_cache: Whether to use caching (default True) """ self.use_cache = use_cache def _get_redis_key(self, card_id: int) -> str: """ Get Redis key for card ratings. Args: card_id: PD card ID Returns: Redis key string """ return f"{REDIS_KEY_PREFIX}:{card_id}" async def get_ratings_for_card( self, card_id: int, league_id: str ) -> List[PositionRating]: """ Get all position ratings for a card with Redis caching. Args: card_id: PD card ID league_id: League identifier ('pd') Returns: List of PositionRating objects """ # Only cache for PD league if league_id != 'pd': logger.debug(f"Skipping cache for non-PD league: {league_id}") return [] # Try Redis cache first if self.use_cache: try: from app.services import redis_client if redis_client.is_connected: redis_key = self._get_redis_key(card_id) cached_json = await redis_client.client.get(redis_key) if cached_json: logger.debug(f"Redis cache hit for card {card_id}") cached_data = json.loads(cached_json) return [PositionRating(**data) for data in cached_data] else: logger.debug(f"Redis cache miss for card {card_id}") else: logger.warning("Redis not connected, skipping cache") except RedisError as e: logger.warning(f"Redis error for card {card_id}: {e}, falling back to API") except Exception as e: logger.error(f"Unexpected cache error for card {card_id}: {e}") # Cache miss or error - fetch from API logger.debug(f"Fetching ratings from API for card {card_id}") try: # Note: card_id maps to player_id in PD API terminology ratings = await pd_api_client.get_position_ratings(player_id=card_id) # Cache the results in Redis with TTL if self.use_cache and ratings: try: from app.services import redis_client if redis_client.is_connected: redis_key = self._get_redis_key(card_id) ratings_json = json.dumps([r.model_dump() for r in ratings]) await redis_client.client.setex( redis_key, REDIS_TTL_SECONDS, ratings_json ) logger.debug(f"Cached {len(ratings)} ratings for card {card_id} (TTL: {REDIS_TTL_SECONDS}s)") except RedisError as e: logger.warning(f"Failed to cache ratings for card {card_id}: {e}") return ratings except Exception as e: logger.error(f"Failed to fetch ratings for card {card_id}: {e}") return [] # Return empty list on error async def get_rating_for_position( self, card_id: int, position: str, league_id: str ) -> Optional[PositionRating]: """ Get rating for specific position. Args: card_id: PD card ID position: Position code (SS, LF, etc.) league_id: League identifier Returns: PositionRating if found, None otherwise """ ratings = await self.get_ratings_for_card(card_id, league_id) for rating in ratings: if rating.position == position: return rating logger.warning(f"No rating found for card {card_id} at position {position}") return None async def clear_cache(self, card_id: Optional[int] = None) -> None: """ Clear Redis cache for position ratings. Args: card_id: If provided, clear only this card's cache. If None, clear all position rating caches. """ try: from app.services import redis_client if not redis_client.is_connected: logger.warning("Redis not connected, cannot clear cache") return if card_id is not None: # Clear specific card redis_key = self._get_redis_key(card_id) await redis_client.client.delete(redis_key) logger.info(f"Cleared cache for card {card_id}") else: # Clear all position rating caches pattern = f"{REDIS_KEY_PREFIX}:*" keys = [] async for key in redis_client.client.scan_iter(match=pattern): keys.append(key) if keys: await redis_client.client.delete(*keys) logger.info(f"Cleared {len(keys)} position rating cache entries") else: logger.info("No position rating cache entries to clear") except RedisError as e: logger.error(f"Failed to clear cache: {e}") except Exception as e: logger.error(f"Unexpected error clearing cache: {e}") # Singleton instance position_rating_service = PositionRatingService()