Balabce globalized

parent a1afa146
...@@ -248,6 +248,15 @@ class UpdatesResponseHandler(ResponseHandler): ...@@ -248,6 +248,15 @@ class UpdatesResponseHandler(ResponseHandler):
matches = fixture_data.get('matches', []) matches = fixture_data.get('matches', [])
logger.debug(f"Synchronizing fixture {fixture_id} with {len(matches)} matches") logger.debug(f"Synchronizing fixture {fixture_id} with {len(matches)} matches")
# Handle both list and dict formats for matches
if isinstance(matches, dict):
# Convert dict to list of match data
matches = [match_data for match_data in matches.values()]
logger.debug(f"Converted matches dict to list with {len(matches)} items")
elif not isinstance(matches, list):
logger.warning(f"Unexpected matches format: {type(matches)}, skipping fixture {fixture_id}")
continue
for match_data in matches: for match_data in matches:
match_number = match_data.get('match_number', 'unknown') match_number = match_data.get('match_number', 'unknown')
try: try:
...@@ -282,6 +291,9 @@ class UpdatesResponseHandler(ResponseHandler): ...@@ -282,6 +291,9 @@ class UpdatesResponseHandler(ResponseHandler):
logger.debug(f"Committing {processed_data['synchronized_matches']} synchronized matches to database") logger.debug(f"Committing {processed_data['synchronized_matches']} synchronized matches to database")
session.commit() session.commit()
logger.debug("Database commit completed successfully") logger.debug("Database commit completed successfully")
# Clean up old fixture templates and keep only the new ones
self._cleanup_old_fixture_templates(session, fixtures)
finally: finally:
session.close() session.close()
...@@ -314,22 +326,45 @@ class UpdatesResponseHandler(ResponseHandler): ...@@ -314,22 +326,45 @@ class UpdatesResponseHandler(ResponseHandler):
match_data['zip_url'] = match_data['zip_download_url'] match_data['zip_url'] = match_data['zip_download_url']
logger.debug(f"Found ZIP file to download: {zip_filename} for match {match_number} in fixture {fixture_id}") logger.debug(f"Found ZIP file to download: {zip_filename} for match {match_number} in fixture {fixture_id}")
# Send progress update - starting individual download # Attempt download with retries
progress_percent = int((processed_data['downloaded_zips'] / max(1, processed_data['expected_zips'])) * 100) download_success = False
self._send_download_progress(processed_data['downloaded_zips'], processed_data['expected_zips'], max_retries = 3
f"Downloading {zip_filename}...") for attempt in range(max_retries):
try:
download_success = self._download_zip_file(match_data) # Send progress update - starting/retrying download
if download_success: attempt_desc = f" (attempt {attempt + 1}/{max_retries})" if attempt > 0 else ""
processed_data['downloaded_zips'] += 1 progress_percent = int((processed_data['downloaded_zips'] / max(1, processed_data['expected_zips'])) * 100)
logger.debug(f"Successfully downloaded ZIP file: {zip_filename} for match {match_number}") self._send_download_progress(processed_data['downloaded_zips'], processed_data['expected_zips'],
f"Downloading {zip_filename}{attempt_desc}...")
# Send progress update - download completed
progress_percent = int((processed_data['downloaded_zips'] / max(1, processed_data['expected_zips'])) * 100) download_success = self._download_zip_file(match_data)
self._send_download_progress(processed_data['downloaded_zips'], processed_data['expected_zips'], if download_success:
f"Downloaded {zip_filename}") processed_data['downloaded_zips'] += 1
elif 'zip_download_url' in match_data: logger.debug(f"Successfully downloaded ZIP file: {zip_filename} for match {match_number}")
logger.debug(f"ZIP file download skipped or failed: {zip_filename} for match {match_number}")
# Send progress update - download completed
progress_percent = int((processed_data['downloaded_zips'] / max(1, processed_data['expected_zips'])) * 100)
self._send_download_progress(processed_data['downloaded_zips'], processed_data['expected_zips'],
f"Downloaded {zip_filename}")
break
else:
if attempt < max_retries - 1:
logger.warning(f"ZIP download attempt {attempt + 1} failed for {zip_filename}, retrying...")
# Small delay before retry
import time
time.sleep(1)
else:
logger.error(f"All {max_retries} download attempts failed for {zip_filename}")
except Exception as retry_e:
if attempt < max_retries - 1:
logger.warning(f"ZIP download attempt {attempt + 1} failed for {zip_filename}: {retry_e}, retrying...")
import time
time.sleep(1)
else:
logger.error(f"All {max_retries} download attempts failed for {zip_filename}: {retry_e}")
if not download_success:
logger.debug(f"ZIP file download failed after retries: {zip_filename} for match {match_number}")
except requests.exceptions.HTTPError as http_err: except requests.exceptions.HTTPError as http_err:
# Check if this is a "fixture no longer available" error (404) # Check if this is a "fixture no longer available" error (404)
...@@ -423,14 +458,45 @@ class UpdatesResponseHandler(ResponseHandler): ...@@ -423,14 +458,45 @@ class UpdatesResponseHandler(ResponseHandler):
# Remove existing outcomes # Remove existing outcomes
session.query(MatchOutcomeTemplateModel).filter_by(match_id=match.id).delete() session.query(MatchOutcomeTemplateModel).filter_by(match_id=match.id).delete()
# Normalize outcomes_data to dict format
normalized_outcomes = {}
try:
if isinstance(outcomes_data, dict):
# Check if values are dicts with 'value' key or direct float values
for key, value in outcomes_data.items():
if isinstance(value, dict) and 'value' in value:
normalized_outcomes[key] = value['value']
elif isinstance(value, (int, float)):
normalized_outcomes[key] = value
else:
logger.warning(f"Unexpected outcome value type for {key}: {type(value)}, skipping")
continue
elif isinstance(outcomes_data, list):
# Handle list format [{"name": "WIN1", "value": 2.5}, ...]
for item in outcomes_data:
if isinstance(item, dict) and 'name' in item and 'value' in item:
normalized_outcomes[item['name']] = item['value']
else:
logger.warning(f"Unexpected list item format: {item}, skipping")
continue
else:
logger.warning(f"Unexpected outcomes_data type: {type(outcomes_data)}, expected dict or list")
except Exception as e:
logger.error(f"Failed to normalize outcomes_data: {e}")
# Continue without outcomes if normalization fails
# Add new outcomes # Add new outcomes
for column_name, float_value in outcomes_data.items(): for column_name, float_value in normalized_outcomes.items():
outcome = MatchOutcomeTemplateModel( try:
match_id=match.id, outcome = MatchOutcomeTemplateModel(
column_name=column_name, match_id=match.id,
float_value=float(float_value) column_name=str(column_name),
) float_value=float(float_value)
session.add(outcome) )
session.add(outcome)
except (ValueError, TypeError) as e:
logger.warning(f"Failed to process outcome {column_name}: {float_value} - {e}")
continue
except Exception as e: except Exception as e:
logger.error(f"Failed to synchronize match template: {e}") logger.error(f"Failed to synchronize match template: {e}")
...@@ -500,6 +566,23 @@ class UpdatesResponseHandler(ResponseHandler): ...@@ -500,6 +566,23 @@ class UpdatesResponseHandler(ResponseHandler):
logger.debug(f"Starting validation for downloaded ZIP file: {zip_filename}") logger.debug(f"Starting validation for downloaded ZIP file: {zip_filename}")
if not self._validate_downloaded_zip(zip_path, match_data): if not self._validate_downloaded_zip(zip_path, match_data):
logger.error(f"ZIP file validation failed: {zip_filename}") logger.error(f"ZIP file validation failed: {zip_filename}")
# Update database validation status to 'invalid'
try:
session = self.db_manager.get_session()
try:
# Find the match template and update validation status
match_template = session.query(MatchTemplateModel).filter_by(
zip_filename=zip_filename
).first()
if match_template:
match_template.zip_validation_status = 'invalid'
session.commit()
logger.debug(f"Updated database validation status to 'invalid' for {zip_filename}")
finally:
session.close()
except Exception as db_e:
logger.warning(f"Failed to update database validation status for {zip_filename}: {db_e}")
# Remove corrupted file # Remove corrupted file
try: try:
zip_path.unlink() zip_path.unlink()
...@@ -508,6 +591,43 @@ class UpdatesResponseHandler(ResponseHandler): ...@@ -508,6 +591,43 @@ class UpdatesResponseHandler(ResponseHandler):
logger.warning(f"Failed to remove corrupted ZIP file {zip_filename}: {cleanup_e}") logger.warning(f"Failed to remove corrupted ZIP file {zip_filename}: {cleanup_e}")
return False return False
# Update database validation status to 'valid' after successful validation
try:
session = self.db_manager.get_session()
try:
# Find the match template and update validation status
match_template = session.query(MatchTemplateModel).filter_by(
zip_filename=zip_filename
).first()
if match_template:
match_template.zip_validation_status = 'valid'
session.commit()
logger.debug(f"Updated database validation status to 'valid' for {zip_filename}")
finally:
session.close()
except Exception as db_e:
logger.warning(f"Failed to update database validation status for {zip_filename}: {db_e}")
# Start detailed ZIP validation (checking for video files) asynchronously
logger.debug(f"Starting detailed ZIP validation for {zip_filename}")
try:
# Find the match template to get its ID for validation
session = self.db_manager.get_session()
try:
match_template = session.query(MatchTemplateModel).filter_by(
zip_filename=zip_filename
).first()
if match_template:
# Start detailed validation asynchronously
self._validate_single_zip_async(match_template.id, session, MatchTemplateModel)
logger.debug(f"Started detailed validation for match template {match_template.id}")
else:
logger.warning(f"Could not find match template for ZIP file {zip_filename} to start detailed validation")
finally:
session.close()
except Exception as val_e:
logger.warning(f"Failed to start detailed ZIP validation for {zip_filename}: {val_e}")
logger.info(f"Successfully downloaded and validated ZIP file: {zip_filename}") logger.info(f"Successfully downloaded and validated ZIP file: {zip_filename}")
return True return True
...@@ -1259,9 +1379,6 @@ class APIClient(ThreadedComponent): ...@@ -1259,9 +1379,6 @@ class APIClient(ThreadedComponent):
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,
......
...@@ -6,7 +6,7 @@ import time ...@@ -6,7 +6,7 @@ import time
import json import json
import logging import logging
import threading import threading
from datetime import datetime, timedelta from datetime import datetime, timedelta, date
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from .thread_manager import ThreadedComponent from .thread_manager import ThreadedComponent
...@@ -35,26 +35,27 @@ class GamesThread(ThreadedComponent): ...@@ -35,26 +35,27 @@ class GamesThread(ThreadedComponent):
return get_today_venue_date(self.db_manager) return get_today_venue_date(self.db_manager)
def _check_and_handle_day_change(self) -> bool: def _check_and_handle_day_change(self) -> bool:
"""Check if day has changed and handle cleanup/reset accordingly. """Check if day has changed and handle continuous flow.
Returns True if day change was detected and handled, False otherwise.""" Returns True if day change was detected, False otherwise.
Note: No database changes or game state reset - allows continuous flow."""
try: try:
session = self.db_manager.get_session() session = self.db_manager.get_session()
try: try:
today = self._get_today_venue_date() today = self._get_today_venue_date()
# Get the current fixture if any # Get the current fixture if any
if not self.current_fixture_id: if not self.current_fixture_id:
return False return False
# Get matches for current fixture # Get matches for current fixture
current_matches = session.query(MatchModel).filter( current_matches = session.query(MatchModel).filter(
MatchModel.fixture_id == self.current_fixture_id, MatchModel.fixture_id == self.current_fixture_id,
MatchModel.active_status == True MatchModel.active_status == True
).all() ).all()
if not current_matches: if not current_matches:
return False return False
# Check if all matches in current fixture are from a previous day # Check if all matches in current fixture are from a previous day
all_matches_old_day = True all_matches_old_day = True
for match in current_matches: for match in current_matches:
...@@ -66,38 +67,18 @@ class GamesThread(ThreadedComponent): ...@@ -66,38 +67,18 @@ class GamesThread(ThreadedComponent):
if match_date == today: if match_date == today:
all_matches_old_day = False all_matches_old_day = False
break break
if all_matches_old_day: if all_matches_old_day:
logger.info(f"Day change detected! Current fixture {self.current_fixture_id} is from previous day") logger.info(f"Day change detected! Current fixture {self.current_fixture_id} is from previous day - allowing continuous flow")
# Note: No database changes or game state reset - system will continue with existing matches
# Cancel all pending/bet/scheduled matches in the old fixture # and create new fixtures with new fixture_id when current fixture is exhausted
old_matches = session.query(MatchModel).filter(
MatchModel.fixture_id == self.current_fixture_id,
MatchModel.active_status == True,
MatchModel.status.in_(['pending', 'scheduled', 'bet', 'ingame'])
).all()
for match in old_matches:
logger.info(f"Cancelling old match {match.match_number} due to day change: {match.fighter1_township} vs {match.fighter2_township}")
match.status = 'cancelled'
# Cancel/refund associated bets
self._cancel_match_bets(match.id, session)
session.commit()
logger.info(f"Cancelled {len(old_matches)} old matches due to day change")
# Reset game state
self.game_active = False
self.current_fixture_id = None
logger.info("Game state reset due to day change - will initialize new fixture")
return True return True
return False return False
finally: finally:
session.close() session.close()
except Exception as e: except Exception as e:
logger.error(f"Failed to check/handle day change: {e}") logger.error(f"Failed to check/handle day change: {e}")
return False return False
...@@ -416,8 +397,8 @@ class GamesThread(ThreadedComponent): ...@@ -416,8 +397,8 @@ class GamesThread(ThreadedComponent):
self._activate_fixture(new_fixture_id, message) self._activate_fixture(new_fixture_id, message)
return return
else: else:
logger.warning("Could not create new fixture from templates - discarding START_GAME message to allow retry when fixtures become available") logger.warning("Could not create new fixture from templates - waiting for templates to become available")
# Do not send response - discard message so it will be retried when fixtures become available self._send_response(message, "waiting_for_downloads", "Waiting for match templates to be downloaded and validated")
return return
finally: finally:
...@@ -744,7 +725,23 @@ class GamesThread(ThreadedComponent): ...@@ -744,7 +725,23 @@ 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") logger.info(f"All matches completed for fixture {self.current_fixture_id} - checking day change for new fixture creation")
# Check if day has changed - if so, create new fixture with new fixture_id
day_changed = self._check_and_handle_day_change()
if day_changed:
logger.info("Day change detected - creating new fixture with new fixture_id")
new_fixture_id = self._initialize_new_fixture()
if new_fixture_id:
logger.info(f"Created new fixture {new_fixture_id} for new day - switching to it")
self.current_fixture_id = new_fixture_id
return
else:
logger.warning("Could not create new fixture for new day")
# Fall through to create matches in current fixture
# No day change or failed to create new fixture - create matches in current fixture
logger.info(f"Creating new matches in current fixture {self.current_fixture_id}")
# First try: Create 5 new matches from match templates # First try: Create 5 new matches from match templates
template_matches = self._select_random_match_templates(5, session) template_matches = self._select_random_match_templates(5, session)
...@@ -1348,6 +1345,25 @@ class GamesThread(ThreadedComponent): ...@@ -1348,6 +1345,25 @@ class GamesThread(ThreadedComponent):
if match: if match:
match.zip_validation_status = 'invalid' match.zip_validation_status = 'invalid'
session.commit() session.commit()
# If this is a MatchTemplateModel and validation failed, trigger re-download
if model_class == MatchTemplateModel and hasattr(match, 'zip_filename') and match.zip_filename:
logger.info(f"Detailed validation failed for {match.zip_filename}, triggering re-download")
# Reset status to allow re-download on next sync
match.zip_validation_status = 'pending'
session.commit()
# Remove the corrupted file to force re-download
try:
from ..config.settings import get_user_data_dir
user_data_dir = get_user_data_dir()
zip_path = user_data_dir / "zip_files" / match.zip_filename
if zip_path.exists():
zip_path.unlink()
logger.debug(f"Removed corrupted ZIP file {match.zip_filename} to allow re-download")
except Exception as cleanup_e:
logger.warning(f"Failed to remove corrupted ZIP file {match.zip_filename}: {cleanup_e}")
session.close() session.close()
except Exception as update_e: except Exception as update_e:
logger.error(f"Failed to update validation status after error: {update_e}") logger.error(f"Failed to update validation status after error: {update_e}")
...@@ -2046,27 +2062,28 @@ class GamesThread(ThreadedComponent): ...@@ -2046,27 +2062,28 @@ class GamesThread(ThreadedComponent):
logger.error(f"Failed to get redistribution CAP: {e}") logger.error(f"Failed to get redistribution CAP: {e}")
return 70.0 return 70.0
def _get_daily_redistribution_adjustment(self, date, session) -> float: def _get_global_redistribution_adjustment(self, session) -> float:
"""Get accumulated redistribution adjustment (persistent across application restarts)""" """Get accumulated global redistribution adjustment"""
try: try:
# Get the most recent accumulated_shortfall value from any record # Get the global redistribution adjustment record (fixed date 1970-01-01)
latest_record = session.query(PersistentRedistributionAdjustmentModel).order_by( global_date = date(1970, 1, 1)
PersistentRedistributionAdjustmentModel.updated_at.desc() global_record = session.query(PersistentRedistributionAdjustmentModel)\
).first() .filter_by(date=global_date)\
.first()
if latest_record: if global_record:
logger.debug(f"Found persistent redistribution adjustment: {latest_record.accumulated_shortfall}") logger.debug(f"Found global redistribution adjustment: {global_record.accumulated_shortfall}")
return latest_record.accumulated_shortfall return global_record.accumulated_shortfall
else: else:
logger.debug("No redistribution adjustment records found, returning 0.0") logger.debug("No global redistribution adjustment record found, returning 0.0")
return 0.0 return 0.0
except Exception as e: except Exception as e:
logger.error(f"Failed to get persistent redistribution adjustment: {e}") logger.error(f"Failed to get global redistribution adjustment: {e}")
return 0.0 return 0.0
def _update_daily_redistribution_adjustment(self, date, payin_amount, redistributed_amount, cap_percentage, session): def _update_global_redistribution_adjustment(self, payin_amount, redistributed_amount, cap_percentage, session):
"""Update persistent redistribution adjustment tracking after extraction""" """Update persistent global redistribution adjustment tracking after extraction"""
try: try:
# Calculate the redistribution adjustment for this extraction # Calculate the redistribution adjustment for this extraction
# Positive: under-redistribution (shortfall), Negative: over-redistribution (surplus) # Positive: under-redistribution (shortfall), Negative: over-redistribution (surplus)
...@@ -2076,8 +2093,8 @@ class GamesThread(ThreadedComponent): ...@@ -2076,8 +2093,8 @@ class GamesThread(ThreadedComponent):
logger.info(f"💰 [ADJUSTMENT DEBUG] Payin: {payin_amount:.2f}, Expected: {expected_redistribution:.2f}, Redistributed: {redistributed_amount:.2f}, Adjustment: {adjustment:.2f}") logger.info(f"💰 [ADJUSTMENT DEBUG] Payin: {payin_amount:.2f}, Expected: {expected_redistribution:.2f}, Redistributed: {redistributed_amount:.2f}, Adjustment: {adjustment:.2f}")
# Use a fixed date for the global persistent record # Use a fixed date for the global persistent record
global_date = datetime.date(1970, 1, 1) # Fixed date for global record global_date = date(1970, 1, 1) # Fixed date for global record
# Get or create the global record # Get or create the global record
adjustment_record = session.query(PersistentRedistributionAdjustmentModel).filter_by( adjustment_record = session.query(PersistentRedistributionAdjustmentModel).filter_by(
date=global_date date=global_date
...@@ -2585,9 +2602,8 @@ class GamesThread(ThreadedComponent): ...@@ -2585,9 +2602,8 @@ class GamesThread(ThreadedComponent):
# Get redistribution CAP # Get redistribution CAP
cap_percentage = self._get_redistribution_cap() cap_percentage = self._get_redistribution_cap()
# Get accumulated redistribution adjustment from previous extractions for today # Get accumulated redistribution adjustment from previous extractions
today = self._get_today_venue_date() accumulated_shortfall = self._get_global_redistribution_adjustment(session)
accumulated_shortfall = self._get_daily_redistribution_adjustment(today, session)
logger.info(f"🎯 [EXTRACTION DEBUG] Accumulated redistribution adjustment: {accumulated_shortfall:.2f}") logger.info(f"🎯 [EXTRACTION DEBUG] Accumulated redistribution adjustment: {accumulated_shortfall:.2f}")
# Calculate base CAP threshold using ALL bets (UNDER/OVER + other bets) # Calculate base CAP threshold using ALL bets (UNDER/OVER + other bets)
...@@ -2665,10 +2681,9 @@ class GamesThread(ThreadedComponent): ...@@ -2665,10 +2681,9 @@ class GamesThread(ThreadedComponent):
logger.info(f"📈 [EXTRACTION DEBUG] Step 9: Collecting match statistics") logger.info(f"📈 [EXTRACTION DEBUG] Step 9: Collecting match statistics")
self._collect_match_statistics(match_id, fixture_id, selected_result, session) self._collect_match_statistics(match_id, fixture_id, selected_result, session)
# Step 10: Update daily redistribution adjustment tracking # Step 10: Update global redistribution adjustment tracking
logger.info(f"💰 [EXTRACTION DEBUG] Step 10: Updating daily redistribution adjustment tracking") logger.info(f"💰 [EXTRACTION DEBUG] Step 10: Updating global redistribution adjustment tracking")
today = self._get_today_venue_date() self._update_global_redistribution_adjustment(total_payin_all_bets, payouts[selected_result], cap_percentage, session)
self._update_daily_redistribution_adjustment(today, total_payin_all_bets, payouts[selected_result], cap_percentage, session)
logger.info(f"✅ [EXTRACTION DEBUG] Result extraction completed successfully: selected {selected_result}") logger.info(f"✅ [EXTRACTION DEBUG] Result extraction completed successfully: selected {selected_result}")
...@@ -3796,23 +3811,25 @@ class GamesThread(ThreadedComponent): ...@@ -3796,23 +3811,25 @@ class GamesThread(ThreadedComponent):
return [] return []
def _select_random_match_templates(self, count: int, session) -> List[MatchTemplateModel]: def _select_random_match_templates(self, count: int, session) -> List[MatchTemplateModel]:
"""Select random match templates from the database""" """Select random match templates from the database that have validated ZIP files"""
try: try:
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
# Get all active match templates # Get all active match templates with validated ZIP files
match_templates = session.query(MatchTemplateModel).options(joinedload(MatchTemplateModel.outcomes)).filter( match_templates = session.query(MatchTemplateModel).options(joinedload(MatchTemplateModel.outcomes)).filter(
MatchTemplateModel.active_status == True MatchTemplateModel.active_status == True,
MatchTemplateModel.zip_upload_status == 'completed',
MatchTemplateModel.zip_validation_status == 'valid'
).all() ).all()
if len(match_templates) < count: if len(match_templates) < count:
logger.warning(f"Only {len(match_templates)} match templates found, requested {count}") logger.warning(f"Only {len(match_templates)} validated match templates found, requested {count}")
return match_templates return match_templates
# Select random templates # Select random templates
import random import random
selected_templates = random.sample(match_templates, count) selected_templates = random.sample(match_templates, count)
logger.info(f"Selected {len(selected_templates)} random match templates") logger.info(f"Selected {len(selected_templates)} random validated match templates")
return selected_templates return selected_templates
except Exception as e: except Exception as e:
......
...@@ -745,23 +745,25 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -745,23 +745,25 @@ class MatchTimerComponent(ThreadedComponent):
return 'all_bets_on_start' return 'all_bets_on_start'
def _select_random_match_templates(self, count: int, session) -> List[Any]: def _select_random_match_templates(self, count: int, session) -> List[Any]:
"""Select random match templates from the database""" """Select random match templates from the database that have validated ZIP files"""
try: try:
from ..database.models import MatchTemplateModel from ..database.models import MatchTemplateModel
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
# Get all active match templates # Get all active match templates with validated ZIP files
match_templates = session.query(MatchTemplateModel).options(joinedload(MatchTemplateModel.outcomes)).filter( match_templates = session.query(MatchTemplateModel).options(joinedload(MatchTemplateModel.outcomes)).filter(
MatchTemplateModel.active_status == True MatchTemplateModel.active_status == True,
MatchTemplateModel.zip_upload_status == 'completed',
MatchTemplateModel.zip_validation_status == 'valid'
).all() ).all()
if len(match_templates) < count: if len(match_templates) < count:
logger.warning(f"Only {len(match_templates)} match templates found, requested {count}") logger.warning(f"Only {len(match_templates)} validated match templates found, requested {count}")
return match_templates return match_templates
# Select random templates # Select random templates
selected_templates = random.sample(match_templates, count) selected_templates = random.sample(match_templates, count)
logger.info(f"Selected {len(selected_templates)} random match templates") logger.info(f"Selected {len(selected_templates)} random validated match templates")
return selected_templates return selected_templates
except Exception as e: except Exception as e:
......
...@@ -2890,6 +2890,55 @@ class Migration_038_AddWin1Win2Associations(DatabaseMigration): ...@@ -2890,6 +2890,55 @@ class Migration_038_AddWin1Win2Associations(DatabaseMigration):
return False return False
class Migration_039_AddMatchNumberToBetDetails(DatabaseMigration):
"""Add match_number column to bets_details table for storing match numbers directly"""
def __init__(self):
super().__init__("039", "Add match_number column to bets_details table")
def up(self, db_manager) -> bool:
"""Add match_number column to bets_details table"""
try:
with db_manager.engine.connect() as conn:
# Check if match_number column already exists
result = conn.execute(text("PRAGMA table_info(bets_details)"))
columns = [row[1] for row in result.fetchall()]
if 'match_number' not in columns:
# Add match_number column
conn.execute(text("""
ALTER TABLE bets_details
ADD COLUMN match_number INTEGER
"""))
# Populate existing records with match numbers from matches table
conn.execute(text("""
UPDATE bets_details
SET match_number = (
SELECT m.match_number
FROM matches m
WHERE m.id = bets_details.match_id
)
WHERE match_number IS NULL
"""))
conn.commit()
logger.info("match_number column added to bets_details table")
else:
logger.info("match_number column already exists in bets_details table")
return True
except Exception as e:
logger.error(f"Failed to add match_number column to bets_details: {e}")
return False
def down(self, db_manager) -> bool:
"""Remove match_number column - SQLite doesn't support DROP COLUMN easily"""
logger.warning("SQLite doesn't support DROP COLUMN - match_number column will remain")
return True
class Migration_036_AddMatchTemplatesTables(DatabaseMigration): class Migration_036_AddMatchTemplatesTables(DatabaseMigration):
"""Add matches_templates and match_outcomes_templates tables for storing match templates""" """Add matches_templates and match_outcomes_templates tables for storing match templates"""
...@@ -3047,6 +3096,7 @@ MIGRATIONS: List[DatabaseMigration] = [ ...@@ -3047,6 +3096,7 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_036_AddMatchTemplatesTables(), Migration_036_AddMatchTemplatesTables(),
Migration_037_RenameDailyRedistributionShortfallTable(), Migration_037_RenameDailyRedistributionShortfallTable(),
Migration_038_AddWin1Win2Associations(), Migration_038_AddWin1Win2Associations(),
Migration_039_AddMatchNumberToBetDetails(),
] ]
......
...@@ -772,6 +772,7 @@ class BetDetailModel(BaseModel): ...@@ -772,6 +772,7 @@ class BetDetailModel(BaseModel):
bet_id = Column(String(1024), ForeignKey('bets.uuid'), nullable=False, comment='Foreign key to bets table uuid field') bet_id = Column(String(1024), ForeignKey('bets.uuid'), nullable=False, comment='Foreign key to bets table uuid field')
match_id = Column(Integer, ForeignKey('matches.id'), nullable=False, comment='Foreign key to matches table') match_id = Column(Integer, ForeignKey('matches.id'), nullable=False, comment='Foreign key to matches table')
match_number = Column(Integer, comment='Match number for display purposes')
outcome = Column(String(255), nullable=False, comment='Bet outcome/prediction') outcome = Column(String(255), nullable=False, comment='Bet outcome/prediction')
amount = Column(Float(precision=2), nullable=False, comment='Bet amount with 2 decimal precision') amount = Column(Float(precision=2), nullable=False, comment='Bet amount with 2 decimal precision')
win_amount = Column(Float(precision=2), default=0.0, nullable=False, comment='Winning amount (calculated when result is win)') win_amount = Column(Float(precision=2), default=0.0, nullable=False, comment='Winning amount (calculated when result is win)')
......
...@@ -2592,59 +2592,78 @@ def get_fixture_details(fixture_id): ...@@ -2592,59 +2592,78 @@ def get_fixture_details(fixture_id):
@get_api_auth_decorator() @get_api_auth_decorator()
@get_api_auth_decorator(require_admin=True) @get_api_auth_decorator(require_admin=True)
def reset_fixtures(): def reset_fixtures():
"""Reset all fixtures data (admin only) - clear matches, match_outcomes, and ZIP files""" """Reset all fixtures data (admin only) - clear matches, match_outcomes, matches_templates, match_outcomes_templates, bets, extraction_stats, and ZIP files"""
try: try:
from ..database.models import MatchModel, MatchOutcomeModel from ..database.models import MatchModel, MatchOutcomeModel, MatchTemplateModel, MatchOutcomeTemplateModel, BetModel, BetDetailModel, ExtractionStatsModel
from ..config.settings import get_user_data_dir from ..config.settings import get_user_data_dir
from pathlib import Path from pathlib import Path
import shutil import shutil
session = api_bp.db_manager.get_session() session = api_bp.db_manager.get_session()
try: try:
# Count existing data before reset # Count existing data before reset
matches_count = session.query(MatchModel).count() matches_count = session.query(MatchModel).count()
outcomes_count = session.query(MatchOutcomeModel).count() outcomes_count = session.query(MatchOutcomeModel).count()
templates_count = session.query(MatchTemplateModel).count()
# Clear all match outcomes first (due to foreign key constraints) template_outcomes_count = session.query(MatchOutcomeTemplateModel).count()
session.query(MatchOutcomeModel).delete() bets_count = session.query(BetModel).count()
bet_details_count = session.query(BetDetailModel).count()
extraction_stats_count = session.query(ExtractionStatsModel).count()
# Delete in correct order to handle foreign key constraints
# 1. Delete extraction_stats first (references matches)
deleted_extraction_stats = session.query(ExtractionStatsModel).delete()
session.commit() session.commit()
# Clear all matches # 2. Delete bets (will cascade to bet_details due to CASCADE constraint)
session.query(MatchModel).delete() deleted_bets = session.query(BetModel).delete()
session.commit() session.commit()
# 3. Delete matches (will cascade to match_outcomes due to CASCADE constraint)
deleted_matches = session.query(MatchModel).delete()
session.commit()
# 4. Delete match templates (will cascade to match_outcomes_templates due to CASCADE constraint)
deleted_templates = session.query(MatchTemplateModel).delete()
session.commit()
# Clear ZIP files from persistent storage # Clear ZIP files from persistent storage
zip_storage_dir = Path(get_user_data_dir()) / "zip_files" zip_storage_dir = Path(get_user_data_dir()) / "zip_files"
zip_files_removed = 0 zip_files_removed = 0
if zip_storage_dir.exists(): if zip_storage_dir.exists():
zip_files = list(zip_storage_dir.glob("*.zip")) zip_files = list(zip_storage_dir.glob("*.zip"))
zip_files_removed = len(zip_files) zip_files_removed = len(zip_files)
# Remove all ZIP files # Remove all ZIP files
for zip_file in zip_files: for zip_file in zip_files:
try: try:
zip_file.unlink() zip_file.unlink()
except Exception as e: except Exception as e:
logger.warning(f"Failed to remove ZIP file {zip_file}: {e}") logger.warning(f"Failed to remove ZIP file {zip_file}: {e}")
logger.info(f"Removed {zip_files_removed} ZIP files from {zip_storage_dir}") logger.info(f"Removed {zip_files_removed} ZIP files from {zip_storage_dir}")
logger.info(f"Fixtures reset completed - Removed {matches_count} matches, {outcomes_count} outcomes, {zip_files_removed} ZIP files") logger.info(f"Fixtures reset completed - Removed {matches_count} matches, {outcomes_count} outcomes, {templates_count} templates, {template_outcomes_count} template outcomes, {bets_count} bets, {bet_details_count} bet details, {extraction_stats_count} extraction stats, {zip_files_removed} ZIP files")
return jsonify({ return jsonify({
"success": True, "success": True,
"message": "Fixtures data reset successfully", "message": "Fixtures data reset successfully",
"removed": { "removed": {
"matches": matches_count, "matches": matches_count,
"outcomes": outcomes_count, "outcomes": outcomes_count,
"templates": templates_count,
"template_outcomes": template_outcomes_count,
"bets": bets_count,
"bet_details": bet_details_count,
"extraction_stats": extraction_stats_count,
"zip_files": zip_files_removed "zip_files": zip_files_removed
} }
}) })
finally: finally:
session.close() session.close()
except Exception as e: except Exception as e:
logger.error(f"API fixtures reset error: {e}") logger.error(f"API fixtures reset error: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
...@@ -4120,19 +4139,20 @@ def get_redistribution_balance(): ...@@ -4120,19 +4139,20 @@ def get_redistribution_balance():
session = api_bp.db_manager.get_session() session = api_bp.db_manager.get_session()
try: try:
# Get the latest redistribution adjustment record # Get the global redistribution adjustment record (fixed date 1970-01-01)
latest_record = session.query(PersistentRedistributionAdjustmentModel)\ global_date = date(1970, 1, 1)
.order_by(PersistentRedistributionAdjustmentModel.date.desc())\ global_record = session.query(PersistentRedistributionAdjustmentModel)\
.filter_by(date=global_date)\
.first() .first()
current_balance = 0.0 current_balance = 0.0
if latest_record: if global_record:
current_balance = float(latest_record.accumulated_shortfall) current_balance = float(global_record.accumulated_shortfall)
return jsonify({ return jsonify({
"success": True, "success": True,
"redistribution_balance": current_balance, "redistribution_balance": current_balance,
"last_updated": latest_record.date.isoformat() if latest_record else None "last_updated": global_record.updated_at.isoformat() if global_record else None
}) })
finally: finally:
...@@ -4154,19 +4174,20 @@ def reset_redistribution_balance(): ...@@ -4154,19 +4174,20 @@ def reset_redistribution_balance():
session = api_bp.db_manager.get_session() session = api_bp.db_manager.get_session()
try: try:
# Get the latest redistribution adjustment record # Get the global redistribution adjustment record (fixed date 1970-01-01)
latest_record = session.query(PersistentRedistributionAdjustmentModel)\ global_date = date(1970, 1, 1)
.order_by(PersistentRedistributionAdjustmentModel.date.desc())\ global_record = session.query(PersistentRedistributionAdjustmentModel)\
.filter_by(date=global_date)\
.first() .first()
if latest_record: if global_record:
# Reset the accumulated shortfall to zero # Reset the accumulated shortfall to zero
old_balance = float(latest_record.accumulated_shortfall) old_balance = float(global_record.accumulated_shortfall)
latest_record.accumulated_shortfall = 0.0 global_record.accumulated_shortfall = 0.0
latest_record.updated_at = datetime.utcnow() global_record.updated_at = datetime.utcnow()
session.commit() session.commit()
logger.info(f"Redistribution balance reset from {old_balance} to 0.0") logger.info(f"Global redistribution balance reset from {old_balance} to 0.0")
return jsonify({ return jsonify({
"success": True, "success": True,
...@@ -4175,21 +4196,20 @@ def reset_redistribution_balance(): ...@@ -4175,21 +4196,20 @@ def reset_redistribution_balance():
"new_balance": 0.0 "new_balance": 0.0
}) })
else: else:
# No record exists, create one with zero balance # No global record exists, create one with zero balance
today = date.today()
new_record = PersistentRedistributionAdjustmentModel( new_record = PersistentRedistributionAdjustmentModel(
date=today, date=global_date,
accumulated_shortfall=0.0, accumulated_shortfall=0.0,
cap_percentage=70.0 # Default cap cap_percentage=70.0 # Default cap
) )
session.add(new_record) session.add(new_record)
session.commit() session.commit()
logger.info("Created new redistribution adjustment record with zero balance") logger.info("Created global redistribution adjustment record with zero balance")
return jsonify({ return jsonify({
"success": True, "success": True,
"message": "Redistribution balance set to 0.00 (new record created)", "message": "Redistribution balance set to 0.00 (global record created)",
"old_balance": None, "old_balance": None,
"new_balance": 0.0 "new_balance": 0.0
}) })
...@@ -4626,6 +4646,8 @@ def create_cashier_bet(): ...@@ -4626,6 +4646,8 @@ def create_cashier_bet():
existing_match = session.query(MatchModel).filter_by(id=match_id).first() existing_match = session.query(MatchModel).filter_by(id=match_id).first()
if not existing_match: if not existing_match:
return jsonify({"error": f"Match {match_id} not found"}), 404 return jsonify({"error": f"Match {match_id} not found"}), 404
# Store match_number for later use
detail['_match_number'] = existing_match.match_number
# Generate UUID for the bet # Generate UUID for the bet
bet_uuid = str(uuid_lib.uuid4()) bet_uuid = str(uuid_lib.uuid4())
...@@ -4702,6 +4724,7 @@ def create_cashier_bet(): ...@@ -4702,6 +4724,7 @@ def create_cashier_bet():
bet_detail = BetDetailModel( bet_detail = BetDetailModel(
bet_id=bet_uuid, bet_id=bet_uuid,
match_id=detail_data['match_id'], match_id=detail_data['match_id'],
match_number=detail_data['_match_number'],
outcome=detail_data['outcome'], outcome=detail_data['outcome'],
amount=float(detail_data['amount']), amount=float(detail_data['amount']),
result='pending' result='pending'
...@@ -4773,7 +4796,10 @@ def get_cashier_bet_details(bet_id): ...@@ -4773,7 +4796,10 @@ def get_cashier_bet_details(bet_id):
for detail in bet_details: for detail in bet_details:
detail_data = detail.to_dict() detail_data = detail.to_dict()
# Include stored match_number
detail_data['match_number'] = detail.match_number
# Get match information # Get match information
match = session.query(MatchModel).filter_by(id=detail.match_id).first() match = session.query(MatchModel).filter_by(id=detail.match_id).first()
if match: if match:
...@@ -5179,6 +5205,9 @@ def verify_bet_details(bet_id): ...@@ -5179,6 +5205,9 @@ def verify_bet_details(bet_id):
for detail in bet_details: for detail in bet_details:
detail_data = detail.to_dict() detail_data = detail.to_dict()
# Include stored match_number
detail_data['match_number'] = detail.match_number
# Get match information # Get match information
match = session.query(MatchModel).filter_by(id=detail.match_id).first() match = session.query(MatchModel).filter_by(id=detail.match_id).first()
if match: if match:
...@@ -5255,6 +5284,9 @@ def verify_barcode(): ...@@ -5255,6 +5284,9 @@ def verify_barcode():
detail_data = detail.to_dict() detail_data = detail.to_dict()
total_amount += float(detail.amount) total_amount += float(detail.amount)
# Include stored match_number
detail_data['match_number'] = detail.match_number
# Get match information # Get match information
match = session.query(MatchModel).filter_by(id=detail.match_id).first() match = session.query(MatchModel).filter_by(id=detail.match_id).first()
if match: if match:
......
...@@ -89,9 +89,9 @@ ...@@ -89,9 +89,9 @@
{% for detail in bet.bet_details %} {% for detail in bet.bet_details %}
<tr> <tr>
<td> <td>
<strong>Match #{{ detail.match.match_number }}</strong><br> <strong>Match #{{ detail.match_number }}</strong><br>
<small class="text-muted"> <small class="text-muted">
{{ detail.match.fighter1_township }} vs {{ detail.match.fighter2_township }} {{ detail.match.fighter1_township if detail.match else 'Unknown' }} vs {{ detail.match.fighter2_township if detail.match else 'Unknown' }}
</small> </small>
</td> </td>
<td> <td>
...@@ -450,7 +450,7 @@ ...@@ -450,7 +450,7 @@
"bet_details": [ "bet_details": [
{% for detail in bet.bet_details %} {% for detail in bet.bet_details %}
{ {
"match_number": "{{ detail.match.match_number if detail.match else 'Unknown' }}", "match_number": "{{ detail.match_number }}",
"fighter1": "{{ detail.match.fighter1_township if detail.match else 'Unknown' }}", "fighter1": "{{ detail.match.fighter1_township if detail.match else 'Unknown' }}",
"fighter2": "{{ detail.match.fighter2_township if detail.match else 'Unknown' }}", "fighter2": "{{ detail.match.fighter2_township if detail.match else 'Unknown' }}",
"venue": "{{ detail.match.venue_kampala_township if detail.match else 'Unknown' }}", "venue": "{{ detail.match.venue_kampala_township if detail.match else 'Unknown' }}",
......
...@@ -533,7 +533,7 @@ function updateBetsTable(data, container) { ...@@ -533,7 +533,7 @@ function updateBetsTable(data, container) {
const totalAmount = parseFloat(bet.total_amount).toFixed(2); const totalAmount = parseFloat(bet.total_amount).toFixed(2);
// Collect unique match numbers // Collect unique match numbers
const matchNumbers = [...new Set(bet.details ? bet.details.map(detail => detail.match ? detail.match.match_number : 'Unknown').filter(n => n !== 'Unknown') : [])]; const matchNumbers = [...new Set(bet.details ? bet.details.map(detail => detail.match_number || 'Unknown').filter(n => n !== 'Unknown') : [])];
// Determine overall bet status based on details // Determine overall bet status based on details
let overallStatus = 'pending'; let overallStatus = 'pending';
...@@ -719,7 +719,7 @@ function transformBetDataForReceipt(betData) { ...@@ -719,7 +719,7 @@ function transformBetDataForReceipt(betData) {
total_amount: betData.total_amount, total_amount: betData.total_amount,
bet_count: betData.details_count || betData.details.length, bet_count: betData.details_count || betData.details.length,
bet_details: betData.details.map(detail => ({ bet_details: betData.details.map(detail => ({
match_number: detail.match ? detail.match.match_number : 'Unknown', match_number: detail.match_number || 'Unknown',
fighter1: detail.match ? detail.match.fighter1_township : 'Unknown', fighter1: detail.match ? detail.match.fighter1_township : 'Unknown',
fighter2: detail.match ? detail.match.fighter2_township : 'Unknown', fighter2: detail.match ? detail.match.fighter2_township : 'Unknown',
venue: detail.match ? detail.match.venue_kampala_township : 'Unknown', venue: detail.match ? detail.match.venue_kampala_township : 'Unknown',
......
...@@ -477,6 +477,7 @@ function getUploadStatusBadge(fixture) { ...@@ -477,6 +477,7 @@ function getUploadStatusBadge(fixture) {
function resetFixtures() { function resetFixtures() {
const confirmMessage = 'WARNING: This will permanently delete ALL fixture data including:\n\n' + const confirmMessage = 'WARNING: This will permanently delete ALL fixture data including:\n\n' +
'• All synchronized matches and outcomes\n' + '• All synchronized matches and outcomes\n' +
'• All match templates and template outcomes\n' +
'• All downloaded ZIP files\n' + '• All downloaded ZIP files\n' +
'• This action cannot be undone!\n\n' + '• This action cannot be undone!\n\n' +
'Are you sure you want to reset all fixtures data?'; 'Are you sure you want to reset all fixtures data?';
...@@ -500,7 +501,7 @@ function resetFixtures() { ...@@ -500,7 +501,7 @@ function resetFixtures() {
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
alert(`Fixtures reset successfully!\n\nRemoved:\n• ${data.removed.matches} matches\n• ${data.removed.outcomes} outcomes\n• ${data.removed.zip_files} ZIP files`); alert(`Fixtures reset successfully!\n\nRemoved:\n• ${data.removed.matches} matches\n• ${data.removed.outcomes} outcomes\n• ${data.removed.templates} match templates\n• ${data.removed.template_outcomes} template outcomes\n• ${data.removed.zip_files} ZIP files`);
// Reload fixtures to show empty state // Reload fixtures to show empty state
loadFixtures(); loadFixtures();
} else { } else {
......
...@@ -499,6 +499,8 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -499,6 +499,8 @@ document.addEventListener('DOMContentLoaded', function() {
// Load redistribution balance (admin only) // Load redistribution balance (admin only)
if (document.getElementById('redistribution-balance')) { if (document.getElementById('redistribution-balance')) {
loadRedistributionBalance(); loadRedistributionBalance();
// Periodic update of redistribution balance
setInterval(loadRedistributionBalance, 5000); // Update every 5 seconds
} }
// Quick action buttons // Quick action buttons
......
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