diff --git a/app/services/interfaces.py b/app/services/interfaces.py index 14caa8e..01b088d 100644 --- a/app/services/interfaces.py +++ b/app/services/interfaces.py @@ -3,7 +3,7 @@ Abstract Base Classes (Protocols) for Dependency Injection Defines interfaces that can be mocked for testing. """ -from typing import List, Dict, Any, Optional, Protocol +from typing import List, Dict, Any, Optional, Protocol, runtime_checkable class PlayerData(Dict): @@ -16,6 +16,7 @@ class TeamData(Dict): pass +@runtime_checkable class QueryResult(Protocol): """Protocol for query-like objects.""" @@ -35,12 +36,62 @@ class QueryResult(Protocol): ... -class CacheProtocol(Protocol): - """Protocol for cache operations.""" +@runtime_checkable +class AbstractPlayerRepository(Protocol): + """Abstract interface for player data access.""" + + def select_season(self, season: int) -> QueryResult: + ... + + def get_by_id(self, player_id: int) -> Optional[PlayerData]: + ... + + def get_or_none(self, *conditions, **field_conditions) -> Optional[PlayerData]: + ... + + def update(self, data: Dict, *conditions, **field_conditions) -> int: + ... + + def insert_many(self, data: List[Dict]) -> int: + ... + + def delete_by_id(self, player_id: int) -> int: + ... + + +@runtime_checkable +class AbstractTeamRepository(Protocol): + """Abstract interface for team data access.""" + + def select_season(self, season: int) -> QueryResult: + ... + + def get_by_id(self, team_id: int) -> Optional[TeamData]: + ... + + def get_or_none(self, *conditions, **field_conditions) -> Optional[TeamData]: + ... + + def update(self, data: Dict, *conditions, **field_conditions) -> int: + ... + + def insert_many(self, data: List[Dict]) -> int: + ... + + def delete_by_id(self, team_id: int) -> int: + ... + + +@runtime_checkable +class AbstractCacheService(Protocol): + """Abstract interface for cache operations.""" def get(self, key: str) -> Optional[str]: ... + def set(self, key: str, value: str, ttl: int = 300) -> bool: + ... + def setex(self, key: str, ttl: int, value: str) -> bool: ... @@ -49,60 +100,6 @@ class CacheProtocol(Protocol): def delete(self, *keys: str) -> int: ... - - -class AbstractPlayerRepository(Protocol): - """Abstract interface for player data access.""" - - def select_season(self, season: int) -> QueryResult: - ... - - def get_by_id(self, player_id: int) -> Optional[PlayerData]: - ... - - def get_or_none(self, *conditions) -> Optional[PlayerData]: - ... - - def update(self, data: Dict, *conditions) -> int: - ... - - def insert_many(self, data: List[Dict]) -> int: - ... - - def delete_by_id(self, player_id: int) -> int: - ... - - -class AbstractTeamRepository(Protocol): - """Abstract interface for team data access.""" - - def select_season(self, season: int) -> QueryResult: - ... - - def get_by_id(self, team_id: int) -> Optional[TeamData]: - ... - - def get_or_none(self, *conditions) -> Optional[TeamData]: - ... - - def update(self, data: Dict, *conditions) -> int: - ... - - def insert_many(self, data: List[Dict]) -> int: - ... - - def delete_by_id(self, team_id: int) -> int: - ... - - -class AbstractCacheService(Protocol): - """Abstract interface for cache operations.""" - - def get(self, key: str) -> Optional[str]: - ... - - def set(self, key: str, value: str, ttl: int = 300) -> bool: - ... def invalidate_pattern(self, pattern: str) -> int: ... diff --git a/app/services/mocks.py b/app/services/mocks.py index 8feecf9..344e07f 100644 --- a/app/services/mocks.py +++ b/app/services/mocks.py @@ -106,10 +106,15 @@ class EnhancedMockRepository: """Get item by ID.""" return self._data.get(entity_id) - def get_or_none(self, *conditions) -> Optional[Dict]: + def get_or_none(self, *conditions, **field_conditions) -> Optional[Dict]: """Get first item matching conditions.""" + # Convert field_conditions to conditions + converted_conditions = list(conditions) + for field, value in field_conditions.items(): + converted_conditions.append(lambda item, f=field, v=value: item.get(f) == v) + for item in self._data.values(): - if self._matches(item, conditions): + if self._matches(item, converted_conditions): return item return None diff --git a/app/services/player_service.py b/app/services/player_service.py index 14f2ab1..9fb36f9 100644 --- a/app/services/player_service.py +++ b/app/services/player_service.py @@ -5,7 +5,6 @@ Business logic for player operations with injectable dependencies. import logging from typing import List, Optional, Dict, Any -from peewee import fn as peewee_fn from .base import BaseService from .interfaces import AbstractPlayerRepository, QueryResult @@ -131,39 +130,52 @@ class PlayerService(BaseService): """Apply player filters in a repo-agnostic way.""" # Check if repo supports where() method (real DB) - if hasattr(query, 'where') and hasattr(self.player_repo, 'select_season'): - # Use DB-native filtering for real repos - from ..db_engine import Player - - if team_id: - query = query.where(Player.team_id << team_id) - - if strat_code: - code_list = [x.lower() for x in strat_code] - query = query.where(peewee_fn.Lower(Player.strat_code) << code_list) - - if name: - query = query.where(peewee_fn.lower(Player.name) == name.lower()) - - if pos: - p_list = [x.upper() for x in pos] - pos_conditions = ( - (Player.pos_1 << p_list) | - (Player.pos_2 << p_list) | - (Player.pos_3 << p_list) | - (Player.pos_4 << p_list) | - (Player.pos_5 << p_list) | - (Player.pos_6 << p_list) | - (Player.pos_7 << p_list) | - (Player.pos_8 << p_list) - ) - query = query.where(pos_conditions) - - if is_injured is not None: - if is_injured: - query = query.where(Player.il_return.is_null(False)) - else: - query = query.where(Player.il_return.is_null(True)) + # Only use DB-native filtering if: + # 1. Query has where() method + # 2. Items are Peewee models (not dicts) + first_item = None + for item in query: + first_item = item + break + + # Use DB-native filtering only for real Peewee models + if first_item is not None and not isinstance(first_item, dict): + try: + from ..db_engine import Player + from peewee import fn as peewee_fn + + if team_id: + query = query.where(Player.team_id << team_id) + + if strat_code: + code_list = [x.lower() for x in strat_code] + query = query.where(peewee_fn.Lower(Player.strat_code) << code_list) + + if name: + query = query.where(peewee_fn.lower(Player.name) == name.lower()) + + if pos: + p_list = [x.upper() for x in pos] + pos_conditions = ( + (Player.pos_1 << p_list) | + (Player.pos_2 << p_list) | + (Player.pos_3 << p_list) | + (Player.pos_4 << p_list) | + (Player.pos_5 << p_list) | + (Player.pos_6 << p_list) | + (Player.pos_7 << p_list) | + (Player.pos_8 << p_list) + ) + query = query.where(pos_conditions) + + if is_injured is not None: + if is_injured: + query = query.where(Player.il_return.is_null(False)) + else: + query = query.where(Player.il_return.is_null(True)) + except ImportError: + # DB not available, fall back to Python filtering + pass else: # Use Python filtering for mocks def matches(player): @@ -205,22 +217,33 @@ class PlayerService(BaseService): ) -> QueryResult: """Apply player sorting in a repo-agnostic way.""" - if hasattr(query, 'order_by'): - # Use DB-native sorting - from ..db_engine import Player - - if sort == "cost-asc": - query = query.order_by(Player.wara) - elif sort == "cost-desc": - query = query.order_by(-Player.wara) - elif sort == "name-asc": - query = query.order_by(Player.name) - elif sort == "name-desc": - query = query.order_by(-Player.name) - else: - query = query.order_by(Player.id) - else: - # Use Python sorting for mocks + # Check if items are Peewee models (not dicts) + first_item = None + for item in query: + first_item = item + break + + # Use DB-native sorting only for real Peewee models + if first_item is not None and not isinstance(first_item, dict): + try: + from ..db_engine import Player + + if sort == "cost-asc": + query = query.order_by(Player.wara) + elif sort == "cost-desc": + query = query.order_by(-Player.wara) + elif sort == "name-asc": + query = query.order_by(Player.name) + elif sort == "name-desc": + query = query.order_by(-Player.name) + else: + query = query.order_by(Player.id) + except ImportError: + # Fall back to Python sorting if DB not available + pass + + # Use Python sorting for mocks or if DB sort failed + if not hasattr(query, 'order_by') or isinstance(query, InMemoryQueryResult): def get_sort_key(player): name = player.get('name', '') wara = player.get('wara', 0) @@ -233,11 +256,11 @@ class PlayerService(BaseService): elif sort == "name-asc": return (name, wara, player_id) elif sort == "name-desc": - return (-len(name), name, wara, player_id) # reversed + return (name[::-1], wara, player_id) if name else ('', wara, player_id) else: return (player_id,) - sorted_list = sorted(query, key=get_sort_key) + sorted_list = sorted(list(query), key=get_sort_key) query = InMemoryQueryResult(sorted_list) return query @@ -358,12 +381,17 @@ class PlayerService(BaseService): def _player_to_dict(self, player, recurse: bool = True) -> Dict[str, Any]: """Convert player to dict.""" - from playhouse.shortcuts import model_to_dict - from ..db_engine import Player - + # If already a dict, return as-is if isinstance(player, dict): return player - return model_to_dict(player, recurse=recurse) + + # Try to convert Peewee model + try: + from playhouse.shortcuts import model_to_dict + return model_to_dict(player, recurse=recurse) + except ImportError: + # Fall back to basic dict conversion + return dict(player) def update_player(self, player_id: int, data: Dict[str, Any], token: str) -> Dict[str, Any]: """Update a player (full update via PUT)."""