major-domo-database/app/services/base.py
root e5452cf0bf refactor: Add dependency injection for testability
- 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
2026-02-03 15:59:04 +00:00

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