Skip to content

Commit d63c8bb

Browse files
committed
Add admin bulk submission delete endpoint
1 parent 8a9e329 commit d63c8bb

4 files changed

Lines changed: 171 additions & 1 deletion

File tree

src/kernelbot/api/main.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,23 @@ async def admin_delete_submission(
687687
return {"status": "ok", "submission_id": submission_id}
688688

689689

690+
@app.delete("/admin/submissions")
691+
async def admin_delete_submissions_for_user(
692+
leaderboard_id: int,
693+
user_name: str,
694+
_: Annotated[None, Depends(require_admin)],
695+
db_context=Depends(get_db),
696+
) -> dict:
697+
with db_context as db:
698+
deleted = db.delete_submissions_for_user(leaderboard_id, user_name)
699+
return {
700+
"status": "ok",
701+
"leaderboard_id": leaderboard_id,
702+
"user_name": user_name,
703+
**deleted,
704+
}
705+
706+
690707
@app.get("/admin/stats")
691708
async def admin_stats(
692709
_: Annotated[None, Depends(require_admin)],

src/libkernelbot/leaderboard_db.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,6 +1095,72 @@ def delete_submission(self, submission_id: int):
10951095
logger.exception("Could not delete submission %s.", submission_id, exc_info=e)
10961096
raise KernelBotError(f"Could not delete submission {submission_id}!") from e
10971097

1098+
def delete_submissions_for_user(self, leaderboard_id: int, user_name: str) -> dict[str, int]:
1099+
try:
1100+
self.cursor.execute(
1101+
"""
1102+
SELECT 1
1103+
FROM leaderboard.leaderboard
1104+
WHERE id = %s
1105+
""",
1106+
(leaderboard_id,),
1107+
)
1108+
if self.cursor.fetchone() is None:
1109+
raise LeaderboardDoesNotExist(str(leaderboard_id))
1110+
1111+
self.cursor.execute(
1112+
"""
1113+
WITH target_submissions AS (
1114+
SELECT s.id
1115+
FROM leaderboard.submission s
1116+
JOIN leaderboard.user_info ui ON ui.id = s.user_id
1117+
WHERE s.leaderboard_id = %s
1118+
AND ui.user_name = %s
1119+
),
1120+
deleted_job_status AS (
1121+
DELETE FROM leaderboard.submission_job_status sjs
1122+
WHERE sjs.submission_id IN (SELECT id FROM target_submissions)
1123+
RETURNING sjs.id
1124+
),
1125+
deleted_runs AS (
1126+
DELETE FROM leaderboard.runs r
1127+
WHERE r.submission_id IN (SELECT id FROM target_submissions)
1128+
RETURNING r.id
1129+
),
1130+
deleted_submissions AS (
1131+
DELETE FROM leaderboard.submission s
1132+
WHERE s.id IN (SELECT id FROM target_submissions)
1133+
RETURNING s.id
1134+
)
1135+
SELECT
1136+
(SELECT COUNT(*) FROM deleted_job_status) AS deleted_job_status,
1137+
(SELECT COUNT(*) FROM deleted_runs) AS deleted_runs,
1138+
(SELECT COUNT(*) FROM deleted_submissions) AS deleted_submissions
1139+
""",
1140+
(leaderboard_id, user_name),
1141+
)
1142+
deleted_job_status, deleted_runs, deleted_submissions = self.cursor.fetchone()
1143+
self.connection.commit()
1144+
return {
1145+
"deleted_job_status": deleted_job_status,
1146+
"deleted_runs": deleted_runs,
1147+
"deleted_submissions": deleted_submissions,
1148+
}
1149+
except KernelBotError:
1150+
self.connection.rollback()
1151+
raise
1152+
except psycopg2.Error as e:
1153+
self.connection.rollback()
1154+
logger.exception(
1155+
"Could not delete submissions for leaderboard %s user %s.",
1156+
leaderboard_id,
1157+
user_name,
1158+
exc_info=e,
1159+
)
1160+
raise KernelBotError(
1161+
f"Could not delete submissions for leaderboard {leaderboard_id} and user {user_name}!"
1162+
) from e
1163+
10981164
def get_user_submissions(
10991165
self,
11001166
user_id: str,

tests/test_admin_api.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,31 @@ def test_delete_submission(self, test_client, mock_backend):
184184
assert response.status_code == 200
185185
mock_backend.db.delete_submission.assert_called_once_with(123)
186186

187+
def test_delete_submissions_for_user(self, test_client, mock_backend):
188+
"""DELETE /admin/submissions deletes by leaderboard ID and username."""
189+
mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db)
190+
mock_backend.db.__exit__ = MagicMock(return_value=None)
191+
mock_backend.db.delete_submissions_for_user = MagicMock(return_value={
192+
"deleted_job_status": 2,
193+
"deleted_runs": 5,
194+
"deleted_submissions": 3,
195+
})
196+
197+
response = test_client.delete(
198+
"/admin/submissions?leaderboard_id=765&user_name=Borui%20Xu",
199+
headers={"Authorization": "Bearer test_token"}
200+
)
201+
assert response.status_code == 200
202+
assert response.json() == {
203+
"status": "ok",
204+
"leaderboard_id": 765,
205+
"user_name": "Borui Xu",
206+
"deleted_job_status": 2,
207+
"deleted_runs": 5,
208+
"deleted_submissions": 3,
209+
}
210+
mock_backend.db.delete_submissions_for_user.assert_called_once_with(765, "Borui Xu")
211+
187212

188213
class TestAdminLeaderboards:
189214
"""Test admin leaderboard endpoints."""

tests/test_leaderboard_db.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,69 @@ def test_delete_leaderboard(database, submit_leaderboard):
481481
assert db.get_leaderboard_names() == []
482482

483483

484+
def test_delete_submissions_for_user(database, submit_leaderboard):
485+
with database as db:
486+
target_a = db.create_submission(
487+
"submit-leaderboard",
488+
"submission.py",
489+
5,
490+
"pass",
491+
datetime.datetime.now(),
492+
user_name="target-user",
493+
)
494+
target_b = db.create_submission(
495+
"submit-leaderboard",
496+
"submission.py",
497+
5,
498+
"pass 2",
499+
datetime.datetime.now(),
500+
user_name="target-user",
501+
)
502+
other = db.create_submission(
503+
"submit-leaderboard",
504+
"submission.py",
505+
7,
506+
"different",
507+
datetime.datetime.now(),
508+
user_name="other-user",
509+
)
510+
511+
_create_submission_run(db, target_a, mode="leaderboard", secret=False, runner="A100", score=5)
512+
_create_submission_run(db, target_b, mode="test", secret=False, runner="A100", score=None)
513+
_create_submission_run(db, other, mode="leaderboard", secret=False, runner="A100", score=7)
514+
db.upsert_submission_job_status(target_a, "running", None)
515+
db.upsert_submission_job_status(target_b, "pending", None)
516+
517+
deleted = db.delete_submissions_for_user(db.get_leaderboard_id("submit-leaderboard"), "target-user")
518+
519+
assert deleted == {
520+
"deleted_job_status": 2,
521+
"deleted_runs": 2,
522+
"deleted_submissions": 2,
523+
}
524+
assert db.get_submission_by_id(target_a) is None
525+
assert db.get_submission_by_id(target_b) is None
526+
assert db.get_submission_by_id(other) is not None
527+
528+
db.cursor.execute("SELECT COUNT(*) FROM leaderboard.runs")
529+
assert db.cursor.fetchone()[0] == 1
530+
531+
db.cursor.execute("SELECT COUNT(*) FROM leaderboard.submission_job_status")
532+
assert db.cursor.fetchone()[0] == 0
533+
534+
db.cursor.execute("SELECT COUNT(*) FROM leaderboard.submission")
535+
assert db.cursor.fetchone()[0] == 1
536+
537+
538+
def test_delete_submissions_for_user_missing_leaderboard(database):
539+
with database as db:
540+
with pytest.raises(
541+
leaderboard_db.LeaderboardDoesNotExist,
542+
match="Leaderboard `999999` does not exist.",
543+
):
544+
db.delete_submissions_for_user(999999, "target-user")
545+
546+
484547
def test_delete_leaderboard_with_runs(database, submit_leaderboard):
485548
with database as db:
486549
db.create_submission(
@@ -1135,4 +1198,3 @@ def test_check_rate_limit_categories_independent(database, submit_leaderboard):
11351198
# Test should be blocked
11361199
result = db.check_rate_limit("submit-leaderboard", "123", "test")
11371200
assert result["allowed"] is False
1138-

0 commit comments

Comments
 (0)