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
2bcbdd45
Commit
2bcbdd45
authored
May 09, 2026
by
Your Name
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: align reports and repair historical stats
parent
e9e7b98b
Changes
4
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
726 additions
and
7 deletions
+726
-7
migrations.py
mbetterclient/database/migrations.py
+118
-1
routes.py
mbetterclient/web_dashboard/routes.py
+19
-6
test_reports_daily_alignment.py
test_reports_daily_alignment.py
+318
-0
test_reports_stats_repair_migration.py
test_reports_stats_repair_migration.py
+271
-0
No files found.
mbetterclient/database/migrations.py
View file @
2bcbdd45
...
...
@@ -7541,6 +7541,122 @@ class Migration_068_ResetRedistributionBalance(DatabaseMigration):
return
True
class
Migration_069_RepairExtractionStatsFinancials
(
DatabaseMigration
):
"""Repair historical extraction stats financial fields from authoritative bet details"""
def
__init__
(
self
):
super
()
.
__init__
(
"069"
,
"Repair extraction stats financial fields"
)
def
_snapshot_financial_fields
(
self
,
stat_row
):
return
{
'total_bets'
:
getattr
(
stat_row
,
'total_bets'
,
0
),
'total_amount_collected'
:
getattr
(
stat_row
,
'total_amount_collected'
,
0.0
),
'total_redistributed'
:
getattr
(
stat_row
,
'total_redistributed'
,
0.0
),
'under_bets'
:
getattr
(
stat_row
,
'under_bets'
,
0
),
'under_amount'
:
getattr
(
stat_row
,
'under_amount'
,
0.0
),
'over_bets'
:
getattr
(
stat_row
,
'over_bets'
,
0
),
'over_amount'
:
getattr
(
stat_row
,
'over_amount'
,
0.0
),
'result_breakdown'
:
getattr
(
stat_row
,
'result_breakdown'
,
None
),
}
def
_build_delta_log
(
self
,
match_id
,
before_values
,
after_values
):
changes
=
[]
for
field_name
,
before_value
in
before_values
.
items
():
after_value
=
after_values
[
field_name
]
if
before_value
!=
after_value
:
changes
.
append
(
f
"{field_name}: {before_value} -> {after_value}"
)
if
not
changes
:
return
None
return
f
"Repaired extraction stats row for match_id={match_id}: "
+
", "
.
join
(
changes
)
def
_rebuild_result_breakdown
(
self
,
stat_row
,
active_details
):
breakdown
=
{}
for
detail
in
active_details
:
outcome
=
getattr
(
detail
,
'outcome'
,
None
)
if
not
outcome
:
continue
if
outcome
not
in
breakdown
:
breakdown
[
outcome
]
=
{
'bets'
:
0
,
'amount'
:
0.0
}
breakdown
[
outcome
][
'bets'
]
+=
1
breakdown
[
outcome
][
'amount'
]
+=
float
(
getattr
(
detail
,
'amount'
,
0
)
or
0
)
stat_row
.
result_breakdown
=
breakdown
def
_recompute_stat_row
(
self
,
stat_row
,
detail_rows
):
active_details
=
[
detail
for
detail
in
detail_rows
if
getattr
(
detail
,
'result'
,
None
)
!=
'cancelled'
]
stat_row
.
total_bets
=
len
(
active_details
)
stat_row
.
total_amount_collected
=
sum
(
float
(
getattr
(
detail
,
'amount'
,
0
)
or
0
)
for
detail
in
active_details
)
stat_row
.
total_redistributed
=
sum
(
float
(
getattr
(
detail
,
'win_amount'
,
0
)
or
0
)
for
detail
in
active_details
if
getattr
(
detail
,
'result'
,
None
)
==
'win'
)
stat_row
.
under_bets
=
sum
(
1
for
detail
in
active_details
if
getattr
(
detail
,
'outcome'
,
None
)
==
'UNDER'
)
stat_row
.
under_amount
=
sum
(
float
(
getattr
(
detail
,
'amount'
,
0
)
or
0
)
for
detail
in
active_details
if
getattr
(
detail
,
'outcome'
,
None
)
==
'UNDER'
)
stat_row
.
over_bets
=
sum
(
1
for
detail
in
active_details
if
getattr
(
detail
,
'outcome'
,
None
)
==
'OVER'
)
stat_row
.
over_amount
=
sum
(
float
(
getattr
(
detail
,
'amount'
,
0
)
or
0
)
for
detail
in
active_details
if
getattr
(
detail
,
'outcome'
,
None
)
==
'OVER'
)
self
.
_rebuild_result_breakdown
(
stat_row
,
active_details
)
return
stat_row
def
up
(
self
,
db_manager
)
->
bool
:
session
=
None
try
:
from
.models
import
ExtractionStatsModel
,
BetDetailModel
session
=
db_manager
.
get_session
()
stats_rows
=
session
.
query
(
ExtractionStatsModel
)
.
all
()
modified_rows
=
0
match_counts
=
{}
for
stat_row
in
stats_rows
:
match_counts
[
stat_row
.
match_id
]
=
match_counts
.
get
(
stat_row
.
match_id
,
0
)
+
1
for
match_id
,
row_count
in
match_counts
.
items
():
if
row_count
>
1
:
logger
.
warning
(
f
"Duplicate extraction stats rows detected for match_id {match_id}: {row_count} rows"
)
for
stat_row
in
stats_rows
:
detail_rows
=
session
.
query
(
BetDetailModel
)
.
filter_by
(
match_id
=
stat_row
.
match_id
)
.
all
()
before_values
=
self
.
_snapshot_financial_fields
(
stat_row
)
self
.
_recompute_stat_row
(
stat_row
,
detail_rows
)
after_values
=
self
.
_snapshot_financial_fields
(
stat_row
)
delta_log
=
self
.
_build_delta_log
(
stat_row
.
match_id
,
before_values
,
after_values
)
if
delta_log
:
modified_rows
+=
1
logger
.
info
(
delta_log
)
session
.
commit
()
logger
.
info
(
f
"Modified {modified_rows} extraction stats rows out of {len(stats_rows)} processed"
)
return
True
except
Exception
as
e
:
logger
.
error
(
f
"Failed to repair extraction stats financial fields: {e}"
)
if
session
:
session
.
rollback
()
return
False
finally
:
if
session
:
session
.
close
()
def
down
(
self
,
db_manager
)
->
bool
:
"""Cannot rollback this migration because prior incorrect values are not preserved"""
logger
.
warning
(
"Cannot rollback Migration_069_RepairExtractionStatsFinancials - original financial values not preserved"
)
return
True
MIGRATIONS
:
List
[
DatabaseMigration
]
=
[
Migration_001_InitialSchema
(),
Migration_002_AddIndexes
(),
...
...
@@ -7610,6 +7726,7 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_066_AddWalletTables
(),
Migration_067_FixMatchResults
(),
Migration_068_ResetRedistributionBalance
(),
Migration_069_RepairExtractionStatsFinancials
(),
]
...
...
@@ -7821,4 +7938,4 @@ def get_migration_status(db_manager) -> Dict[str, Any]:
'total_migrations'
:
0
,
'applied_migrations'
:
0
,
'pending_migrations'
:
0
}
\ No newline at end of file
}
mbetterclient/web_dashboard/routes.py
View file @
2bcbdd45
...
...
@@ -7944,6 +7944,8 @@ def get_match_reports():
'payout'
:
0.0
}
match
=
session
.
query
(
MatchModel
)
.
filter_by
(
id
=
match_id
)
.
first
()
match_stats
[
match_id
][
'bets_count'
]
+=
1
match_stats
[
match_id
][
'payin'
]
+=
float
(
detail
.
amount
)
...
...
@@ -8176,11 +8178,22 @@ def download_excel_report():
total_payin
+=
bet_total
total_bets
+=
len
(
bet_details
)
extraction_stats
=
session
.
query
(
ExtractionStatsModel
)
.
filter
(
ExtractionStatsModel
.
match_datetime
>=
start_datetime
,
ExtractionStatsModel
.
match_datetime
<=
end_datetime
)
.
all
()
total_payout
=
sum
(
float
(
stat
.
total_redistributed
)
for
stat
in
extraction_stats
)
bet_details_query
=
session
.
query
(
BetDetailModel
)
.
join
(
BetModel
,
BetDetailModel
.
bet_id
==
BetModel
.
uuid
)
.
filter
(
BetModel
.
bet_datetime
>=
start_datetime
,
BetModel
.
bet_datetime
<=
end_datetime
,
BetDetailModel
.
result
!=
'cancelled'
)
bet_details
=
bet_details_query
.
all
()
total_payout
=
0.0
for
detail
in
bet_details
:
match
=
session
.
query
(
MatchModel
)
.
filter_by
(
id
=
detail
.
match_id
)
.
first
()
if
is_bet_detail_winning
(
detail
,
match
,
session
):
total_payout
+=
float
(
detail
.
win_amount
or
0
)
unique_match_ids
=
{
detail
.
match_id
for
detail
in
bet_details
}
# Summary headers
ws_summary
[
'A3'
]
=
"Metric"
...
...
@@ -8196,7 +8209,7 @@ def download_excel_report():
(
"Total Payout"
,
f
"{total_payout:.2f}"
),
(
"Net Profit"
,
f
"{total_payin - total_payout:.2f}"
),
(
"Total Bets"
,
str
(
total_bets
)),
(
"Total Matches"
,
str
(
len
(
extraction_stat
s
)))
(
"Total Matches"
,
str
(
len
(
unique_match_id
s
)))
]
for
i
,
(
metric
,
value
)
in
enumerate
(
summary_data
,
4
):
...
...
test_reports_daily_alignment.py
0 → 100644
View file @
2bcbdd45
import
os
import
sys
from
datetime
import
datetime
from
io
import
BytesIO
from
unittest.mock
import
Mock
import
pytest
from
flask
import
Flask
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
class
DummyDetail
:
def
__init__
(
self
,
match_id
,
amount
,
win_amount
,
result
,
outcome
=
'WIN1'
,
bet_id
=
'bet1'
):
self
.
match_id
=
match_id
self
.
amount
=
amount
self
.
win_amount
=
win_amount
self
.
result
=
result
self
.
outcome
=
outcome
self
.
bet_id
=
bet_id
class
DummyBet
:
def
__init__
(
self
,
uuid
,
bet_datetime
):
self
.
uuid
=
uuid
self
.
bet_datetime
=
bet_datetime
class
DummyMatch
:
def
__init__
(
self
,
match_id
,
match_number
=
1
):
self
.
id
=
match_id
self
.
match_number
=
match_number
self
.
fighter1_township
=
'A'
self
.
fighter2_township
=
'B'
self
.
venue_kampala_township
=
'V'
self
.
status
=
'completed'
self
.
winning_outcomes
=
[
'WIN1'
]
self
.
result
=
'WIN1'
class
QueryStub
:
def
__init__
(
self
,
rows
,
joins
=
None
):
self
.
rows
=
rows
self
.
joins
=
joins
or
{}
def
join
(
self
,
*
args
,
**
kwargs
):
for
join_target
in
args
:
model_name
=
getattr
(
join_target
,
'__name__'
,
None
)
if
model_name
and
model_name
not
in
self
.
joins
:
self
.
joins
[
model_name
]
=
[]
return
self
def
filter
(
self
,
*
args
,
**
kwargs
):
filtered
=
self
.
rows
for
predicate
in
args
:
filtered
=
[
row
for
row
in
filtered
if
self
.
_matches_predicate
(
row
,
predicate
)]
return
QueryStub
(
filtered
,
joins
=
self
.
joins
)
def
filter_by
(
self
,
**
kwargs
):
if
not
kwargs
:
return
self
filtered
=
[
row
for
row
in
self
.
rows
if
all
(
getattr
(
row
,
key
,
None
)
==
value
for
key
,
value
in
kwargs
.
items
())
]
return
QueryStub
(
filtered
,
joins
=
self
.
joins
)
def
order_by
(
self
,
*
args
,
**
kwargs
):
return
self
def
all
(
self
):
return
self
.
rows
def
first
(
self
):
return
self
.
rows
[
0
]
if
self
.
rows
else
None
def
_matches_predicate
(
self
,
row
,
predicate
):
if
not
isinstance
(
predicate
,
tuple
)
or
len
(
predicate
)
!=
3
:
return
True
operator
,
field_name
,
expected
=
predicate
actual
=
self
.
_resolve_field_value
(
row
,
field_name
)
if
operator
==
'>='
:
return
actual
is
not
None
and
actual
>=
expected
if
operator
==
'<='
:
return
actual
is
not
None
and
actual
<=
expected
if
operator
==
'=='
:
return
actual
==
expected
if
operator
==
'!='
:
return
actual
!=
expected
return
True
def
_resolve_field_value
(
self
,
row
,
field_name
):
direct_value
=
getattr
(
row
,
field_name
,
None
)
if
direct_value
is
not
None
:
return
direct_value
if
field_name
==
'bet_datetime'
:
bets
=
self
.
joins
.
get
(
'BetModel'
,
[])
bet_id
=
getattr
(
row
,
'bet_id'
,
None
)
if
bet_id
is
not
None
:
for
bet
in
bets
:
if
getattr
(
bet
,
'uuid'
,
None
)
==
bet_id
:
return
getattr
(
bet
,
'bet_datetime'
,
None
)
return
None
class
DummySession
:
def
__init__
(
self
,
bet_details
,
bets
,
matches
,
extraction_stats
=
None
):
self
.
bet_details
=
list
(
bet_details
)
self
.
bets
=
list
(
bets
)
self
.
matches
=
list
(
matches
)
self
.
extraction_stats
=
list
(
extraction_stats
or
[])
def
query
(
self
,
model
):
model_name
=
getattr
(
model
,
'__name__'
,
str
(
model
))
if
model_name
==
'BetDetailModel'
:
return
QueryStub
(
self
.
bet_details
,
joins
=
{
'BetModel'
:
self
.
bets
})
if
model_name
==
'BetModel'
:
return
QueryStub
(
self
.
bets
)
if
model_name
==
'MatchModel'
:
return
QueryStub
(
self
.
matches
)
if
model_name
==
'ExtractionStatsModel'
:
return
QueryStub
(
self
.
extraction_stats
)
return
QueryStub
([])
def
close
(
self
):
return
None
class
DummyDbManager
:
def
__init__
(
self
,
session
):
self
.
_session
=
session
def
get_session
(
self
):
return
self
.
_session
class
DummyLoginManager
:
def
_load_user
(
self
):
return
None
@
property
def
unauthorized
(
self
):
return
lambda
:
(
'Unauthorized'
,
401
)
class
DummyModelColumn
:
def
__init__
(
self
,
name
):
self
.
name
=
name
def
__ge__
(
self
,
other
):
return
(
'>='
,
self
.
name
,
other
)
def
__le__
(
self
,
other
):
return
(
'<='
,
self
.
name
,
other
)
def
__eq__
(
self
,
other
):
return
(
'=='
,
self
.
name
,
other
)
def
__ne__
(
self
,
other
):
return
(
'!='
,
self
.
name
,
other
)
def
_dummy_model
(
name
,
**
columns
):
attrs
=
{
'__name__'
:
name
}
attrs
.
update
(
columns
)
return
type
(
name
,
(),
attrs
)
DummyBetModel
=
_dummy_model
(
'BetModel'
,
uuid
=
DummyModelColumn
(
'uuid'
),
bet_datetime
=
DummyModelColumn
(
'bet_datetime'
),
)
DummyBetDetailModel
=
_dummy_model
(
'BetDetailModel'
,
bet_id
=
DummyModelColumn
(
'bet_id'
),
match_id
=
DummyModelColumn
(
'match_id'
),
result
=
DummyModelColumn
(
'result'
),
)
DummyMatchModel
=
_dummy_model
(
'MatchModel'
,
id
=
DummyModelColumn
(
'id'
))
DummyExtractionStatsModel
=
_dummy_model
(
'ExtractionStatsModel'
,
match_datetime
=
DummyModelColumn
(
'match_datetime'
),
)
@
pytest
.
fixture
def
isolated_api_blueprint
():
original_db_manager
=
api_bp
.
db_manager
original_auth_manager
=
api_bp
.
auth_manager
try
:
yield
api_bp
finally
:
api_bp
.
db_manager
=
original_db_manager
api_bp
.
auth_manager
=
original_auth_manager
def
make_test_client
(
session
,
blueprint
):
app
=
Flask
(
__name__
)
app
.
config
[
'TESTING'
]
=
True
app
.
secret_key
=
'test-secret'
app
.
login_manager
=
DummyLoginManager
()
app
.
register_blueprint
(
blueprint
,
url_prefix
=
'/api'
)
blueprint
.
db_manager
=
DummyDbManager
(
session
)
blueprint
.
auth_manager
=
None
return
app
.
test_client
()
def
install_route_test_doubles
(
monkeypatch
):
monkeypatch
.
setattr
(
routes_module
,
'get_venue_timezone'
,
lambda
db_manager
:
None
)
monkeypatch
.
setattr
(
routes_module
,
'venue_to_utc_datetime'
,
lambda
value
,
db_manager
:
value
)
monkeypatch
.
setattr
(
routes_module
,
'login_required'
,
lambda
func
:
func
)
import
sys
as
_sys
models_module
=
type
(
_sys
)(
'mbetterclient.database.models'
)
models_module
.
BetModel
=
DummyBetModel
models_module
.
BetDetailModel
=
DummyBetDetailModel
models_module
.
MatchModel
=
DummyMatchModel
models_module
.
ExtractionStatsModel
=
DummyExtractionStatsModel
monkeypatch
.
setitem
(
_sys
.
modules
,
'mbetterclient.database.models'
,
models_module
)
def
build_sample_excel_bytes
(
monkeypatch
,
blueprint
):
install_route_test_doubles
(
monkeypatch
)
detail
=
DummyDetail
(
match_id
=
10
,
amount
=
100.0
,
win_amount
=
250.0
,
result
=
'lost'
)
cancelled_detail
=
DummyDetail
(
match_id
=
10
,
amount
=
70.0
,
win_amount
=
0.0
,
result
=
'cancelled'
,
bet_id
=
'bet-cancelled'
)
out_of_window_detail
=
DummyDetail
(
match_id
=
99
,
amount
=
40.0
,
win_amount
=
500.0
,
result
=
'lost'
,
bet_id
=
'bet-outside'
)
bet
=
DummyBet
(
uuid
=
'bet1'
,
bet_datetime
=
datetime
(
2026
,
5
,
1
,
12
,
0
,
0
))
cancelled_bet
=
DummyBet
(
uuid
=
'bet-cancelled'
,
bet_datetime
=
datetime
(
2026
,
5
,
1
,
13
,
0
,
0
))
out_of_window_bet
=
DummyBet
(
uuid
=
'bet-outside'
,
bet_datetime
=
datetime
(
2026
,
5
,
2
,
12
,
0
,
0
))
match
=
DummyMatch
(
match_id
=
10
)
outside_match
=
DummyMatch
(
match_id
=
99
,
match_number
=
2
)
extraction_stat
=
type
(
'ExtractionStat'
,
(),
{
'match_datetime'
:
datetime
(
2026
,
5
,
1
,
15
,
0
,
0
),
'total_redistributed'
:
999.0
})()
session
=
DummySession
(
[
detail
,
cancelled_detail
,
out_of_window_detail
],
[
bet
,
cancelled_bet
,
out_of_window_bet
],
[
match
,
outside_match
],
[
extraction_stat
],
)
client
=
make_test_client
(
session
,
blueprint
)
response
=
client
.
get
(
'/api/reports/download-excel?date=2026-05-01'
)
assert
response
.
status_code
==
200
return
response
.
data
def
test_match_report_payout_uses_loaded_match
():
detail
=
DummyDetail
(
match_id
=
10
,
amount
=
100.0
,
win_amount
=
250.0
,
result
=
'lost'
)
match
=
DummyMatch
(
match_id
=
10
)
session
=
Mock
()
assert
is_bet_detail_winning
(
detail
,
match
,
session
)
is
True
def
test_match_reports_route_counts_winning_payout_after_loading_match
(
monkeypatch
,
isolated_api_blueprint
):
install_route_test_doubles
(
monkeypatch
)
match
=
DummyMatch
(
match_id
=
10
)
detail
=
DummyDetail
(
match_id
=
10
,
amount
=
100.0
,
win_amount
=
250.0
,
result
=
'lost'
)
cancelled_detail
=
DummyDetail
(
match_id
=
10
,
amount
=
15.0
,
win_amount
=
0.0
,
result
=
'cancelled'
,
bet_id
=
'bet-cancelled'
)
out_of_window_detail
=
DummyDetail
(
match_id
=
99
,
amount
=
50.0
,
win_amount
=
500.0
,
result
=
'lost'
,
bet_id
=
'bet-outside'
)
bet
=
DummyBet
(
uuid
=
'bet1'
,
bet_datetime
=
datetime
(
2026
,
5
,
1
,
12
,
0
,
0
))
cancelled_bet
=
DummyBet
(
uuid
=
'bet-cancelled'
,
bet_datetime
=
datetime
(
2026
,
5
,
1
,
13
,
0
,
0
))
out_of_window_bet
=
DummyBet
(
uuid
=
'bet-outside'
,
bet_datetime
=
datetime
(
2026
,
5
,
2
,
12
,
0
,
0
))
outside_match
=
DummyMatch
(
match_id
=
99
,
match_number
=
2
)
client
=
make_test_client
(
DummySession
(
[
detail
,
cancelled_detail
,
out_of_window_detail
],
[
bet
,
cancelled_bet
,
out_of_window_bet
],
[
match
,
outside_match
],
),
isolated_api_blueprint
,
)
response
=
client
.
get
(
'/api/reports/match-reports?date=2026-05-01'
)
assert
response
.
status_code
==
200
payload
=
response
.
get_json
()
assert
payload
[
'total'
]
==
1
assert
payload
[
'matches'
][
0
][
'payin'
]
==
100.0
assert
payload
[
'matches'
][
0
][
'payout'
]
==
250.0
assert
payload
[
'matches'
][
0
][
'net'
]
==
-
150.0
def
test_excel_summary_uses_same_payout_basis_as_daily_summary
(
monkeypatch
,
isolated_api_blueprint
):
workbook
=
load_workbook
(
BytesIO
(
build_sample_excel_bytes
(
monkeypatch
,
isolated_api_blueprint
)))
sheet
=
workbook
[
'Daily Summary'
]
assert
sheet
[
'B4'
]
.
value
==
'100.00'
assert
sheet
[
'B5'
]
.
value
==
'250.00'
assert
sheet
[
'B6'
]
.
value
==
'-150.00'
assert
sheet
[
'B7'
]
.
value
==
'1'
assert
sheet
[
'B8'
]
.
value
==
'1'
def
test_excel_download_returns_xlsx_response
(
monkeypatch
,
isolated_api_blueprint
):
install_route_test_doubles
(
monkeypatch
)
detail
=
DummyDetail
(
match_id
=
10
,
amount
=
100.0
,
win_amount
=
250.0
,
result
=
'lost'
)
out_of_window_detail
=
DummyDetail
(
match_id
=
99
,
amount
=
40.0
,
win_amount
=
500.0
,
result
=
'lost'
,
bet_id
=
'bet-outside'
)
bet
=
DummyBet
(
uuid
=
'bet1'
,
bet_datetime
=
datetime
(
2026
,
5
,
1
,
12
,
0
,
0
))
out_of_window_bet
=
DummyBet
(
uuid
=
'bet-outside'
,
bet_datetime
=
datetime
(
2026
,
5
,
2
,
12
,
0
,
0
))
match
=
DummyMatch
(
match_id
=
10
)
outside_match
=
DummyMatch
(
match_id
=
99
,
match_number
=
2
)
client
=
make_test_client
(
DummySession
([
detail
,
out_of_window_detail
],
[
bet
,
out_of_window_bet
],
[
match
,
outside_match
]),
isolated_api_blueprint
,
)
response
=
client
.
get
(
'/api/reports/download-excel?date=2026-05-01'
)
assert
response
.
status_code
==
200
assert
response
.
mimetype
==
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
test_reports_stats_repair_migration.py
0 → 100644
View file @
2bcbdd45
import
os
import
sys
from
datetime
import
datetime
from
types
import
SimpleNamespace
from
sqlalchemy
import
create_engine
from
sqlalchemy.orm
import
sessionmaker
sys
.
path
.
insert
(
0
,
os
.
path
.
dirname
(
os
.
path
.
abspath
(
__file__
)))
from
mbetterclient.database.migrations
import
Migration_069_RepairExtractionStatsFinancials
from
mbetterclient.database.models
import
Base
,
BetDetailModel
,
ExtractionStatsModel
,
MatchModel
class
StatRow
:
def
__init__
(
self
):
self
.
match_id
=
10
self
.
total_bets
=
99
self
.
total_amount_collected
=
9999.0
self
.
total_redistributed
=
8888.0
self
.
under_bets
=
9
self
.
under_amount
=
999.0
self
.
over_bets
=
8
self
.
over_amount
=
888.0
self
.
actual_result
=
'WIN1'
self
.
extraction_result
=
'WIN1'
self
.
match_datetime
=
'keep-me'
self
.
result_breakdown
=
{
'WIN1'
:
{
'bets'
:
2
,
'amount'
:
50.0
}}
class
DetailRow
:
def
__init__
(
self
,
outcome
,
amount
,
win_amount
,
result
):
self
.
outcome
=
outcome
self
.
amount
=
amount
self
.
win_amount
=
win_amount
self
.
result
=
result
class
SessionStub
:
def
__init__
(
self
,
stats_rows
,
detail_rows_by_match_id
):
self
.
stats_rows
=
list
(
stats_rows
)
self
.
detail_rows_by_match_id
=
dict
(
detail_rows_by_match_id
)
self
.
committed
=
False
self
.
rolled_back
=
False
self
.
closed
=
False
def
query
(
self
,
model
):
model_name
=
getattr
(
model
,
'__name__'
,
str
(
model
))
if
model_name
==
'ExtractionStatsModel'
:
return
QueryStub
(
self
.
stats_rows
)
if
model_name
==
'BetDetailModel'
:
return
DetailQueryStub
(
self
.
detail_rows_by_match_id
)
raise
AssertionError
(
f
'unexpected model: {model_name}'
)
def
commit
(
self
):
self
.
committed
=
True
def
rollback
(
self
):
self
.
rolled_back
=
True
def
close
(
self
):
self
.
closed
=
True
class
QueryStub
:
def
__init__
(
self
,
rows
):
self
.
rows
=
rows
def
all
(
self
):
return
list
(
self
.
rows
)
class
DetailQueryStub
:
def
__init__
(
self
,
detail_rows_by_match_id
):
self
.
detail_rows_by_match_id
=
detail_rows_by_match_id
def
filter_by
(
self
,
**
kwargs
):
match_id
=
kwargs
[
'match_id'
]
return
QueryStub
(
self
.
detail_rows_by_match_id
.
get
(
match_id
,
[]))
class
DummyDbManager
:
def
__init__
(
self
,
session
):
self
.
_session
=
session
def
get_session
(
self
):
return
self
.
_session
def
test_repair_migration_recomputes_financial_fields_from_bet_details
():
migration
=
Migration_069_RepairExtractionStatsFinancials
()
stat
=
StatRow
()
details
=
[
DetailRow
(
'UNDER'
,
100.0
,
0.0
,
'lost'
),
DetailRow
(
'WIN1'
,
50.0
,
125.0
,
'win'
),
DetailRow
(
'OVER'
,
75.0
,
0.0
,
'cancelled'
),
]
repaired
=
migration
.
_recompute_stat_row
(
stat
,
details
)
assert
repaired
.
total_bets
==
2
assert
repaired
.
total_amount_collected
==
150.0
assert
repaired
.
total_redistributed
==
125.0
assert
repaired
.
under_bets
==
1
assert
repaired
.
under_amount
==
100.0
assert
repaired
.
over_bets
==
0
assert
repaired
.
over_amount
==
0.0
assert
repaired
.
actual_result
==
'WIN1'
assert
repaired
.
extraction_result
==
'WIN1'
assert
repaired
.
match_datetime
==
'keep-me'
assert
repaired
.
result_breakdown
==
{
'UNDER'
:
{
'bets'
:
1
,
'amount'
:
100.0
},
'WIN1'
:
{
'bets'
:
1
,
'amount'
:
50.0
},
}
def
test_repair_migration_ignores_unsupported_non_enum_result_values
():
migration
=
Migration_069_RepairExtractionStatsFinancials
()
stat
=
StatRow
()
details
=
[
DetailRow
(
'WIN1'
,
50.0
,
125.0
,
'won'
),
]
repaired
=
migration
.
_recompute_stat_row
(
stat
,
details
)
assert
repaired
.
total_bets
==
1
assert
repaired
.
total_amount_collected
==
50.0
assert
repaired
.
total_redistributed
==
0.0
assert
repaired
.
result_breakdown
==
{
'WIN1'
:
{
'bets'
:
1
,
'amount'
:
50.0
},
}
def
test_repair_migration_excludes_cancelled_bets_from_all_totals
():
migration
=
Migration_069_RepairExtractionStatsFinancials
()
stat
=
StatRow
()
details
=
[
DetailRow
(
'OVER'
,
40.0
,
500.0
,
'cancelled'
),
]
repaired
=
migration
.
_recompute_stat_row
(
stat
,
details
)
assert
repaired
.
total_bets
==
0
assert
repaired
.
total_amount_collected
==
0.0
assert
repaired
.
total_redistributed
==
0.0
assert
repaired
.
over_bets
==
0
assert
repaired
.
over_amount
==
0.0
assert
repaired
.
result_breakdown
==
{}
def
test_repair_migration_updates_rows_logs_duplicates_and_change_deltas
(
monkeypatch
):
migration
=
Migration_069_RepairExtractionStatsFinancials
()
stat_one
=
StatRow
()
stat_one
.
match_id
=
10
stat_two
=
StatRow
()
stat_two
.
match_id
=
10
stat_three
=
StatRow
()
stat_three
.
match_id
=
11
session
=
SessionStub
(
[
stat_one
,
stat_two
,
stat_three
],
{
10
:
[
DetailRow
(
'UNDER'
,
25.0
,
0.0
,
'lost'
)],
11
:
[
DetailRow
(
'OVER'
,
40.0
,
120.0
,
'win'
)],
},
)
models_module
=
SimpleNamespace
(
ExtractionStatsModel
=
type
(
'ExtractionStatsModel'
,
(),
{}),
BetDetailModel
=
type
(
'BetDetailModel'
,
(),
{}),
)
monkeypatch
.
setitem
(
sys
.
modules
,
'mbetterclient.database.models'
,
models_module
)
info_logs
=
[]
warning_logs
=
[]
monkeypatch
.
setattr
(
'mbetterclient.database.migrations.logger.info'
,
lambda
message
:
info_logs
.
append
(
message
))
monkeypatch
.
setattr
(
'mbetterclient.database.migrations.logger.warning'
,
lambda
message
:
warning_logs
.
append
(
message
))
success
=
migration
.
up
(
DummyDbManager
(
session
))
assert
success
is
True
assert
stat_one
.
total_bets
==
1
assert
stat_one
.
total_amount_collected
==
25.0
assert
stat_one
.
total_redistributed
==
0.0
assert
stat_two
.
total_bets
==
1
assert
stat_two
.
total_amount_collected
==
25.0
assert
stat_two
.
total_redistributed
==
0.0
assert
stat_three
.
total_bets
==
1
assert
stat_three
.
total_amount_collected
==
40.0
assert
stat_three
.
total_redistributed
==
120.0
assert
stat_three
.
result_breakdown
==
{
'OVER'
:
{
'bets'
:
1
,
'amount'
:
40.0
}}
assert
session
.
committed
is
True
assert
session
.
rolled_back
is
False
assert
session
.
closed
is
True
assert
any
(
'Duplicate extraction stats rows detected for match_id 10'
in
message
for
message
in
warning_logs
)
assert
any
(
'Modified 3 extraction stats rows'
in
message
for
message
in
info_logs
)
assert
any
(
'match_id=10'
in
message
and
'total_bets: 99 -> 1'
in
message
for
message
in
info_logs
)
assert
any
(
'match_id=10'
in
message
and
'result_breakdown:'
in
message
for
message
in
info_logs
)
assert
any
(
'match_id=11'
in
message
and
'total_redistributed: 8888.0 -> 120.0'
in
message
for
message
in
info_logs
)
def
test_repair_migration_runs_against_real_sqlalchemy_models
(
monkeypatch
):
engine
=
create_engine
(
'sqlite:///:memory:'
)
Base
.
metadata
.
create_all
(
engine
)
Session
=
sessionmaker
(
bind
=
engine
)
session
=
Session
()
match
=
MatchModel
(
id
=
77
,
match_number
=
1
,
fighter1_township
=
'Town A'
,
fighter2_township
=
'Town B'
,
venue_kampala_township
=
'Kampala'
,
filename
=
'fixture.json'
,
file_sha1sum
=
'sha1-fixture'
,
fixture_id
=
'fixture-77'
,
start_time
=
datetime
(
2026
,
5
,
1
,
12
,
0
,
0
),
status
=
'done'
,
result
=
'WIN1'
,
winning_outcomes
=
[
'WIN1'
],
active_status
=
False
,
)
stat
=
ExtractionStatsModel
(
match_id
=
77
,
fixture_id
=
'fixture-77'
,
match_datetime
=
datetime
(
2026
,
5
,
1
,
12
,
0
,
0
),
total_bets
=
9
,
total_amount_collected
=
900.0
,
total_redistributed
=
800.0
,
actual_result
=
'WIN1'
,
result_breakdown
=
'{"LEGACY":{"bets":9,"amount":900.0,"coefficient":2.0}}'
,
under_bets
=
4
,
under_amount
=
400.0
,
over_bets
=
5
,
over_amount
=
500.0
,
extraction_result
=
'WIN1'
,
cap_applied
=
False
,
)
detail_win
=
BetDetailModel
(
match_id
=
77
,
bet_id
=
'bet-1'
,
outcome
=
'WIN1'
,
amount
=
50.0
,
win_amount
=
125.0
,
result
=
'win'
)
detail_under
=
BetDetailModel
(
match_id
=
77
,
bet_id
=
'bet-2'
,
outcome
=
'UNDER'
,
amount
=
25.0
,
win_amount
=
0.0
,
result
=
'lost'
)
detail_cancelled
=
BetDetailModel
(
match_id
=
77
,
bet_id
=
'bet-3'
,
outcome
=
'OVER'
,
amount
=
100.0
,
win_amount
=
999.0
,
result
=
'cancelled'
)
session
.
add_all
([
match
,
stat
,
detail_win
,
detail_under
,
detail_cancelled
])
session
.
commit
()
info_logs
=
[]
warning_logs
=
[]
monkeypatch
.
setattr
(
'mbetterclient.database.migrations.logger.info'
,
lambda
message
:
info_logs
.
append
(
message
))
monkeypatch
.
setattr
(
'mbetterclient.database.migrations.logger.warning'
,
lambda
message
:
warning_logs
.
append
(
message
))
migration
=
Migration_069_RepairExtractionStatsFinancials
()
success
=
migration
.
up
(
DummyDbManager
(
session
))
session
.
expire_all
()
repaired
=
session
.
query
(
ExtractionStatsModel
)
.
filter_by
(
match_id
=
77
)
.
one
()
assert
success
is
True
assert
repaired
.
total_bets
==
2
assert
repaired
.
total_amount_collected
==
75.0
assert
repaired
.
total_redistributed
==
125.0
assert
repaired
.
under_bets
==
1
assert
repaired
.
under_amount
==
25.0
assert
repaired
.
over_bets
==
0
assert
repaired
.
over_amount
==
0.0
assert
repaired
.
get_result_breakdown
()
==
{
'UNDER'
:
{
'bets'
:
1
,
'amount'
:
25.0
},
'WIN1'
:
{
'bets'
:
1
,
'amount'
:
50.0
},
}
assert
any
(
'Modified 1 extraction stats rows out of 1 processed'
in
message
for
message
in
info_logs
)
assert
any
(
'result_breakdown:'
in
message
for
message
in
info_logs
)
assert
not
warning_logs
session
.
close
()
engine
.
dispose
()
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