Skip to content

Commit 2abd398

Browse files
author
Mark Saroufim
authored
Add optional leaderboard_name filter to admin show-stats (#445)
* Add optional leaderboard_name filter to admin show-stats Support filtering stats by a specific leaderboard in both the Discord command and the API endpoint. Defaults to all leaderboards when omitted. * Extract _stats_filter helper and qualify column references Deduplicate JOIN/WHERE/params construction across stats queries into a shared _stats_filter staticmethod. Alias leaderboard.submission as s in _generate_submission_stats and qualify all column references. * Add Query description for leaderboard_name and use flexible assertions Add FastAPI Query metadata so leaderboard_name shows a description in OpenAPI docs. Use call_args-based assertions in tests instead of positional assert_called_once_with to decouple from argument passing style.
1 parent 862914d commit 2abd398

5 files changed

Lines changed: 118 additions & 30 deletions

File tree

src/kernelbot/api/main.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from dataclasses import asdict
88
from typing import Annotated, Any, Optional
99

10-
from fastapi import Depends, FastAPI, Header, HTTPException, Request, UploadFile
10+
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, UploadFile
1111
from fastapi.responses import JSONResponse, StreamingResponse
1212

1313
from kernelbot.env import env
@@ -586,9 +586,10 @@ async def admin_stats(
586586
_: Annotated[None, Depends(require_admin)],
587587
db_context=Depends(get_db),
588588
last_day_only: bool = False,
589+
leaderboard_name: Optional[str] = Query(None, description="Filter stats to a specific leaderboard name"),
589590
) -> dict:
590591
with db_context as db:
591-
stats = db.generate_stats(last_day_only)
592+
stats = db.generate_stats(last_day_only, leaderboard_name)
592593
return {"status": "ok", "stats": stats}
593594

594595

src/kernelbot/cogs/admin_cog.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -764,8 +764,14 @@ async def update_competition(
764764
logger.exception("Error updating problem set", exc_info=e)
765765

766766
@with_error_handling
767-
@discord.app_commands.describe(last_day_only="Only show stats for the last day")
768-
async def show_bot_stats(self, interaction: discord.Interaction, last_day_only: bool):
767+
@discord.app_commands.describe(
768+
last_day_only="Only show stats for the last day",
769+
leaderboard_name="Filter stats to a specific leaderboard (default: all)",
770+
)
771+
@discord.app_commands.autocomplete(leaderboard_name=leaderboard_name_autocomplete)
772+
async def show_bot_stats(
773+
self, interaction: discord.Interaction, last_day_only: bool, leaderboard_name: Optional[str] = None
774+
):
769775
is_admin = await self.admin_check(interaction)
770776
if not is_admin:
771777
await send_discord_message(
@@ -776,7 +782,7 @@ async def show_bot_stats(self, interaction: discord.Interaction, last_day_only:
776782
return
777783

778784
with self.bot.leaderboard_db as db:
779-
stats = db.generate_stats(last_day_only)
785+
stats = db.generate_stats(last_day_only, leaderboard_name)
780786
msg = """```"""
781787
for k, v in stats.items():
782788
msg += f"\n{k} = {v}"

src/libkernelbot/leaderboard_db.py

Lines changed: 62 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -713,15 +713,33 @@ def get_leaderboard_submissions(
713713

714714
return result
715715

716-
def generate_stats(self, last_day: bool):
716+
def generate_stats(self, last_day: bool, leaderboard_name: Optional[str] = None):
717717
try:
718-
return self._generate_stats(last_day)
718+
return self._generate_stats(last_day, leaderboard_name)
719719
except Exception as e:
720720
logger.exception("error generating stats", exc_info=e)
721721
raise
722722

723-
def _generate_runner_stats(self, last_day: bool = False):
724-
select_expr = "WHERE NOW() - s.submission_time <= interval '24 hours'" if last_day else ""
723+
@staticmethod
724+
def _stats_filter(last_day: bool, leaderboard_name: Optional[str], submission_alias: str = "s"):
725+
joins = ""
726+
conditions = []
727+
params = []
728+
729+
if leaderboard_name:
730+
joins = f"JOIN leaderboard.leaderboard lb ON {submission_alias}.leaderboard_id = lb.id"
731+
conditions.append("lb.name = %s")
732+
params.append(leaderboard_name)
733+
734+
if last_day:
735+
conditions.append(f"NOW() - {submission_alias}.submission_time <= interval '24 hours'")
736+
737+
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
738+
return joins, where_clause, params
739+
740+
def _generate_runner_stats(self, last_day: bool = False, leaderboard_name: Optional[str] = None):
741+
joins, where_clause, params = self._stats_filter(last_day, leaderboard_name)
742+
725743
# per-runner stats
726744
self.cursor.execute(
727745
f"""
@@ -735,9 +753,11 @@ def _generate_runner_stats(self, last_day: bool = False):
735753
AVG(runs.start_time - s.submission_time),
736754
SUM(runs.end_time - runs.start_time)
737755
FROM leaderboard.runs JOIN leaderboard.submission s ON submission_id = s.id
738-
{select_expr}
756+
{joins}
757+
{where_clause}
739758
GROUP BY runner;
740-
"""
759+
""",
760+
params,
741761
)
742762

743763
result = {}
@@ -752,18 +772,21 @@ def _generate_runner_stats(self, last_day: bool = False):
752772

753773
return result
754774

755-
def _generate_submission_stats(self, last_day: bool = False):
756-
select_expr = "WHERE NOW() - submission_time <= interval '24 hours'" if last_day else ""
775+
def _generate_submission_stats(self, last_day: bool = False, leaderboard_name: Optional[str] = None):
776+
joins, where_clause, params = self._stats_filter(last_day, leaderboard_name)
777+
757778
self.cursor.execute(
758779
f"""
759780
SELECT
760781
COUNT(*),
761-
COUNT(*) FILTER (WHERE NOT done),
762-
COUNT(DISTINCT user_id)
763-
FROM leaderboard.submission
764-
{select_expr}
782+
COUNT(*) FILTER (WHERE NOT s.done),
783+
COUNT(DISTINCT s.user_id)
784+
FROM leaderboard.submission s
785+
{joins}
786+
{where_clause}
765787
;
766-
"""
788+
""",
789+
params,
767790
)
768791
num_sub, num_sub_wait, num_users = self.cursor.fetchone()
769792
return {
@@ -772,30 +795,44 @@ def _generate_submission_stats(self, last_day: bool = False):
772795
"num_users": num_users,
773796
}
774797

775-
def _generate_stats(self, last_day: bool = False):
776-
result = self._generate_submission_stats(last_day)
777-
result.update(self._generate_runner_stats(last_day))
798+
def _generate_stats(self, last_day: bool = False, leaderboard_name: Optional[str] = None):
799+
result = self._generate_submission_stats(last_day, leaderboard_name)
800+
result.update(self._generate_runner_stats(last_day, leaderboard_name))
778801

779802
# code-level stats
780803
if not last_day:
781-
self.cursor.execute(
782-
"""
783-
SELECT COUNT(*) FROM leaderboard.code_files;
784-
"""
785-
)
804+
if leaderboard_name:
805+
self.cursor.execute(
806+
"""
807+
SELECT COUNT(DISTINCT s.code_id)
808+
FROM leaderboard.submission s
809+
JOIN leaderboard.leaderboard lb ON s.leaderboard_id = lb.id
810+
WHERE lb.name = %s;
811+
""",
812+
(leaderboard_name,),
813+
)
814+
else:
815+
self.cursor.execute(
816+
"""
817+
SELECT COUNT(*) FROM leaderboard.code_files;
818+
"""
819+
)
786820
result["num_unique_codes"] = self.cursor.fetchone()[0]
787821

788822
else:
789823
# calculate heavy hitters
824+
joins, where_clause, params = self._stats_filter(last_day, leaderboard_name)
825+
790826
self.cursor.execute(
791-
"""
827+
f"""
792828
WITH run_durations AS (
793829
SELECT
794830
s.user_id AS user_id,
795831
r.end_time - r.start_time AS duration
796832
FROM leaderboard.runs r
797833
JOIN leaderboard.submission s ON r.submission_id = s.id
798-
WHERE NOW() - s.submission_time <= interval '24 hours'
834+
{joins}
835+
{where_clause}
799836
)
800837
SELECT
801838
user_id,
@@ -804,7 +841,8 @@ def _generate_stats(self, last_day: bool = False):
804841
GROUP BY user_id
805842
ORDER BY total DESC
806843
LIMIT 10;
807-
"""
844+
""",
845+
params,
808846
)
809847

810848
for row in self.cursor.fetchall():

tests/test_admin_api.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,27 @@ def test_admin_stats_last_day_only(self, test_client, mock_backend):
115115
headers={"Authorization": "Bearer test_token"}
116116
)
117117
assert response.status_code == 200
118-
mock_backend.db.generate_stats.assert_called_once_with(True)
118+
mock_backend.db.generate_stats.assert_called_once()
119+
args, kwargs = mock_backend.db.generate_stats.call_args
120+
assert args[0] is True # last_day_only
121+
122+
def test_admin_stats_with_leaderboard_name(self, test_client, mock_backend):
123+
"""GET /admin/stats with leaderboard_name parameter."""
124+
mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db)
125+
mock_backend.db.__exit__ = MagicMock(return_value=None)
126+
mock_backend.db.generate_stats = MagicMock(return_value={
127+
"num_submissions": 5,
128+
"num_users": 3,
129+
})
130+
131+
response = test_client.get(
132+
"/admin/stats?leaderboard_name=my-leaderboard",
133+
headers={"Authorization": "Bearer test_token"}
134+
)
135+
assert response.status_code == 200
136+
mock_backend.db.generate_stats.assert_called_once()
137+
args, kwargs = mock_backend.db.generate_stats.call_args
138+
assert args[1] == "my-leaderboard" # leaderboard_name
119139

120140

121141
class TestAdminSubmissions:

tests/test_leaderboard_db.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,29 @@ def test_generate_stats(database, submit_leaderboard):
604604
"total_runtime.A100": datetime.timedelta(seconds=35),
605605
}
606606

607+
# Same results when filtering by the correct leaderboard
608+
assert db.generate_stats(False, leaderboard_name="submit-leaderboard") == {
609+
"avg_delay.A100": datetime.timedelta(seconds=10),
610+
"max_delay.A100": datetime.timedelta(seconds=20),
611+
"num_run.A100": 3,
612+
"num_submissions": 1,
613+
"num_unique_codes": 1,
614+
"num_users": 1,
615+
"runs_passed.A100": 3,
616+
"runs_scored.A100": 3,
617+
"runs_secret.A100": 1,
618+
"sub_waiting": 0,
619+
"total_runtime.A100": datetime.timedelta(seconds=35),
620+
}
621+
622+
# Empty results when filtering by a non-existent leaderboard
623+
assert db.generate_stats(False, leaderboard_name="nonexistent") == {
624+
"num_submissions": 0,
625+
"sub_waiting": 0,
626+
"num_users": 0,
627+
"num_unique_codes": 0,
628+
}
629+
607630

608631
def test_get_user_submissions_empty(database, submit_leaderboard):
609632
"""Test get_user_submissions returns empty list for user with no submissions"""

0 commit comments

Comments
 (0)