- Created ServiceConfig for dependency configuration - Created Abstract interfaces (Protocols) for mocking - Created MockPlayerRepository, MockTeamRepository, MockCacheService - Refactored BaseService and PlayerService to accept injectable dependencies - Added pytest configuration and unit tests - Tests can run without real database (uses mocks) Benefits: - Unit tests run in seconds without DB - Easy to swap implementations - Clear separation of concerns
266 lines
9.5 KiB
Python
266 lines
9.5 KiB
Python
"""
|
|
Base Service Class - Dependency Injection Version
|
|
Provides common functionality with configurable dependencies.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional, Any, Dict, TypeVar, Type
|
|
|
|
from .interfaces import AbstractPlayerRepository, AbstractTeamRepository, AbstractCacheService
|
|
from .mocks import MockCacheService
|
|
|
|
logger = logging.getLogger('discord_app')
|
|
|
|
T = TypeVar('T')
|
|
|
|
|
|
class ServiceConfig:
|
|
"""Configuration for service dependencies."""
|
|
|
|
def __init__(
|
|
self,
|
|
player_repo: Optional[AbstractPlayerRepository] = None,
|
|
team_repo: Optional[AbstractTeamRepository] = None,
|
|
cache: Optional[AbstractCacheService] = None,
|
|
):
|
|
self.player_repo = player_repo
|
|
self.team_repo = team_repo
|
|
self.cache = cache
|
|
|
|
|
|
# Default configuration
|
|
_default_config = ServiceConfig()
|
|
|
|
|
|
class BaseService:
|
|
"""Base class for all services with dependency injection support."""
|
|
|
|
# Subclasses should override these
|
|
cache_patterns = []
|
|
|
|
def __init__(
|
|
self,
|
|
config: Optional[ServiceConfig] = None,
|
|
player_repo: Optional[AbstractPlayerRepository] = None,
|
|
team_repo: Optional[AbstractTeamRepository] = None,
|
|
cache: Optional[AbstractCacheService] = None,
|
|
):
|
|
"""
|
|
Initialize service with dependencies.
|
|
|
|
Args:
|
|
config: Optional ServiceConfig containing all dependencies
|
|
player_repo: Override for player repository
|
|
team_repo: Override for team repository
|
|
cache: Override for cache service
|
|
"""
|
|
# Use config if provided, otherwise use overrides or defaults
|
|
if config:
|
|
self._player_repo = config.player_repo
|
|
self._team_repo = config.team_repo
|
|
self._cache = config.cache
|
|
else:
|
|
self._player_repo = player_repo
|
|
self._team_repo = team_repo
|
|
self._cache = cache
|
|
|
|
# Lazy imports for defaults (avoids circular imports)
|
|
self._using_defaults = (
|
|
self._player_repo is None and
|
|
self._team_repo is None and
|
|
self._cache is None
|
|
)
|
|
|
|
@property
|
|
def player_repo(self) -> AbstractPlayerRepository:
|
|
"""Get player repository, importing from db_engine if not set."""
|
|
if self._player_repo is None:
|
|
from ..db_engine import Player
|
|
class DefaultPlayerRepo:
|
|
def select_season(self, season):
|
|
return Player.select_season(season)
|
|
|
|
def get_by_id(self, player_id):
|
|
return Player.get_or_none(Player.id == player_id)
|
|
|
|
def get_or_none(self, *conditions):
|
|
return Player.get_or_none(*conditions)
|
|
|
|
def update(self, data, *conditions):
|
|
return Player.update(data).where(*conditions).execute()
|
|
|
|
def insert_many(self, data):
|
|
return Player.insert_many(data).execute()
|
|
|
|
def delete_by_id(self, player_id):
|
|
player = Player.get_by_id(player_id)
|
|
if player:
|
|
return player.delete_instance()
|
|
return 0
|
|
|
|
self._player_repo = DefaultPlayerRepo()
|
|
return self._player_repo
|
|
|
|
@property
|
|
def team_repo(self) -> AbstractTeamRepository:
|
|
"""Get team repository, importing from db_engine if not set."""
|
|
if self._team_repo is None:
|
|
from ..db_engine import Team
|
|
|
|
class DefaultTeamRepo:
|
|
def select_season(self, season):
|
|
return Team.select_season(season)
|
|
|
|
def get_by_id(self, team_id):
|
|
return Team.get_by_id(team_id)
|
|
|
|
def get_or_none(self, *conditions):
|
|
return Team.get_or_none(*conditions)
|
|
|
|
def update(self, data, *conditions):
|
|
return Team.update(data).where(*conditions).execute()
|
|
|
|
def insert_many(self, data):
|
|
return Team.insert_many(data).execute()
|
|
|
|
def delete_by_id(self, team_id):
|
|
team = Team.get_by_id(team_id)
|
|
if team:
|
|
return team.delete_instance()
|
|
return 0
|
|
|
|
self._team_repo = DefaultTeamRepo()
|
|
return self._team_repo
|
|
|
|
@property
|
|
def cache(self) -> AbstractCacheService:
|
|
"""Get cache service, importing from dependencies if not set."""
|
|
if self._cache is None:
|
|
try:
|
|
from ..dependencies import redis_client, invalidate_cache
|
|
|
|
class DefaultCache:
|
|
def get(self, key: str):
|
|
if redis_client is None:
|
|
return None
|
|
return redis_client.get(key)
|
|
|
|
def set(self, key: str, value: str, ttl: int = 300):
|
|
if redis_client is None:
|
|
return False
|
|
redis_client.setex(key, ttl, value)
|
|
return True
|
|
|
|
def setex(self, key: str, ttl: int, value: str):
|
|
return self.set(key, value, ttl)
|
|
|
|
def keys(self, pattern: str):
|
|
if redis_client is None:
|
|
return []
|
|
return redis_client.keys(pattern)
|
|
|
|
def delete(self, *keys: str):
|
|
if redis_client is None:
|
|
return 0
|
|
return redis_client.delete(*keys)
|
|
|
|
def invalidate_pattern(self, pattern: str):
|
|
if redis_client is None:
|
|
return 0
|
|
keys = self.keys(pattern)
|
|
return self.delete(*keys)
|
|
|
|
def exists(self, key: str):
|
|
if redis_client is None:
|
|
return False
|
|
return redis_client.exists(key)
|
|
|
|
self._cache = DefaultCache()
|
|
except ImportError:
|
|
# Fall back to mock if dependencies not available
|
|
self._cache = MockCacheService()
|
|
|
|
return self._cache
|
|
|
|
def close_db(self):
|
|
"""Safely close database connection (for non-injected repos)."""
|
|
if self._using_defaults:
|
|
try:
|
|
from ..db_engine import db
|
|
db.close()
|
|
except Exception:
|
|
pass # Connection may already be closed
|
|
|
|
def invalidate_cache_for(self, entity_type: str, entity_id: Optional[int] = None):
|
|
"""Invalidate cache entries for an entity."""
|
|
if entity_id:
|
|
self.cache.invalidate_pattern(f"{entity_type}*{entity_id}*")
|
|
else:
|
|
self.cache.invalidate_pattern(f"{entity_type}*")
|
|
|
|
def invalidate_related_cache(self, patterns: list):
|
|
"""Invalidate multiple cache patterns."""
|
|
for pattern in patterns:
|
|
self.cache.invalidate_pattern(pattern)
|
|
|
|
def handle_error(self, operation: str, error: Exception, rethrow: bool = True) -> dict:
|
|
"""Handle errors consistently."""
|
|
logger.error(f"{operation}: {error}")
|
|
if rethrow:
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=500, detail=f"{operation}: {str(error)}")
|
|
return {"error": operation, "detail": str(error)}
|
|
|
|
def require_auth(self, token: str) -> bool:
|
|
"""Validate authentication token."""
|
|
from fastapi import HTTPException
|
|
from ..dependencies import valid_token
|
|
|
|
if not valid_token(token):
|
|
logger.warning(f"Unauthorized access attempt with token: {token[:10]}...")
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
return True
|
|
|
|
def format_csv_response(self, headers: list, rows: list) -> str:
|
|
"""Format data as CSV."""
|
|
from pandas import DataFrame
|
|
all_data = [headers] + rows
|
|
return DataFrame(all_data).to_csv(header=False, index=False)
|
|
|
|
def parse_query_params(self, params: dict, remove_none: bool = True) -> dict:
|
|
"""Parse and clean query parameters."""
|
|
if remove_none:
|
|
return {k: v for k, v in params.items() if v is not None and v != [] and v != ""}
|
|
return params
|
|
|
|
def with_cache(
|
|
self,
|
|
key: str,
|
|
ttl: int = 300,
|
|
fallback: Optional[callable] = None
|
|
):
|
|
"""
|
|
Decorator-style cache wrapper for methods.
|
|
|
|
Usage:
|
|
@service.with_cache("player:123", ttl=600)
|
|
def get_player(self):
|
|
...
|
|
"""
|
|
def decorator(func):
|
|
async def wrapper(*args, **kwargs):
|
|
# Try cache first
|
|
cached = self.cache.get(key)
|
|
if cached:
|
|
return json.loads(cached)
|
|
|
|
# Execute and cache result
|
|
result = func(*args, **kwargs)
|
|
if result is not None:
|
|
import json
|
|
self.cache.set(key, json.dumps(result, default=str), ttl)
|
|
|
|
return result
|
|
return wrapper
|
|
return decorator
|