This commit includes cleanup from model refactoring and terminal client
modularization for better code organization and maintainability.
## Game Models Refactor
**Removed RunnerState class:**
- Eliminated separate RunnerState model (was redundant)
- Replaced runners: List[RunnerState] with direct base references:
- on_first: Optional[LineupPlayerState]
- on_second: Optional[LineupPlayerState]
- on_third: Optional[LineupPlayerState]
- Updated helper methods:
- get_runner_at_base() now returns LineupPlayerState directly
- get_all_runners() returns List[Tuple[int, LineupPlayerState]]
- is_runner_on_X() simplified to direct None checks
**Benefits:**
- Matches database structure (plays table has on_first_id, etc.)
- Simpler state management (direct references vs list management)
- Better type safety (LineupPlayerState vs generic runner)
- Easier to work with in game engine logic
**Updated files:**
- app/models/game_models.py - Removed RunnerState, updated GameState
- app/core/play_resolver.py - Use get_all_runners() instead of state.runners
- app/core/validators.py - Updated runner access patterns
- tests/unit/models/test_game_models.py - Updated test assertions
- tests/unit/core/test_play_resolver.py - Updated test data
- tests/unit/core/test_validators.py - Updated test data
## Terminal Client Refactor
**Modularization (DRY principle):**
Created separate modules for better code organization:
1. **terminal_client/commands.py** (10,243 bytes)
- Shared command functions for game operations
- Used by both CLI (main.py) and REPL (repl.py)
- Functions: submit_defensive_decision, submit_offensive_decision,
resolve_play, quick_play_sequence
- Single source of truth for command logic
2. **terminal_client/arg_parser.py** (7,280 bytes)
- Centralized argument parsing and validation
- Handles defensive/offensive decision arguments
- Validates formats (alignment, depths, hold runners, steal attempts)
3. **terminal_client/completions.py** (10,357 bytes)
- TAB completion support for REPL mode
- Command completions, option completions, dynamic completions
- Game ID completions, defensive/offensive option suggestions
4. **terminal_client/help_text.py** (10,839 bytes)
- Centralized help text and command documentation
- Detailed command descriptions
- Usage examples for all commands
**Updated main modules:**
- terminal_client/main.py - Simplified by using shared commands module
- terminal_client/repl.py - Cleaner with shared functions and completions
**Benefits:**
- DRY: Behavior consistent between CLI and REPL modes
- Maintainability: Changes in one place affect both interfaces
- Testability: Can test commands module independently
- Organization: Clear separation of concerns
## Documentation
**New files:**
- app/models/visual_model_relationships.md
- Visual documentation of model relationships
- Helps understand data flow between models
- terminal_client/update_docs/ (6 phase documentation files)
- Phased documentation for terminal client evolution
- Historical context for implementation decisions
## Tests
**New test files:**
- tests/unit/terminal_client/__init__.py
- tests/unit/terminal_client/test_arg_parser.py
- tests/unit/terminal_client/test_commands.py
- tests/unit/terminal_client/test_completions.py
- tests/unit/terminal_client/test_help_text.py
**Updated tests:**
- Integration tests updated for new runner model
- Unit tests updated for model changes
- All tests passing with new structure
## Summary
- ✅ Simplified game state model (removed RunnerState)
- ✅ Better alignment with database structure
- ✅ Modularized terminal client (DRY principle)
- ✅ Shared command logic between CLI and REPL
- ✅ Comprehensive test coverage
- ✅ Improved documentation
Total changes: 26 files modified/created
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
309 lines
10 KiB
Python
309 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Database Cleanup Script
|
|
|
|
Clear out test data from database tables.
|
|
Use with caution - this will DELETE data!
|
|
|
|
Usage:
|
|
python clean_test_data.py # Interactive mode
|
|
python clean_test_data.py --all # Delete all data
|
|
python clean_test_data.py --game <id> # Delete specific game
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import sys
|
|
from uuid import UUID
|
|
from sqlalchemy import select, delete
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database.session import AsyncSessionLocal
|
|
from app.models.db_models import (
|
|
Game,
|
|
Play,
|
|
Lineup,
|
|
GameSession,
|
|
RosterLink,
|
|
GameCardsetLink
|
|
)
|
|
|
|
# Setup logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(levelname)s - %(message)s'
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class DatabaseCleaner:
|
|
"""Clean test data from database."""
|
|
|
|
async def count_records(self, session: AsyncSession) -> dict:
|
|
"""Count records in each table."""
|
|
counts = {}
|
|
|
|
# Count games
|
|
result = await session.execute(select(Game))
|
|
counts['games'] = len(list(result.scalars().all()))
|
|
|
|
# Count plays
|
|
result = await session.execute(select(Play))
|
|
counts['plays'] = len(list(result.scalars().all()))
|
|
|
|
# Count lineups
|
|
result = await session.execute(select(Lineup))
|
|
counts['lineups'] = len(list(result.scalars().all()))
|
|
|
|
# Count roster links
|
|
result = await session.execute(select(RosterLink))
|
|
counts['roster_links'] = len(list(result.scalars().all()))
|
|
|
|
# Count game sessions
|
|
result = await session.execute(select(GameSession))
|
|
counts['game_sessions'] = len(list(result.scalars().all()))
|
|
|
|
# Count cardset links
|
|
result = await session.execute(select(GameCardsetLink))
|
|
counts['game_cardset_links'] = len(list(result.scalars().all()))
|
|
|
|
return counts
|
|
|
|
async def delete_game(self, game_id: UUID) -> None:
|
|
"""
|
|
Delete a specific game and all related data.
|
|
|
|
Cascade deletes will automatically remove:
|
|
- Plays
|
|
- Lineups
|
|
- RosterLinks
|
|
- GameSession
|
|
- GameCardsetLinks
|
|
"""
|
|
async with AsyncSessionLocal() as session:
|
|
try:
|
|
# Check if game exists
|
|
result = await session.execute(
|
|
select(Game).where(Game.id == game_id)
|
|
)
|
|
game = result.scalar_one_or_none()
|
|
|
|
if not game:
|
|
logger.warning(f"Game {game_id} not found")
|
|
return
|
|
|
|
logger.info(f"Deleting game {game_id} ({game.league_id})...")
|
|
|
|
# Delete game (cascade will handle related records)
|
|
await session.delete(game)
|
|
await session.commit()
|
|
|
|
logger.info(f"✅ Successfully deleted game {game_id}")
|
|
|
|
except Exception as e:
|
|
await session.rollback()
|
|
logger.error(f"❌ Error deleting game: {e}", exc_info=True)
|
|
raise
|
|
|
|
async def delete_all(self, confirm: bool = False) -> None:
|
|
"""
|
|
Delete ALL data from all tables.
|
|
|
|
WARNING: This is destructive and cannot be undone!
|
|
"""
|
|
if not confirm:
|
|
logger.warning("⚠️ delete_all() requires confirm=True parameter")
|
|
return
|
|
|
|
async with AsyncSessionLocal() as session:
|
|
try:
|
|
logger.info("🗑️ Deleting ALL data from database...")
|
|
|
|
# Get counts before deletion
|
|
before_counts = await self.count_records(session)
|
|
logger.info(f"Current record counts: {before_counts}")
|
|
|
|
# Delete in correct order (children first)
|
|
logger.info("Deleting plays...")
|
|
await session.execute(delete(Play))
|
|
|
|
logger.info("Deleting lineups...")
|
|
await session.execute(delete(Lineup))
|
|
|
|
logger.info("Deleting roster links...")
|
|
await session.execute(delete(RosterLink))
|
|
|
|
logger.info("Deleting game sessions...")
|
|
await session.execute(delete(GameSession))
|
|
|
|
logger.info("Deleting game cardset links...")
|
|
await session.execute(delete(GameCardsetLink))
|
|
|
|
logger.info("Deleting games...")
|
|
await session.execute(delete(Game))
|
|
|
|
await session.commit()
|
|
|
|
# Verify deletion
|
|
after_counts = await self.count_records(session)
|
|
logger.info(f"After deletion: {after_counts}")
|
|
|
|
logger.info("✅ Successfully deleted all data")
|
|
|
|
except Exception as e:
|
|
await session.rollback()
|
|
logger.error(f"❌ Error deleting data: {e}", exc_info=True)
|
|
raise
|
|
|
|
async def list_games(self) -> None:
|
|
"""List all games in the database."""
|
|
async with AsyncSessionLocal() as session:
|
|
result = await session.execute(
|
|
select(Game).order_by(Game.created_at.desc())
|
|
)
|
|
games = result.scalars().all()
|
|
|
|
if not games:
|
|
logger.info("No games found in database")
|
|
return
|
|
|
|
logger.info(f"\n{'='*80}")
|
|
logger.info(f"Found {len(games)} game(s) in database:")
|
|
logger.info(f"{'='*80}")
|
|
|
|
for game in games:
|
|
logger.info(f"\nGame ID: {game.id}")
|
|
logger.info(f" League: {game.league_id}")
|
|
logger.info(f" Status: {game.status}")
|
|
logger.info(f" Mode: {game.game_mode}")
|
|
logger.info(f" Teams: {game.away_team_id} @ {game.home_team_id}")
|
|
logger.info(f" Score: {game.away_score}-{game.home_score}")
|
|
if game.current_inning: # type: ignore
|
|
logger.info(f" State: {game.current_half} {game.current_inning}")
|
|
logger.info(f" AI: Home={game.home_team_is_ai}, Away={game.away_team_is_ai}")
|
|
logger.info(f" Created: {game.created_at}")
|
|
|
|
async def show_stats(self) -> None:
|
|
"""Show database statistics."""
|
|
async with AsyncSessionLocal() as session:
|
|
counts = await self.count_records(session)
|
|
|
|
logger.info(f"\n{'='*80}")
|
|
logger.info("DATABASE STATISTICS")
|
|
logger.info(f"{'='*80}")
|
|
logger.info(f"Games: {counts['games']:>6}")
|
|
logger.info(f"Plays: {counts['plays']:>6}")
|
|
logger.info(f"Lineups: {counts['lineups']:>6}")
|
|
logger.info(f"Roster Links: {counts['roster_links']:>6}")
|
|
logger.info(f"Game Sessions: {counts['game_sessions']:>6}")
|
|
logger.info(f"Cardset Links: {counts['game_cardset_links']:>6}")
|
|
logger.info(f"{'='*80}\n")
|
|
|
|
|
|
async def interactive_menu():
|
|
"""Interactive menu for cleanup operations."""
|
|
cleaner = DatabaseCleaner()
|
|
|
|
while True:
|
|
print("\n" + "="*60)
|
|
print("DATABASE CLEANUP MENU")
|
|
print("="*60)
|
|
print("1. Show database statistics")
|
|
print("2. List all games")
|
|
print("3. Delete specific game")
|
|
print("4. Delete ALL data (⚠️ DANGER)")
|
|
print("5. Exit")
|
|
print("="*60)
|
|
|
|
choice = input("\nEnter your choice (1-5): ").strip()
|
|
|
|
if choice == "1":
|
|
await cleaner.show_stats()
|
|
|
|
elif choice == "2":
|
|
await cleaner.list_games()
|
|
|
|
elif choice == "3":
|
|
game_id_str = input("Enter game UUID: ").strip()
|
|
try:
|
|
game_id = UUID(game_id_str)
|
|
confirm = input(f"Delete game {game_id}? (yes/no): ").strip().lower()
|
|
if confirm == "yes":
|
|
await cleaner.delete_game(game_id)
|
|
else:
|
|
logger.info("Cancelled")
|
|
except ValueError:
|
|
logger.error("Invalid UUID format")
|
|
|
|
elif choice == "4":
|
|
await cleaner.show_stats()
|
|
print("\n⚠️ WARNING: This will DELETE ALL DATA from the database!")
|
|
confirm1 = input("Are you absolutely sure? (type 'DELETE ALL'): ").strip()
|
|
if confirm1 == "DELETE ALL":
|
|
confirm2 = input("Type 'yes' to confirm: ").strip().lower()
|
|
if confirm2 == "yes":
|
|
await cleaner.delete_all(confirm=True)
|
|
else:
|
|
logger.info("Cancelled")
|
|
else:
|
|
logger.info("Cancelled")
|
|
|
|
elif choice == "5":
|
|
logger.info("Exiting...")
|
|
break
|
|
|
|
else:
|
|
logger.warning("Invalid choice")
|
|
|
|
|
|
async def main():
|
|
"""Main entry point."""
|
|
cleaner = DatabaseCleaner()
|
|
|
|
# Parse command line arguments
|
|
if len(sys.argv) == 1:
|
|
# No arguments - run interactive mode
|
|
await interactive_menu()
|
|
|
|
elif "--help" in sys.argv or "-h" in sys.argv:
|
|
print(__doc__)
|
|
|
|
elif "--stats" in sys.argv:
|
|
await cleaner.show_stats()
|
|
|
|
elif "--list" in sys.argv:
|
|
await cleaner.list_games()
|
|
|
|
elif "--all" in sys.argv:
|
|
await cleaner.show_stats()
|
|
print("\n⚠️ WARNING: This will DELETE ALL DATA!")
|
|
confirm = input("Type 'yes' to confirm: ").strip().lower()
|
|
if confirm == "yes":
|
|
await cleaner.delete_all(confirm=True)
|
|
else:
|
|
logger.info("Cancelled")
|
|
|
|
elif "--game" in sys.argv:
|
|
idx = sys.argv.index("--game")
|
|
if idx + 1 < len(sys.argv):
|
|
try:
|
|
game_id = UUID(sys.argv[idx + 1])
|
|
await cleaner.delete_game(game_id)
|
|
except ValueError:
|
|
logger.error("Invalid UUID format")
|
|
else:
|
|
logger.error("--game requires a UUID argument")
|
|
|
|
else:
|
|
print("Unknown arguments. Use --help for usage information.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
asyncio.run(main())
|
|
except KeyboardInterrupt:
|
|
logger.info("\nInterrupted by user")
|
|
except Exception as e:
|
|
logger.error(f"Error: {e}", exc_info=True)
|
|
sys.exit(1)
|