Changing the fixgture today logic

parent 4b0a8e87
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [1.0.11] - 2026-01-20 ## [1.0.12] - 2026-01-26
### Added ### Added
- **Venue Timezone System**: Complete timezone-aware system to fix UTC midnight day change issues - **Venue Timezone System**: Complete timezone-aware system to fix UTC midnight day change issues
...@@ -30,6 +30,22 @@ All notable changes to this project will be documented in this file. ...@@ -30,6 +30,22 @@ All notable changes to this project will be documented in this file.
- **Backward Compatibility**: Existing UTC data remains intact while new operations use venue timezone - **Backward Compatibility**: Existing UTC data remains intact while new operations use venue timezone
- **Fallback Strategy**: System timezone → Environment variable (VENUE_TIMEZONE) → UTC ultimate fallback - **Fallback Strategy**: System timezone → Environment variable (VENUE_TIMEZONE) → UTC ultimate fallback
### Added
- **Match Templates Database Tables**: New `matches_templates` and `match_outcomes_templates` tables for storing reusable match configurations
- **Template Model Classes**: `MatchTemplateModel` and `MatchOutcomeTemplateModel` SQLAlchemy models with full relationship support
- **Database Migration System**: Migration_036_AddMatchTemplatesTables with comprehensive indexing and foreign key relationships
- **Template Storage Architecture**: Complete database schema for storing match templates with outcomes and metadata
- **Fixture Synchronization Refactor**: Modified fixture update process to use template tables instead of regular match tables
- **Template Cleanup Logic**: Automatic cleanup of old fixture templates when new fixtures are fully validated
- **Template-Based Fixture Management**: New workflow where fixtures are stored in templates first, then cleaned up after validation
### Technical Details
- **Database Schema**: Added matches_templates and match_outcomes_templates tables with identical structure to matches/match_outcomes
- **Foreign Key Relationships**: Proper foreign key constraints between template tables with CASCADE delete support
- **Indexing Strategy**: Comprehensive indexing for optimal query performance on template data
- **Model Integration**: New template models fully integrated into database module exports
- **Migration System**: Automated table creation with proper SQLite syntax and constraint handling
## [1.2.11] - 2025-08-28 ## [1.2.11] - 2025-08-28
### Fixed ### Fixed
......
...@@ -14,7 +14,7 @@ from typing import List, Dict, Any ...@@ -14,7 +14,7 @@ from typing import List, Dict, Any
# Build configuration # Build configuration
BUILD_CONFIG = { BUILD_CONFIG = {
'app_name': 'MbetterClient', 'app_name': 'MbetterClient',
'app_version': '1.0.11', 'app_version': '1.0.12',
'description': 'Cross-platform multimedia client application', 'description': 'Cross-platform multimedia client application',
'author': 'MBetter Team', 'author': 'MBetter Team',
'entry_point': 'main.py', 'entry_point': 'main.py',
......
...@@ -211,7 +211,7 @@ Examples: ...@@ -211,7 +211,7 @@ Examples:
parser.add_argument( parser.add_argument(
'--version', '--version',
action='version', action='version',
version='MbetterClient 1.0.11' version='MbetterClient 1.0.12'
) )
# Timer options # Timer options
......
...@@ -4,7 +4,7 @@ MbetterClient - Cross-platform multimedia client application ...@@ -4,7 +4,7 @@ MbetterClient - Cross-platform multimedia client application
A multi-threaded application with video playback, web dashboard, and REST API integration. A multi-threaded application with video playback, web dashboard, and REST API integration.
""" """
__version__ = "1.0.11" __version__ = "1.0.12"
__author__ = "MBetter Project" __author__ = "MBetter Project"
__email__ = "dev@mbetter.net" __email__ = "dev@mbetter.net"
__description__ = "Cross-platform multimedia client with video overlay and web dashboard" __description__ = "Cross-platform multimedia client with video overlay and web dashboard"
......
...@@ -21,7 +21,7 @@ from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder ...@@ -21,7 +21,7 @@ from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..config.settings import ApiConfig from ..config.settings import ApiConfig
from ..config.manager import ConfigManager from ..config.manager import ConfigManager
from ..database.manager import DatabaseManager from ..database.manager import DatabaseManager
from ..database.models import MatchModel, MatchOutcomeModel from ..database.models import MatchModel, MatchOutcomeModel, MatchTemplateModel, MatchOutcomeTemplateModel
from ..config.settings import get_user_data_dir from ..config.settings import get_user_data_dir
from ..utils.ssl_utils import create_requests_session_with_ssl_support from ..utils.ssl_utils import create_requests_session_with_ssl_support
...@@ -365,32 +365,32 @@ class UpdatesResponseHandler(ResponseHandler): ...@@ -365,32 +365,32 @@ class UpdatesResponseHandler(ResponseHandler):
return self.handle_error(endpoint, e) return self.handle_error(endpoint, e)
def _synchronize_match(self, session, match_data: Dict[str, Any]): def _synchronize_match(self, session, match_data: Dict[str, Any]):
"""Synchronize match data to database""" """Synchronize match data to template database tables"""
try: try:
match_number = match_data.get('match_number') match_number = match_data.get('match_number')
fixture_id = match_data.get('fixture_id') fixture_id = match_data.get('fixture_id')
if not match_number or not fixture_id: if not match_number or not fixture_id:
logger.warning(f"Skipping match with missing match_number ({match_number}) or fixture_id ({fixture_id})") logger.warning(f"Skipping match with missing match_number ({match_number}) or fixture_id ({fixture_id})")
return return
# Check if match already exists by both match_number AND fixture_id # Check if match already exists by both match_number AND fixture_id in template tables
existing_match = session.query(MatchModel).filter_by( existing_match = session.query(MatchTemplateModel).filter_by(
match_number=match_number, match_number=match_number,
fixture_id=fixture_id fixture_id=fixture_id
).first() ).first()
if existing_match: if existing_match:
# Update existing match # Update existing match template
for key, value in match_data.items(): for key, value in match_data.items():
if hasattr(existing_match, key) and key not in ['id', 'created_at']: if hasattr(existing_match, key) and key not in ['id', 'created_at']:
setattr(existing_match, key, value) setattr(existing_match, key, value)
existing_match.updated_at = datetime.utcnow() existing_match.updated_at = datetime.utcnow()
match = existing_match match = existing_match
logger.debug(f"Updated existing match {match_number} for fixture {fixture_id}") logger.debug(f"Updated existing match template {match_number} for fixture {fixture_id}")
else: else:
# Create new match # Create new match template
match = MatchModel( match = MatchTemplateModel(
match_number=match_data.get('match_number'), match_number=match_data.get('match_number'),
fighter1_township=match_data.get('fighter1_township', ''), fighter1_township=match_data.get('fighter1_township', ''),
fighter2_township=match_data.get('fighter2_township', ''), fighter2_township=match_data.get('fighter2_township', ''),
...@@ -408,31 +408,32 @@ class UpdatesResponseHandler(ResponseHandler): ...@@ -408,31 +408,32 @@ class UpdatesResponseHandler(ResponseHandler):
zip_upload_progress=match_data.get('zip_upload_progress', 0.0), zip_upload_progress=match_data.get('zip_upload_progress', 0.0),
done=match_data.get('done', False), done=match_data.get('done', False),
running=match_data.get('running', False), running=match_data.get('running', False),
fixture_active_time=match_data.get('fixture_active_time') fixture_active_time=match_data.get('fixture_active_time'),
created_by=1 # Default admin user
) )
session.add(match) session.add(match)
logger.debug(f"Created new match {match_number} for fixture {fixture_id}") logger.debug(f"Created new match template {match_number} for fixture {fixture_id}")
# Flush to get the match ID for outcomes # Flush to get the match ID for outcomes
session.flush() session.flush()
# Handle match outcomes # Handle match outcomes in template table
outcomes_data = match_data.get('outcomes', {}) outcomes_data = match_data.get('outcomes', {})
if outcomes_data: if outcomes_data:
# Remove existing outcomes # Remove existing outcomes
session.query(MatchOutcomeModel).filter_by(match_id=match.id).delete() session.query(MatchOutcomeTemplateModel).filter_by(match_id=match.id).delete()
# Add new outcomes # Add new outcomes
for column_name, float_value in outcomes_data.items(): for column_name, float_value in outcomes_data.items():
outcome = MatchOutcomeModel( outcome = MatchOutcomeTemplateModel(
match_id=match.id, match_id=match.id,
column_name=column_name, column_name=column_name,
float_value=float(float_value) float_value=float(float_value)
) )
session.add(outcome) session.add(outcome)
except Exception as e: except Exception as e:
logger.error(f"Failed to synchronize match: {e}") logger.error(f"Failed to synchronize match template: {e}")
# Rollback this session to clean state # Rollback this session to clean state
session.rollback() session.rollback()
raise raise
...@@ -613,6 +614,53 @@ class UpdatesResponseHandler(ResponseHandler): ...@@ -613,6 +614,53 @@ class UpdatesResponseHandler(ResponseHandler):
logger.error(f"Failed to validate ZIP file {zip_path}: {e}") logger.error(f"Failed to validate ZIP file {zip_path}: {e}")
return False return False
def _cleanup_old_fixture_templates(self, session, fixtures):
"""Clean up old fixture templates and keep only the new ones"""
try:
# Get fixture IDs from the current fixtures that were just synchronized
current_fixture_ids = set()
for fixture_data in fixtures:
fixture_id = fixture_data.get('fixture_id')
if fixture_id:
current_fixture_ids.add(fixture_id)
if not current_fixture_ids:
logger.warning("No current fixture IDs found - skipping cleanup")
return
logger.info(f"Cleaning up old fixture templates - keeping fixtures: {current_fixture_ids}")
# Get all fixture IDs currently in the template tables
existing_fixture_ids = set()
existing_fixtures = session.query(MatchTemplateModel.fixture_id).distinct().all()
for row in existing_fixtures:
existing_fixture_ids.add(row.fixture_id)
# Find fixture IDs to remove (existing ones not in current fixtures)
fixtures_to_remove = existing_fixture_ids - current_fixture_ids
if not fixtures_to_remove:
logger.info("No old fixture templates to remove")
return
logger.info(f"Removing old fixture templates: {fixtures_to_remove}")
# Delete matches from old fixtures (CASCADE will delete outcomes)
deleted_matches = 0
for fixture_id in fixtures_to_remove:
deleted_count = session.query(MatchTemplateModel).filter(
MatchTemplateModel.fixture_id == fixture_id
).delete()
deleted_matches += deleted_count
logger.info(f"Removed fixture template {fixture_id} with {deleted_count} matches")
session.commit()
logger.info(f"Successfully cleaned up {len(fixtures_to_remove)} old fixture templates, removed {deleted_matches} matches total")
except Exception as e:
logger.error(f"Failed to cleanup old fixture templates: {e}")
session.rollback()
def _send_download_progress(self, downloaded: int, total: int, message: str): def _send_download_progress(self, downloaded: int, total: int, message: str):
"""Send download progress update via message bus""" """Send download progress update via message bus"""
try: try:
...@@ -1210,6 +1258,10 @@ class APIClient(ThreadedComponent): ...@@ -1210,6 +1258,10 @@ class APIClient(ThreadedComponent):
if downloaded_zips == expected_zips: if downloaded_zips == expected_zips:
logger.info(f"Fixture update completed successfully - {synchronized_matches} matches synchronized, {downloaded_zips}/{expected_zips} ZIPs downloaded") logger.info(f"Fixture update completed successfully - {synchronized_matches} matches synchronized, {downloaded_zips}/{expected_zips} ZIPs downloaded")
logger.debug("All expected ZIP files downloaded - fixtures are ready for games") logger.debug("All expected ZIP files downloaded - fixtures are ready for games")
# Clean up old fixture templates and keep only the new ones
self._cleanup_old_fixture_templates(session, fixtures)
# Send a message to trigger game start check # Send a message to trigger game start check
game_start_check_message = Message( game_start_check_message = Message(
type=MessageType.SYSTEM_STATUS, type=MessageType.SYSTEM_STATUS,
......
...@@ -262,7 +262,7 @@ class ApiConfig: ...@@ -262,7 +262,7 @@ class ApiConfig:
# Request settings # Request settings
verify_ssl: bool = True verify_ssl: bool = True
user_agent: str = "MbetterClient/1.0r11" user_agent: str = "MbetterClient/1.0r12"
max_response_size_mb: int = 100 max_response_size_mb: int = 100
# Additional API client settings # Additional API client settings
...@@ -366,7 +366,7 @@ class AppSettings: ...@@ -366,7 +366,7 @@ class AppSettings:
timer: TimerConfig = field(default_factory=TimerConfig) timer: TimerConfig = field(default_factory=TimerConfig)
# Application settings # Application settings
version: str = "1.0.11" version: str = "1.0.12"
debug_mode: bool = False debug_mode: bool = False
dev_message: bool = False # Enable debug mode showing only message bus messages dev_message: bool = False # Enable debug mode showing only message bus messages
debug_messages: bool = False # Show all messages passing through the message bus on screen debug_messages: bool = False # Show all messages passing through the message bus on screen
......
...@@ -12,7 +12,7 @@ from typing import Optional, Dict, Any, List ...@@ -12,7 +12,7 @@ from typing import Optional, Dict, Any, List
from .thread_manager import ThreadedComponent from .thread_manager import ThreadedComponent
from .message_bus import MessageBus, Message, MessageType, MessageBuilder from .message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..database.manager import DatabaseManager from ..database.manager import DatabaseManager
from ..database.models import MatchModel, MatchStatus, BetDetailModel, MatchOutcomeModel, GameConfigModel, ExtractionAssociationModel, DailyRedistributionShortfallModel from ..database.models import MatchModel, MatchStatus, BetDetailModel, MatchOutcomeModel, GameConfigModel, ExtractionAssociationModel, DailyRedistributionShortfallModel, MatchTemplateModel, MatchOutcomeTemplateModel
from ..utils.timezone_utils import get_today_venue_date from ..utils.timezone_utils import get_today_venue_date
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -189,9 +189,6 @@ class GamesThread(ThreadedComponent): ...@@ -189,9 +189,6 @@ class GamesThread(ThreadedComponent):
# Clean up any stale 'ingame' matches from previous crashed sessions # Clean up any stale 'ingame' matches from previous crashed sessions
self._cleanup_stale_ingame_matches() self._cleanup_stale_ingame_matches()
# Validate all unvalidated ZIP files at launch
self._validate_all_pending_zips_at_launch()
# Register with message bus first # Register with message bus first
self.message_queue = self.message_bus.register_component(self.name) self.message_queue = self.message_bus.register_component(self.name)
...@@ -271,100 +268,160 @@ class GamesThread(ThreadedComponent): ...@@ -271,100 +268,160 @@ class GamesThread(ThreadedComponent):
try: try:
logger.info(f"Processing START_GAME message from {message.sender}") logger.info(f"Processing START_GAME message from {message.sender}")
# Check for day change and handle cleanup if needed fixture_id = message.data.get("fixture_id")
day_change_handled = self._check_and_handle_day_change()
if day_change_handled:
logger.info("Day change was detected and handled - proceeding with new fixture")
# If any fixture is currently being downloaded, wait for all ZIP validation to complete if fixture_id:
if self.waiting_for_validation_fixture is not None: # Case 1: fixture_id is provided
logger.info(f"Fixture {self.waiting_for_validation_fixture} is currently downloading - waiting for all ZIP files to be validated") logger.info(f"Fixture ID {fixture_id} provided - checking fixture availability")
self._send_response(message, "waiting_for_downloads", f"Waiting for fixture {self.waiting_for_validation_fixture} downloads to complete")
return
# Check if any ZIP files are currently being validated system-wide session = self.db_manager.get_session()
if self._are_any_zips_being_validated(): try:
logger.info("ZIP files are currently being validated system-wide - waiting for all downloads to complete") # Check if any match with the fixture_id exists
self._send_response(message, "waiting_for_downloads", "Waiting for all ZIP file downloads and validation to complete") fixture_matches = session.query(MatchModel).filter(
return MatchModel.fixture_id == fixture_id,
MatchModel.active_status == True
).all()
fixture_id = message.data.get("fixture_id") if fixture_matches:
logger.info(f"Fixture {fixture_id} exists with {len(fixture_matches)} matches")
if fixture_id: # Check if there are at least 5 non-completed matches
# If fixture_id is provided, check if it's in terminal state non_completed_count = session.query(MatchModel).filter(
if self._is_fixture_all_terminal(fixture_id): MatchModel.fixture_id == fixture_id,
logger.info(f"Fixture {fixture_id} is in terminal state - discarding START_GAME message") MatchModel.active_status == True,
self._send_response(message, "discarded", f"Fixture {fixture_id} is already completed") MatchModel.status.notin_(['done', 'cancelled', 'failed', 'paused'])
return ).count()
# Check if this fixture is currently being downloaded logger.info(f"Fixture {fixture_id} has {non_completed_count} non-completed matches")
if fixture_id == self.waiting_for_validation_fixture:
# This fixture is being downloaded, wait for it
logger.info(f"Fixture {fixture_id} is currently being downloaded - waiting")
self._send_response(message, "waiting_for_downloads", f"Waiting for fixture {fixture_id} to finish downloading")
return
else:
# Start game with this fixture as usual
logger.info(f"Starting game with provided fixture {fixture_id}")
self._activate_fixture(fixture_id, message)
# Start ZIP validation asynchronously in background
self._start_async_zip_validation(fixture_id)
return
# No fixture_id provided - check today's fixtures if non_completed_count >= 5:
if self._has_today_fixtures_all_terminal(): # Enough matches, proceed with activation
logger.info("All today's fixtures are in terminal states - discarding START_GAME message") logger.info(f"Fixture {fixture_id} has sufficient matches ({non_completed_count} >= 5) - activating")
self._send_response(message, "discarded", "All today's fixtures are already completed") self._activate_fixture(fixture_id, message)
return return
else:
# Not enough matches, create new ones from templates
matches_needed = 5 - non_completed_count
logger.info(f"Fixture {fixture_id} needs {matches_needed} more matches - creating from templates")
# Create new matches from templates
template_matches = self._select_random_match_templates(matches_needed, session)
if template_matches:
self._create_matches_from_templates(fixture_id, template_matches, session)
logger.info(f"Created {len(template_matches)} new matches in fixture {fixture_id}")
# Now activate the fixture
self._activate_fixture(fixture_id, message)
return
else:
logger.warning(f"No match templates available to create matches for fixture {fixture_id}")
self._send_response(message, "error", f"No match templates available for fixture {fixture_id}")
return
else:
logger.info(f"Fixture {fixture_id} does not exist - proceeding as if no fixture_id was provided")
# Fall through to no fixture_id case
# Step 2: Handle matches currently in "ingame" status finally:
ingame_handled = self._handle_ingame_matches(message) session.close()
if ingame_handled:
# Message was handled (either discarded or processed) - return
return
# Step 3: Check if there are active fixtures with today's date # Case 2: No fixture_id provided or fixture doesn't exist
active_fixture = self._find_active_today_fixture() logger.info("No fixture_id provided or fixture doesn't exist - checking database for available matches")
if active_fixture:
logger.info(f"Found active fixture for today: {active_fixture}") session = self.db_manager.get_session()
# Check if this fixture is currently being downloaded try:
if active_fixture == self.waiting_for_validation_fixture: # Check if any matches are available in the database
# This fixture is being downloaded, skip it and initialize new fixture total_matches = session.query(MatchModel).filter(
logger.info(f"Active fixture {active_fixture} is currently being downloaded - initializing new fixture") MatchModel.active_status == True
new_fixture_id = self._initialize_new_fixture() ).count()
if new_fixture_id:
self._activate_fixture(new_fixture_id, message) if total_matches > 0:
# Start ZIP validation asynchronously in background logger.info(f"Found {total_matches} matches in database")
self._start_async_zip_validation(new_fixture_id)
# Get the last match in the matches table
last_match = session.query(MatchModel).filter(
MatchModel.active_status == True
).order_by(MatchModel.created_at.desc()).first()
if last_match:
fixture_id = last_match.fixture_id
logger.info(f"Selected fixture {fixture_id} from last match (ID: {last_match.id})")
# Check if all matches in this fixture are completed
completed_count = session.query(MatchModel).filter(
MatchModel.fixture_id == fixture_id,
MatchModel.active_status == True,
MatchModel.status.in_(['done', 'cancelled', 'failed', 'paused'])
).count()
total_fixture_matches = session.query(MatchModel).filter(
MatchModel.fixture_id == fixture_id,
MatchModel.active_status == True
).count()
if completed_count == total_fixture_matches:
# All matches completed, create new fixture from templates
logger.info(f"All {total_fixture_matches} matches in fixture {fixture_id} are completed - creating new fixture from templates")
new_fixture_id = self._initialize_new_fixture()
if new_fixture_id:
self._activate_fixture(new_fixture_id, message)
return
else:
logger.warning("Could not create new fixture from templates")
self._send_response(message, "error", "Could not create new fixture")
return
else:
# Some matches are not completed, activate this fixture
logger.info(f"Fixture {fixture_id} has active matches - activating")
# Check if at least 5 non-completed matches are available
non_completed_count = session.query(MatchModel).filter(
MatchModel.fixture_id == fixture_id,
MatchModel.active_status == True,
MatchModel.status.notin_(['done', 'cancelled', 'failed', 'paused'])
).count()
logger.info(f"Fixture {fixture_id} has {non_completed_count} non-completed matches")
if non_completed_count >= 5:
# Enough matches, activate
self._activate_fixture(fixture_id, message)
self._start_async_zip_validation(fixture_id)
return
else:
# Not enough matches, create new ones from templates
matches_needed = 5 - non_completed_count
logger.info(f"Fixture {fixture_id} needs {matches_needed} more matches - creating from templates")
template_matches = self._select_random_match_templates(matches_needed, session)
if template_matches:
self._create_matches_from_templates(fixture_id, template_matches, session)
logger.info(f"Created {len(template_matches)} new matches in fixture {fixture_id}")
# Now activate the fixture
self._activate_fixture(fixture_id, message)
return
else:
logger.warning(f"No match templates available to create matches for fixture {fixture_id}")
self._send_response(message, "error", f"No match templates available for fixture {fixture_id}")
return
else: else:
logger.warning("Could not initialize new fixture") logger.warning("No last match found despite having matches in database")
self._send_response(message, "error", "Could not initialize new fixture") # Fall through to create new fixture from templates
else: else:
# Start game with this active fixture as usual logger.info("No matches available in database - creating new fixture from templates")
logger.info(f"Starting game with active fixture {active_fixture}")
self._activate_fixture(active_fixture, message)
# Start ZIP validation asynchronously in background
self._start_async_zip_validation(active_fixture)
return
# Step 4: No active fixtures found - initialize new fixture # Create new fixture from templates
logger.info("No active fixtures found - initializing new fixture") new_fixture_id = self._initialize_new_fixture()
new_fixture_id = self._initialize_new_fixture() if new_fixture_id:
if new_fixture_id:
# Check if all ZIPs are validated before activating
if self._are_all_zips_validated_for_fixture(new_fixture_id):
# All ZIPs validated, activate fixture
logger.info(f"All ZIPs validated for new fixture {new_fixture_id} - activating")
self._activate_fixture(new_fixture_id, message) self._activate_fixture(new_fixture_id, message)
return
else: else:
# Not all ZIPs validated, start validation and wait logger.warning("Could not create new fixture from templates - discarding START_GAME message to allow retry when fixtures become available")
logger.info(f"Not all ZIPs validated for new fixture {new_fixture_id} - starting validation and waiting") # Do not send response - discard message so it will be retried when fixtures become available
self.waiting_for_validation_fixture = new_fixture_id return
self._start_async_zip_validation(new_fixture_id)
self._send_response(message, "waiting_for_downloads", f"Waiting for ZIP files to be validated for fixture {new_fixture_id}") finally:
else: session.close()
logger.warning("Could not initialize new fixture - no fixtures available")
self._send_response(message, "no_fixtures_available", "No fixtures available to start game")
except Exception as e: except Exception as e:
logger.error(f"Failed to handle START_GAME message: {e}") logger.error(f"Failed to handle START_GAME message: {e}")
...@@ -687,28 +744,37 @@ class GamesThread(ThreadedComponent): ...@@ -687,28 +744,37 @@ class GamesThread(ThreadedComponent):
).count() ).count()
if active_count == 0: if active_count == 0:
logger.info(f"All matches completed for fixture {self.current_fixture_id} - creating new matches from old completed ones") logger.info(f"All matches completed for fixture {self.current_fixture_id} - creating new matches")
# Instead of stopping the game, create 5 new matches from old completed matches # First try: Create 5 new matches from match templates
old_matches = self._select_random_completed_matches_with_fallback(5, self.current_fixture_id, session) template_matches = self._select_random_match_templates(5, session)
if template_matches:
self._create_matches_from_templates(self.current_fixture_id, template_matches, session)
logger.info(f"Created 5 new matches in fixture {self.current_fixture_id} from match templates")
return
# Second try: Create 5 new matches from old completed matches
logger.info("No match templates available, trying to reuse old completed matches")
old_matches = self._select_random_completed_matches(5, session)
if old_matches: if old_matches:
self._create_matches_from_old_matches(self.current_fixture_id, old_matches, session) self._create_matches_from_old_matches(self.current_fixture_id, old_matches, session)
logger.info(f"Created 5 new matches in fixture {self.current_fixture_id} from old completed matches") logger.info(f"Created 5 new matches in fixture {self.current_fixture_id} from old matches")
else: return
logger.warning("No old completed matches found - cannot create new matches")
# If no old matches available, stop the game # No matches available at all - stop the game
self.game_active = False logger.warning("No match templates or old completed matches found - cannot create new matches")
completed_message = Message( self.game_active = False
type=MessageType.GAME_STATUS, completed_message = Message(
sender=self.name, type=MessageType.GAME_STATUS,
data={ sender=self.name,
"status": "completed_no_old_matches", data={
"fixture_id": self.current_fixture_id, "status": "completed_no_matches_available",
"timestamp": time.time() "fixture_id": self.current_fixture_id,
} "timestamp": time.time()
) }
self.message_bus.publish(completed_message) )
self.current_fixture_id = None self.message_bus.publish(completed_message)
self.current_fixture_id = None
finally: finally:
session.close() session.close()
...@@ -1083,23 +1149,23 @@ class GamesThread(ThreadedComponent): ...@@ -1083,23 +1149,23 @@ class GamesThread(ThreadedComponent):
session = self.db_manager.get_session() session = self.db_manager.get_session()
try: try:
# Get all active matches with ZIP files that are not validated # Get all active match templates with ZIP files that are not validated
pending_matches = session.query(MatchModel).filter( pending_templates = session.query(MatchTemplateModel).filter(
MatchModel.active_status == True, MatchTemplateModel.active_status == True,
MatchModel.zip_filename.isnot(None), MatchTemplateModel.zip_filename.isnot(None),
MatchModel.zip_validation_status.in_(['pending', None]) MatchTemplateModel.zip_validation_status.in_(['pending', None])
).all() ).all()
if not pending_matches: if not pending_templates:
logger.info("No unvalidated ZIP files found at launch") logger.info("No unvalidated ZIP files found at launch")
return return
logger.info(f"Found {len(pending_matches)} unvalidated ZIP files to validate at launch") logger.info(f"Found {len(pending_templates)} unvalidated ZIP files to validate at launch")
# Validate each pending ZIP file # Validate each pending ZIP file
for match in pending_matches: for template in pending_templates:
# Start validation for this match # Start validation for this template
self._validate_single_zip_async(match.id, session) self._validate_single_zip_async(template.id, session, MatchTemplateModel)
finally: finally:
session.close() session.close()
...@@ -1150,7 +1216,7 @@ class GamesThread(ThreadedComponent): ...@@ -1150,7 +1216,7 @@ class GamesThread(ThreadedComponent):
continue continue
# Start validation for this match # Start validation for this match
self._validate_single_zip_async(match.id, session) self._validate_single_zip_async(match.id, session, MatchModel)
finally: finally:
session.close() session.close()
...@@ -1178,10 +1244,10 @@ class GamesThread(ThreadedComponent): ...@@ -1178,10 +1244,10 @@ class GamesThread(ThreadedComponent):
except Exception as e: except Exception as e:
logger.error(f"Async ZIP validation failed for fixture {fixture_id}: {e}") logger.error(f"Async ZIP validation failed for fixture {fixture_id}: {e}")
def _validate_single_zip_async(self, match_id: int, session): def _validate_single_zip_async(self, match_id: int, session, model_class=MatchModel):
"""Validate a single ZIP file asynchronously""" """Validate a single ZIP file asynchronously"""
try: try:
match = session.query(MatchModel).filter(MatchModel.id == match_id).first() match = session.query(model_class).filter(model_class.id == match_id).first()
if not match: if not match:
logger.warning(f"Match {match_id} not found for ZIP validation") logger.warning(f"Match {match_id} not found for ZIP validation")
return return
...@@ -1193,7 +1259,7 @@ class GamesThread(ThreadedComponent): ...@@ -1193,7 +1259,7 @@ class GamesThread(ThreadedComponent):
# Start validation in separate thread # Start validation in separate thread
validation_thread = threading.Thread( validation_thread = threading.Thread(
target=self._perform_zip_validation, target=self._perform_zip_validation,
args=(match_id,), args=(match_id, model_class),
daemon=True daemon=True
) )
validation_thread.start() validation_thread.start()
...@@ -1201,12 +1267,12 @@ class GamesThread(ThreadedComponent): ...@@ -1201,12 +1267,12 @@ class GamesThread(ThreadedComponent):
except Exception as e: except Exception as e:
logger.error(f"Failed to start ZIP validation for match {match_id}: {e}") logger.error(f"Failed to start ZIP validation for match {match_id}: {e}")
def _perform_zip_validation(self, match_id: int): def _perform_zip_validation(self, match_id: int, model_class=MatchModel):
"""Perform actual ZIP validation""" """Perform actual ZIP validation"""
try: try:
session = self.db_manager.get_session() session = self.db_manager.get_session()
try: try:
match = session.query(MatchModel).filter(MatchModel.id == match_id).first() match = session.query(model_class).filter(model_class.id == match_id).first()
if not match: if not match:
logger.warning(f"Match {match_id} not found during ZIP validation") logger.warning(f"Match {match_id} not found during ZIP validation")
return return
...@@ -1278,7 +1344,7 @@ class GamesThread(ThreadedComponent): ...@@ -1278,7 +1344,7 @@ class GamesThread(ThreadedComponent):
logger.error(f"ZIP validation failed for match {match_id}: {e}") logger.error(f"ZIP validation failed for match {match_id}: {e}")
try: try:
session = self.db_manager.get_session() session = self.db_manager.get_session()
match = session.query(MatchModel).filter(MatchModel.id == match_id).first() match = session.query(model_class).filter(model_class.id == match_id).first()
if match: if match:
match.zip_validation_status = 'invalid' match.zip_validation_status = 'invalid'
session.commit() session.commit()
...@@ -1325,65 +1391,25 @@ class GamesThread(ThreadedComponent): ...@@ -1325,65 +1391,25 @@ class GamesThread(ThreadedComponent):
return None return None
def _initialize_new_fixture(self) -> Optional[str]: def _initialize_new_fixture(self) -> Optional[str]:
"""Initialize a new fixture by finding the first one where ALL matches have start_time NULL or today, or create one from old matches""" """Initialize a new fixture by creating one from templates or old matches"""
try: try:
session = self.db_manager.get_session() session = self.db_manager.get_session()
try: try:
# Get today's date in venue timezone (for day change detection) # First try: Create new fixture from match templates
today = self._get_today_venue_date() logger.info("Creating new fixture from match templates")
template_matches = self._select_random_match_templates(5, session)
# Convert venue date range to UTC for database query if template_matches:
from ..utils.timezone_utils import venue_to_utc_datetime fixture_id = self._create_new_fixture_from_templates(template_matches, session)
venue_start = datetime.combine(today, datetime.min.time()) if fixture_id:
venue_end = datetime.combine(today, datetime.max.time()) logger.info(f"Created new fixture {fixture_id} from match templates")
utc_start = venue_to_utc_datetime(venue_start, self.db_manager)
utc_end = venue_to_utc_datetime(venue_end, self.db_manager)
# Find candidate fixtures that have at least one match with start_time NULL or today
candidate_fixtures = session.query(MatchModel.fixture_id).filter(
MatchModel.active_status == True,
((MatchModel.start_time.is_(None)) |
(MatchModel.start_time >= utc_start) &
(MatchModel.start_time < utc_end))
).distinct().order_by(MatchModel.created_at.asc()).all()
# Check each candidate fixture to ensure ALL matches are NULL or today
for fixture_row in candidate_fixtures:
fixture_id = fixture_row.fixture_id
# Get all matches for this fixture
all_matches = session.query(MatchModel).filter(
MatchModel.fixture_id == fixture_id,
MatchModel.active_status == True
).all()
# Check if ALL matches have start_time NULL or today (convert UTC to venue timezone)
all_valid = all(
match.start_time is None or
utc_to_venue_datetime(match.start_time, self.db_manager).date() == today
for match in all_matches
)
if all_valid:
logger.info(f"Initializing existing fixture where all matches are NULL or today: {fixture_id}")
# Set start_time to now for matches that don't have it
now = datetime.utcnow()
for match in all_matches:
if match.start_time is None:
match.start_time = now
match.status = 'scheduled'
logger.debug(f"Set/confirmed start_time for match {match.match_number}")
session.commit()
return fixture_id return fixture_id
else:
logger.warning("Failed to create new fixture from templates")
return None
# No suitable existing fixtures found - create new fixture from old matches # Second try: Create new fixture from old completed matches
logger.info("No fixtures found where all matches are NULL or today - creating new fixture from old completed matches") logger.info("No match templates available, trying to create fixture from old completed matches")
old_matches = self._select_random_completed_matches(5, session)
# No fixtures with no start_time found - create a new fixture from old completed matches
logger.info("No fixtures with no start_time found - creating new fixture from old completed matches")
old_matches = self._select_random_completed_matches_with_fallback(5, None, session)
if old_matches: if old_matches:
fixture_id = self._create_new_fixture_from_old_matches(old_matches, session) fixture_id = self._create_new_fixture_from_old_matches(old_matches, session)
if fixture_id: if fixture_id:
...@@ -1392,9 +1418,10 @@ class GamesThread(ThreadedComponent): ...@@ -1392,9 +1418,10 @@ class GamesThread(ThreadedComponent):
else: else:
logger.warning("Failed to create new fixture from old matches") logger.warning("Failed to create new fixture from old matches")
return None return None
else:
logger.warning("No old completed matches found - cannot create new fixture") # No matches available at all
return None logger.warning("No match templates or old completed matches found - cannot create new fixture")
return None
finally: finally:
session.close() session.close()
...@@ -3319,6 +3346,68 @@ class GamesThread(ThreadedComponent): ...@@ -3319,6 +3346,68 @@ class GamesThread(ThreadedComponent):
session.rollback() session.rollback()
raise raise
def _create_matches_from_templates(self, fixture_id: str, template_matches: List[MatchTemplateModel], session):
"""Create new matches in the fixture by copying from match templates"""
try:
now = datetime.utcnow()
# Determine the status for new matches based on system state
new_match_status = self._determine_new_match_status(fixture_id, session)
# Find the maximum match_number in the fixture and increment from there
max_match_number = session.query(MatchModel.match_number).filter(
MatchModel.fixture_id == fixture_id
).order_by(MatchModel.match_number.desc()).first()
match_number = (max_match_number[0] + 1) if max_match_number else 1
for template_match in template_matches:
# Create a new match based on the template
new_match = MatchModel(
match_number=match_number,
fighter1_township=template_match.fighter1_township,
fighter2_township=template_match.fighter2_township,
venue_kampala_township=template_match.venue_kampala_township,
start_time=now,
status=new_match_status,
fixture_id=fixture_id,
filename=template_match.filename,
file_sha1sum=template_match.file_sha1sum,
active_status=True,
zip_filename=template_match.zip_filename,
zip_sha1sum=template_match.zip_sha1sum,
zip_upload_status='completed', # Assume ZIP is already available
zip_validation_status='valid', # ZIP already validated from template
fixture_active_time=int(now.timestamp()),
result=None, # Reset result for new match
end_time=None, # Reset end time for new match
done=False, # Reset done flag for new match
running=False # Reset running flag for new match
)
session.add(new_match)
session.flush() # Get the ID
# Copy match outcomes from template
for template_outcome in template_match.outcomes:
new_outcome = MatchOutcomeModel(
match_id=new_match.id,
column_name=template_outcome.column_name,
float_value=template_outcome.float_value
)
session.add(new_outcome)
logger.debug(f"Created new match #{match_number} from template #{template_match.match_number} with status {new_match_status}")
match_number += 1
session.commit()
logger.info(f"Created {len(template_matches)} new matches in fixture {fixture_id} with status {new_match_status}")
except Exception as e:
logger.error(f"Failed to create matches from templates: {e}")
session.rollback()
raise
def _create_new_fixture_from_old_matches(self, old_matches: List[MatchModel], session) -> Optional[str]: def _create_new_fixture_from_old_matches(self, old_matches: List[MatchModel], session) -> Optional[str]:
"""Create a new fixture with matches copied from old completed matches""" """Create a new fixture with matches copied from old completed matches"""
try: try:
...@@ -3380,6 +3469,67 @@ class GamesThread(ThreadedComponent): ...@@ -3380,6 +3469,67 @@ class GamesThread(ThreadedComponent):
session.rollback() session.rollback()
return None return None
def _create_new_fixture_from_templates(self, template_matches: List[MatchTemplateModel], session) -> Optional[str]:
"""Create a new fixture with matches copied from match templates"""
try:
# Generate a unique fixture ID
import uuid
fixture_id = f"template_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow()
# Determine the status for new matches based on system state
new_match_status = self._determine_new_match_status(fixture_id, session)
# For a new fixture, start match_number from 1
match_number = 1
for template_match in template_matches:
# Create a new match based on the template
new_match = MatchModel(
match_number=match_number,
fighter1_township=template_match.fighter1_township,
fighter2_township=template_match.fighter2_township,
venue_kampala_township=template_match.venue_kampala_township,
start_time=now,
status=new_match_status,
fixture_id=fixture_id,
filename=template_match.filename,
file_sha1sum=template_match.file_sha1sum,
active_status=True,
zip_filename=template_match.zip_filename,
zip_sha1sum=template_match.zip_sha1sum,
zip_upload_status='completed', # Assume ZIP is already available
zip_validation_status='valid', # ZIP already validated from template
fixture_active_time=int(now.timestamp()),
result=None, # Reset result for new match
end_time=None, # Reset end time for new match
done=False, # Reset done flag for new match
running=False # Reset running flag for new match
)
session.add(new_match)
session.flush() # Get the ID
# Copy match outcomes from template
for template_outcome in template_match.outcomes:
new_outcome = MatchOutcomeModel(
match_id=new_match.id,
column_name=template_outcome.column_name,
float_value=template_outcome.float_value
)
session.add(new_outcome)
logger.debug(f"Created match #{match_number} in new fixture {fixture_id} from template #{template_match.match_number}")
match_number += 1
session.commit()
logger.info(f"Created new fixture {fixture_id} with {len(template_matches)} matches from match templates (status: {new_match_status})")
return fixture_id
except Exception as e:
logger.error(f"Failed to create new fixture from templates: {e}")
session.rollback()
return None
def _determine_game_status(self) -> str: def _determine_game_status(self) -> str:
"""Determine the current game status for status requests""" """Determine the current game status for status requests"""
try: try:
...@@ -3463,27 +3613,33 @@ class GamesThread(ThreadedComponent): ...@@ -3463,27 +3613,33 @@ class GamesThread(ThreadedComponent):
return 0 return 0
def _ensure_minimum_matches_in_fixture(self, fixture_id: str, minimum_required: int): def _ensure_minimum_matches_in_fixture(self, fixture_id: str, minimum_required: int):
"""Ensure fixture has at least minimum_required matches by creating new ones from old completed matches""" """Ensure fixture has at least minimum_required matches by creating new ones from templates or old matches"""
try: try:
logger.info(f"🔄 Ensuring fixture {fixture_id} has at least {minimum_required} matches") logger.info(f"🔄 Ensuring fixture {fixture_id} has at least {minimum_required} matches")
session = self.db_manager.get_session() session = self.db_manager.get_session()
try: try:
# Get the last played match ID to exclude it from selection # First try: Select random match templates
last_played_match_id = self._get_last_played_match_id(fixture_id, session) template_matches = self._select_random_match_templates(minimum_required, session)
logger.info(f"🎯 Last played match ID: {last_played_match_id}")
# Select random completed matches using progressive fallback (excludes last 3 matches) if template_matches:
old_matches = self._select_random_completed_matches_with_fallback( logger.info(f"📋 Selected {len(template_matches)} match templates to create new matches")
minimum_required, fixture_id, session self._create_matches_from_templates(fixture_id, template_matches, session)
) logger.info(f"✅ Created {len(template_matches)} new matches in fixture {fixture_id} from templates")
return
# Second try: Select random completed matches from matches table
logger.info("No match templates available, trying to reuse old completed matches")
old_matches = self._select_random_completed_matches(minimum_required, session)
if old_matches: if old_matches:
logger.info(f"📋 Selected {len(old_matches)} old matches to create new ones") logger.info(f"📋 Selected {len(old_matches)} old completed matches to create new matches")
self._create_matches_from_old_matches(fixture_id, old_matches, session) self._create_matches_from_old_matches(fixture_id, old_matches, session)
logger.info(f"✅ Created {len(old_matches)} new matches in fixture {fixture_id}") logger.info(f"✅ Created {len(old_matches)} new matches in fixture {fixture_id} from old matches")
else: return
logger.warning(f"⚠️ No suitable old matches found to create new ones for fixture {fixture_id}")
# No matches available at all
logger.warning(f"⚠️ No match templates or old completed matches found - cannot create new matches for fixture {fixture_id}")
finally: finally:
session.close() session.close()
...@@ -3634,6 +3790,30 @@ class GamesThread(ThreadedComponent): ...@@ -3634,6 +3790,30 @@ class GamesThread(ThreadedComponent):
logger.error(f"Failed to select random completed matches with fallback: {e}") logger.error(f"Failed to select random completed matches with fallback: {e}")
return [] return []
def _select_random_match_templates(self, count: int, session) -> List[MatchTemplateModel]:
"""Select random match templates from the database"""
try:
from sqlalchemy.orm import joinedload
# Get all active match templates
match_templates = session.query(MatchTemplateModel).options(joinedload(MatchTemplateModel.outcomes)).filter(
MatchTemplateModel.active_status == True
).all()
if len(match_templates) < count:
logger.warning(f"Only {len(match_templates)} match templates found, requested {count}")
return match_templates
# Select random templates
import random
selected_templates = random.sample(match_templates, count)
logger.info(f"Selected {len(selected_templates)} random match templates")
return selected_templates
except Exception as e:
logger.error(f"Failed to select random match templates: {e}")
return []
def _get_available_matches_excluding_recent(self, fixture_id: Optional[str], exclude_last_n: int, fighters_only: bool, session) -> List[MatchModel]: def _get_available_matches_excluding_recent(self, fixture_id: Optional[str], exclude_last_n: int, fighters_only: bool, session) -> List[MatchModel]:
"""Get available matches excluding the last N recent matches in the fixture""" """Get available matches excluding the last N recent matches in the fixture"""
try: try:
......
...@@ -262,9 +262,22 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -262,9 +262,22 @@ class MatchTimerComponent(ThreadedComponent):
self._send_timer_update() self._send_timer_update()
logger.info(f"Timer updated with fixture {match_info['fixture_id']}") logger.info(f"Timer updated with fixture {match_info['fixture_id']}")
else: else:
logger.info("No more matches to start, stopping timer") logger.info("No more matches available and none could be created - stopping timer and exiting game")
self._stop_timer() self._stop_timer()
# Send message to exit game status since no matches are available
exit_game_message = Message(
type=MessageType.GAME_STATUS,
sender=self.name,
data={
"status": "completed_no_matches_available",
"fixture_id": fixture_id,
"timestamp": time.time()
}
)
self.message_bus.publish(exit_game_message, broadcast=True)
logger.info(f"Sent game exit message due to no matches available in fixture {fixture_id}")
except Exception as e: except Exception as e:
logger.error(f"Failed to handle NEXT_MATCH message: {e}") logger.error(f"Failed to handle NEXT_MATCH message: {e}")
# On error, try to restart timer anyway # On error, try to restart timer anyway
...@@ -508,25 +521,31 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -508,25 +521,31 @@ class MatchTimerComponent(ThreadedComponent):
return 0 return 0
def _ensure_minimum_matches_in_fixture(self, fixture_id: str, minimum_required: int, session): def _ensure_minimum_matches_in_fixture(self, fixture_id: str, minimum_required: int, session):
"""Ensure fixture has at least minimum_required matches by creating new ones from old completed matches""" """Ensure fixture has at least minimum_required matches by creating new ones from templates or old matches"""
try: try:
from ..database.models import MatchModel
logger.info(f"Ensuring fixture {fixture_id} has at least {minimum_required} additional matches") logger.info(f"Ensuring fixture {fixture_id} has at least {minimum_required} additional matches")
# Get the last played match ID to exclude it from selection # First try: Select random match templates
last_played_match_id = self._get_last_played_match_id(fixture_id, session) template_matches = self._select_random_match_templates(minimum_required, session)
logger.info(f"Last played match ID: {last_played_match_id}")
if template_matches:
logger.info(f"Selected {len(template_matches)} match templates to create new matches")
self._create_matches_from_templates(fixture_id, template_matches, session)
logger.info(f"Created {len(template_matches)} new matches in fixture {fixture_id} from templates")
return
# Select random completed matches, excluding the last played one # Second try: Select random completed matches from matches table
old_matches = self._select_random_completed_matches_excluding_last(minimum_required, last_played_match_id, session) logger.info("No match templates available, trying to reuse old completed matches")
old_matches = self._select_random_completed_matches_excluding_last(minimum_required, None, session)
if old_matches: if old_matches:
logger.info(f"Selected {len(old_matches)} old matches to create new ones") logger.info(f"Selected {len(old_matches)} old completed matches to create new matches")
self._create_matches_from_old_matches(fixture_id, old_matches, session) self._create_matches_from_old_matches(fixture_id, old_matches, session)
logger.info(f"Created {len(old_matches)} new matches in fixture {fixture_id}") logger.info(f"Created {len(old_matches)} new matches in fixture {fixture_id} from old matches")
else: return
logger.warning(f"No suitable old matches found to create new ones for fixture {fixture_id}")
# No matches available at all
logger.warning(f"No match templates or old completed matches found - cannot create new matches for fixture {fixture_id}")
except Exception as e: except Exception as e:
logger.error(f"Failed to ensure minimum matches in fixture {fixture_id}: {e}") logger.error(f"Failed to ensure minimum matches in fixture {fixture_id}: {e}")
...@@ -725,6 +744,94 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -725,6 +744,94 @@ class MatchTimerComponent(ThreadedComponent):
# Default fallback # Default fallback
return 'all_bets_on_start' return 'all_bets_on_start'
def _select_random_match_templates(self, count: int, session) -> List[Any]:
"""Select random match templates from the database"""
try:
from ..database.models import MatchTemplateModel
from sqlalchemy.orm import joinedload
# Get all active match templates
match_templates = session.query(MatchTemplateModel).options(joinedload(MatchTemplateModel.outcomes)).filter(
MatchTemplateModel.active_status == True
).all()
if len(match_templates) < count:
logger.warning(f"Only {len(match_templates)} match templates found, requested {count}")
return match_templates
# Select random templates
selected_templates = random.sample(match_templates, count)
logger.info(f"Selected {len(selected_templates)} random match templates")
return selected_templates
except Exception as e:
logger.error(f"Failed to select random match templates: {e}")
return []
def _create_matches_from_templates(self, fixture_id: str, template_matches: List[Any], session):
"""Create new matches in the fixture by copying from match templates"""
try:
from ..database.models import MatchModel, MatchOutcomeModel
now = datetime.utcnow()
# Determine the status for new matches based on system state
new_match_status = self._determine_new_match_status(fixture_id, session)
# Find the maximum match_number in the fixture and increment from there
max_match_number = session.query(MatchModel.match_number).filter(
MatchModel.fixture_id == fixture_id
).order_by(MatchModel.match_number.desc()).first()
match_number = (max_match_number[0] + 1) if max_match_number else 1
for template_match in template_matches:
# Create a new match based on the template
new_match = MatchModel(
match_number=match_number,
fighter1_township=template_match.fighter1_township,
fighter2_township=template_match.fighter2_township,
venue_kampala_township=template_match.venue_kampala_township,
start_time=now,
status=new_match_status,
fixture_id=fixture_id,
filename=template_match.filename,
file_sha1sum=template_match.file_sha1sum,
active_status=True,
zip_filename=template_match.zip_filename,
zip_sha1sum=template_match.zip_sha1sum,
zip_upload_status='completed', # Assume ZIP is already available
zip_validation_status='valid', # ZIP already validated from template
fixture_active_time=int(now.timestamp()),
result=None, # Reset result for new match
end_time=None, # Reset end time for new match
done=False, # Reset done flag for new match
running=False # Reset running flag for new match
)
session.add(new_match)
session.flush() # Get the ID
# Copy match outcomes from template
for template_outcome in template_match.outcomes:
new_outcome = MatchOutcomeModel(
match_id=new_match.id,
column_name=template_outcome.column_name,
float_value=template_outcome.float_value
)
session.add(new_outcome)
logger.debug(f"Created new match #{match_number} from template #{template_match.match_number} with status {new_match_status}")
match_number += 1
session.commit()
logger.info(f"Created {len(template_matches)} new matches in fixture {fixture_id} with status {new_match_status}")
except Exception as e:
logger.error(f"Failed to create matches from templates: {e}")
session.rollback()
raise
def _send_timer_update(self): def _send_timer_update(self):
"""Send timer update message to all clients""" """Send timer update message to all clients"""
try: try:
......
...@@ -12,7 +12,9 @@ from .models import ( ...@@ -12,7 +12,9 @@ from .models import (
LogEntryModel, LogEntryModel,
TemplateModel, TemplateModel,
AvailableBetModel, AvailableBetModel,
ResultOptionModel ResultOptionModel,
MatchTemplateModel,
MatchOutcomeTemplateModel
) )
from .migrations import DatabaseMigration, run_migrations from .migrations import DatabaseMigration, run_migrations
...@@ -27,6 +29,8 @@ __all__ = [ ...@@ -27,6 +29,8 @@ __all__ = [
'TemplateModel', 'TemplateModel',
'AvailableBetModel', 'AvailableBetModel',
'ResultOptionModel', 'ResultOptionModel',
'MatchTemplateModel',
'MatchOutcomeTemplateModel',
'DatabaseMigration', 'DatabaseMigration',
'run_migrations' 'run_migrations'
] ]
\ No newline at end of file
...@@ -181,7 +181,6 @@ class DatabaseManager: ...@@ -181,7 +181,6 @@ class DatabaseManager:
if not self._initialized: if not self._initialized:
raise RuntimeError("Database manager not initialized") raise RuntimeError("Database manager not initialized")
session = self.Session() session = self.Session()
logger.debug(f"DEBUG: Database manager returning session for database: {self.db_path}")
return session return session
def close(self): def close(self):
......
...@@ -2731,6 +2731,121 @@ class Migration_035_AddDailyRedistributionShortfallTable(DatabaseMigration): ...@@ -2731,6 +2731,121 @@ class Migration_035_AddDailyRedistributionShortfallTable(DatabaseMigration):
return False return False
class Migration_036_AddMatchTemplatesTables(DatabaseMigration):
"""Add matches_templates and match_outcomes_templates tables for storing match templates"""
def __init__(self):
super().__init__("036", "Add matches_templates and match_outcomes_templates tables for storing match templates")
def up(self, db_manager) -> bool:
"""Create matches_templates and match_outcomes_templates tables"""
try:
with db_manager.engine.connect() as conn:
# Create matches_templates table (identical structure to matches table)
conn.execute(text("""
CREATE TABLE IF NOT EXISTS matches_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_number INTEGER NOT NULL,
fighter1_township VARCHAR(255) NOT NULL,
fighter2_township VARCHAR(255) NOT NULL,
venue_kampala_township VARCHAR(255) NOT NULL,
start_time DATETIME NULL,
end_time DATETIME NULL,
result VARCHAR(255) NULL,
winning_outcomes TEXT NULL,
under_over_result VARCHAR(50) NULL,
done BOOLEAN DEFAULT FALSE NOT NULL,
running BOOLEAN DEFAULT FALSE NOT NULL,
status VARCHAR(20) DEFAULT 'pending' NOT NULL,
fixture_active_time INTEGER NULL,
filename VARCHAR(1024) NOT NULL,
file_sha1sum VARCHAR(255) NOT NULL,
fixture_id VARCHAR(255) NOT NULL,
active_status BOOLEAN DEFAULT FALSE,
zip_filename VARCHAR(1024) NULL,
zip_sha1sum VARCHAR(255) NULL,
zip_upload_status VARCHAR(20) DEFAULT 'pending',
zip_upload_progress REAL DEFAULT 0.0,
zip_validation_status VARCHAR(20) DEFAULT 'pending',
created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(fixture_id, match_number)
)
"""))
# Create match_outcomes_templates table (identical structure to match_outcomes table)
conn.execute(text("""
CREATE TABLE IF NOT EXISTS match_outcomes_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_id INTEGER NOT NULL REFERENCES matches_templates(id) ON DELETE CASCADE,
column_name VARCHAR(255) NOT NULL,
float_value REAL NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(match_id, column_name)
)
"""))
# Create indexes for matches_templates table
indexes = [
"CREATE INDEX IF NOT EXISTS ix_matches_templates_match_number ON matches_templates(match_number)",
"CREATE INDEX IF NOT EXISTS ix_matches_templates_fixture_id ON matches_templates(fixture_id)",
"CREATE INDEX IF NOT EXISTS ix_matches_templates_active_status ON matches_templates(active_status)",
"CREATE INDEX IF NOT EXISTS ix_matches_templates_file_sha1sum ON matches_templates(file_sha1sum)",
"CREATE INDEX IF NOT EXISTS ix_matches_templates_zip_sha1sum ON matches_templates(zip_sha1sum)",
"CREATE INDEX IF NOT EXISTS ix_matches_templates_zip_upload_status ON matches_templates(zip_upload_status)",
"CREATE INDEX IF NOT EXISTS ix_matches_templates_created_by ON matches_templates(created_by)",
"CREATE INDEX IF NOT EXISTS ix_matches_templates_fixture_active_time ON matches_templates(fixture_active_time)",
"CREATE INDEX IF NOT EXISTS ix_matches_templates_done ON matches_templates(done)",
"CREATE INDEX IF NOT EXISTS ix_matches_templates_running ON matches_templates(running)",
"CREATE INDEX IF NOT EXISTS ix_matches_templates_status ON matches_templates(status)",
"CREATE INDEX IF NOT EXISTS ix_matches_templates_composite ON matches_templates(active_status, zip_upload_status, created_at)",
]
# Create indexes for match_outcomes_templates table
indexes.extend([
"CREATE INDEX IF NOT EXISTS ix_match_outcomes_templates_match_id ON match_outcomes_templates(match_id)",
"CREATE INDEX IF NOT EXISTS ix_match_outcomes_templates_column_name ON match_outcomes_templates(column_name)",
"CREATE INDEX IF NOT EXISTS ix_match_outcomes_templates_float_value ON match_outcomes_templates(float_value)",
"CREATE INDEX IF NOT EXISTS ix_match_outcomes_templates_composite ON match_outcomes_templates(match_id, column_name)",
])
for index_sql in indexes:
conn.execute(text(index_sql))
conn.commit()
logger.info("Matches templates and match outcomes templates tables created successfully")
return True
except Exception as e:
logger.error(f"Failed to create match templates tables: {e}")
return False
def down(self, db_manager) -> bool:
"""Drop matches_templates and match_outcomes_templates tables"""
try:
with db_manager.engine.connect() as conn:
# Drop tables in reverse order (child first due to foreign keys)
conn.execute(text("DROP TABLE IF EXISTS match_outcomes_templates"))
conn.execute(text("DROP TABLE IF EXISTS matches_templates"))
conn.commit()
logger.info("Matches templates and match outcomes templates tables dropped")
return True
except Exception as e:
logger.error(f"Failed to drop match templates tables: {e}")
return False
# Registry of all migrations in order # Registry of all migrations in order
# Registry of all migrations in order # Registry of all migrations in order
...@@ -2770,6 +2885,7 @@ MIGRATIONS: List[DatabaseMigration] = [ ...@@ -2770,6 +2885,7 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_033_AddBarcodeFieldsToBets(), Migration_033_AddBarcodeFieldsToBets(),
Migration_034_AddDefaultLicenseText(), Migration_034_AddDefaultLicenseText(),
Migration_035_AddDailyRedistributionShortfallTable(), Migration_035_AddDailyRedistributionShortfallTable(),
Migration_036_AddMatchTemplatesTables(),
] ]
......
...@@ -964,3 +964,148 @@ class DailyRedistributionShortfallModel(BaseModel): ...@@ -964,3 +964,148 @@ class DailyRedistributionShortfallModel(BaseModel):
def __repr__(self): def __repr__(self):
return f'<DailyRedistributionShortfall {self.date}: shortfall={self.accumulated_shortfall:.2f}, payin={self.total_payin:.2f}, redistributed={self.total_redistributed:.2f}>' return f'<DailyRedistributionShortfall {self.date}: shortfall={self.accumulated_shortfall:.2f}, payin={self.total_payin:.2f}, redistributed={self.total_redistributed:.2f}>'
class MatchTemplateModel(BaseModel):
"""Match templates for storing reusable match configurations"""
__tablename__ = 'matches_templates'
__table_args__ = (
Index('ix_matches_templates_match_number', 'match_number'),
Index('ix_matches_templates_fixture_id', 'fixture_id'),
Index('ix_matches_templates_active_status', 'active_status'),
Index('ix_matches_templates_file_sha1sum', 'file_sha1sum'),
Index('ix_matches_templates_zip_sha1sum', 'zip_sha1sum'),
Index('ix_matches_templates_zip_upload_status', 'zip_upload_status'),
Index('ix_matches_templates_zip_validation_status', 'zip_validation_status'),
Index('ix_matches_templates_created_by', 'created_by'),
Index('ix_matches_templates_fixture_active_time', 'fixture_active_time'),
Index('ix_matches_templates_done', 'done'),
Index('ix_matches_templates_running', 'running'),
Index('ix_matches_templates_status', 'status'),
Index('ix_matches_templates_composite', 'active_status', 'zip_upload_status', 'created_at'),
UniqueConstraint('fixture_id', 'match_number', name='uq_matches_templates_fixture_match'),
)
# Core match data from fixture file
match_number = Column(Integer, nullable=False, comment='Match # from fixture file')
fighter1_township = Column(String(255), nullable=False, comment='Fighter1 (Township)')
fighter2_township = Column(String(255), nullable=False, comment='Fighter2 (Township)')
venue_kampala_township = Column(String(255), nullable=False, comment='Venue (Kampala Township)')
# Match timing and results
start_time = Column(DateTime, comment='Match start time')
end_time = Column(DateTime, comment='Match end time')
result = Column(String(255), comment='Match result/outcome (main result only, e.g. RET2)')
winning_outcomes = Column(JSON, comment='Array of winning outcomes from extraction associations (e.g., ["WIN1", "X1", "12"])')
under_over_result = Column(String(50), comment='UNDER/OVER result if applicable')
done = Column(Boolean, default=False, nullable=False, comment='Match completion flag (0=pending, 1=done)')
running = Column(Boolean, default=False, nullable=False, comment='Match running flag (0=not running, 1=running)')
status = Column(Enum('pending', 'scheduled', 'bet', 'ingame', 'done', 'cancelled', 'failed', 'paused'), default='pending', nullable=False, comment='Match status enum')
fixture_active_time = Column(Integer, nullable=True, comment='Unix timestamp when fixture became active on server')
# File metadata
filename = Column(String(1024), nullable=False, comment='Original fixture filename')
file_sha1sum = Column(String(255), nullable=False, comment='SHA1 checksum of fixture file')
fixture_id = Column(String(255), nullable=False, comment='Fixture identifier (multiple matches can share same fixture)')
active_status = Column(Boolean, default=False, nullable=False, comment='Active status flag')
# ZIP file related fields
zip_filename = Column(String(1024), comment='Associated ZIP filename')
zip_sha1sum = Column(String(255), comment='SHA1 checksum of ZIP file')
zip_upload_status = Column(String(20), default='pending', comment='Upload status: pending, uploading, completed, failed')
zip_upload_progress = Column(Float, default=0.0, comment='Upload progress percentage (0.0-100.0)')
zip_validation_status = Column(String(20), default='pending', comment='Validation status: pending, validating, valid, invalid, failed')
# User tracking
created_by = Column(Integer, ForeignKey('users.id'), comment='User who created this record')
# Relationships
creator = relationship('UserModel', foreign_keys=[created_by])
outcomes = relationship('MatchOutcomeTemplateModel', back_populates='match', cascade='all, delete-orphan')
def is_upload_pending(self) -> bool:
"""Check if ZIP upload is pending"""
return self.zip_upload_status == 'pending'
def is_upload_in_progress(self) -> bool:
"""Check if ZIP upload is in progress"""
return self.zip_upload_status == 'uploading'
def is_upload_completed(self) -> bool:
"""Check if ZIP upload is completed"""
return self.zip_upload_status == 'completed'
def is_upload_failed(self) -> bool:
"""Check if ZIP upload failed"""
return self.zip_upload_status == 'failed'
def set_upload_status(self, status: str, progress: float = None):
"""Set upload status and optionally progress"""
valid_statuses = ['pending', 'uploading', 'completed', 'failed']
if status not in valid_statuses:
raise ValueError(f"Invalid status: {status}. Must be one of {valid_statuses}")
self.zip_upload_status = status
if progress is not None:
self.zip_upload_progress = min(100.0, max(0.0, progress))
self.updated_at = datetime.utcnow()
def activate(self):
"""Activate this match"""
self.active_status = True
self.updated_at = datetime.utcnow()
def deactivate(self):
"""Deactivate this match"""
self.active_status = False
self.updated_at = datetime.utcnow()
def get_outcomes_dict(self) -> Dict[str, float]:
"""Get match outcomes as a dictionary"""
return {outcome.column_name: outcome.float_value for outcome in self.outcomes}
def add_outcome(self, column_name: str, float_value: float):
"""Add or update match outcome"""
# Check if outcome already exists
existing = next((o for o in self.outcomes if o.column_name == column_name), None)
if existing:
existing.float_value = float_value
existing.updated_at = datetime.utcnow()
else:
outcome = MatchOutcomeTemplateModel(
column_name=column_name,
float_value=float_value
)
self.outcomes.append(outcome)
def to_dict(self, exclude_fields: Optional[List[str]] = None) -> Dict[str, Any]:
"""Convert to dictionary with outcomes"""
result = super().to_dict(exclude_fields)
result['outcomes'] = self.get_outcomes_dict()
result['outcome_count'] = len(self.outcomes)
return result
def __repr__(self):
return f'<MatchTemplate #{self.match_number}: {self.fighter1_township} vs {self.fighter2_township}>'
class MatchOutcomeTemplateModel(BaseModel):
"""Match outcome template values for storing reusable match configurations"""
__tablename__ = 'match_outcomes_templates'
__table_args__ = (
Index('ix_match_outcomes_templates_match_id', 'match_id'),
Index('ix_match_outcomes_templates_column_name', 'column_name'),
Index('ix_match_outcomes_templates_float_value', 'float_value'),
Index('ix_match_outcomes_templates_composite', 'match_id', 'column_name'),
UniqueConstraint('match_id', 'column_name', name='uq_match_outcomes_templates_match_column'),
)
match_id = Column(Integer, ForeignKey('matches_templates.id', ondelete='CASCADE'), nullable=False, comment='Foreign key to matches_templates table')
column_name = Column(String(255), nullable=False, comment='Result column name from fixture file')
float_value = Column(Float, nullable=False, comment='Float value with precision')
# Relationships
match = relationship('MatchTemplateModel', back_populates='outcomes')
def __repr__(self):
return f'<MatchOutcomeTemplate {self.column_name}={self.float_value} for MatchTemplate {self.match_id}>'
...@@ -209,7 +209,7 @@ class WebDashboard(ThreadedComponent): ...@@ -209,7 +209,7 @@ class WebDashboard(ThreadedComponent):
def inject_globals(): def inject_globals():
return { return {
'app_name': 'MbetterClient', 'app_name': 'MbetterClient',
'app_version': '1.0.11', 'app_version': '1.0.12',
'current_time': time.time(), 'current_time': time.time(),
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment