Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Contribute to GitLab
Sign in
Toggle navigation
M
MBetterc
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Mbetter
MBetterc
Commits
8ed954c2
Commit
8ed954c2
authored
May 14, 2026
by
Your Name
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Fix match progression and sanitize SMTP defaults
parent
0ca2b9bb
Changes
8
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
632 additions
and
230 deletions
+632
-230
games_thread.py
mbetterclient/core/games_thread.py
+28
-11
match_timer.py
mbetterclient/core/match_timer.py
+46
-5
rtsp_streamer.py
mbetterclient/core/rtsp_streamer.py
+22
-2
migrations.py
mbetterclient/database/migrations.py
+75
-0
player.py
mbetterclient/qt_player/player.py
+4
-5
app.py
mbetterclient/web_dashboard/app.py
+140
-1
routes.py
mbetterclient/web_dashboard/routes.py
+167
-205
test_reports_daily_alignment.py
test_reports_daily_alignment.py
+150
-1
No files found.
mbetterclient/core/games_thread.py
View file @
8ed954c2
...
...
@@ -6,7 +6,7 @@ import time
import
json
import
logging
import
threading
from
datetime
import
datetime
,
timedelta
,
date
from
datetime
import
datetime
,
timedelta
,
date
,
timezone
from
typing
import
Optional
,
Dict
,
Any
,
List
from
.thread_manager
import
ThreadedComponent
...
...
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
def
utcnow
():
return
datetime
.
now
(
datetime
.
UTC
)
.
replace
(
tzinfo
=
None
)
return
datetime
.
now
(
timezone
.
utc
)
.
replace
(
tzinfo
=
None
)
import
random
...
...
@@ -1069,6 +1069,25 @@ class GamesThread(ThreadedComponent):
logger
.
error
(
f
"Failed canonical settlement for match {match.id}: {exc}"
)
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
:
"""Get coefficient for a specific outcome from match outcomes.
...
...
@@ -3705,9 +3724,6 @@ class GamesThread(ThreadedComponent):
# Send MATCH_DONE message with 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
:
logger
.
error
(
f
"Failed to handle PLAY_VIDEO_RESULTS_DONE message: {e}"
)
...
...
@@ -3753,8 +3769,8 @@ class GamesThread(ThreadedComponent):
logger
.
info
(
f
"🛡️ [SAFETY NET] Calling _ensure_all_bets_resolved for match {match_id}"
)
self
.
_ensure_all_bets_resolved
(
match_id
,
result
)
#
NEXT_MATCH is now sent immediately in _handle_play_video_result_done
# to avoid the 2-second delay and ensure proper sequencing
#
Advance only after the match has been fully finalized in the database.
self
.
_send_next_match
(
fixture_id
,
match_id
)
except
Exception
as
e
:
logger
.
error
(
f
"Failed to handle MATCH_DONE message: {e}"
)
...
...
@@ -4122,10 +4138,11 @@ class GamesThread(ThreadedComponent):
match
.
result
=
selected_result
logger
.
info
(
f
"DEBUG _update_bet_results: Set match.result to '{selected_result}'"
)
# Set winning outcomes as JSON array in separate field
if
extraction_winning_outcome_names
:
match
.
winning_outcomes
=
json
.
dumps
(
extraction_winning_outcome_names
)
logger
.
info
(
f
"DEBUG _update_bet_results: Set match.winning_outcomes to {extraction_winning_outcome_names}"
)
# Set winning outcomes as JSON array in separate field, filtered to active outcomes only
filtered_winning_outcome_names
=
self
.
_filter_active_winning_outcomes
(
match
,
extraction_winning_outcome_names
)
if
filtered_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
:
match
.
winning_outcomes
=
None
logger
.
info
(
f
"DEBUG _update_bet_results: No winning outcomes, set match.winning_outcomes to None"
)
...
...
mbetterclient/core/match_timer.py
View file @
8ed954c2
...
...
@@ -33,6 +33,7 @@ class MatchTimerComponent(ThreadedComponent):
self
.
current_fixture_id
:
Optional
[
str
]
=
None
self
.
current_match_id
:
Optional
[
int
]
=
None
self
.
pending_match_id
:
Optional
[
int
]
=
None
# Match prepared by START_INTRO
self
.
last_completed_match_id
:
Optional
[
int
]
=
None
# Synchronization
self
.
_timer_lock
=
threading
.
RLock
()
...
...
@@ -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
.
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
.
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
.
START_INTRO
,
self
.
_handle_start_intro
)
...
...
@@ -115,6 +117,8 @@ class MatchTimerComponent(ThreadedComponent):
self
.
_handle_schedule_games
(
message
)
elif
message
.
type
==
MessageType
.
CUSTOM
:
self
.
_handle_custom_message
(
message
)
elif
message
.
type
==
MessageType
.
MATCH_DONE
:
self
.
_handle_match_done
(
message
)
elif
message
.
type
==
MessageType
.
NEXT_MATCH
:
self
.
_handle_next_match
(
message
)
elif
message
.
type
==
MessageType
.
START_INTRO
:
...
...
@@ -243,6 +247,9 @@ class MatchTimerComponent(ThreadedComponent):
logger
.
info
(
f
"Received NEXT_MATCH message for fixture {fixture_id}, match {match_id}"
)
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
match_interval
=
self
.
_get_match_interval
()
self
.
_start_timer
(
match_interval
*
60
,
fixture_id
)
...
...
@@ -287,6 +294,37 @@ class MatchTimerComponent(ThreadedComponent):
except
Exception
as
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
):
"""Handle START_INTRO message - store the match_id for later MATCH_START"""
try
:
...
...
@@ -446,9 +484,10 @@ class MatchTimerComponent(ThreadedComponent):
self
.
message_bus
.
publish
(
start_intro_message
)
# Set the pending match for timer expiration
with
self
.
_timer_lock
:
self
.
pending_match_id
=
target_match
.
id
self
.
current_fixture_id
=
fixture_id
with
self
.
_timer_lock
:
self
.
pending_match_id
=
target_match
.
id
self
.
current_fixture_id
=
fixture_id
self
.
last_completed_match_id
=
None
return
{
"fixture_id"
:
fixture_id
,
...
...
@@ -467,10 +506,12 @@ class MatchTimerComponent(ThreadedComponent):
def
_find_next_match_in_list
(
self
,
matches
:
list
)
->
Optional
[
Any
]:
"""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
for
status
in
[
'bet'
,
'scheduled'
,
'pending'
]:
for
match
in
matches
:
if
match
.
status
==
status
:
if
match
.
status
==
status
and
match
.
id
!=
last_completed_match_id
:
return
match
return
None
...
...
@@ -898,4 +939,4 @@ class MatchTimerComponent(ThreadedComponent):
self
.
message_bus
.
publish
(
update_message
,
broadcast
=
True
)
except
Exception
as
e
:
logger
.
error
(
f
"Failed to send timer update: {e}"
)
\ No newline at end of file
logger
.
error
(
f
"Failed to send timer update: {e}"
)
mbetterclient/core/rtsp_streamer.py
View file @
8ed954c2
...
...
@@ -1271,6 +1271,8 @@ class RTSPStreamer(ThreadedComponent):
def
_handle_play_video_result
(
self
,
message
:
Message
):
"""Handle PLAY_VIDEO_RESULT message"""
try
:
from
..database.models
import
MatchModel
logger
.
info
(
"Handling PLAY_VIDEO_RESULT message"
)
fixture_id
=
message
.
data
.
get
(
"fixture_id"
)
...
...
@@ -1286,10 +1288,28 @@ class RTSPStreamer(ThreadedComponent):
if
self
.
is_playing_match_video
:
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
({
'outcome'
:
result
,
'result'
:
result
,
'winningOutcomes'
:
[{
'outcome'
:
o
}
for
o
in
winning_outcomes
],
'winningOutcomes'
:
[{
'outcome'
:
o
}
for
o
in
filtered_
winning_outcomes
],
'match_id'
:
match_id
,
'fixture_id'
:
fixture_id
,
'is_result_video'
:
True
...
...
@@ -1582,4 +1602,4 @@ class RTSPStreamer(ThreadedComponent):
logger
.
error
(
f
"Result video not found for {self.current_result}"
)
except
Exception
as
e
:
logger
.
error
(
f
"Failed to play result after OVER/UNDER: {e}"
)
\ No newline at end of file
logger
.
error
(
f
"Failed to play result after OVER/UNDER: {e}"
)
mbetterclient/database/migrations.py
View file @
8ed954c2
...
...
@@ -7657,6 +7657,80 @@ class Migration_069_RepairExtractionStatsFinancials(DatabaseMigration):
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
]
=
[
Migration_001_InitialSchema
(),
Migration_002_AddIndexes
(),
...
...
@@ -7727,6 +7801,7 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_067_FixMatchResults
(),
Migration_068_ResetRedistributionBalance
(),
Migration_069_RepairExtractionStatsFinancials
(),
Migration_070_SeedSmtpEmailSettings
(),
]
...
...
mbetterclient/qt_player/player.py
View file @
8ed954c2
...
...
@@ -4916,11 +4916,10 @@ class QtVideoPlayer(QObject):
self
.
window
.
current_match_video_filename
is
not
None
)
if
currently_playing_match
:
# Stop the current match video and play result video immediately
logger
.
info
(
"Match video currently playing, stopping it and playing result video"
)
if
self
.
window
and
hasattr
(
self
.
window
,
'media_player'
):
self
.
window
.
stop_playback
()
self
.
_play_result_video
(
result_video_info
)
# Queue the result video and let the normal EndOfMedia path send PLAY_VIDEO_MATCH_DONE.
# Stopping playback here can bypass match completion flow and cause the same match to repeat.
logger
.
info
(
"Match video currently playing, queueing result video until match video completes"
)
self
.
queued_result_video
=
result_video_info
else
:
# Play result video immediately
logger
.
info
(
"No match video playing, playing result video immediately"
)
...
...
mbetterclient/web_dashboard/app.py
View file @
8ed954c2
...
...
@@ -4,6 +4,7 @@ Flask web dashboard application for MbetterClient
import
time
import
logging
from
datetime
import
datetime
,
date
,
timedelta
,
timezone
from
pathlib
import
Path
from
typing
import
Optional
,
Dict
,
Any
,
List
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
from
flask_cors
import
CORS
from
.auth
import
AuthManager
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
.billing_api
import
BillingAPI
,
init_billing_routes
from
..billing.billing_engine
import
BillingEngine
from
..database.models
import
ConfigurationModel
from
..utils.timezone_utils
import
utc_to_venue_datetime
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
):
"""Flask web dashboard component"""
...
...
@@ -68,6 +82,9 @@ class WebDashboard(ThreadedComponent):
self
.
notification_lock
=
threading
.
Lock
()
self
.
waiting_clients
:
List
[
threading
.
Event
]
=
[]
# Events for waiting long-poll clients
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
self
.
message_queue
=
self
.
message_bus
.
register_component
(
self
.
name
)
...
...
@@ -455,6 +472,13 @@ class WebDashboard(ThreadedComponent):
daemon
=
True
)
server_thread
.
start
()
report_thread
=
threading
.
Thread
(
target
=
self
.
_run_daily_report_email_loop
,
name
=
"DailyReportEmail"
,
daemon
=
True
)
report_thread
.
start
()
# Message processing loop
while
self
.
running
and
not
self
.
shutdown_event
.
is_set
():
...
...
@@ -476,6 +500,8 @@ class WebDashboard(ThreadedComponent):
# Wait for server thread to finish (with timeout since it's daemon)
if
server_thread
and
server_thread
.
is_alive
():
server_thread
.
join
(
timeout
=
2.0
)
if
report_thread
and
report_thread
.
is_alive
():
report_thread
.
join
(
timeout
=
2.0
)
except
Exception
as
e
:
logger
.
error
(
f
"WebDashboard run failed: {e}"
)
...
...
@@ -505,6 +531,119 @@ class WebDashboard(ThreadedComponent):
else
:
# Expected during shutdown
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
):
"""Setup logging filter to suppress expected SSL connection errors"""
...
...
mbetterclient/web_dashboard/routes.py
View file @
8ed954c2
This diff is collapsed.
Click to expand it.
test_reports_daily_alignment.py
View file @
8ed954c2
...
...
@@ -11,8 +11,15 @@ from openpyxl import load_workbook
sys
.
path
.
insert
(
0
,
os
.
path
.
dirname
(
os
.
path
.
abspath
(
__file__
)))
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.web_dashboard.app
import
(
WebDashboard
,
DAILY_REPORT_RECIPIENT
,
DAILY_REPORT_LAST_SUCCESS_CONFIG_KEY
,
)
from
mbetterclient.core.match_timer
import
MatchTimerComponent
class
DummyDetail
:
...
...
@@ -460,3 +467,145 @@ def test_qt_overlay_winning_outcomes_return_empty_without_settled_wins():
overlay
=
OverlayWebChannel
(
db_manager
=
DummyDbManager
(
OverlaySessionStub
()))
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
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment