diff --git a/src/kernelbot/api/main.py b/src/kernelbot/api/main.py index 808f5edf7..e89bbbb51 100644 --- a/src/kernelbot/api/main.py +++ b/src/kernelbot/api/main.py @@ -687,6 +687,23 @@ async def admin_delete_submission( return {"status": "ok", "submission_id": submission_id} +@app.delete("/admin/submissions") +async def admin_delete_submissions_for_user( + leaderboard_id: int, + user_name: str, + _: Annotated[None, Depends(require_admin)], + db_context=Depends(get_db), +) -> dict: + with db_context as db: + deleted = db.delete_submissions_for_user(leaderboard_id, user_name) + return { + "status": "ok", + "leaderboard_id": leaderboard_id, + "user_name": user_name, + **deleted, + } + + @app.get("/admin/stats") async def admin_stats( _: Annotated[None, Depends(require_admin)], diff --git a/src/libkernelbot/leaderboard_db.py b/src/libkernelbot/leaderboard_db.py index 650599af7..d2350c9bd 100644 --- a/src/libkernelbot/leaderboard_db.py +++ b/src/libkernelbot/leaderboard_db.py @@ -1095,6 +1095,72 @@ def delete_submission(self, submission_id: int): logger.exception("Could not delete submission %s.", submission_id, exc_info=e) raise KernelBotError(f"Could not delete submission {submission_id}!") from e + def delete_submissions_for_user(self, leaderboard_id: int, user_name: str) -> dict[str, int]: + try: + self.cursor.execute( + """ + SELECT 1 + FROM leaderboard.leaderboard + WHERE id = %s + """, + (leaderboard_id,), + ) + if self.cursor.fetchone() is None: + raise LeaderboardDoesNotExist(str(leaderboard_id)) + + self.cursor.execute( + """ + WITH target_submissions AS ( + SELECT s.id + FROM leaderboard.submission s + JOIN leaderboard.user_info ui ON ui.id = s.user_id + WHERE s.leaderboard_id = %s + AND ui.user_name = %s + ), + deleted_job_status AS ( + DELETE FROM leaderboard.submission_job_status sjs + WHERE sjs.submission_id IN (SELECT id FROM target_submissions) + RETURNING sjs.id + ), + deleted_runs AS ( + DELETE FROM leaderboard.runs r + WHERE r.submission_id IN (SELECT id FROM target_submissions) + RETURNING r.id + ), + deleted_submissions AS ( + DELETE FROM leaderboard.submission s + WHERE s.id IN (SELECT id FROM target_submissions) + RETURNING s.id + ) + SELECT + (SELECT COUNT(*) FROM deleted_job_status) AS deleted_job_status, + (SELECT COUNT(*) FROM deleted_runs) AS deleted_runs, + (SELECT COUNT(*) FROM deleted_submissions) AS deleted_submissions + """, + (leaderboard_id, user_name), + ) + deleted_job_status, deleted_runs, deleted_submissions = self.cursor.fetchone() + self.connection.commit() + return { + "deleted_job_status": deleted_job_status, + "deleted_runs": deleted_runs, + "deleted_submissions": deleted_submissions, + } + except KernelBotError: + self.connection.rollback() + raise + except psycopg2.Error as e: + self.connection.rollback() + logger.exception( + "Could not delete submissions for leaderboard %s user %s.", + leaderboard_id, + user_name, + exc_info=e, + ) + raise KernelBotError( + f"Could not delete submissions for leaderboard {leaderboard_id} and user {user_name}!" + ) from e + def get_user_submissions( self, user_id: str, diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index e31306065..46d7fd48b 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -184,6 +184,31 @@ def test_delete_submission(self, test_client, mock_backend): assert response.status_code == 200 mock_backend.db.delete_submission.assert_called_once_with(123) + def test_delete_submissions_for_user(self, test_client, mock_backend): + """DELETE /admin/submissions deletes by leaderboard ID and username.""" + mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db) + mock_backend.db.__exit__ = MagicMock(return_value=None) + mock_backend.db.delete_submissions_for_user = MagicMock(return_value={ + "deleted_job_status": 2, + "deleted_runs": 5, + "deleted_submissions": 3, + }) + + response = test_client.delete( + "/admin/submissions?leaderboard_id=765&user_name=Borui%20Xu", + headers={"Authorization": "Bearer test_token"} + ) + assert response.status_code == 200 + assert response.json() == { + "status": "ok", + "leaderboard_id": 765, + "user_name": "Borui Xu", + "deleted_job_status": 2, + "deleted_runs": 5, + "deleted_submissions": 3, + } + mock_backend.db.delete_submissions_for_user.assert_called_once_with(765, "Borui Xu") + class TestAdminLeaderboards: """Test admin leaderboard endpoints.""" diff --git a/tests/test_leaderboard_db.py b/tests/test_leaderboard_db.py index 9c1160f8e..1ebf3b290 100644 --- a/tests/test_leaderboard_db.py +++ b/tests/test_leaderboard_db.py @@ -481,6 +481,69 @@ def test_delete_leaderboard(database, submit_leaderboard): assert db.get_leaderboard_names() == [] +def test_delete_submissions_for_user(database, submit_leaderboard): + with database as db: + target_a = db.create_submission( + "submit-leaderboard", + "submission.py", + 5, + "pass", + datetime.datetime.now(), + user_name="target-user", + ) + target_b = db.create_submission( + "submit-leaderboard", + "submission.py", + 5, + "pass 2", + datetime.datetime.now(), + user_name="target-user", + ) + other = db.create_submission( + "submit-leaderboard", + "submission.py", + 7, + "different", + datetime.datetime.now(), + user_name="other-user", + ) + + _create_submission_run(db, target_a, mode="leaderboard", secret=False, runner="A100", score=5) + _create_submission_run(db, target_b, mode="test", secret=False, runner="A100", score=None) + _create_submission_run(db, other, mode="leaderboard", secret=False, runner="A100", score=7) + db.upsert_submission_job_status(target_a, "running", None) + db.upsert_submission_job_status(target_b, "pending", None) + + deleted = db.delete_submissions_for_user(db.get_leaderboard_id("submit-leaderboard"), "target-user") + + assert deleted == { + "deleted_job_status": 2, + "deleted_runs": 2, + "deleted_submissions": 2, + } + assert db.get_submission_by_id(target_a) is None + assert db.get_submission_by_id(target_b) is None + assert db.get_submission_by_id(other) is not None + + db.cursor.execute("SELECT COUNT(*) FROM leaderboard.runs") + assert db.cursor.fetchone()[0] == 1 + + db.cursor.execute("SELECT COUNT(*) FROM leaderboard.submission_job_status") + assert db.cursor.fetchone()[0] == 0 + + db.cursor.execute("SELECT COUNT(*) FROM leaderboard.submission") + assert db.cursor.fetchone()[0] == 1 + + +def test_delete_submissions_for_user_missing_leaderboard(database): + with database as db: + with pytest.raises( + leaderboard_db.LeaderboardDoesNotExist, + match="Leaderboard `999999` does not exist.", + ): + db.delete_submissions_for_user(999999, "target-user") + + def test_delete_leaderboard_with_runs(database, submit_leaderboard): with database as db: db.create_submission( @@ -1135,4 +1198,3 @@ def test_check_rate_limit_categories_independent(database, submit_leaderboard): # Test should be blocked result = db.check_rate_limit("submit-leaderboard", "123", "test") assert result["allowed"] is False -