major-domo-v2/services/base_service.py
Cal Corum 620fa0ef2d CLAUDE: Initial commit for discord-app-v2 rebuild
Complete rebuild of the Discord bot with modern architecture including:
- Modular API client with proper error handling
- Clean separation of models, services, and commands
- Comprehensive test coverage with pytest
- Structured logging and configuration management
- Organized command structure for scalability

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-15 00:04:50 -05:00

352 lines
12 KiB
Python

"""
Base service class for Discord Bot v2.0
Provides common CRUD operations and error handling for all data services.
"""
import logging
from typing import Optional, Type, TypeVar, Generic, Dict, Any, List, Tuple
from api.client import get_global_client, APIClient
from models.base import SBABaseModel
from exceptions import APIException
logger = logging.getLogger(f'{__name__}.BaseService')
T = TypeVar('T', bound=SBABaseModel)
class BaseService(Generic[T]):
"""
Base service class providing common CRUD operations for SBA models.
Features:
- Generic type support for any SBABaseModel subclass
- Automatic model validation and conversion
- Standardized error handling
- API response format handling (count + list format)
- Connection management via global client
"""
def __init__(self,
model_class: Type[T],
endpoint: str,
client: Optional[APIClient] = None):
"""
Initialize base service.
Args:
model_class: Pydantic model class for this service
endpoint: API endpoint path (e.g., 'players', 'teams')
client: Optional API client override (uses global client by default)
"""
self.model_class = model_class
self.endpoint = endpoint
self._client = client
self._cached_client: Optional[APIClient] = None
logger.debug(f"Initialized {self.__class__.__name__} for {model_class.__name__} at endpoint '{endpoint}'")
async def get_client(self) -> APIClient:
"""
Get API client instance with caching to reduce async overhead.
Returns:
APIClient instance (cached after first access)
"""
if self._client:
return self._client
# Cache the global client to avoid repeated async calls
if self._cached_client is None:
self._cached_client = await get_global_client()
return self._cached_client
async def get_by_id(self, object_id: int) -> Optional[T]:
"""
Get single object by ID.
Args:
object_id: Unique identifier for the object
Returns:
Model instance or None if not found
Raises:
APIException: For API errors
ValueError: For invalid data
"""
try:
client = await self.get_client()
data = await client.get(self.endpoint, object_id=object_id)
if not data:
logger.debug(f"{self.model_class.__name__} {object_id} not found")
return None
model = self.model_class.from_api_data(data)
logger.debug(f"Retrieved {self.model_class.__name__} {object_id}: {model}")
return model
except APIException:
logger.error(f"API error retrieving {self.model_class.__name__} {object_id}")
raise
except Exception as e:
logger.error(f"Error retrieving {self.model_class.__name__} {object_id}: {e}")
raise APIException(f"Failed to retrieve {self.model_class.__name__}: {e}")
async def get_all(self, params: Optional[List[tuple]] = None) -> Tuple[List[T], int]:
"""
Get all objects with optional query parameters.
Args:
params: Query parameters as list of (key, value) tuples
Returns:
Tuple of (list of model instances, total count)
Raises:
APIException: For API errors
"""
try:
client = await self.get_client()
data = await client.get(self.endpoint, params=params)
if not data:
logger.debug(f"No {self.model_class.__name__} objects found")
return [], 0
# Handle API response format: {'count': int, '<endpoint>': [...]}
items, count = self._extract_items_and_count_from_response(data)
models = [self.model_class.from_api_data(item) for item in items]
logger.debug(f"Retrieved {len(models)} of {count} {self.model_class.__name__} objects")
return models, count
except APIException:
logger.error(f"API error retrieving {self.model_class.__name__} list")
raise
except Exception as e:
logger.error(f"Error retrieving {self.model_class.__name__} list: {e}")
raise APIException(f"Failed to retrieve {self.model_class.__name__} list: {e}")
async def get_all_items(self, params: Optional[List[tuple]] = None) -> List[T]:
"""
Get all objects (convenience method that only returns the list).
Args:
params: Query parameters as list of (key, value) tuples
Returns:
List of model instances
"""
items, _ = await self.get_all(params=params)
return items
async def create(self, model_data: Dict[str, Any]) -> Optional[T]:
"""
Create new object from data dictionary.
Args:
model_data: Dictionary of model fields
Returns:
Created model instance or None
Raises:
APIException: For API errors
ValueError: For invalid data
"""
try:
client = await self.get_client()
response = await client.post(self.endpoint, model_data)
if not response:
logger.warning(f"No response from {self.model_class.__name__} creation")
return None
model = self.model_class.from_api_data(response)
logger.debug(f"Created {self.model_class.__name__}: {model}")
return model
except APIException:
logger.error(f"API error creating {self.model_class.__name__}")
raise
except Exception as e:
logger.error(f"Error creating {self.model_class.__name__}: {e}")
raise APIException(f"Failed to create {self.model_class.__name__}: {e}")
async def create_from_model(self, model: T) -> Optional[T]:
"""
Create new object from model instance.
Args:
model: Model instance to create
Returns:
Created model instance or None
"""
return await self.create(model.to_dict(exclude_none=True))
async def update(self, object_id: int, model_data: Dict[str, Any]) -> Optional[T]:
"""
Update existing object.
Args:
object_id: ID of object to update
model_data: Dictionary of fields to update
Returns:
Updated model instance or None if not found
Raises:
APIException: For API errors
"""
try:
client = await self.get_client()
response = await client.put(self.endpoint, model_data, object_id=object_id)
if not response:
logger.debug(f"{self.model_class.__name__} {object_id} not found for update")
return None
model = self.model_class.from_api_data(response)
logger.debug(f"Updated {self.model_class.__name__} {object_id}: {model}")
return model
except APIException:
logger.error(f"API error updating {self.model_class.__name__} {object_id}")
raise
except Exception as e:
logger.error(f"Error updating {self.model_class.__name__} {object_id}: {e}")
raise APIException(f"Failed to update {self.model_class.__name__}: {e}")
async def update_from_model(self, model: T) -> Optional[T]:
"""
Update object from model instance.
Args:
model: Model instance to update (must have ID)
Returns:
Updated model instance or None
Raises:
ValueError: If model has no ID
"""
if not model.id:
raise ValueError(f"Cannot update {self.model_class.__name__} without ID")
return await self.update(model.id, model.to_dict(exclude_none=True))
async def delete(self, object_id: int) -> bool:
"""
Delete object by ID.
Args:
object_id: ID of object to delete
Returns:
True if deleted, False if not found
Raises:
APIException: For API errors
"""
try:
client = await self.get_client()
success = await client.delete(self.endpoint, object_id=object_id)
if success:
logger.debug(f"Deleted {self.model_class.__name__} {object_id}")
else:
logger.debug(f"{self.model_class.__name__} {object_id} not found for deletion")
return success
except APIException:
logger.error(f"API error deleting {self.model_class.__name__} {object_id}")
raise
except Exception as e:
logger.error(f"Error deleting {self.model_class.__name__} {object_id}: {e}")
raise APIException(f"Failed to delete {self.model_class.__name__}: {e}")
async def search(self, query: str, **kwargs) -> List[T]:
"""
Search for objects by query string.
Args:
query: Search query
**kwargs: Additional search parameters
Returns:
List of matching model instances
"""
params = [('q', query)]
params.extend(kwargs.items())
return await self.get_all_items(params=params)
async def get_by_field(self, field: str, value: Any) -> List[T]:
"""
Get objects by specific field value.
Args:
field: Field name to search
value: Field value to match
Returns:
List of matching model instances
"""
params = [(field, str(value))]
return await self.get_all_items(params=params)
async def count(self, params: Optional[List[tuple]] = None) -> int:
"""
Get count of objects matching parameters.
Args:
params: Query parameters
Returns:
Number of matching objects (from API count field)
"""
_, count = await self.get_all(params=params)
return count
def _extract_items_and_count_from_response(self, data: Any) -> Tuple[List[Dict[str, Any]], int]:
"""
Extract items list and count from API response with optimized parsing.
Expected format: {'count': int, '<endpoint>': [...]}
Single object format: {'id': 1, 'name': '...'}
Args:
data: API response data
Returns:
Tuple of (items list, total count)
"""
if isinstance(data, list):
return data, len(data)
if not isinstance(data, dict):
logger.warning(f"Unexpected response format for {self.model_class.__name__}: {type(data)}")
return [], 0
# Single pass through the response dict - get count first
count = data.get('count', 0)
# Priority order for finding items list (most common first)
field_candidates = [self.endpoint, 'items', 'data', 'results']
for field_name in field_candidates:
if field_name in data and isinstance(data[field_name], list):
return data[field_name], count or len(data[field_name])
# Single object response (check for common identifying fields)
if any(key in data for key in ['id', 'name', 'abbrev']):
return [data], 1
return [], count
def __repr__(self) -> str:
return f"{self.__class__.__name__}(model={self.model_class.__name__}, endpoint='{self.endpoint}')"