Commit 8ed954c2 authored by Your Name's avatar Your Name

Fix match progression and sanitize SMTP defaults

parent 0ca2b9bb
...@@ -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, date from datetime import datetime, timedelta, date, timezone
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from .thread_manager import ThreadedComponent from .thread_manager import ThreadedComponent
...@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__) ...@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
def utcnow(): def utcnow():
return datetime.now(datetime.UTC).replace(tzinfo=None) return datetime.now(timezone.utc).replace(tzinfo=None)
import random import random
...@@ -1069,6 +1069,25 @@ class GamesThread(ThreadedComponent): ...@@ -1069,6 +1069,25 @@ class GamesThread(ThreadedComponent):
logger.error(f"Failed canonical settlement for match {match.id}: {exc}") logger.error(f"Failed canonical settlement for match {match.id}: {exc}")
raise raise
def _filter_active_winning_outcomes(self, match: MatchModel, winning_outcomes: List[str]) -> List[str]:
"""Keep only outcomes active on this match, plus the under/over result."""
if not match or not winning_outcomes:
return []
active_outcomes = set()
try:
active_outcomes = set(match.get_outcomes_dict().keys())
except Exception as exc:
logger.warning(f"Failed to determine active outcomes for match {getattr(match, 'id', None)}: {exc}")
filtered = []
for outcome in winning_outcomes:
if outcome in active_outcomes or outcome == match.under_over_result:
if outcome not in filtered:
filtered.append(outcome)
return filtered
def _get_outcome_coefficient_for_bet(self, match_id: int, outcome: str, session) -> float: def _get_outcome_coefficient_for_bet(self, match_id: int, outcome: str, session) -> float:
"""Get coefficient for a specific outcome from match outcomes. """Get coefficient for a specific outcome from match outcomes.
...@@ -3705,9 +3724,6 @@ class GamesThread(ThreadedComponent): ...@@ -3705,9 +3724,6 @@ class GamesThread(ThreadedComponent):
# Send MATCH_DONE message with result # Send MATCH_DONE message with result
self._send_match_done(fixture_id, match_id, result) self._send_match_done(fixture_id, match_id, result)
# Send NEXT_MATCH message to advance to next match
self._send_next_match(fixture_id, match_id)
except Exception as e: except Exception as e:
logger.error(f"Failed to handle PLAY_VIDEO_RESULTS_DONE message: {e}") logger.error(f"Failed to handle PLAY_VIDEO_RESULTS_DONE message: {e}")
...@@ -3753,8 +3769,8 @@ class GamesThread(ThreadedComponent): ...@@ -3753,8 +3769,8 @@ class GamesThread(ThreadedComponent):
logger.info(f"🛡️ [SAFETY NET] Calling _ensure_all_bets_resolved for match {match_id}") logger.info(f"🛡️ [SAFETY NET] Calling _ensure_all_bets_resolved for match {match_id}")
self._ensure_all_bets_resolved(match_id, result) self._ensure_all_bets_resolved(match_id, result)
# NEXT_MATCH is now sent immediately in _handle_play_video_result_done # Advance only after the match has been fully finalized in the database.
# to avoid the 2-second delay and ensure proper sequencing self._send_next_match(fixture_id, match_id)
except Exception as e: except Exception as e:
logger.error(f"Failed to handle MATCH_DONE message: {e}") logger.error(f"Failed to handle MATCH_DONE message: {e}")
...@@ -4122,10 +4138,11 @@ class GamesThread(ThreadedComponent): ...@@ -4122,10 +4138,11 @@ class GamesThread(ThreadedComponent):
match.result = selected_result match.result = selected_result
logger.info(f"DEBUG _update_bet_results: Set match.result to '{selected_result}'") logger.info(f"DEBUG _update_bet_results: Set match.result to '{selected_result}'")
# Set winning outcomes as JSON array in separate field # Set winning outcomes as JSON array in separate field, filtered to active outcomes only
if extraction_winning_outcome_names: filtered_winning_outcome_names = self._filter_active_winning_outcomes(match, extraction_winning_outcome_names)
match.winning_outcomes = json.dumps(extraction_winning_outcome_names) if filtered_winning_outcome_names:
logger.info(f"DEBUG _update_bet_results: Set match.winning_outcomes to {extraction_winning_outcome_names}") match.winning_outcomes = json.dumps(filtered_winning_outcome_names)
logger.info(f"DEBUG _update_bet_results: Set filtered match.winning_outcomes to {filtered_winning_outcome_names}")
else: else:
match.winning_outcomes = None match.winning_outcomes = None
logger.info(f"DEBUG _update_bet_results: No winning outcomes, set match.winning_outcomes to None") logger.info(f"DEBUG _update_bet_results: No winning outcomes, set match.winning_outcomes to None")
......
...@@ -33,6 +33,7 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -33,6 +33,7 @@ class MatchTimerComponent(ThreadedComponent):
self.current_fixture_id: Optional[str] = None self.current_fixture_id: Optional[str] = None
self.current_match_id: Optional[int] = None self.current_match_id: Optional[int] = None
self.pending_match_id: Optional[int] = None # Match prepared by START_INTRO self.pending_match_id: Optional[int] = None # Match prepared by START_INTRO
self.last_completed_match_id: Optional[int] = None
# Synchronization # Synchronization
self._timer_lock = threading.RLock() self._timer_lock = threading.RLock()
...@@ -45,6 +46,7 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -45,6 +46,7 @@ class MatchTimerComponent(ThreadedComponent):
self.message_bus.subscribe(self.name, MessageType.GAME_STARTED, self._handle_game_started) self.message_bus.subscribe(self.name, MessageType.GAME_STARTED, self._handle_game_started)
self.message_bus.subscribe(self.name, MessageType.SCHEDULE_GAMES, self._handle_schedule_games) self.message_bus.subscribe(self.name, MessageType.SCHEDULE_GAMES, self._handle_schedule_games)
self.message_bus.subscribe(self.name, MessageType.CUSTOM, self._handle_custom_message) self.message_bus.subscribe(self.name, MessageType.CUSTOM, self._handle_custom_message)
self.message_bus.subscribe(self.name, MessageType.MATCH_DONE, self._handle_match_done)
self.message_bus.subscribe(self.name, MessageType.NEXT_MATCH, self._handle_next_match) self.message_bus.subscribe(self.name, MessageType.NEXT_MATCH, self._handle_next_match)
self.message_bus.subscribe(self.name, MessageType.START_INTRO, self._handle_start_intro) self.message_bus.subscribe(self.name, MessageType.START_INTRO, self._handle_start_intro)
...@@ -115,6 +117,8 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -115,6 +117,8 @@ class MatchTimerComponent(ThreadedComponent):
self._handle_schedule_games(message) self._handle_schedule_games(message)
elif message.type == MessageType.CUSTOM: elif message.type == MessageType.CUSTOM:
self._handle_custom_message(message) self._handle_custom_message(message)
elif message.type == MessageType.MATCH_DONE:
self._handle_match_done(message)
elif message.type == MessageType.NEXT_MATCH: elif message.type == MessageType.NEXT_MATCH:
self._handle_next_match(message) self._handle_next_match(message)
elif message.type == MessageType.START_INTRO: elif message.type == MessageType.START_INTRO:
...@@ -243,6 +247,9 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -243,6 +247,9 @@ class MatchTimerComponent(ThreadedComponent):
logger.info(f"Received NEXT_MATCH message for fixture {fixture_id}, match {match_id}") logger.info(f"Received NEXT_MATCH message for fixture {fixture_id}, match {match_id}")
logger.info("Previous match completed - restarting timer for next interval") logger.info("Previous match completed - restarting timer for next interval")
with self._timer_lock:
self.last_completed_match_id = match_id
# Start timer first to ensure countdown is visible immediately # Start timer first to ensure countdown is visible immediately
match_interval = self._get_match_interval() match_interval = self._get_match_interval()
self._start_timer(match_interval * 60, fixture_id) self._start_timer(match_interval * 60, fixture_id)
...@@ -287,6 +294,37 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -287,6 +294,37 @@ class MatchTimerComponent(ThreadedComponent):
except Exception as restart_e: except Exception as restart_e:
logger.error(f"Failed to restart timer after NEXT_MATCH error: {restart_e}") logger.error(f"Failed to restart timer after NEXT_MATCH error: {restart_e}")
def _handle_match_done(self, message: Message):
"""Handle MATCH_DONE message - trigger next-match progression."""
try:
fixture_id = message.data.get("fixture_id")
match_id = message.data.get("match_id")
result = message.data.get("result")
logger.info(f"Received MATCH_DONE message for fixture {fixture_id}, match {match_id}, result {result}")
if fixture_id is None or match_id is None:
logger.warning(f"MATCH_DONE missing required data: {message.data}")
return
with self._timer_lock:
self.last_completed_match_id = match_id
next_match_message = Message(
type=MessageType.NEXT_MATCH,
sender=self.name,
recipient=self.name,
data={
"fixture_id": fixture_id,
"match_id": match_id,
},
)
logger.info(f"Dispatching NEXT_MATCH for fixture {fixture_id}, match {match_id} after MATCH_DONE")
self._handle_next_match(next_match_message)
except Exception as e:
logger.error(f"Failed to handle MATCH_DONE message: {e}")
def _handle_start_intro(self, message: Message): def _handle_start_intro(self, message: Message):
"""Handle START_INTRO message - store the match_id for later MATCH_START""" """Handle START_INTRO message - store the match_id for later MATCH_START"""
try: try:
...@@ -446,9 +484,10 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -446,9 +484,10 @@ class MatchTimerComponent(ThreadedComponent):
self.message_bus.publish(start_intro_message) self.message_bus.publish(start_intro_message)
# Set the pending match for timer expiration # Set the pending match for timer expiration
with self._timer_lock: with self._timer_lock:
self.pending_match_id = target_match.id self.pending_match_id = target_match.id
self.current_fixture_id = fixture_id self.current_fixture_id = fixture_id
self.last_completed_match_id = None
return { return {
"fixture_id": fixture_id, "fixture_id": fixture_id,
...@@ -467,10 +506,12 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -467,10 +506,12 @@ class MatchTimerComponent(ThreadedComponent):
def _find_next_match_in_list(self, matches: list) -> Optional[Any]: def _find_next_match_in_list(self, matches: list) -> Optional[Any]:
"""Find the next match to start from a list of matches""" """Find the next match to start from a list of matches"""
last_completed_match_id = getattr(self, 'last_completed_match_id', None)
# Priority order: bet -> scheduled -> pending # Priority order: bet -> scheduled -> pending
for status in ['bet', 'scheduled', 'pending']: for status in ['bet', 'scheduled', 'pending']:
for match in matches: for match in matches:
if match.status == status: if match.status == status and match.id != last_completed_match_id:
return match return match
return None return None
...@@ -898,4 +939,4 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -898,4 +939,4 @@ class MatchTimerComponent(ThreadedComponent):
self.message_bus.publish(update_message, broadcast=True) self.message_bus.publish(update_message, broadcast=True)
except Exception as e: except Exception as e:
logger.error(f"Failed to send timer update: {e}") logger.error(f"Failed to send timer update: {e}")
\ No newline at end of file
...@@ -1271,6 +1271,8 @@ class RTSPStreamer(ThreadedComponent): ...@@ -1271,6 +1271,8 @@ class RTSPStreamer(ThreadedComponent):
def _handle_play_video_result(self, message: Message): def _handle_play_video_result(self, message: Message):
"""Handle PLAY_VIDEO_RESULT message""" """Handle PLAY_VIDEO_RESULT message"""
try: try:
from ..database.models import MatchModel
logger.info("Handling PLAY_VIDEO_RESULT message") logger.info("Handling PLAY_VIDEO_RESULT message")
fixture_id = message.data.get("fixture_id") fixture_id = message.data.get("fixture_id")
...@@ -1286,10 +1288,28 @@ class RTSPStreamer(ThreadedComponent): ...@@ -1286,10 +1288,28 @@ class RTSPStreamer(ThreadedComponent):
if self.is_playing_match_video: if self.is_playing_match_video:
self.is_playing_match_video = False self.is_playing_match_video = False
filtered_winning_outcomes = winning_outcomes
if match_id and self.db_manager:
session = self.db_manager.get_session()
try:
match = session.query(MatchModel).filter_by(id=match_id).first()
if match:
try:
active_outcomes = set(match.get_outcomes_dict().keys())
except Exception:
active_outcomes = set()
filtered_winning_outcomes = [
outcome for outcome in winning_outcomes
if outcome in active_outcomes or outcome == match.under_over_result
]
finally:
session.close()
self._update_overlay({ self._update_overlay({
'outcome': result, 'outcome': result,
'result': result, 'result': result,
'winningOutcomes': [{'outcome': o} for o in winning_outcomes], 'winningOutcomes': [{'outcome': o} for o in filtered_winning_outcomes],
'match_id': match_id, 'match_id': match_id,
'fixture_id': fixture_id, 'fixture_id': fixture_id,
'is_result_video': True 'is_result_video': True
...@@ -1582,4 +1602,4 @@ class RTSPStreamer(ThreadedComponent): ...@@ -1582,4 +1602,4 @@ class RTSPStreamer(ThreadedComponent):
logger.error(f"Result video not found for {self.current_result}") logger.error(f"Result video not found for {self.current_result}")
except Exception as e: except Exception as e:
logger.error(f"Failed to play result after OVER/UNDER: {e}") logger.error(f"Failed to play result after OVER/UNDER: {e}")
\ No newline at end of file
...@@ -7657,6 +7657,80 @@ class Migration_069_RepairExtractionStatsFinancials(DatabaseMigration): ...@@ -7657,6 +7657,80 @@ class Migration_069_RepairExtractionStatsFinancials(DatabaseMigration):
return True return True
class Migration_070_SeedSmtpEmailSettings(DatabaseMigration):
"""Seed SMTP email settings with safe disabled defaults."""
def __init__(self):
super().__init__("070", "Seed safe SMTP email settings")
def up(self, db_manager) -> bool:
try:
import json
timestamp_sql = get_current_timestamp_sql(db_manager)
insert_ignore = get_insert_ignore_sql(db_manager)
seeded_settings = {
'enabled': False,
'smtp_host': '',
'smtp_port': 587,
'smtp_username': '',
'smtp_password': '',
'smtp_from_email': '',
'smtp_from_name': 'MbetterClient',
'smtp_use_tls': 'tls',
'app_domain': '',
}
with db_manager.engine.connect() as conn:
conn.execute(text(f"""
{insert_ignore} INTO game_config
(config_key, config_value, value_type, description, is_system, created_at, updated_at)
VALUES (:config_key, :config_value, :value_type, :description, :is_system, {timestamp_sql}, {timestamp_sql})
"""), {
'config_key': 'email_settings',
'config_value': json.dumps(seeded_settings),
'value_type': 'json',
'description': 'Email/SMTP settings for user verification and password reset',
'is_system': False,
})
conn.execute(text("""
UPDATE game_config
SET config_value = :config_value,
value_type = :value_type,
description = :description,
is_system = :is_system,
updated_at = """ + timestamp_sql + """
WHERE config_key = :config_key
"""), {
'config_key': 'email_settings',
'config_value': json.dumps(seeded_settings),
'value_type': 'json',
'description': 'Email/SMTP settings for user verification and password reset',
'is_system': False,
})
conn.commit()
logger.info("Safe SMTP email settings seeded successfully")
return True
except Exception as e:
logger.error(f"Failed to seed safe SMTP email settings: {e}")
return False
def down(self, db_manager) -> bool:
try:
with db_manager.engine.connect() as conn:
conn.execute(text("DELETE FROM game_config WHERE config_key = 'email_settings'"))
conn.commit()
logger.info("SMTP email settings removed")
return True
except Exception as e:
logger.error(f"Failed to remove SMTP email settings: {e}")
return False
MIGRATIONS: List[DatabaseMigration] = [ MIGRATIONS: List[DatabaseMigration] = [
Migration_001_InitialSchema(), Migration_001_InitialSchema(),
Migration_002_AddIndexes(), Migration_002_AddIndexes(),
...@@ -7727,6 +7801,7 @@ MIGRATIONS: List[DatabaseMigration] = [ ...@@ -7727,6 +7801,7 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_067_FixMatchResults(), Migration_067_FixMatchResults(),
Migration_068_ResetRedistributionBalance(), Migration_068_ResetRedistributionBalance(),
Migration_069_RepairExtractionStatsFinancials(), Migration_069_RepairExtractionStatsFinancials(),
Migration_070_SeedSmtpEmailSettings(),
] ]
......
...@@ -4916,11 +4916,10 @@ class QtVideoPlayer(QObject): ...@@ -4916,11 +4916,10 @@ class QtVideoPlayer(QObject):
self.window.current_match_video_filename is not None) self.window.current_match_video_filename is not None)
if currently_playing_match: if currently_playing_match:
# Stop the current match video and play result video immediately # Queue the result video and let the normal EndOfMedia path send PLAY_VIDEO_MATCH_DONE.
logger.info("Match video currently playing, stopping it and playing result video") # Stopping playback here can bypass match completion flow and cause the same match to repeat.
if self.window and hasattr(self.window, 'media_player'): logger.info("Match video currently playing, queueing result video until match video completes")
self.window.stop_playback() self.queued_result_video = result_video_info
self._play_result_video(result_video_info)
else: else:
# Play result video immediately # Play result video immediately
logger.info("No match video playing, playing result video immediately") logger.info("No match video playing, playing result video immediately")
......
...@@ -4,6 +4,7 @@ Flask web dashboard application for MbetterClient ...@@ -4,6 +4,7 @@ Flask web dashboard application for MbetterClient
import time import time
import logging import logging
from datetime import datetime, date, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from flask import Flask, request, jsonify, render_template, redirect, url_for, session, g from flask import Flask, request, jsonify, render_template, redirect, url_for, session, g
...@@ -24,13 +25,26 @@ from ..utils.ssl_utils import get_ssl_certificate_paths, create_ssl_context ...@@ -24,13 +25,26 @@ from ..utils.ssl_utils import get_ssl_certificate_paths, create_ssl_context
from flask_cors import CORS from flask_cors import CORS
from .auth import AuthManager from .auth import AuthManager
from .api import DashboardAPI from .api import DashboardAPI
from .routes import main_bp, auth_bp, api_bp from .routes import (
main_bp,
auth_bp,
api_bp,
compute_daily_report_summary,
load_email_settings,
send_email_message,
)
from .screen_cast_routes import screen_cast_bp from .screen_cast_routes import screen_cast_bp
from .billing_api import BillingAPI, init_billing_routes from .billing_api import BillingAPI, init_billing_routes
from ..billing.billing_engine import BillingEngine from ..billing.billing_engine import BillingEngine
from ..database.models import ConfigurationModel
from ..utils.timezone_utils import utc_to_venue_datetime
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DAILY_REPORT_RECIPIENT = "stefy@aisbf.cloud"
DAILY_REPORT_LAST_SUCCESS_CONFIG_KEY = "daily_report_email_last_success_date"
DAILY_REPORT_START_HOUR = 5
class WebDashboard(ThreadedComponent): class WebDashboard(ThreadedComponent):
"""Flask web dashboard component""" """Flask web dashboard component"""
...@@ -68,6 +82,9 @@ class WebDashboard(ThreadedComponent): ...@@ -68,6 +82,9 @@ class WebDashboard(ThreadedComponent):
self.notification_lock = threading.Lock() self.notification_lock = threading.Lock()
self.waiting_clients: List[threading.Event] = [] # Events for waiting long-poll clients self.waiting_clients: List[threading.Event] = [] # Events for waiting long-poll clients
self.waiting_clients_lock = threading.Lock() self.waiting_clients_lock = threading.Lock()
self.last_daily_report_attempt_at: float = 0.0
self.daily_report_retry_interval_seconds: int = 20 * 60
self.daily_report_check_interval_seconds: int = 60
# Register message queue # Register message queue
self.message_queue = self.message_bus.register_component(self.name) self.message_queue = self.message_bus.register_component(self.name)
...@@ -455,6 +472,13 @@ class WebDashboard(ThreadedComponent): ...@@ -455,6 +472,13 @@ class WebDashboard(ThreadedComponent):
daemon=True daemon=True
) )
server_thread.start() server_thread.start()
report_thread = threading.Thread(
target=self._run_daily_report_email_loop,
name="DailyReportEmail",
daemon=True
)
report_thread.start()
# Message processing loop # Message processing loop
while self.running and not self.shutdown_event.is_set(): while self.running and not self.shutdown_event.is_set():
...@@ -476,6 +500,8 @@ class WebDashboard(ThreadedComponent): ...@@ -476,6 +500,8 @@ class WebDashboard(ThreadedComponent):
# Wait for server thread to finish (with timeout since it's daemon) # Wait for server thread to finish (with timeout since it's daemon)
if server_thread and server_thread.is_alive(): if server_thread and server_thread.is_alive():
server_thread.join(timeout=2.0) server_thread.join(timeout=2.0)
if report_thread and report_thread.is_alive():
report_thread.join(timeout=2.0)
except Exception as e: except Exception as e:
logger.error(f"WebDashboard run failed: {e}") logger.error(f"WebDashboard run failed: {e}")
...@@ -505,6 +531,119 @@ class WebDashboard(ThreadedComponent): ...@@ -505,6 +531,119 @@ class WebDashboard(ThreadedComponent):
else: else:
# Expected during shutdown # Expected during shutdown
logger.debug(f"Server stopped during shutdown: {e}") logger.debug(f"Server stopped during shutdown: {e}")
def _run_daily_report_email_loop(self):
"""Send previous-day report summary once per day with retry until success."""
logger.info("Daily report email loop started")
try:
while self.running and not self.shutdown_event.is_set():
try:
self._maybe_send_daily_report_email()
except Exception as e:
logger.error(f"Daily report email loop error: {e}")
self.shutdown_event.wait(self.daily_report_check_interval_seconds)
finally:
logger.info("Daily report email loop stopped")
def _maybe_send_daily_report_email(self):
"""Send yesterday's report if SMTP is enabled and it has not succeeded yet."""
if not self._is_daily_report_send_window_open():
return
now_ts = time.time()
if self.last_daily_report_attempt_at and (
now_ts - self.last_daily_report_attempt_at < self.daily_report_retry_interval_seconds
):
return
session = self.db_manager.get_session()
try:
email_settings = load_email_settings(session)
if not email_settings.get('enabled'):
return
recipient_email = DAILY_REPORT_RECIPIENT
target_date = date.today() - timedelta(days=1)
target_date_key = target_date.isoformat()
if self._get_last_daily_report_success_date(session) == target_date_key:
return
self.last_daily_report_attempt_at = now_ts
summary = compute_daily_report_summary(session, self.db_manager, target_date)
html_content = self._build_daily_report_email_html(summary)
subject = f"Daily report summary - {target_date_key}"
send_email_message(email_settings, recipient_email, subject, html_content)
self._set_last_daily_report_success_date(session, target_date_key)
logger.info(f"Daily report email sent successfully for {target_date_key} to {recipient_email}")
finally:
session.close()
def _is_daily_report_send_window_open(self) -> bool:
"""Allow daily report sending only from 5 AM venue-local time onward."""
now_utc = datetime.utcnow().replace(tzinfo=timezone.utc)
venue_now = utc_to_venue_datetime(now_utc, self.db_manager)
if venue_now is None:
return False
return venue_now.hour >= DAILY_REPORT_START_HOUR
def _get_last_daily_report_success_date(self, session) -> Optional[str]:
"""Read the persisted last successful daily report date."""
config = session.query(ConfigurationModel).filter_by(
key=DAILY_REPORT_LAST_SUCCESS_CONFIG_KEY
).first()
if not config:
return None
value = config.get_typed_value()
if isinstance(value, str):
return value
return None
def _set_last_daily_report_success_date(self, session, target_date_key: str):
"""Persist the last successful daily report date."""
config = session.query(ConfigurationModel).filter_by(
key=DAILY_REPORT_LAST_SUCCESS_CONFIG_KEY
).first()
if config:
config.value = target_date_key
config.value_type = 'string'
config.description = 'Last successful daily report email date'
config.is_system = True
config.updated_at = datetime.utcnow()
else:
config = ConfigurationModel(
key=DAILY_REPORT_LAST_SUCCESS_CONFIG_KEY,
value=target_date_key,
value_type='string',
description='Last successful daily report email date',
is_system=True,
)
session.add(config)
session.commit()
def _build_daily_report_email_html(self, summary: Dict[str, Any]) -> str:
"""Build HTML for previous-day summary email."""
return f"""
<html>
<body style="font-family: Arial, sans-serif; max-width: 700px; margin: 0 auto; color: #222;">
<h2>Report Summary from the Day Before</h2>
<p>Date: <strong>{summary['date']}</strong></p>
<table style="border-collapse: collapse; width: 100%;">
<tr><td style="padding: 8px; border: 1px solid #ddd;">Number of Bets</td><td style="padding: 8px; border: 1px solid #ddd;">{summary['total_bets']}</td></tr>
<tr><td style="padding: 8px; border: 1px solid #ddd;">Number of Matches</td><td style="padding: 8px; border: 1px solid #ddd;">{summary['total_matches']}</td></tr>
<tr><td style="padding: 8px; border: 1px solid #ddd;">Total Payin</td><td style="padding: 8px; border: 1px solid #ddd;">{summary['total_payin']:.2f}</td></tr>
<tr><td style="padding: 8px; border: 1px solid #ddd;">Total Payout</td><td style="padding: 8px; border: 1px solid #ddd;">{summary['total_payout']:.2f}</td></tr>
<tr><td style="padding: 8px; border: 1px solid #ddd;">Business Balance</td><td style="padding: 8px; border: 1px solid #ddd;">{summary['business_balance']:.2f}</td></tr>
<tr><td style="padding: 8px; border: 1px solid #ddd;">Shortfall Accumulated</td><td style="padding: 8px; border: 1px solid #ddd;">{summary['shortfall_accumulated']:.2f}</td></tr>
</table>
</body>
</html>
"""
def _setup_ssl_error_suppression(self): def _setup_ssl_error_suppression(self):
"""Setup logging filter to suppress expected SSL connection errors""" """Setup logging filter to suppress expected SSL connection errors"""
......
This diff is collapsed.
...@@ -11,8 +11,15 @@ from openpyxl import load_workbook ...@@ -11,8 +11,15 @@ from openpyxl import load_workbook
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from mbetterclient.web_dashboard import routes as routes_module from mbetterclient.web_dashboard import routes as routes_module
from mbetterclient.web_dashboard.routes import api_bp, is_bet_detail_winning, is_settled_bet_detail_winning from mbetterclient.web_dashboard import app as app_module
from mbetterclient.web_dashboard.routes import api_bp, is_bet_detail_winning, is_settled_bet_detail_winning, get_active_winning_outcomes
from mbetterclient.qt_player.player import OverlayWebChannel from mbetterclient.qt_player.player import OverlayWebChannel
from mbetterclient.web_dashboard.app import (
WebDashboard,
DAILY_REPORT_RECIPIENT,
DAILY_REPORT_LAST_SUCCESS_CONFIG_KEY,
)
from mbetterclient.core.match_timer import MatchTimerComponent
class DummyDetail: class DummyDetail:
...@@ -460,3 +467,145 @@ def test_qt_overlay_winning_outcomes_return_empty_without_settled_wins(): ...@@ -460,3 +467,145 @@ def test_qt_overlay_winning_outcomes_return_empty_without_settled_wins():
overlay = OverlayWebChannel(db_manager=DummyDbManager(OverlaySessionStub())) overlay = OverlayWebChannel(db_manager=DummyDbManager(OverlaySessionStub()))
assert overlay._get_winning_outcomes_from_database(10) == [] assert overlay._get_winning_outcomes_from_database(10) == []
def test_daily_report_email_html_contains_requested_fields():
dashboard = WebDashboard.__new__(WebDashboard)
html = dashboard._build_daily_report_email_html({
'date': '2026-05-13',
'total_bets': 12,
'total_matches': 4,
'total_payin': 500.0,
'total_payout': 320.0,
'business_balance': 180.0,
'shortfall_accumulated': 45.0,
})
assert 'Report Summary from the Day Before' in html
assert 'Number of Bets' in html
assert 'Number of Matches' in html
assert 'Total Payin' in html
assert 'Total Payout' in html
assert 'Business Balance' in html
assert 'Shortfall Accumulated' in html
def test_daily_report_email_waits_for_retry_interval(monkeypatch):
dashboard = WebDashboard.__new__(WebDashboard)
dashboard.running = True
dashboard.shutdown_event = Mock()
dashboard.last_daily_report_attempt_at = 100.0
dashboard.daily_report_retry_interval_seconds = 1200
monkeypatch.setattr(app_module.time, 'time', lambda: 200.0)
monkeypatch.setattr(WebDashboard, '_is_daily_report_send_window_open', lambda self: True)
called = {'session': False}
class DummySessionLocal:
def close(self):
called['session'] = True
dashboard.db_manager = type('DbMgr', (), {'get_session': lambda self: DummySessionLocal()})()
dashboard.last_daily_report_success_date = None
dashboard._maybe_send_daily_report_email()
assert called['session'] is False
def test_daily_report_recipient_is_fixed_address():
assert DAILY_REPORT_RECIPIENT == 'stefy@aisbf.cloud'
def test_daily_report_success_date_is_persisted_in_config():
dashboard = WebDashboard.__new__(WebDashboard)
class DummyConfig:
def __init__(self, key, value, value_type='string', description='', is_system=False):
self.key = key
self.value = value
self.value_type = value_type
self.description = description
self.is_system = is_system
self.updated_at = None
def get_typed_value(self):
return self.value
class ConfigQuery:
def __init__(self, store):
self.store = store
self.key = None
def filter_by(self, **kwargs):
self.key = kwargs.get('key')
return self
def first(self):
return self.store.get(self.key)
class ConfigSession:
def __init__(self):
self.store = {}
self.committed = False
def query(self, model):
return ConfigQuery(self.store)
def add(self, config):
self.store[config.key] = config
def commit(self):
self.committed = True
session = ConfigSession()
app_module.ConfigurationModel = DummyConfig
dashboard._set_last_daily_report_success_date(session, '2026-05-13')
assert session.committed is True
assert session.store[DAILY_REPORT_LAST_SUCCESS_CONFIG_KEY].value == '2026-05-13'
assert dashboard._get_last_daily_report_success_date(session) == '2026-05-13'
def test_daily_report_send_window_opens_at_5am(monkeypatch):
dashboard = WebDashboard.__new__(WebDashboard)
dashboard.db_manager = Mock()
class FakeVenueTime:
def __init__(self, hour):
self.hour = hour
monkeypatch.setattr(app_module, 'utc_to_venue_datetime', lambda now, db: FakeVenueTime(4))
assert dashboard._is_daily_report_send_window_open() is False
monkeypatch.setattr(app_module, 'utc_to_venue_datetime', lambda now, db: FakeVenueTime(5))
assert dashboard._is_daily_report_send_window_open() is True
def test_active_winning_outcomes_filter_inactive_combo_outcomes():
class MatchWithOutcomes:
result = 'KO1'
under_over_result = 'UNDER'
winning_outcomes = ['KO1', 'WIN1', 'X1', '12']
def get_outcomes_dict(self):
return {'KO1': 2.0, 'WIN1': 1.5}
outcomes = get_active_winning_outcomes(Mock(), MatchWithOutcomes())
assert outcomes == ['KO1', 'WIN1', 'UNDER']
def test_match_timer_skips_last_completed_match_when_selecting_next():
timer = MatchTimerComponent.__new__(MatchTimerComponent)
timer.last_completed_match_id = 10
match_done = type('Match', (), {'id': 10, 'status': 'pending'})()
match_next = type('Match', (), {'id': 11, 'status': 'pending'})()
selected = timer._find_next_match_in_list([match_done, match_next])
assert selected.id == 11
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