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]:
'openpyxl',
'openpyxl.styles',
'openpyxl.utils',
# Timezone support
'pytz',
]
# Conditionally add ffmpeg module if available
......
......@@ -31,22 +31,37 @@ class ConfigManager:
# Create default settings
self._settings = AppSettings()
self.save_settings(self._settings)
# Initialize venue timezone if not configured
self._initialize_venue_timezone()
# Validate settings
if not self._settings.validate():
logger.error("Configuration validation failed")
return False
# Ensure directories exist
self._settings.ensure_directories()
logger.info("Configuration manager initialized")
return True
except Exception as e:
logger.error(f"Failed to initialize configuration manager: {e}")
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:
"""Get current application settings"""
if self._settings is None:
......
......@@ -13,6 +13,7 @@ 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 ..utils.timezone_utils import get_today_venue_date
logger = logging.getLogger(__name__)
......@@ -29,9 +30,9 @@ class GamesThread(ThreadedComponent):
self.message_queue = None
self.waiting_for_validation_fixture: Optional[str] = None
def _get_today_utc_date(self) -> datetime.date:
"""Get today's date in UTC (consistent with database storage)"""
return datetime.utcnow().date()
def _get_today_venue_date(self) -> datetime.date:
"""Get today's date in venue timezone (for day change detection)"""
return get_today_venue_date(self.db_manager)
def _check_and_handle_day_change(self) -> bool:
"""Check if day has changed and handle cleanup/reset accordingly.
......@@ -39,7 +40,7 @@ class GamesThread(ThreadedComponent):
try:
session = self.db_manager.get_session()
try:
today = self._get_today_utc_date()
today = self._get_today_venue_date()
# Get the current fixture if any
if not self.current_fixture_id:
......@@ -58,7 +59,10 @@ class GamesThread(ThreadedComponent):
all_matches_old_day = True
for match in current_matches:
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:
all_matches_old_day = False
break
......@@ -103,14 +107,21 @@ class GamesThread(ThreadedComponent):
try:
session = self.db_manager.get_session()
try:
# Get today's date in UTC (consistent with database)
today = self._get_today_utc_date()
# Get today's date in venue timezone (for day change detection)
today = self._get_today_venue_date()
# 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(
MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()),
MatchModel.start_time < datetime.combine(today, datetime.max.time()),
MatchModel.start_time >= utc_start,
MatchModel.start_time < utc_end,
MatchModel.status == 'ingame',
MatchModel.active_status == True
).all()
......@@ -131,10 +142,7 @@ class GamesThread(ThreadedComponent):
MatchModel.status == 'bet',
MatchModel.active_status == True,
# Exclude today's matches to avoid interfering with active games
~MatchModel.start_time.between(
datetime.combine(today, datetime.min.time()),
datetime.combine(today, datetime.max.time())
)
~MatchModel.start_time.between(utc_start, utc_end)
).all()
if old_bet_matches:
......@@ -860,14 +868,21 @@ class GamesThread(ThreadedComponent):
try:
session = self.db_manager.get_session()
try:
# Get today's date in UTC (consistent with database)
today = self._get_today_utc_date()
# 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 all fixtures that have matches with today's start_time
fixtures_with_today_matches = session.query(MatchModel.fixture_id).filter(
MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()),
MatchModel.start_time < datetime.combine(today, datetime.max.time())
MatchModel.start_time >= utc_start,
MatchModel.start_time < utc_end
).distinct().all()
if not fixtures_with_today_matches:
......@@ -893,14 +908,21 @@ class GamesThread(ThreadedComponent):
try:
session = self.db_manager.get_session()
try:
# Get today's date in UTC (consistent with database)
today = self._get_today_utc_date()
# 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 fixtures with ingame matches today
ingame_matches = session.query(MatchModel).filter(
MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()),
MatchModel.start_time < datetime.combine(today, datetime.max.time()),
MatchModel.start_time >= utc_start,
MatchModel.start_time < utc_end,
MatchModel.status == 'ingame',
MatchModel.active_status == True
).all()
......@@ -993,14 +1015,21 @@ class GamesThread(ThreadedComponent):
try:
session = self.db_manager.get_session()
try:
# Get today's date in UTC (consistent with database)
today = self._get_today_utc_date()
# 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 all fixtures with today's matches
all_fixtures = session.query(MatchModel.fixture_id).filter(
MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()),
MatchModel.start_time < datetime.combine(today, datetime.max.time())
MatchModel.start_time >= utc_start,
MatchModel.start_time < utc_end
).distinct().all()
# Check each fixture except the current one
......@@ -1262,16 +1291,23 @@ class GamesThread(ThreadedComponent):
try:
session = self.db_manager.get_session()
try:
# Get today's date in UTC (consistent with database)
today = self._get_today_utc_date()
# 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 fixtures with today's matches that are not in terminal states
terminal_states = ['done', 'cancelled', 'failed', 'paused']
active_matches = session.query(MatchModel).filter(
MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()),
MatchModel.start_time < datetime.combine(today, datetime.max.time()),
MatchModel.start_time >= utc_start,
MatchModel.start_time < utc_end,
MatchModel.status.notin_(terminal_states),
MatchModel.active_status == True
).order_by(MatchModel.start_time.asc()).all()
......@@ -1293,15 +1329,22 @@ class GamesThread(ThreadedComponent):
try:
session = self.db_manager.get_session()
try:
# Get today's date in UTC (consistent with database storage)
today = self._get_today_utc_date()
# 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 >= datetime.combine(today, datetime.min.time())) &
(MatchModel.start_time < datetime.combine(today, datetime.max.time())))
(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
......@@ -1314,9 +1357,10 @@ class GamesThread(ThreadedComponent):
MatchModel.active_status == True
).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(
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
)
......@@ -2510,7 +2554,7 @@ class GamesThread(ThreadedComponent):
cap_percentage = self._get_redistribution_cap()
# 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)
logger.info(f"🎯 [EXTRACTION DEBUG] Accumulated shortfall: {accumulated_shortfall:.2f}")
......@@ -2591,7 +2635,7 @@ class GamesThread(ThreadedComponent):
# Step 10: Update 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)
logger.info(f"✅ [EXTRACTION DEBUG] Result extraction completed successfully: selected {selected_result}")
......@@ -3350,14 +3394,21 @@ class GamesThread(ThreadedComponent):
# Check if there are any active fixtures (matches in non-terminal states)
session = self.db_manager.get_session()
try:
# Get today's date in UTC (consistent with database)
today = self._get_today_utc_date()
# 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)
# Check for active matches today
active_matches = session.query(MatchModel).filter(
MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()),
MatchModel.start_time < datetime.combine(today, datetime.max.time()),
MatchModel.start_time >= utc_start,
MatchModel.start_time < utc_end,
MatchModel.status.notin_(['done', 'cancelled', 'failed', 'paused']),
MatchModel.active_status == True
).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
from .auth import AuthenticatedUser
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):
......@@ -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
# Which is 2025-12-16 22:00 UTC to 2025-12-17 21:59 UTC
# Create the date range in UTC directly
# Since bets are stored in UTC, we need to query for the entire day in UTC
# 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)
# Create the date range using venue timezone
venue_tz = get_venue_timezone(api_bp.db_manager)
local_start = datetime.combine(target_date, datetime.min.time())
local_end = datetime.combine(target_date, datetime.max.time())
# Convert local date range to naive UTC datetimes
start_datetime = (local_start - utc_offset)
end_datetime = (local_end - utc_offset)
# 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)
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():
MatchModel.status == 'bet'
).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(
MatchModel.start_time >= datetime.combine(today, datetime.min.time()),
MatchModel.start_time < datetime.combine(today, datetime.max.time())
MatchModel.start_time >= start_datetime,
MatchModel.start_time < end_datetime
).all()
if today_matches:
......@@ -5513,13 +5514,14 @@ def get_daily_reports_summary():
session = api_bp.db_manager.get_session()
try:
# Create the date range in UTC directly (same logic as cashier bets)
utc_offset = timedelta(hours=2) # User is in UTC+2
# Create the date range using venue timezone
venue_tz = get_venue_timezone(api_bp.db_manager)
local_start = datetime.combine(target_date, datetime.min.time())
local_end = datetime.combine(target_date, datetime.max.time())
start_datetime = (local_start - utc_offset)
end_datetime = (local_end - utc_offset)
# 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)
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():
session = api_bp.db_manager.get_session()
try:
# Create the date range in UTC directly (same logic as cashier bets)
utc_offset = timedelta(hours=2) # User is in UTC+2
# Create the date range using venue timezone
venue_tz = get_venue_timezone(api_bp.db_manager)
local_start = datetime.combine(target_date, datetime.min.time())
local_end = datetime.combine(target_date, datetime.max.time())
start_datetime = (local_start - utc_offset)
end_datetime = (local_end - utc_offset)
# 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)
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():
session = api_bp.db_manager.get_session()
try:
# Create the date range in UTC directly (same logic as cashier bets)
utc_offset = timedelta(hours=2) # User is in UTC+2
# Create the date range using venue timezone
venue_tz = get_venue_timezone(api_bp.db_manager)
local_start = datetime.combine(target_date, datetime.min.time())
local_end = datetime.combine(target_date, datetime.max.time())
start_datetime = (local_start - utc_offset)
end_datetime = (local_end - utc_offset)
# 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)
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():
session = api_bp.db_manager.get_session()
try:
# Create the date range in UTC directly (same logic as other report endpoints)
utc_offset = timedelta(hours=2) # User is in UTC+2
# Create the date range using venue timezone
venue_tz = get_venue_timezone(api_bp.db_manager)
local_start = datetime.combine(target_date, datetime.min.time())
local_end = datetime.combine(target_date, datetime.max.time())
start_datetime = (local_start - utc_offset)
end_datetime = (local_end - utc_offset)
# 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)
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():
logger.error(f"API upload intro video error: {e}")
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 @@
</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 -->
<div class="card mb-4">
<div class="card-header">
......@@ -711,6 +755,8 @@
// Load current global betting mode and currency settings on page load
document.addEventListener('DOMContentLoaded', function() {
loadBettingMode();
loadTimezoneSettings();
setupTimezoneHandlers();
loadCurrencySettings();
setupCurrencyHandlers();
loadBarcodeSettings();
......@@ -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
function setupCurrencyHandlers() {
const symbolSelect = document.getElementById('currency-symbol');
......
......@@ -31,6 +31,7 @@ psutil>=5.8.0
click>=8.0.0
watchdog>=3.0.0
netifaces>=0.11.0
pytz>=2023.0
# Video and image processing
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