Prevents players who are already claimed in another team's pending transaction (frozen=false, cancelled=false) from being added to a new transaction for the same week. Changes: - Add is_player_in_pending_transaction() to TransactionService - Make TransactionBuilder.add_move() async with validation - Add check_pending_transactions flag (default True for /dropadd) - Skip validation for /ilmove and trades (check_pending_transactions=False) - Add tests/conftest.py for proper test isolation - Add 4 new tests for pending transaction validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
494 lines
19 KiB
Python
494 lines
19 KiB
Python
"""
|
|
Transaction service for Discord Bot v2.0
|
|
|
|
Handles transaction CRUD operations and business logic.
|
|
"""
|
|
import logging
|
|
from typing import Optional, List, Tuple
|
|
from datetime import datetime, UTC
|
|
|
|
from services.base_service import BaseService
|
|
from models.transaction import Transaction, RosterValidation
|
|
from models.roster import TeamRoster
|
|
from exceptions import APIException
|
|
|
|
logger = logging.getLogger(f'{__name__}.TransactionService')
|
|
|
|
|
|
class TransactionService(BaseService[Transaction]):
|
|
"""Service for transaction operations."""
|
|
|
|
def __init__(self):
|
|
"""Initialize transaction service."""
|
|
super().__init__(
|
|
model_class=Transaction,
|
|
endpoint='transactions'
|
|
)
|
|
logger.debug("TransactionService initialized")
|
|
|
|
async def get_team_transactions(
|
|
self,
|
|
team_abbrev: str,
|
|
season: int,
|
|
cancelled: Optional[bool] = None,
|
|
frozen: Optional[bool] = None,
|
|
week_start: Optional[int] = None,
|
|
week_end: Optional[int] = None
|
|
) -> List[Transaction]:
|
|
"""
|
|
Get transactions for a specific team.
|
|
|
|
Args:
|
|
team_abbrev: Team abbreviation
|
|
season: Season number
|
|
cancelled: Filter by cancelled status
|
|
frozen: Filter by frozen status
|
|
week_start: Start week for filtering
|
|
week_end: End week for filtering
|
|
|
|
Returns:
|
|
List of matching transactions
|
|
"""
|
|
try:
|
|
params = [
|
|
('season', str(season)),
|
|
('team_abbrev', team_abbrev)
|
|
]
|
|
|
|
if cancelled is not None:
|
|
params.append(('cancelled', str(cancelled).lower()))
|
|
if frozen is not None:
|
|
params.append(('frozen', str(frozen).lower()))
|
|
if week_start is not None:
|
|
params.append(('week_start', str(week_start)))
|
|
if week_end is not None:
|
|
params.append(('week_end', str(week_end)))
|
|
|
|
transactions = await self.get_all_items(params=params)
|
|
|
|
# Sort by week, then by moveid
|
|
transactions.sort(key=lambda t: (t.week, t.moveid))
|
|
|
|
logger.debug(f"Retrieved {len(transactions)} transactions for {team_abbrev}")
|
|
return transactions
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting transactions for team {team_abbrev}: {e}")
|
|
raise APIException(f"Failed to retrieve transactions: {e}")
|
|
|
|
async def get_pending_transactions(self, team_abbrev: str, season: int) -> List[Transaction]:
|
|
"""Get pending (future) transactions for a team."""
|
|
try:
|
|
# Get current week to filter future transactions
|
|
current_data = await self.get_client()
|
|
current_response = await current_data.get('current')
|
|
current_week = current_response.get('week', 0) if current_response else 0
|
|
|
|
# Get transactions from current week onward
|
|
return await self.get_team_transactions(
|
|
team_abbrev,
|
|
season,
|
|
cancelled=False,
|
|
frozen=False,
|
|
week_start=current_week
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Could not get current week, returning all non-cancelled/non-frozen transactions: {e}")
|
|
# Fallback to all non-cancelled/non-frozen if we can't get current week
|
|
return await self.get_team_transactions(
|
|
team_abbrev,
|
|
season,
|
|
cancelled=False,
|
|
frozen=False
|
|
)
|
|
|
|
async def get_frozen_transactions(self, team_abbrev: str, season: int) -> List[Transaction]:
|
|
"""Get frozen (scheduled for processing) transactions for a team."""
|
|
return await self.get_team_transactions(
|
|
team_abbrev,
|
|
season,
|
|
frozen=True
|
|
)
|
|
|
|
async def get_processed_transactions(
|
|
self,
|
|
team_abbrev: str,
|
|
season: int,
|
|
recent_weeks: int = 4
|
|
) -> List[Transaction]:
|
|
"""Get recently processed transactions for a team."""
|
|
# Get current week to limit search
|
|
try:
|
|
current_data = await self.get_client()
|
|
current_response = await current_data.get('current')
|
|
current_week = current_response.get('week', 0) if current_response else 0
|
|
|
|
week_start = max(1, current_week - recent_weeks)
|
|
|
|
# For processed transactions, we need to filter by completed/processed status
|
|
# Since the API structure doesn't have a processed status, we'll get all non-pending/non-frozen
|
|
all_transactions = await self.get_team_transactions(
|
|
team_abbrev,
|
|
season,
|
|
week_start=week_start
|
|
)
|
|
# Filter for transactions that are neither pending nor frozen (i.e., processed)
|
|
processed = [t for t in all_transactions if not t.is_pending and not t.is_frozen and not t.cancelled]
|
|
return processed
|
|
except Exception as e:
|
|
logger.warning(f"Could not get current week, using basic query: {e}")
|
|
all_transactions = await self.get_team_transactions(
|
|
team_abbrev,
|
|
season
|
|
)
|
|
# Filter for processed transactions
|
|
processed = [t for t in all_transactions if not t.is_pending and not t.is_frozen and not t.cancelled]
|
|
return processed
|
|
|
|
async def validate_transaction(self, transaction: Transaction) -> RosterValidation:
|
|
"""
|
|
Validate a transaction for legality.
|
|
|
|
Args:
|
|
transaction: Transaction to validate
|
|
|
|
Returns:
|
|
Validation results with any errors or warnings
|
|
"""
|
|
try:
|
|
validation = RosterValidation(is_legal=True)
|
|
|
|
# Basic validation rules for single-move transactions
|
|
if not transaction.player:
|
|
validation.is_legal = False
|
|
validation.errors.append("Transaction has no player")
|
|
|
|
if not transaction.oldteam or not transaction.newteam:
|
|
validation.is_legal = False
|
|
validation.errors.append("Transaction missing team information")
|
|
|
|
# Validate player eligibility (basic checks)
|
|
if transaction.player and transaction.player.wara < 0:
|
|
validation.warnings.append("Player has negative WARA")
|
|
|
|
# Add more validation logic as needed
|
|
# - Roster size limits
|
|
# - Position requirements
|
|
# - Contract constraints
|
|
# - etc.
|
|
|
|
logger.debug(f"Validated transaction {transaction.id}: legal={validation.is_legal}")
|
|
return validation
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error validating transaction {transaction.id}: {e}")
|
|
# Return failed validation on error
|
|
return RosterValidation(
|
|
is_legal=False,
|
|
errors=[f"Validation error: {str(e)}"]
|
|
)
|
|
|
|
async def create_transaction_batch(self, transactions: List[Transaction]) -> List[Transaction]:
|
|
"""
|
|
Create multiple transactions via API POST (for immediate execution).
|
|
|
|
This is used for real-time transactions (like IL moves) that need to be
|
|
posted to the database immediately rather than scheduled for later processing.
|
|
|
|
The API expects a TransactionList format:
|
|
{
|
|
"count": 2,
|
|
"moves": [
|
|
{
|
|
"week": 17,
|
|
"player_id": 123,
|
|
"oldteam_id": 10,
|
|
"newteam_id": 11,
|
|
"season": 12,
|
|
"moveid": "Season-012-Week-17-123456",
|
|
"cancelled": false,
|
|
"frozen": false
|
|
},
|
|
...
|
|
]
|
|
}
|
|
|
|
Args:
|
|
transactions: List of Transaction objects to create
|
|
|
|
Returns:
|
|
List of created Transaction objects with API-assigned IDs
|
|
|
|
Raises:
|
|
APIException: If transaction creation fails
|
|
"""
|
|
try:
|
|
# Convert Transaction objects to API format (simple ID references only)
|
|
moves = []
|
|
for transaction in transactions:
|
|
move = {
|
|
"week": transaction.week,
|
|
"player_id": transaction.player.id,
|
|
"oldteam_id": transaction.oldteam.id,
|
|
"newteam_id": transaction.newteam.id,
|
|
"season": transaction.season,
|
|
"moveid": transaction.moveid,
|
|
"cancelled": transaction.cancelled or False,
|
|
"frozen": transaction.frozen or False
|
|
}
|
|
moves.append(move)
|
|
|
|
# Create batch request payload
|
|
batch_data = {
|
|
"count": len(moves),
|
|
"moves": moves
|
|
}
|
|
|
|
# POST batch to API
|
|
client = await self.get_client()
|
|
response = await client.post(self.endpoint, data=batch_data)
|
|
|
|
# API returns a string like "2 transactions have been added"
|
|
# We need to return the original Transaction objects (they won't have IDs assigned by API)
|
|
if response and isinstance(response, str) and "transactions have been added" in response:
|
|
logger.info(f"Successfully created batch: {response}")
|
|
return transactions
|
|
else:
|
|
logger.error(f"Unexpected API response: {response}")
|
|
raise APIException(f"Unexpected API response: {response}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error creating transaction batch: {e}")
|
|
raise APIException(f"Failed to create transactions: {e}")
|
|
|
|
async def cancel_transaction(self, transaction_id: str) -> bool:
|
|
"""
|
|
Cancel a pending transaction.
|
|
|
|
Note: When using moveid, this updates ALL transactions with that moveid (bulk update).
|
|
The API returns a message string like "Updated 4 transactions" instead of the transaction object.
|
|
|
|
Args:
|
|
transaction_id: Move ID of transaction to cancel (e.g., "Season-012-Week-17-08-18:57:21")
|
|
|
|
Returns:
|
|
True if cancelled successfully
|
|
"""
|
|
try:
|
|
# Update transaction status using direct API call to handle bulk updates
|
|
update_data = {
|
|
'cancelled': True,
|
|
'cancelled_at': datetime.now(UTC).isoformat()
|
|
}
|
|
|
|
# Call API directly since bulk update returns a message string, not a Transaction object
|
|
client = await self.get_client()
|
|
response = await client.patch(
|
|
self.endpoint,
|
|
update_data,
|
|
object_id=transaction_id,
|
|
use_query_params=True
|
|
)
|
|
|
|
# Check if response indicates success
|
|
# Response will be a string like "Updated 4 transactions" for bulk updates
|
|
if response and (isinstance(response, str) and 'Updated' in response):
|
|
logger.info(f"Cancelled transaction(s) {transaction_id}: {response}")
|
|
return True
|
|
elif response:
|
|
# If we got a dict response, it's a single transaction update
|
|
logger.info(f"Cancelled transaction {transaction_id}")
|
|
return True
|
|
else:
|
|
logger.warning(f"Failed to cancel transaction {transaction_id}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error cancelling transaction {transaction_id}: {e}")
|
|
return False
|
|
|
|
async def unfreeze_transaction(self, transaction_id: str) -> bool:
|
|
"""
|
|
Unfreeze a frozen transaction, allowing it to be processed.
|
|
|
|
Note: When using moveid, this updates ALL transactions with that moveid (bulk update).
|
|
The API returns a message string like "Updated 4 transactions" instead of the transaction object.
|
|
|
|
Args:
|
|
transaction_id: Move ID of transaction to unfreeze (e.g., "Season-012-Week-17-08-18:57:21")
|
|
|
|
Returns:
|
|
True if unfrozen successfully
|
|
"""
|
|
try:
|
|
# Call API directly since bulk update returns a message string, not a Transaction object
|
|
client = await self.get_client()
|
|
response = await client.patch(
|
|
self.endpoint,
|
|
{'frozen': False},
|
|
object_id=transaction_id,
|
|
use_query_params=True
|
|
)
|
|
|
|
# Check if response indicates success
|
|
# Response will be a string like "Updated 4 transactions" for bulk updates
|
|
if response and (isinstance(response, str) and 'Updated' in response):
|
|
logger.info(f"Unfroze transaction(s) {transaction_id}: {response}")
|
|
return True
|
|
elif response:
|
|
# If we got a dict response, it's a single transaction update
|
|
logger.info(f"Unfroze transaction {transaction_id}")
|
|
return True
|
|
else:
|
|
logger.warning(f"Failed to unfreeze transaction {transaction_id}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error unfreezing transaction {transaction_id}: {e}")
|
|
return False
|
|
|
|
async def get_frozen_transactions_by_week(
|
|
self,
|
|
season: int,
|
|
week_start: int,
|
|
week_end: int
|
|
) -> List[Transaction]:
|
|
"""
|
|
Get all frozen transactions for a week range (all teams).
|
|
|
|
This is used during freeze processing to get all contested transactions
|
|
across the entire league.
|
|
|
|
Args:
|
|
season: Season number
|
|
week_start: Starting week number
|
|
week_end: Ending week number
|
|
|
|
Returns:
|
|
List of frozen transactions for the week range
|
|
"""
|
|
try:
|
|
params = [
|
|
('season', str(season)),
|
|
('week_start', str(week_start)),
|
|
('week_end', str(week_end)),
|
|
('frozen', 'true')
|
|
]
|
|
|
|
transactions = await self.get_all_items(params=params)
|
|
|
|
logger.debug(f"Retrieved {len(transactions)} frozen transactions for weeks {week_start}-{week_end}")
|
|
return transactions
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting frozen transactions for weeks {week_start}-{week_end}: {e}")
|
|
return []
|
|
|
|
async def get_contested_transactions(self, season: int, week: int) -> List[Transaction]:
|
|
"""
|
|
Get transactions that may be contested (multiple teams want same player).
|
|
|
|
Args:
|
|
season: Season number
|
|
week: Week number
|
|
|
|
Returns:
|
|
List of potentially contested transactions
|
|
"""
|
|
try:
|
|
# Get all pending transactions for the week
|
|
params = [
|
|
('season', str(season)),
|
|
('week', str(week)),
|
|
('cancelled', 'false'),
|
|
('frozen', 'false')
|
|
]
|
|
|
|
transactions = await self.get_all_items(params=params)
|
|
|
|
# Group by players being targeted (simplified contest detection)
|
|
player_target_map = {}
|
|
contested = []
|
|
|
|
for transaction in transactions:
|
|
# In the new model, each transaction is a single player move
|
|
# Contest occurs when multiple teams try to acquire the same player
|
|
if transaction.newteam.abbrev != 'FA': # Not dropping to free agency
|
|
player_name = transaction.player.name.lower()
|
|
if player_name not in player_target_map:
|
|
player_target_map[player_name] = []
|
|
player_target_map[player_name].append(transaction)
|
|
|
|
# Find contested players (wanted by multiple teams)
|
|
for player_name, player_transactions in player_target_map.items():
|
|
if len(player_transactions) > 1:
|
|
contested.extend(player_transactions)
|
|
|
|
# Remove duplicates while preserving order
|
|
seen = set()
|
|
result = []
|
|
for transaction in contested:
|
|
if transaction.id not in seen:
|
|
seen.add(transaction.id)
|
|
result.append(transaction)
|
|
|
|
logger.debug(f"Found {len(result)} potentially contested transactions for week {week}")
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting contested transactions: {e}")
|
|
return []
|
|
|
|
async def is_player_in_pending_transaction(
|
|
self,
|
|
player_id: int,
|
|
week: int,
|
|
season: int
|
|
) -> tuple[bool, Optional[str]]:
|
|
"""
|
|
Check if a player is already in a pending transaction for a specific week.
|
|
|
|
This checks ALL teams' pending transactions (frozen=false, cancelled=false)
|
|
to prevent duplicate claims on the same player.
|
|
|
|
Args:
|
|
player_id: Player ID to check
|
|
week: Week number to check
|
|
season: Season number
|
|
|
|
Returns:
|
|
Tuple of (is_in_pending_transaction, claiming_team_abbrev or None)
|
|
"""
|
|
try:
|
|
# Get all pending transactions for the week (all teams)
|
|
params = [
|
|
('season', str(season)),
|
|
('week', str(week)),
|
|
('cancelled', 'false'),
|
|
('frozen', 'false')
|
|
]
|
|
|
|
transactions = await self.get_all_items(params=params)
|
|
|
|
# Check if the player is in any of these transactions
|
|
for transaction in transactions:
|
|
if transaction.player and transaction.player.id == player_id:
|
|
# Found the player in a pending transaction
|
|
claiming_team = transaction.newteam.abbrev if transaction.newteam else "Unknown"
|
|
logger.info(
|
|
f"Player {player_id} already in pending transaction for week {week} "
|
|
f"(claimed by {claiming_team})"
|
|
)
|
|
return True, claiming_team
|
|
|
|
return False, None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error checking pending transactions for player {player_id}: {e}")
|
|
# On error, allow the transaction (fail open) but log the issue
|
|
# The freeze task will still catch duplicates if they occur
|
|
return False, None
|
|
|
|
|
|
# Global service instance
|
|
transaction_service = TransactionService() |