Implement venue timezone system to fix UTC midnight day change issues

- Add timezone_utils.py with system timezone auto-detection
- Configure venue timezone with system timezone as default fallback
- Update games_thread.py to use venue timezone for day calculations
- Update web dashboard API routes to use venue timezone for date filtering
- Add venue timezone configuration to web UI
- Fix day boundary issues where UTC midnight didn't match venue local time
- Ensure fixtures and bets are correctly associated with venue days

This resolves the problem where operations stored in UTC caused incorrect
day change calculations when midnight UTC didn't correspond to actual
midnight in the venue's timezone.
parent a00788be
...@@ -239,6 +239,9 @@ def collect_hidden_imports() -> List[str]: ...@@ -239,6 +239,9 @@ def collect_hidden_imports() -> List[str]:
'openpyxl', 'openpyxl',
'openpyxl.styles', 'openpyxl.styles',
'openpyxl.utils', 'openpyxl.utils',
# Timezone support
'pytz',
] ]
# Conditionally add ffmpeg module if available # Conditionally add ffmpeg module if available
......
...@@ -32,6 +32,9 @@ class ConfigManager: ...@@ -32,6 +32,9 @@ class ConfigManager:
self._settings = AppSettings() self._settings = AppSettings()
self.save_settings(self._settings) self.save_settings(self._settings)
# Initialize venue timezone if not configured
self._initialize_venue_timezone()
# Validate settings # Validate settings
if not self._settings.validate(): if not self._settings.validate():
logger.error("Configuration validation failed") logger.error("Configuration validation failed")
...@@ -47,6 +50,18 @@ class ConfigManager: ...@@ -47,6 +50,18 @@ class ConfigManager:
logger.error(f"Failed to initialize configuration manager: {e}") logger.error(f"Failed to initialize configuration manager: {e}")
return False return False
def _initialize_venue_timezone(self):
"""Initialize venue timezone configuration"""
try:
# Set default venue timezone if not configured
if not self.get_config_value('venue_timezone'):
from ..utils.timezone_utils import get_default_venue_timezone
default_tz = get_default_venue_timezone()
self.set_config_value('venue_timezone', default_tz)
logger.info(f"Initialized venue timezone to: {default_tz}")
except Exception as e:
logger.error(f"Failed to initialize venue timezone: {e}")
def get_settings(self) -> AppSettings: def get_settings(self) -> AppSettings:
"""Get current application settings""" """Get current application settings"""
if self._settings is None: if self._settings is None:
......
...@@ -13,6 +13,7 @@ from .thread_manager import ThreadedComponent ...@@ -13,6 +13,7 @@ from .thread_manager import ThreadedComponent
from .message_bus import MessageBus, Message, MessageType, MessageBuilder from .message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..database.manager import DatabaseManager from ..database.manager import DatabaseManager
from ..database.models import MatchModel, MatchStatus, BetDetailModel, MatchOutcomeModel, GameConfigModel, ExtractionAssociationModel, DailyRedistributionShortfallModel from ..database.models import MatchModel, MatchStatus, BetDetailModel, MatchOutcomeModel, GameConfigModel, ExtractionAssociationModel, DailyRedistributionShortfallModel
from ..utils.timezone_utils import get_today_venue_date
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -29,9 +30,9 @@ class GamesThread(ThreadedComponent): ...@@ -29,9 +30,9 @@ class GamesThread(ThreadedComponent):
self.message_queue = None self.message_queue = None
self.waiting_for_validation_fixture: Optional[str] = None self.waiting_for_validation_fixture: Optional[str] = None
def _get_today_utc_date(self) -> datetime.date: def _get_today_venue_date(self) -> datetime.date:
"""Get today's date in UTC (consistent with database storage)""" """Get today's date in venue timezone (for day change detection)"""
return datetime.utcnow().date() 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 cleanup/reset accordingly.
...@@ -39,7 +40,7 @@ class GamesThread(ThreadedComponent): ...@@ -39,7 +40,7 @@ class GamesThread(ThreadedComponent):
try: try:
session = self.db_manager.get_session() session = self.db_manager.get_session()
try: try:
today = self._get_today_utc_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:
...@@ -58,7 +59,10 @@ class GamesThread(ThreadedComponent): ...@@ -58,7 +59,10 @@ class GamesThread(ThreadedComponent):
all_matches_old_day = True all_matches_old_day = True
for match in current_matches: for match in current_matches:
if match.start_time: if match.start_time:
match_date = match.start_time.date() # Convert UTC start_time to venue timezone for date comparison
from ..utils.timezone_utils import utc_to_venue_datetime
venue_start_time = utc_to_venue_datetime(match.start_time, self.db_manager)
match_date = venue_start_time.date()
if match_date == today: if match_date == today:
all_matches_old_day = False all_matches_old_day = False
break break
...@@ -103,14 +107,21 @@ class GamesThread(ThreadedComponent): ...@@ -103,14 +107,21 @@ class GamesThread(ThreadedComponent):
try: try:
session = self.db_manager.get_session() session = self.db_manager.get_session()
try: try:
# Get today's date in UTC (consistent with database) # Get today's date in venue timezone (for day change detection)
today = self._get_today_utc_date() today = self._get_today_venue_date()
# PART 1: Clean up stale 'ingame' matches from today (existing logic) # PART 1: Clean up stale 'ingame' matches from today (existing logic)
# 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)
stale_matches = session.query(MatchModel).filter( stale_matches = session.query(MatchModel).filter(
MatchModel.start_time.isnot(None), MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()), MatchModel.start_time >= utc_start,
MatchModel.start_time < datetime.combine(today, datetime.max.time()), MatchModel.start_time < utc_end,
MatchModel.status == 'ingame', MatchModel.status == 'ingame',
MatchModel.active_status == True MatchModel.active_status == True
).all() ).all()
...@@ -131,10 +142,7 @@ class GamesThread(ThreadedComponent): ...@@ -131,10 +142,7 @@ class GamesThread(ThreadedComponent):
MatchModel.status == 'bet', MatchModel.status == 'bet',
MatchModel.active_status == True, MatchModel.active_status == True,
# Exclude today's matches to avoid interfering with active games # Exclude today's matches to avoid interfering with active games
~MatchModel.start_time.between( ~MatchModel.start_time.between(utc_start, utc_end)
datetime.combine(today, datetime.min.time()),
datetime.combine(today, datetime.max.time())
)
).all() ).all()
if old_bet_matches: if old_bet_matches:
...@@ -860,14 +868,21 @@ class GamesThread(ThreadedComponent): ...@@ -860,14 +868,21 @@ class GamesThread(ThreadedComponent):
try: try:
session = self.db_manager.get_session() session = self.db_manager.get_session()
try: try:
# Get today's date in UTC (consistent with database) # Get today's date in venue timezone (for day change detection)
today = self._get_today_utc_date() 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 all fixtures that have matches with today's start_time # Find all fixtures that have matches with today's start_time
fixtures_with_today_matches = session.query(MatchModel.fixture_id).filter( fixtures_with_today_matches = session.query(MatchModel.fixture_id).filter(
MatchModel.start_time.isnot(None), MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()), MatchModel.start_time >= utc_start,
MatchModel.start_time < datetime.combine(today, datetime.max.time()) MatchModel.start_time < utc_end
).distinct().all() ).distinct().all()
if not fixtures_with_today_matches: if not fixtures_with_today_matches:
...@@ -893,14 +908,21 @@ class GamesThread(ThreadedComponent): ...@@ -893,14 +908,21 @@ class GamesThread(ThreadedComponent):
try: try:
session = self.db_manager.get_session() session = self.db_manager.get_session()
try: try:
# Get today's date in UTC (consistent with database) # Get today's date in venue timezone (for day change detection)
today = self._get_today_utc_date() 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 fixtures with ingame matches today # Find fixtures with ingame matches today
ingame_matches = session.query(MatchModel).filter( ingame_matches = session.query(MatchModel).filter(
MatchModel.start_time.isnot(None), MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()), MatchModel.start_time >= utc_start,
MatchModel.start_time < datetime.combine(today, datetime.max.time()), MatchModel.start_time < utc_end,
MatchModel.status == 'ingame', MatchModel.status == 'ingame',
MatchModel.active_status == True MatchModel.active_status == True
).all() ).all()
...@@ -993,14 +1015,21 @@ class GamesThread(ThreadedComponent): ...@@ -993,14 +1015,21 @@ class GamesThread(ThreadedComponent):
try: try:
session = self.db_manager.get_session() session = self.db_manager.get_session()
try: try:
# Get today's date in UTC (consistent with database) # Get today's date in venue timezone (for day change detection)
today = self._get_today_utc_date() 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 all fixtures with today's matches # Find all fixtures with today's matches
all_fixtures = session.query(MatchModel.fixture_id).filter( all_fixtures = session.query(MatchModel.fixture_id).filter(
MatchModel.start_time.isnot(None), MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()), MatchModel.start_time >= utc_start,
MatchModel.start_time < datetime.combine(today, datetime.max.time()) MatchModel.start_time < utc_end
).distinct().all() ).distinct().all()
# Check each fixture except the current one # Check each fixture except the current one
...@@ -1262,16 +1291,23 @@ class GamesThread(ThreadedComponent): ...@@ -1262,16 +1291,23 @@ class GamesThread(ThreadedComponent):
try: try:
session = self.db_manager.get_session() session = self.db_manager.get_session()
try: try:
# Get today's date in UTC (consistent with database) # Get today's date in venue timezone (for day change detection)
today = self._get_today_utc_date() 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 fixtures with today's matches that are not in terminal states # Find fixtures with today's matches that are not in terminal states
terminal_states = ['done', 'cancelled', 'failed', 'paused'] terminal_states = ['done', 'cancelled', 'failed', 'paused']
active_matches = session.query(MatchModel).filter( active_matches = session.query(MatchModel).filter(
MatchModel.start_time.isnot(None), MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()), MatchModel.start_time >= utc_start,
MatchModel.start_time < datetime.combine(today, datetime.max.time()), MatchModel.start_time < utc_end,
MatchModel.status.notin_(terminal_states), MatchModel.status.notin_(terminal_states),
MatchModel.active_status == True MatchModel.active_status == True
).order_by(MatchModel.start_time.asc()).all() ).order_by(MatchModel.start_time.asc()).all()
...@@ -1293,15 +1329,22 @@ class GamesThread(ThreadedComponent): ...@@ -1293,15 +1329,22 @@ class GamesThread(ThreadedComponent):
try: try:
session = self.db_manager.get_session() session = self.db_manager.get_session()
try: try:
# Get today's date in UTC (consistent with database storage) # Get today's date in venue timezone (for day change detection)
today = self._get_today_utc_date() 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 # Find candidate fixtures that have at least one match with start_time NULL or today
candidate_fixtures = session.query(MatchModel.fixture_id).filter( candidate_fixtures = session.query(MatchModel.fixture_id).filter(
MatchModel.active_status == True, MatchModel.active_status == True,
((MatchModel.start_time.is_(None)) | ((MatchModel.start_time.is_(None)) |
(MatchModel.start_time >= datetime.combine(today, datetime.min.time())) & (MatchModel.start_time >= utc_start) &
(MatchModel.start_time < datetime.combine(today, datetime.max.time()))) (MatchModel.start_time < utc_end))
).distinct().order_by(MatchModel.created_at.asc()).all() ).distinct().order_by(MatchModel.created_at.asc()).all()
# Check each candidate fixture to ensure ALL matches are NULL or today # Check each candidate fixture to ensure ALL matches are NULL or today
...@@ -1314,9 +1357,10 @@ class GamesThread(ThreadedComponent): ...@@ -1314,9 +1357,10 @@ class GamesThread(ThreadedComponent):
MatchModel.active_status == True MatchModel.active_status == True
).all() ).all()
# Check if ALL matches have start_time NULL or today # Check if ALL matches have start_time NULL or today (convert UTC to venue timezone)
all_valid = all( all_valid = all(
match.start_time is None or match.start_time.date() == today match.start_time is None or
utc_to_venue_datetime(match.start_time, self.db_manager).date() == today
for match in all_matches for match in all_matches
) )
...@@ -2510,7 +2554,7 @@ class GamesThread(ThreadedComponent): ...@@ -2510,7 +2554,7 @@ class GamesThread(ThreadedComponent):
cap_percentage = self._get_redistribution_cap() cap_percentage = self._get_redistribution_cap()
# Get accumulated shortfall from previous extractions for today # Get accumulated shortfall from previous extractions for today
today = self._get_today_utc_date() today = self._get_today_venue_date()
accumulated_shortfall = self._get_daily_shortfall(today, session) accumulated_shortfall = self._get_daily_shortfall(today, session)
logger.info(f"🎯 [EXTRACTION DEBUG] Accumulated shortfall: {accumulated_shortfall:.2f}") logger.info(f"🎯 [EXTRACTION DEBUG] Accumulated shortfall: {accumulated_shortfall:.2f}")
...@@ -2591,7 +2635,7 @@ class GamesThread(ThreadedComponent): ...@@ -2591,7 +2635,7 @@ class GamesThread(ThreadedComponent):
# Step 10: Update daily shortfall tracking # Step 10: Update daily shortfall tracking
logger.info(f"💰 [EXTRACTION DEBUG] Step 10: Updating daily shortfall tracking") logger.info(f"💰 [EXTRACTION DEBUG] Step 10: Updating daily shortfall tracking")
today = self._get_today_utc_date() today = self._get_today_venue_date()
self._update_daily_shortfall(today, total_payin_all_bets, payouts[selected_result], cap_percentage, session) self._update_daily_shortfall(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}")
...@@ -3350,14 +3394,21 @@ class GamesThread(ThreadedComponent): ...@@ -3350,14 +3394,21 @@ class GamesThread(ThreadedComponent):
# Check if there are any active fixtures (matches in non-terminal states) # Check if there are any active fixtures (matches in non-terminal states)
session = self.db_manager.get_session() session = self.db_manager.get_session()
try: try:
# Get today's date in UTC (consistent with database) # Get today's date in venue timezone (for day change detection)
today = self._get_today_utc_date() 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)
# Check for active matches today # Check for active matches today
active_matches = session.query(MatchModel).filter( active_matches = session.query(MatchModel).filter(
MatchModel.start_time.isnot(None), MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()), MatchModel.start_time >= utc_start,
MatchModel.start_time < datetime.combine(today, datetime.max.time()), MatchModel.start_time < utc_end,
MatchModel.status.notin_(['done', 'cancelled', 'failed', 'paused']), MatchModel.status.notin_(['done', 'cancelled', 'failed', 'paused']),
MatchModel.active_status == True MatchModel.active_status == True
).all() ).all()
......
"""
Timezone utilities for venue-aware date/time handling
"""
import os
import time
import logging
from datetime import datetime, timezone, timedelta
from typing import Optional
try:
import pytz
HAS_PYTZ = True
except ImportError:
HAS_PYTZ = False
logging.warning("pytz not available, timezone detection may be limited")
logger = logging.getLogger(__name__)
def get_system_timezone() -> str:
"""Auto-detect system's local timezone"""
try:
# Method 1: Use pytz for comprehensive detection
if HAS_PYTZ:
local_tz = pytz.timezone('UTC').localize(datetime.utcnow()).astimezone().tzinfo
# Find the IANA name
for tz_name in pytz.all_timezones:
try:
if pytz.timezone(tz_name) == local_tz:
return tz_name
except:
continue
# Method 2: Use dateutil
try:
from dateutil import tz
local_tz = tz.tzlocal()
now = datetime.now(local_tz)
# Try to match with pytz names
if HAS_PYTZ:
for tz_name in pytz.all_timezones:
try:
if pytz.timezone(tz_name) == local_tz:
return tz_name
except:
continue
# Fallback to offset representation
offset_seconds = local_tz.utcoffset(now).total_seconds()
offset_hours = offset_seconds / 3600
return f"UTC{offset_hours:+.0f}".replace('.0', '')
except ImportError:
pass
# Method 3: Basic system timezone detection
if hasattr(time, 'tzname') and time.tzname:
tz_name = time.tzname[0]
# Try to map common abbreviations to IANA names
tz_mapping = {
'EST': 'America/New_York',
'EDT': 'America/New_York',
'CST': 'America/Chicago',
'CDT': 'America/Chicago',
'MST': 'America/Denver',
'MDT': 'America/Denver',
'PST': 'America/Los_Angeles',
'PDT': 'America/Los_Angeles',
'GMT': 'Europe/London',
'BST': 'Europe/London',
'CET': 'Europe/Paris',
'CEST': 'Europe/Paris',
'JST': 'Asia/Tokyo',
'SGT': 'Asia/Singapore',
'IST': 'Asia/Kolkata',
'CAT': 'Africa/Johannesburg',
'SAST': 'Africa/Johannesburg',
'EAT': 'Africa/Nairobi',
'WAT': 'Africa/Lagos',
}
if tz_name in tz_mapping:
return tz_mapping[tz_name]
# If we can't map it, return as-is but log warning
logger.warning(f"Could not map timezone abbreviation '{tz_name}' to IANA name")
return tz_name
# Method 4: Offset-based detection
offset_seconds = -time.timezone if not time.daylight else -time.altzone
offset_hours = offset_seconds / 3600
return f"UTC{offset_hours:+.0f}".replace('.0', '')
except Exception as e:
logger.error(f"Failed to detect system timezone: {e}")
# Fallback to UTC as a safe default
return 'UTC'
def get_default_venue_timezone() -> str:
"""Get default venue timezone with auto-detection and environment override"""
# Check environment variable first (for containers/deployments)
env_tz = os.environ.get('VENUE_TIMEZONE')
if env_tz:
if validate_timezone(env_tz):
logger.info(f"Using venue timezone from environment: {env_tz}")
return env_tz
else:
logger.warning(f"Invalid timezone in VENUE_TIMEZONE: {env_tz}")
# Auto-detect from system
detected_tz = get_system_timezone()
logger.info(f"Auto-detected venue timezone: {detected_tz}")
return detected_tz
def validate_timezone(tz_name: str) -> bool:
"""Validate timezone name"""
if not HAS_PYTZ:
# Basic validation without pytz
try:
timezone(timedelta(hours=0)) # Just test if we can create a timezone
return True
except:
return False
try:
pytz.timezone(tz_name)
return True
except:
return False
def get_venue_timezone(db_manager) -> timezone:
"""Get configured venue timezone object"""
venue_tz_str = db_manager.get_config_value('venue_timezone', get_default_venue_timezone())
if HAS_PYTZ:
return pytz.timezone(venue_tz_str)
else:
# Fallback without pytz - assume UTC offset format
try:
if venue_tz_str.startswith('UTC'):
offset_str = venue_tz_str[3:] # Remove 'UTC'
offset_hours = float(offset_str)
return timezone(timedelta(hours=offset_hours))
else:
logger.warning(f"Non-UTC timezone '{venue_tz_str}' specified but pytz not available")
return timezone.utc
except:
logger.error(f"Invalid timezone format: {venue_tz_str}")
return timezone.utc
def get_today_venue_date(db_manager) -> datetime.date:
"""Get today's date in venue timezone"""
venue_tz = get_venue_timezone(db_manager)
if HAS_PYTZ:
return datetime.now(venue_tz).date()
else:
# Without pytz, assume venue_tz is already a timezone object
return datetime.now(venue_tz).date()
def utc_to_venue_datetime(utc_dt: datetime, db_manager) -> datetime:
"""Convert UTC datetime to venue local time"""
venue_tz = get_venue_timezone(db_manager)
if utc_dt.tzinfo is None:
utc_dt = utc_dt.replace(tzinfo=timezone.utc)
return utc_dt.astimezone(venue_tz)
def venue_to_utc_datetime(venue_dt: datetime, db_manager) -> datetime:
"""Convert venue local time to UTC"""
venue_tz = get_venue_timezone(db_manager)
if venue_dt.tzinfo is None:
if HAS_PYTZ:
venue_dt = venue_tz.localize(venue_dt)
else:
venue_dt = venue_dt.replace(tzinfo=venue_tz)
return venue_dt.astimezone(timezone.utc)
\ No newline at end of file
...@@ -15,6 +15,7 @@ from sqlalchemy import text ...@@ -15,6 +15,7 @@ from sqlalchemy import text
from .auth import AuthenticatedUser from .auth import AuthenticatedUser
from ..core.message_bus import Message, MessageType from ..core.message_bus import Message, MessageType
from ..utils.timezone_utils import get_venue_timezone, utc_to_venue_datetime, venue_to_utc_datetime
def conditional_auth_decorator(condition, auth_decorator, fallback_decorator=login_required): def conditional_auth_decorator(condition, auth_decorator, fallback_decorator=login_required):
...@@ -4306,22 +4307,14 @@ def get_cashier_bets(): ...@@ -4306,22 +4307,14 @@ def get_cashier_bets():
# For example, if user selects "2025-12-17", they want bets from 2025-12-17 in local time # For example, if user selects "2025-12-17", they want bets from 2025-12-17 in local time
# Which is 2025-12-16 22:00 UTC to 2025-12-17 21:59 UTC # Which is 2025-12-16 22:00 UTC to 2025-12-17 21:59 UTC
# Create the date range in UTC directly # Create the date range using venue timezone
# Since bets are stored in UTC, we need to query for the entire day in UTC venue_tz = get_venue_timezone(api_bp.db_manager)
# The user selects a date in their local timezone (UTC+2), so we need to find
# bets from that date in local time, which means the previous day in UTC
# For a user in UTC+2 selecting "2025-12-17", they want bets from
# 2025-12-17 00:00 to 23:59 in local time, which is
# 2025-12-16 22:00 to 2025-12-17 21:59 in UTC
utc_offset = timedelta(hours=2)
local_start = datetime.combine(target_date, datetime.min.time()) local_start = datetime.combine(target_date, datetime.min.time())
local_end = datetime.combine(target_date, datetime.max.time()) local_end = datetime.combine(target_date, datetime.max.time())
# Convert local date range to naive UTC datetimes # Convert venue local time to UTC for database queries
start_datetime = (local_start - utc_offset) start_datetime = venue_to_utc_datetime(local_start, api_bp.db_manager)
end_datetime = (local_end - utc_offset) end_datetime = venue_to_utc_datetime(local_end, api_bp.db_manager)
logger.info(f"Querying bets for local date {date_param}: naive UTC range {start_datetime} to {end_datetime}") logger.info(f"Querying bets for local date {date_param}: naive UTC range {start_datetime} to {end_datetime}")
...@@ -4829,10 +4822,18 @@ def get_available_matches_for_betting(): ...@@ -4829,10 +4822,18 @@ def get_available_matches_for_betting():
MatchModel.status == 'bet' MatchModel.status == 'bet'
).order_by(MatchModel.match_number.asc()) ).order_by(MatchModel.match_number.asc())
# Try to filter by today first # Try to filter by today first using venue timezone
venue_tz = get_venue_timezone(api_bp.db_manager)
local_start = datetime.combine(today, datetime.min.time())
local_end = datetime.combine(today, datetime.max.time())
# Convert venue local time to UTC for database queries
start_datetime = venue_to_utc_datetime(local_start, api_bp.db_manager)
end_datetime = venue_to_utc_datetime(local_end, api_bp.db_manager)
today_matches = matches_query.filter( today_matches = matches_query.filter(
MatchModel.start_time >= datetime.combine(today, datetime.min.time()), MatchModel.start_time >= start_datetime,
MatchModel.start_time < datetime.combine(today, datetime.max.time()) MatchModel.start_time < end_datetime
).all() ).all()
if today_matches: if today_matches:
...@@ -5513,13 +5514,14 @@ def get_daily_reports_summary(): ...@@ -5513,13 +5514,14 @@ def get_daily_reports_summary():
session = api_bp.db_manager.get_session() session = api_bp.db_manager.get_session()
try: try:
# Create the date range in UTC directly (same logic as cashier bets) # Create the date range using venue timezone
utc_offset = timedelta(hours=2) # User is in UTC+2 venue_tz = get_venue_timezone(api_bp.db_manager)
local_start = datetime.combine(target_date, datetime.min.time()) local_start = datetime.combine(target_date, datetime.min.time())
local_end = datetime.combine(target_date, datetime.max.time()) local_end = datetime.combine(target_date, datetime.max.time())
start_datetime = (local_start - utc_offset) # Convert venue local time to UTC for database queries
end_datetime = (local_end - utc_offset) start_datetime = venue_to_utc_datetime(local_start, api_bp.db_manager)
end_datetime = venue_to_utc_datetime(local_end, api_bp.db_manager)
logger.info(f"Querying daily summary for local date {date_param}: UTC range {start_datetime} to {end_datetime}") logger.info(f"Querying daily summary for local date {date_param}: UTC range {start_datetime} to {end_datetime}")
...@@ -5596,13 +5598,14 @@ def get_match_reports(): ...@@ -5596,13 +5598,14 @@ def get_match_reports():
session = api_bp.db_manager.get_session() session = api_bp.db_manager.get_session()
try: try:
# Create the date range in UTC directly (same logic as cashier bets) # Create the date range using venue timezone
utc_offset = timedelta(hours=2) # User is in UTC+2 venue_tz = get_venue_timezone(api_bp.db_manager)
local_start = datetime.combine(target_date, datetime.min.time()) local_start = datetime.combine(target_date, datetime.min.time())
local_end = datetime.combine(target_date, datetime.max.time()) local_end = datetime.combine(target_date, datetime.max.time())
start_datetime = (local_start - utc_offset) # Convert venue local time to UTC for database queries
end_datetime = (local_end - utc_offset) start_datetime = venue_to_utc_datetime(local_start, api_bp.db_manager)
end_datetime = venue_to_utc_datetime(local_end, api_bp.db_manager)
logger.info(f"Querying match reports for local date {date_param}: UTC range {start_datetime} to {end_datetime}") logger.info(f"Querying match reports for local date {date_param}: UTC range {start_datetime} to {end_datetime}")
...@@ -5700,13 +5703,14 @@ def get_match_details(): ...@@ -5700,13 +5703,14 @@ def get_match_details():
session = api_bp.db_manager.get_session() session = api_bp.db_manager.get_session()
try: try:
# Create the date range in UTC directly (same logic as cashier bets) # Create the date range using venue timezone
utc_offset = timedelta(hours=2) # User is in UTC+2 venue_tz = get_venue_timezone(api_bp.db_manager)
local_start = datetime.combine(target_date, datetime.min.time()) local_start = datetime.combine(target_date, datetime.min.time())
local_end = datetime.combine(target_date, datetime.max.time()) local_end = datetime.combine(target_date, datetime.max.time())
start_datetime = (local_start - utc_offset) # Convert venue local time to UTC for database queries
end_datetime = (local_end - utc_offset) start_datetime = venue_to_utc_datetime(local_start, api_bp.db_manager)
end_datetime = venue_to_utc_datetime(local_end, api_bp.db_manager)
logger.info(f"Querying match details for match {match_id}, local date {date_param}: UTC range {start_datetime} to {end_datetime}") logger.info(f"Querying match details for match {match_id}, local date {date_param}: UTC range {start_datetime} to {end_datetime}")
...@@ -5793,13 +5797,14 @@ def download_excel_report(): ...@@ -5793,13 +5797,14 @@ def download_excel_report():
session = api_bp.db_manager.get_session() session = api_bp.db_manager.get_session()
try: try:
# Create the date range in UTC directly (same logic as other report endpoints) # Create the date range using venue timezone
utc_offset = timedelta(hours=2) # User is in UTC+2 venue_tz = get_venue_timezone(api_bp.db_manager)
local_start = datetime.combine(target_date, datetime.min.time()) local_start = datetime.combine(target_date, datetime.min.time())
local_end = datetime.combine(target_date, datetime.max.time()) local_end = datetime.combine(target_date, datetime.max.time())
start_datetime = (local_start - utc_offset) # Convert venue local time to UTC for database queries
end_datetime = (local_end - utc_offset) start_datetime = venue_to_utc_datetime(local_start, api_bp.db_manager)
end_datetime = venue_to_utc_datetime(local_end, api_bp.db_manager)
logger.info(f"Generating Excel report for local date {date_param}: UTC range {start_datetime} to {end_datetime}") logger.info(f"Generating Excel report for local date {date_param}: UTC range {start_datetime} to {end_datetime}")
...@@ -6307,3 +6312,104 @@ def upload_intro_video(): ...@@ -6307,3 +6312,104 @@ def upload_intro_video():
logger.error(f"API upload intro video error: {e}") logger.error(f"API upload intro video error: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
# Timezone Settings API routes
@api_bp.route('/timezone-settings')
@get_api_auth_decorator()
def get_timezone_settings():
"""Get venue timezone configuration"""
try:
if api_bp.db_manager:
from ..utils.timezone_utils import get_venue_timezone
venue_tz = get_venue_timezone(api_bp.db_manager)
settings = {
'timezone': str(venue_tz)
}
else:
settings = {
'timezone': 'Africa/Johannesburg' # Default
}
return jsonify({
"success": True,
"settings": settings
})
except Exception as e:
logger.error(f"API get timezone settings error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/timezone-settings', methods=['POST'])
@get_api_auth_decorator()
@get_api_auth_decorator(require_admin=True)
def set_timezone_settings():
"""Set venue timezone configuration (admin only)"""
try:
data = request.get_json() or {}
timezone_str = data.get('timezone', '').strip()
# Validate timezone
import pytz
try:
pytz.timezone(timezone_str)
except pytz.exceptions.UnknownTimeZoneError:
return jsonify({"error": f"Invalid timezone: {timezone_str}"}), 400
if api_bp.db_manager:
# Save timezone configuration to database
api_bp.db_manager.set_config_value('venue.timezone', timezone_str)
logger.info(f"Venue timezone updated to {timezone_str}")
return jsonify({
"success": True,
"message": f"Venue timezone set to {timezone_str}",
"settings": {
"timezone": timezone_str
}
})
else:
return jsonify({"error": "Database manager not available"}), 500
except Exception as e:
logger.error(f"API set timezone settings error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/timezone/current-time', methods=['POST'])
@get_api_auth_decorator()
def get_current_venue_time():
"""Get current time in specified timezone"""
try:
data = request.get_json() or {}
timezone_str = data.get('timezone', '').strip()
if not timezone_str:
return jsonify({"error": "Timezone parameter is required"}), 400
# Validate timezone
import pytz
try:
tz = pytz.timezone(timezone_str)
except pytz.exceptions.UnknownTimeZoneError:
return jsonify({"error": f"Invalid timezone: {timezone_str}"}), 400
# Get current time in the specified timezone
from datetime import datetime
now_utc = datetime.now(pytz.UTC)
now_local = now_utc.astimezone(tz)
# Format for display
current_time = now_local.strftime('%Y-%m-%d %H:%M:%S %Z')
return jsonify({
"success": True,
"current_time": current_time,
"timezone": timezone_str,
"utc_offset": now_local.strftime('%z')
})
except Exception as e:
logger.error(f"API get current venue time error: {e}")
return jsonify({"error": str(e)}), 500
...@@ -240,6 +240,50 @@ ...@@ -240,6 +240,50 @@
</div> </div>
</div> </div>
<!-- Timezone Settings -->
<div class="card mb-4">
<div class="card-header">
<h5><i class="fas fa-clock"></i> Venue Timezone Settings</h5>
<small class="text-muted">Configure the timezone for your venue location (affects day change calculations)</small>
</div>
<div class="card-body">
<form id="timezone-config-form">
<div class="mb-3">
<label for="venue-timezone" class="form-label">Venue Timezone</label>
<select class="form-select" id="venue-timezone">
<option value="Africa/Kampala">East Africa Time (UTC+3) - Kampala, Nairobi, Addis Ababa</option>
<option value="Africa/Johannesburg">South Africa Standard Time (UTC+2) - Johannesburg, Pretoria</option>
<option value="Africa/Nairobi">East Africa Time (UTC+3) - Nairobi, Dar es Salaam</option>
<option value="Africa/Addis_Ababa">East Africa Time (UTC+3) - Addis Ababa</option>
<option value="Africa/Dar_es_Salaam">East Africa Time (UTC+3) - Dar es Salaam</option>
<option value="Africa/Lagos">West Africa Time (UTC+1) - Lagos, Abuja</option>
<option value="Africa/Cairo">Eastern European Time (UTC+2) - Cairo</option>
<option value="Europe/London">Greenwich Mean Time (UTC+0) - London</option>
<option value="America/New_York">Eastern Time (UTC-5/-4) - New York</option>
<option value="Asia/Dubai">Gulf Standard Time (UTC+4) - Dubai</option>
<option value="Asia/Shanghai">China Standard Time (UTC+8) - Shanghai</option>
<option value="Australia/Sydney">Australian Eastern Time (UTC+10) - Sydney</option>
<option value="Pacific/Auckland">New Zealand Time (UTC+12) - Auckland</option>
</select>
<div class="form-text">
<strong>Important:</strong> This timezone determines when "day change" occurs for fixtures and matches.
All dates are stored in UTC internally, but day boundaries are calculated using this venue timezone.
<br><small class="text-muted">Change requires application restart to take full effect.</small>
</div>
</div>
<div class="mb-3">
<label class="form-label">Current Venue Time</label>
<div class="form-control-plaintext">
<span class="badge bg-info text-dark fs-6" id="current-venue-time">Loading...</span>
</div>
<div class="form-text">Current time in the selected venue timezone</div>
</div>
<button type="submit" class="btn btn-primary">Save Venue Timezone</button>
</form>
<div id="timezone-status" class="mt-3"></div>
</div>
</div>
<!-- Currency Settings --> <!-- Currency Settings -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
...@@ -711,6 +755,8 @@ ...@@ -711,6 +755,8 @@
// Load current global betting mode and currency settings on page load // Load current global betting mode and currency settings on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadBettingMode(); loadBettingMode();
loadTimezoneSettings();
setupTimezoneHandlers();
loadCurrencySettings(); loadCurrencySettings();
setupCurrencyHandlers(); setupCurrencyHandlers();
loadBarcodeSettings(); loadBarcodeSettings();
...@@ -779,6 +825,123 @@ ...@@ -779,6 +825,123 @@
}); });
}); });
// Timezone settings handlers
function setupTimezoneHandlers() {
const timezoneSelect = document.getElementById('venue-timezone');
// Handle timezone change to update current time preview
timezoneSelect.addEventListener('change', updateCurrentVenueTime);
// Handle form submission
document.getElementById('timezone-config-form').addEventListener('submit', function(e) {
e.preventDefault();
saveTimezoneSettings();
});
}
function updateCurrentVenueTime() {
const timezoneSelect = document.getElementById('venue-timezone');
const selectedTimezone = timezoneSelect.value;
const timeDisplay = document.getElementById('current-venue-time');
// Get current time in selected timezone
fetch('/api/timezone/current-time', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
timezone: selectedTimezone
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
timeDisplay.textContent = data.current_time;
timeDisplay.className = 'badge bg-success text-white fs-6';
} else {
timeDisplay.textContent = 'Error loading time';
timeDisplay.className = 'badge bg-danger text-white fs-6';
}
})
.catch(error => {
console.error('Error updating venue time:', error);
timeDisplay.textContent = 'Error loading time';
timeDisplay.className = 'badge bg-danger text-white fs-6';
});
}
function loadTimezoneSettings() {
fetch('/api/timezone-settings')
.then(response => response.json())
.then(data => {
if (data.success) {
const settings = data.settings;
document.getElementById('venue-timezone').value = settings.timezone || 'Africa/Johannesburg';
// Update current time display
updateCurrentVenueTime();
// Don't show any status on page load - only after user saves
} else {
// Use default
document.getElementById('venue-timezone').value = 'Africa/Johannesburg';
updateCurrentVenueTime();
document.getElementById('timezone-status').innerHTML =
'<div class="alert alert-info"><small><i class="fas fa-info-circle"></i> Using default venue timezone (South Africa)</small></div>';
}
})
.catch(error => {
console.error('Error loading timezone settings:', error);
// Use default
document.getElementById('venue-timezone').value = 'Africa/Johannesburg';
updateCurrentVenueTime();
document.getElementById('timezone-status').innerHTML =
'<div class="alert alert-warning"><small><i class="fas fa-exclamation-triangle"></i> Error loading timezone settings, using defaults</small></div>';
});
}
function saveTimezoneSettings() {
const statusDiv = document.getElementById('timezone-status');
const timezoneSelect = document.getElementById('venue-timezone');
const settings = {
timezone: timezoneSelect.value
};
statusDiv.innerHTML = '<div class="alert alert-info"><i class="fas fa-spinner fa-spin"></i> Saving venue timezone...</div>';
fetch('/api/timezone-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settings)
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusDiv.innerHTML = '<div class="alert alert-success"><i class="fas fa-check-circle"></i> ' + data.message + '<br><small>Application restart recommended for full effect.</small></div>';
// Update current time display
updateCurrentVenueTime();
// Auto-hide success message after 5 seconds
setTimeout(() => {
statusDiv.innerHTML = '<div class="alert alert-success"><small><i class="fas fa-check-circle"></i> Venue timezone saved</small></div>';
}, 5000);
} else {
statusDiv.innerHTML = '<div class="alert alert-danger"><i class="fas fa-exclamation-circle"></i> Failed to save venue timezone: ' + data.error + '</div>';
}
})
.catch(error => {
console.error('Error saving timezone settings:', error);
statusDiv.innerHTML = '<div class="alert alert-danger"><i class="fas fa-exclamation-circle"></i> Error saving venue timezone: ' + error.message + '</div>';
});
}
// Currency settings handlers // Currency settings handlers
function setupCurrencyHandlers() { function setupCurrencyHandlers() {
const symbolSelect = document.getElementById('currency-symbol'); const symbolSelect = document.getElementById('currency-symbol');
......
...@@ -31,6 +31,7 @@ psutil>=5.8.0 ...@@ -31,6 +31,7 @@ psutil>=5.8.0
click>=8.0.0 click>=8.0.0
watchdog>=3.0.0 watchdog>=3.0.0
netifaces>=0.11.0 netifaces>=0.11.0
pytz>=2023.0
# Video and image processing # Video and image processing
opencv-python>=4.5.0 opencv-python>=4.5.0
......
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