Changing the fixgture today logic

parent 4b0a8e87
......@@ -2,7 +2,7 @@
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
- **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.
- **Backward Compatibility**: Existing UTC data remains intact while new operations use venue timezone
- **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
### Fixed
......
......@@ -14,7 +14,7 @@ from typing import List, Dict, Any
# Build configuration
BUILD_CONFIG = {
'app_name': 'MbetterClient',
'app_version': '1.0.11',
'app_version': '1.0.12',
'description': 'Cross-platform multimedia client application',
'author': 'MBetter Team',
'entry_point': 'main.py',
......
......@@ -211,7 +211,7 @@ Examples:
parser.add_argument(
'--version',
action='version',
version='MbetterClient 1.0.11'
version='MbetterClient 1.0.12'
)
# Timer options
......
......@@ -4,7 +4,7 @@ MbetterClient - Cross-platform multimedia client application
A multi-threaded application with video playback, web dashboard, and REST API integration.
"""
__version__ = "1.0.11"
__version__ = "1.0.12"
__author__ = "MBetter Project"
__email__ = "dev@mbetter.net"
__description__ = "Cross-platform multimedia client with video overlay and web dashboard"
......
......@@ -21,7 +21,7 @@ from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..config.settings import ApiConfig
from ..config.manager import ConfigManager
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 ..utils.ssl_utils import create_requests_session_with_ssl_support
......@@ -365,7 +365,7 @@ class UpdatesResponseHandler(ResponseHandler):
return self.handle_error(endpoint, e)
def _synchronize_match(self, session, match_data: Dict[str, Any]):
"""Synchronize match data to database"""
"""Synchronize match data to template database tables"""
try:
match_number = match_data.get('match_number')
fixture_id = match_data.get('fixture_id')
......@@ -374,23 +374,23 @@ class UpdatesResponseHandler(ResponseHandler):
logger.warning(f"Skipping match with missing match_number ({match_number}) or fixture_id ({fixture_id})")
return
# Check if match already exists by both match_number AND fixture_id
existing_match = session.query(MatchModel).filter_by(
# Check if match already exists by both match_number AND fixture_id in template tables
existing_match = session.query(MatchTemplateModel).filter_by(
match_number=match_number,
fixture_id=fixture_id
).first()
if existing_match:
# Update existing match
# Update existing match template
for key, value in match_data.items():
if hasattr(existing_match, key) and key not in ['id', 'created_at']:
setattr(existing_match, key, value)
existing_match.updated_at = datetime.utcnow()
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:
# Create new match
match = MatchModel(
# Create new match template
match = MatchTemplateModel(
match_number=match_data.get('match_number'),
fighter1_township=match_data.get('fighter1_township', ''),
fighter2_township=match_data.get('fighter2_township', ''),
......@@ -408,23 +408,24 @@ class UpdatesResponseHandler(ResponseHandler):
zip_upload_progress=match_data.get('zip_upload_progress', 0.0),
done=match_data.get('done', 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)
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
session.flush()
# Handle match outcomes
# Handle match outcomes in template table
outcomes_data = match_data.get('outcomes', {})
if outcomes_data:
# 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
for column_name, float_value in outcomes_data.items():
outcome = MatchOutcomeModel(
outcome = MatchOutcomeTemplateModel(
match_id=match.id,
column_name=column_name,
float_value=float(float_value)
......@@ -432,7 +433,7 @@ class UpdatesResponseHandler(ResponseHandler):
session.add(outcome)
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
session.rollback()
raise
......@@ -613,6 +614,53 @@ class UpdatesResponseHandler(ResponseHandler):
logger.error(f"Failed to validate ZIP file {zip_path}: {e}")
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):
"""Send download progress update via message bus"""
try:
......@@ -1210,6 +1258,10 @@ class APIClient(ThreadedComponent):
if downloaded_zips == expected_zips:
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")
# 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
game_start_check_message = Message(
type=MessageType.SYSTEM_STATUS,
......
......@@ -262,7 +262,7 @@ class ApiConfig:
# Request settings
verify_ssl: bool = True
user_agent: str = "MbetterClient/1.0r11"
user_agent: str = "MbetterClient/1.0r12"
max_response_size_mb: int = 100
# Additional API client settings
......@@ -366,7 +366,7 @@ class AppSettings:
timer: TimerConfig = field(default_factory=TimerConfig)
# Application settings
version: str = "1.0.11"
version: str = "1.0.12"
debug_mode: bool = False
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
......
......@@ -12,7 +12,7 @@ from typing import Optional, Dict, Any, List
from .thread_manager import ThreadedComponent
from .message_bus import MessageBus, Message, MessageType, MessageBuilder
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
logger = logging.getLogger(__name__)
......@@ -189,9 +189,6 @@ class GamesThread(ThreadedComponent):
# Clean up any stale 'ingame' matches from previous crashed sessions
self._cleanup_stale_ingame_matches()
# Validate all unvalidated ZIP files at launch
self._validate_all_pending_zips_at_launch()
# Register with message bus first
self.message_queue = self.message_bus.register_component(self.name)
......@@ -271,100 +268,160 @@ class GamesThread(ThreadedComponent):
try:
logger.info(f"Processing START_GAME message from {message.sender}")
# Check for day change and handle cleanup if needed
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")
fixture_id = message.data.get("fixture_id")
# If any fixture is currently being downloaded, wait for all ZIP validation to complete
if self.waiting_for_validation_fixture is not None:
logger.info(f"Fixture {self.waiting_for_validation_fixture} is currently downloading - waiting for all ZIP files to be validated")
self._send_response(message, "waiting_for_downloads", f"Waiting for fixture {self.waiting_for_validation_fixture} downloads to complete")
return
if fixture_id:
# Case 1: fixture_id is provided
logger.info(f"Fixture ID {fixture_id} provided - checking fixture availability")
# Check if any ZIP files are currently being validated system-wide
if self._are_any_zips_being_validated():
logger.info("ZIP files are currently being validated system-wide - waiting for all downloads to complete")
self._send_response(message, "waiting_for_downloads", "Waiting for all ZIP file downloads and validation to complete")
return
session = self.db_manager.get_session()
try:
# Check if any match with the fixture_id exists
fixture_matches = session.query(MatchModel).filter(
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:
# If fixture_id is provided, check if it's in terminal state
if self._is_fixture_all_terminal(fixture_id):
logger.info(f"Fixture {fixture_id} is in terminal state - discarding START_GAME message")
self._send_response(message, "discarded", f"Fixture {fixture_id} is already completed")
return
# Check if there are at least 5 non-completed matches
non_completed_count = session.query(MatchModel).filter(
MatchModel.fixture_id == fixture_id,
MatchModel.active_status == True,
MatchModel.status.notin_(['done', 'cancelled', 'failed', 'paused'])
).count()
# Check if this fixture is currently being downloaded
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")
logger.info(f"Fixture {fixture_id} has {non_completed_count} non-completed matches")
if non_completed_count >= 5:
# Enough matches, proceed with activation
logger.info(f"Fixture {fixture_id} has sufficient matches ({non_completed_count} >= 5) - activating")
self._activate_fixture(fixture_id, message)
return
else:
# Start game with this fixture as usual
logger.info(f"Starting game with provided fixture {fixture_id}")
# 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)
# Start ZIP validation asynchronously in background
self._start_async_zip_validation(fixture_id)
return
# No fixture_id provided - check today's fixtures
if self._has_today_fixtures_all_terminal():
logger.info("All today's fixtures are in terminal states - discarding START_GAME message")
self._send_response(message, "discarded", "All today's fixtures are already completed")
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
ingame_handled = self._handle_ingame_matches(message)
if ingame_handled:
# Message was handled (either discarded or processed) - return
return
finally:
session.close()
# Step 3: Check if there are active fixtures with today's date
active_fixture = self._find_active_today_fixture()
if active_fixture:
logger.info(f"Found active fixture for today: {active_fixture}")
# Check if this fixture is currently being downloaded
if active_fixture == self.waiting_for_validation_fixture:
# This fixture is being downloaded, skip it and initialize new fixture
logger.info(f"Active fixture {active_fixture} is currently being downloaded - initializing new fixture")
# Case 2: No fixture_id provided or fixture doesn't exist
logger.info("No fixture_id provided or fixture doesn't exist - checking database for available matches")
session = self.db_manager.get_session()
try:
# Check if any matches are available in the database
total_matches = session.query(MatchModel).filter(
MatchModel.active_status == True
).count()
if total_matches > 0:
logger.info(f"Found {total_matches} matches in database")
# 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)
# Start ZIP validation asynchronously in background
self._start_async_zip_validation(new_fixture_id)
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:
logger.warning("Could not initialize new fixture")
self._send_response(message, "error", "Could not initialize new fixture")
# 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:
# Start game with this active fixture as usual
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)
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.warning("No last match found despite having matches in database")
# Fall through to create new fixture from templates
else:
logger.info("No matches available in database - creating new fixture from templates")
# Step 4: No active fixtures found - initialize new fixture
logger.info("No active fixtures found - initializing new fixture")
# Create new fixture from templates
new_fixture_id = self._initialize_new_fixture()
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)
return
else:
# Not all ZIPs validated, start validation and wait
logger.info(f"Not all ZIPs validated for new fixture {new_fixture_id} - starting validation and waiting")
self.waiting_for_validation_fixture = new_fixture_id
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}")
else:
logger.warning("Could not initialize new fixture - no fixtures available")
self._send_response(message, "no_fixtures_available", "No fixtures available to start game")
logger.warning("Could not create new fixture from templates - discarding START_GAME message to allow retry when fixtures become available")
# Do not send response - discard message so it will be retried when fixtures become available
return
finally:
session.close()
except Exception as e:
logger.error(f"Failed to handle START_GAME message: {e}")
......@@ -687,22 +744,31 @@ class GamesThread(ThreadedComponent):
).count()
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")
# First try: Create 5 new matches from match templates
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
# Instead of stopping the game, create 5 new matches from old completed matches
old_matches = self._select_random_completed_matches_with_fallback(5, self.current_fixture_id, session)
# 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:
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")
else:
logger.warning("No old completed matches found - cannot create new matches")
# If no old matches available, stop the game
logger.info(f"Created 5 new matches in fixture {self.current_fixture_id} from old matches")
return
# No matches available at all - stop the game
logger.warning("No match templates or old completed matches found - cannot create new matches")
self.game_active = False
completed_message = Message(
type=MessageType.GAME_STATUS,
sender=self.name,
data={
"status": "completed_no_old_matches",
"status": "completed_no_matches_available",
"fixture_id": self.current_fixture_id,
"timestamp": time.time()
}
......@@ -1083,23 +1149,23 @@ class GamesThread(ThreadedComponent):
session = self.db_manager.get_session()
try:
# Get all active matches with ZIP files that are not validated
pending_matches = session.query(MatchModel).filter(
MatchModel.active_status == True,
MatchModel.zip_filename.isnot(None),
MatchModel.zip_validation_status.in_(['pending', None])
# Get all active match templates with ZIP files that are not validated
pending_templates = session.query(MatchTemplateModel).filter(
MatchTemplateModel.active_status == True,
MatchTemplateModel.zip_filename.isnot(None),
MatchTemplateModel.zip_validation_status.in_(['pending', None])
).all()
if not pending_matches:
if not pending_templates:
logger.info("No unvalidated ZIP files found at launch")
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
for match in pending_matches:
# Start validation for this match
self._validate_single_zip_async(match.id, session)
for template in pending_templates:
# Start validation for this template
self._validate_single_zip_async(template.id, session, MatchTemplateModel)
finally:
session.close()
......@@ -1150,7 +1216,7 @@ class GamesThread(ThreadedComponent):
continue
# Start validation for this match
self._validate_single_zip_async(match.id, session)
self._validate_single_zip_async(match.id, session, MatchModel)
finally:
session.close()
......@@ -1178,10 +1244,10 @@ class GamesThread(ThreadedComponent):
except Exception as 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"""
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:
logger.warning(f"Match {match_id} not found for ZIP validation")
return
......@@ -1193,7 +1259,7 @@ class GamesThread(ThreadedComponent):
# Start validation in separate thread
validation_thread = threading.Thread(
target=self._perform_zip_validation,
args=(match_id,),
args=(match_id, model_class),
daemon=True
)
validation_thread.start()
......@@ -1201,12 +1267,12 @@ class GamesThread(ThreadedComponent):
except Exception as 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"""
try:
session = self.db_manager.get_session()
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:
logger.warning(f"Match {match_id} not found during ZIP validation")
return
......@@ -1278,7 +1344,7 @@ class GamesThread(ThreadedComponent):
logger.error(f"ZIP validation failed for match {match_id}: {e}")
try:
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:
match.zip_validation_status = 'invalid'
session.commit()
......@@ -1325,65 +1391,25 @@ class GamesThread(ThreadedComponent):
return None
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:
session = self.db_manager.get_session()
try:
# Get today's date in venue timezone (for day change detection)
today = self._get_today_venue_date()
# Convert venue date range to UTC for database query
from ..utils.timezone_utils import venue_to_utc_datetime
venue_start = datetime.combine(today, datetime.min.time())
venue_end = datetime.combine(today, datetime.max.time())
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()
# First try: Create new fixture from match templates
logger.info("Creating new fixture from match templates")
template_matches = self._select_random_match_templates(5, session)
if template_matches:
fixture_id = self._create_new_fixture_from_templates(template_matches, session)
if fixture_id:
logger.info(f"Created new fixture {fixture_id} from match templates")
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
logger.info("No fixtures found where all matches are NULL or today - creating new fixture from old completed matches")
# 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)
# Second try: Create 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)
if old_matches:
fixture_id = self._create_new_fixture_from_old_matches(old_matches, session)
if fixture_id:
......@@ -1392,8 +1418,9 @@ class GamesThread(ThreadedComponent):
else:
logger.warning("Failed to create new fixture from old matches")
return None
else:
logger.warning("No old completed matches found - cannot create new fixture")
# No matches available at all
logger.warning("No match templates or old completed matches found - cannot create new fixture")
return None
finally:
......@@ -3319,6 +3346,68 @@ class GamesThread(ThreadedComponent):
session.rollback()
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]:
"""Create a new fixture with matches copied from old completed matches"""
try:
......@@ -3380,6 +3469,67 @@ class GamesThread(ThreadedComponent):
session.rollback()
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:
"""Determine the current game status for status requests"""
try:
......@@ -3463,27 +3613,33 @@ class GamesThread(ThreadedComponent):
return 0
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:
logger.info(f"🔄 Ensuring fixture {fixture_id} has at least {minimum_required} matches")
session = self.db_manager.get_session()
try:
# Get the last played match ID to exclude it from selection
last_played_match_id = self._get_last_played_match_id(fixture_id, session)
logger.info(f"🎯 Last played match ID: {last_played_match_id}")
# First try: Select random match templates
template_matches = self._select_random_match_templates(minimum_required, session)
# Select random completed matches using progressive fallback (excludes last 3 matches)
old_matches = self._select_random_completed_matches_with_fallback(
minimum_required, fixture_id, session
)
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
# 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:
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)
logger.info(f"✅ Created {len(old_matches)} new matches in fixture {fixture_id}")
else:
logger.warning(f"⚠️ No suitable old matches found to create new ones for fixture {fixture_id}")
logger.info(f"✅ Created {len(old_matches)} new matches in fixture {fixture_id} from old matches")
return
# 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:
session.close()
......@@ -3634,6 +3790,30 @@ class GamesThread(ThreadedComponent):
logger.error(f"Failed to select random completed matches with fallback: {e}")
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]:
"""Get available matches excluding the last N recent matches in the fixture"""
try:
......
......@@ -262,9 +262,22 @@ class MatchTimerComponent(ThreadedComponent):
self._send_timer_update()
logger.info(f"Timer updated with fixture {match_info['fixture_id']}")
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()
# 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:
logger.error(f"Failed to handle NEXT_MATCH message: {e}")
# On error, try to restart timer anyway
......@@ -508,25 +521,31 @@ class MatchTimerComponent(ThreadedComponent):
return 0
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:
from ..database.models import MatchModel
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
last_played_match_id = self._get_last_played_match_id(fixture_id, session)
logger.info(f"Last played match ID: {last_played_match_id}")
# First try: Select random match templates
template_matches = self._select_random_match_templates(minimum_required, session)
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
old_matches = self._select_random_completed_matches_excluding_last(minimum_required, last_played_match_id, session)
# 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_excluding_last(minimum_required, None, session)
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)
logger.info(f"Created {len(old_matches)} new matches in fixture {fixture_id}")
else:
logger.warning(f"No suitable old matches found to create new ones for fixture {fixture_id}")
logger.info(f"Created {len(old_matches)} new matches in fixture {fixture_id} from old matches")
return
# 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:
logger.error(f"Failed to ensure minimum matches in fixture {fixture_id}: {e}")
......@@ -725,6 +744,94 @@ class MatchTimerComponent(ThreadedComponent):
# Default fallback
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):
"""Send timer update message to all clients"""
try:
......
......@@ -12,7 +12,9 @@ from .models import (
LogEntryModel,
TemplateModel,
AvailableBetModel,
ResultOptionModel
ResultOptionModel,
MatchTemplateModel,
MatchOutcomeTemplateModel
)
from .migrations import DatabaseMigration, run_migrations
......@@ -27,6 +29,8 @@ __all__ = [
'TemplateModel',
'AvailableBetModel',
'ResultOptionModel',
'MatchTemplateModel',
'MatchOutcomeTemplateModel',
'DatabaseMigration',
'run_migrations'
]
\ No newline at end of file
......@@ -181,7 +181,6 @@ class DatabaseManager:
if not self._initialized:
raise RuntimeError("Database manager not initialized")
session = self.Session()
logger.debug(f"DEBUG: Database manager returning session for database: {self.db_path}")
return session
def close(self):
......
......@@ -2731,6 +2731,121 @@ class Migration_035_AddDailyRedistributionShortfallTable(DatabaseMigration):
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
......@@ -2770,6 +2885,7 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_033_AddBarcodeFieldsToBets(),
Migration_034_AddDefaultLicenseText(),
Migration_035_AddDailyRedistributionShortfallTable(),
Migration_036_AddMatchTemplatesTables(),
]
......
......@@ -964,3 +964,148 @@ class DailyRedistributionShortfallModel(BaseModel):
def __repr__(self):
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):
def inject_globals():
return {
'app_name': 'MbetterClient',
'app_version': '1.0.11',
'app_version': '1.0.12',
'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