From d1dc3c12d8ddba6ff78789b97332e2c32c6f62f7 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Fri, 13 Mar 2026 14:43:16 -0700 Subject: [PATCH 1/3] Update runs --- src/kernelbot/api/api_utils.py | 1 + src/kernelbot/api/main.py | 184 +++++++++++++- src/kernelbot/cogs/admin_cog.py | 16 +- src/kernelbot/cogs/leaderboard_cog.py | 1 + src/libkernelbot/db_types.py | 1 + src/libkernelbot/leaderboard_db.py | 177 +++++++++++++- src/libkernelbot/problem_sync.py | 2 + src/libkernelbot/submission.py | 16 +- src/libkernelbot/task.py | 5 +- ...260311_01_closed-leaderboard-visibility.py | 55 +++++ .../20260313_01_invite-multi-leaderboard.py | 66 +++++ tests/conftest.py | 3 +- tests/test_admin_api.py | 230 ++++++++++++++++++ tests/test_leaderboard_db.py | 229 +++++++++++++++++ 14 files changed, 956 insertions(+), 30 deletions(-) create mode 100644 src/migrations/20260311_01_closed-leaderboard-visibility.py create mode 100644 src/migrations/20260313_01_invite-multi-leaderboard.py diff --git a/src/kernelbot/api/api_utils.py b/src/kernelbot/api/api_utils.py index ab1505ac..034f64c5 100644 --- a/src/kernelbot/api/api_utils.py +++ b/src/kernelbot/api/api_utils.py @@ -274,6 +274,7 @@ async def to_submit_info( user_name=user_name, gpus=[gpu_type], leaderboard=leaderboard_name, + identity_type=user_info.get("id_type"), ) except UnicodeDecodeError: raise HTTPException( diff --git a/src/kernelbot/api/main.py b/src/kernelbot/api/main.py index 960d11ff..d3f82089 100644 --- a/src/kernelbot/api/main.py +++ b/src/kernelbot/api/main.py @@ -131,6 +131,7 @@ async def validate_cli_header( if user_info is None: raise HTTPException(status_code=401, detail="Invalid or unauthorized X-Popcorn-Cli-Id") + user_info["id_type"] = "cli" return user_info @@ -178,6 +179,38 @@ async def validate_user_header( return user_info +async def optional_user_header( + x_web_auth_id: Optional[str] = Header(None, alias="X-Web-Auth-Id"), + x_popcorn_cli_id: Optional[str] = Header(None, alias="X-Popcorn-Cli-Id"), + db_context: LeaderboardDB = Depends(get_db), +) -> Optional[Any]: + """Like validate_user_header but returns None instead of raising when no auth header is present.""" + token = x_web_auth_id or x_popcorn_cli_id + if not token: + return None + + if x_web_auth_id: + id_type = IdentityType.WEB + else: + id_type = IdentityType.CLI + + try: + with db_context as db: + user_info = db.validate_identity(token, id_type) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Database error during validation: {e}", + ) from e + + if not user_info: + raise HTTPException( + status_code=401, + detail="Invalid or unauthorized auth header", + ) + return user_info + + def require_admin( authorization: Optional[str] = Header(None, alias="Authorization"), ) -> None: @@ -576,6 +609,10 @@ async def create_dev_leaderboard( except Exception: pass # Leaderboard doesn't exist, that's fine + visibility = payload.get("visibility", "public") + if visibility not in ("public", "closed"): + raise HTTPException(status_code=400, detail="visibility must be 'public' or 'closed'") + db.create_leaderboard( name=leaderboard_name, deadline=deadline_value, @@ -583,6 +620,7 @@ async def create_dev_leaderboard( creator_id=0, forum_id=-1, gpu_types=definition.gpus, + visibility=visibility, ) return {"status": "ok", "leaderboard": leaderboard_name} @@ -652,6 +690,9 @@ async def admin_update_problems( problem_set = payload.get("problem_set") branch = payload.get("branch", "main") force = payload.get("force", False) + visibility = payload.get("visibility", "public") + if visibility not in ("public", "closed"): + raise HTTPException(status_code=400, detail="visibility must be 'public' or 'closed'") try: result = sync_problems( @@ -662,6 +703,7 @@ async def admin_update_problems( force=force, creator_id=0, # API-created forum_id=-1, # No Discord forum + visibility=visibility, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e @@ -693,21 +735,24 @@ async def get_leaderboards(db_context=Depends(get_db)): @app.get("/gpus/{leaderboard_name}") -async def get_gpus(leaderboard_name: str, db_context=Depends(get_db)) -> list[str]: - """An endpoint that returns all GPU types that are available for a given leaderboard and runner. - - Args: - leaderboard_name (str): The name of the leaderboard to get the GPU types for. - runner_name (str): The name of the runner to get the GPU types for. - - Returns: - list[str]: A list of GPU types that are available for the given leaderboard and runner. - """ +async def get_gpus( + leaderboard_name: str, + user_info: Annotated[Optional[Any], Depends(optional_user_header)] = None, + db_context=Depends(get_db), +) -> list[str]: + """An endpoint that returns all GPU types that are available for a given leaderboard and runner.""" await simple_rate_limit() try: with db_context as db: + lb = db.get_leaderboard(leaderboard_name) + if lb.get("visibility") == "closed": + if user_info is None: + raise HTTPException(status_code=401, detail="Authentication required for closed leaderboard") + if not db.check_leaderboard_access(leaderboard_name, user_info["user_id"]): + raise HTTPException(status_code=403, detail="You do not have access to this leaderboard") return db.get_leaderboard_gpu_types(leaderboard_name) - + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=f"Error fetching GPU data: {e}") from e @@ -718,29 +763,49 @@ async def get_submissions( gpu_name: str, limit: int = None, offset: int = 0, + user_info: Annotated[Optional[Any], Depends(optional_user_header)] = None, db_context=Depends(get_db), ) -> list[LeaderboardRankedEntry]: await simple_rate_limit() try: with db_context as db: - # Add validation for leaderboard and GPU? Might be redundant if DB handles it. + lb = db.get_leaderboard(leaderboard_name) + if lb.get("visibility") == "closed": + if user_info is None: + raise HTTPException(status_code=401, detail="Authentication required for closed leaderboard") + if not db.check_leaderboard_access(leaderboard_name, user_info["user_id"]): + raise HTTPException(status_code=403, detail="You do not have access to this leaderboard") return db.get_leaderboard_submissions( leaderboard_name, gpu_name, limit=limit, offset=offset ) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=f"Error fetching submissions: {e}") from e @app.get("/submission_count/{leaderboard_name}/{gpu_name}") async def get_submission_count( - leaderboard_name: str, gpu_name: str, user_id: str = None, db_context=Depends(get_db) + leaderboard_name: str, + gpu_name: str, + user_id: str = None, + user_info: Annotated[Optional[Any], Depends(optional_user_header)] = None, + db_context=Depends(get_db), ) -> dict: """Get the total count of submissions for pagination""" await simple_rate_limit() try: with db_context as db: + lb = db.get_leaderboard(leaderboard_name) + if lb.get("visibility") == "closed": + if user_info is None: + raise HTTPException(status_code=401, detail="Authentication required for closed leaderboard") + if not db.check_leaderboard_access(leaderboard_name, user_info["user_id"]): + raise HTTPException(status_code=403, detail="You do not have access to this leaderboard") count = db.get_leaderboard_submission_count(leaderboard_name, gpu_name, user_id) return {"count": count} + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=f"Error fetching submission count: {e}") from e @@ -865,3 +930,96 @@ async def delete_user_submission( raise except Exception as e: raise HTTPException(status_code=500, detail=f"Error deleting submission: {e}") from e + + +@app.get("/user/me") +async def get_current_user( + user_info: Annotated[dict, Depends(validate_user_header)], +) -> dict: + """Returns the authenticated user's ID and name.""" + return {"user_id": user_info["user_id"], "user_name": user_info["user_name"]} + + +@app.post("/admin/invites") +async def admin_generate_invites( + payload: dict, + _: Annotated[None, Depends(require_admin)], + db_context=Depends(get_db), +) -> dict: + """Generate invite codes covering one or more leaderboards. + + Accepts either: + {"leaderboards": ["lb1", "lb2"], "count": 10} + {"leaderboard": "lb1", "count": 10} (single leaderboard shorthand) + """ + count = payload.get("count") + if not isinstance(count, int) or count < 1 or count > 10000: + raise HTTPException(status_code=400, detail="count must be an integer between 1 and 10000") + leaderboards = payload.get("leaderboards") or [] + if not leaderboards: + single = payload.get("leaderboard") + if single: + leaderboards = [single] + if not leaderboards or not isinstance(leaderboards, list): + raise HTTPException(status_code=400, detail="Must provide 'leaderboards' list or 'leaderboard' string") + with db_context as db: + codes = db.generate_invite_codes(leaderboards, count) + return {"status": "ok", "leaderboards": leaderboards, "codes": codes} + + +@app.get("/admin/leaderboards/{leaderboard_name}/invites") +async def admin_list_invites( + leaderboard_name: str, + _: Annotated[None, Depends(require_admin)], + db_context=Depends(get_db), +) -> dict: + """List all invite codes for a leaderboard with claim status.""" + with db_context as db: + invites = db.get_invite_codes(leaderboard_name) + return {"status": "ok", "leaderboard": leaderboard_name, "invites": invites} + + +@app.delete("/admin/invites/{code}") +async def admin_revoke_invite( + code: str, + _: Annotated[None, Depends(require_admin)], + db_context=Depends(get_db), +) -> dict: + """Revoke an invite code, removing it from the pool.""" + with db_context as db: + result = db.revoke_invite_code(code) + return {"status": "ok", **result} + + +@app.post("/admin/leaderboards/{leaderboard_name}/visibility") +async def admin_set_visibility( + leaderboard_name: str, + payload: dict, + _: Annotated[None, Depends(require_admin)], + db_context=Depends(get_db), +) -> dict: + """Change the visibility of an existing leaderboard.""" + visibility = payload.get("visibility") + if visibility not in ("public", "closed"): + raise HTTPException(status_code=400, detail="visibility must be 'public' or 'closed'") + with db_context as db: + db.set_leaderboard_visibility(leaderboard_name, visibility) + return {"status": "ok", "leaderboard": leaderboard_name, "visibility": visibility} + + +@app.post("/user/join") +async def user_join_leaderboard( + payload: dict, + user_info: Annotated[dict, Depends(validate_cli_header)], + db_context=Depends(get_db), +) -> dict: + """Claim an invite code to join a closed leaderboard. CLI only.""" + code = payload.get("code") + if not code: + raise HTTPException(status_code=400, detail="Missing required field: code") + try: + with db_context as db: + result = db.claim_invite_code(code, user_info["user_id"]) + except KernelBotError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"status": "ok", "leaderboards": result["leaderboards"]} diff --git a/src/kernelbot/cogs/admin_cog.py b/src/kernelbot/cogs/admin_cog.py index c9e2e9f0..e2a31a7d 100644 --- a/src/kernelbot/cogs/admin_cog.py +++ b/src/kernelbot/cogs/admin_cog.py @@ -163,6 +163,7 @@ async def leaderboard_create_local( interaction: discord.Interaction, directory: str, gpu: Optional[app_commands.Choice[str]], + closed: bool = False, ): is_admin = await self.admin_check(interaction) if not is_admin: @@ -212,6 +213,7 @@ async def leaderboard_create_local( definition=definition, forum_id=forum_id, gpu=gpu.value if gpu else None, + visibility="closed" if closed else "public", ): await send_discord_message( interaction, @@ -235,6 +237,7 @@ async def leaderboard_create_impl( # noqa: C901 deadline: str, definition: LeaderboardDefinition, gpus: Optional[str | list[str]], + visibility: str = "public", ): if len(leaderboard_name) > 95: await send_discord_message( @@ -276,7 +279,8 @@ async def leaderboard_create_impl( # noqa: C901 ) success = await self.create_leaderboard_in_db( - interaction, leaderboard_name, date_value, definition, forum_thread.thread.id, gpus + interaction, leaderboard_name, date_value, definition, forum_thread.thread.id, gpus, + visibility=visibility, ) if not success: await forum_thread.delete() @@ -325,6 +329,7 @@ async def create_leaderboard_in_db( definition: LeaderboardDefinition, forum_id: int, gpu: Optional[str | list[str]] = None, + visibility: str = "public", ) -> bool: if gpu is None: # Ask the user to select GPUs @@ -355,6 +360,7 @@ async def create_leaderboard_in_db( gpu_types=selected_gpus, creator_id=interaction.user.id, forum_id=forum_id, + visibility=visibility, ) except KernelBotError as e: await send_discord_message( @@ -515,6 +521,7 @@ async def update_problems( problem_set: Optional[str] = None, branch: Optional[str] = "main", force: bool = False, + closed: bool = False, ): is_admin = await self.admin_check(interaction) if not is_admin: @@ -573,7 +580,7 @@ async def update_problems( ) return for competition in problem_dir.glob("*.yaml"): - await self.update_competition(interaction, competition) + await self.update_competition(interaction, competition, closed=closed) else: problem_set = problem_dir / f"{problem_set}.yaml" if not problem_set.exists(): @@ -586,7 +593,7 @@ async def update_problems( ephemeral=True, ) return - await self.update_competition(interaction, problem_set, force) + await self.update_competition(interaction, problem_set, force, closed=closed) async def _create_update_plan( # noqa: C901 self, @@ -693,7 +700,7 @@ async def _create_update_plan( # noqa: C901 return update_list, create_list async def update_competition( - self, interaction: discord.Interaction, spec_file: Path, force: bool = False + self, interaction: discord.Interaction, spec_file: Path, force: bool = False, closed: bool = False ): try: root = spec_file.parent @@ -732,6 +739,7 @@ async def update_competition( entry["deadline"], make_task_definition(root / entry["directory"]), entry["gpus"], + visibility="closed" if closed else "public", ) steps += "done\n" diff --git a/src/kernelbot/cogs/leaderboard_cog.py b/src/kernelbot/cogs/leaderboard_cog.py index 977212b9..bab62b44 100644 --- a/src/kernelbot/cogs/leaderboard_cog.py +++ b/src/kernelbot/cogs/leaderboard_cog.py @@ -124,6 +124,7 @@ async def submit( user_name=interaction.user.global_name or interaction.user.name, gpus=gpu, leaderboard=leaderboard_name, + identity_type="discord", ) req = prepare_submission(req, self.bot.backend) diff --git a/src/libkernelbot/db_types.py b/src/libkernelbot/db_types.py index 0a03ec52..75db1ffb 100644 --- a/src/libkernelbot/db_types.py +++ b/src/libkernelbot/db_types.py @@ -21,6 +21,7 @@ class LeaderboardItem(TypedDict): gpu_types: List[str] forum_id: int secret_seed: NotRequired[int] + visibility: str class LeaderboardRankedEntry(TypedDict): diff --git a/src/libkernelbot/leaderboard_db.py b/src/libkernelbot/leaderboard_db.py index f76c00c9..4058ddc9 100644 --- a/src/libkernelbot/leaderboard_db.py +++ b/src/libkernelbot/leaderboard_db.py @@ -79,6 +79,7 @@ def create_leaderboard( creator_id: int, forum_id: int, gpu_types: list | str, + visibility: str = "public", ) -> int: # to prevent surprises, ensure we have specified a timezone try: @@ -86,11 +87,11 @@ def create_leaderboard( self.cursor.execute( """ INSERT INTO leaderboard.leaderboard (name, deadline, task, creator_id, - forum_id, description) - VALUES (%s, %s, %s, %s, %s, %s) + forum_id, description, visibility) + VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id """, - (name, deadline, task.to_str(), creator_id, forum_id, definition.description), + (name, deadline, task.to_str(), creator_id, forum_id, definition.description, visibility), ) leaderboard_id = self.cursor.fetchone()[0] @@ -503,7 +504,7 @@ def get_leaderboard_names(self, active_only: bool = False) -> list[str]: def get_leaderboards(self) -> list["LeaderboardItem"]: self.cursor.execute( """ - SELECT id, name, deadline, task, creator_id, forum_id, description, secret_seed + SELECT id, name, deadline, task, creator_id, forum_id, description, secret_seed, visibility FROM leaderboard.leaderboard """ ) @@ -528,6 +529,7 @@ def get_leaderboards(self) -> list["LeaderboardItem"]: forum_id=lb[5], description=lb[6], secret_seed=lb[7], + visibility=lb[8], ) ) @@ -588,7 +590,7 @@ def get_leaderboard_templates(self, leaderboard_name: str) -> Dict[str, str]: def get_leaderboard(self, leaderboard_name: str) -> "LeaderboardItem": self.cursor.execute( """ - SELECT id, name, deadline, task, creator_id, forum_id, secret_seed, description + SELECT id, name, deadline, task, creator_id, forum_id, secret_seed, description, visibility FROM leaderboard.leaderboard WHERE name = %s """, @@ -609,10 +611,175 @@ def get_leaderboard(self, leaderboard_name: str) -> "LeaderboardItem": secret_seed=res[6], gpu_types=self.get_leaderboard_gpu_types(res[1]), description=res[7], + visibility=res[8], ) else: raise LeaderboardDoesNotExist(leaderboard_name) + def check_leaderboard_access(self, leaderboard_name: str, user_id: str) -> bool: + """Returns True if leaderboard is public or user has claimed an invite covering this leaderboard.""" + self.cursor.execute( + """ + SELECT l.visibility + FROM leaderboard.leaderboard l + WHERE l.name = %s + """, + (leaderboard_name,), + ) + row = self.cursor.fetchone() + if row is None: + raise LeaderboardDoesNotExist(leaderboard_name) + if row[0] == "public": + return True + self.cursor.execute( + """ + SELECT 1 + FROM leaderboard.leaderboard_invite li + JOIN leaderboard.leaderboard_invite_scope lis ON li.id = lis.invite_id + JOIN leaderboard.leaderboard l ON lis.leaderboard_id = l.id + WHERE l.name = %s AND li.user_id = %s + """, + (leaderboard_name, str(user_id)), + ) + return self.cursor.fetchone() is not None + + def generate_invite_codes(self, leaderboard_names: list[str], count: int) -> list[str]: + """Generate N unique invite codes covering multiple leaderboards. Returns the codes.""" + import secrets + + lb_ids = [] + for name in leaderboard_names: + lb_ids.append(self.get_leaderboard_id(name)) + + codes = [] + for _ in range(count): + code = secrets.token_urlsafe(16) + self.cursor.execute( + """ + INSERT INTO leaderboard.leaderboard_invite (code) + VALUES (%s) + RETURNING id + """, + (code,), + ) + invite_id = self.cursor.fetchone()[0] + for lb_id in lb_ids: + self.cursor.execute( + """ + INSERT INTO leaderboard.leaderboard_invite_scope (invite_id, leaderboard_id) + VALUES (%s, %s) + """, + (invite_id, lb_id), + ) + codes.append(code) + self.connection.commit() + return codes + + def claim_invite_code(self, code: str, user_id: str) -> dict: + """Claim an invite code for a user. Returns list of leaderboard names. + + Raises KernelBotError if code is invalid or already claimed. + """ + self.cursor.execute( + """ + SELECT li.id, li.user_id + FROM leaderboard.leaderboard_invite li + WHERE li.code = %s + """, + (code,), + ) + row = self.cursor.fetchone() + if row is None: + raise KernelBotError("Invalid invite code") + invite_id, existing_user = row + + # Fetch leaderboard names for this invite + self.cursor.execute( + """ + SELECT l.name + FROM leaderboard.leaderboard_invite_scope lis + JOIN leaderboard.leaderboard l ON lis.leaderboard_id = l.id + WHERE lis.invite_id = %s + ORDER BY l.name + """, + (invite_id,), + ) + leaderboards = [r[0] for r in self.cursor.fetchall()] + + if existing_user == str(user_id): + return {"leaderboards": leaderboards} + if existing_user is not None: + raise KernelBotError("This invite code has already been claimed") + self.cursor.execute( + """ + UPDATE leaderboard.leaderboard_invite + SET user_id = %s, claimed_at = CURRENT_TIMESTAMP + WHERE id = %s AND user_id IS NULL + """, + (str(user_id), invite_id), + ) + if self.cursor.rowcount == 0: + raise KernelBotError("This invite code has already been claimed") + self.connection.commit() + return {"leaderboards": leaderboards} + + def get_invite_codes(self, leaderboard_name: str) -> list[dict]: + """Returns all invite codes that cover a given leaderboard, with claim status.""" + lb_id = self.get_leaderboard_id(leaderboard_name) + self.cursor.execute( + """ + SELECT li.code, li.user_id, ui.user_name, li.claimed_at, li.created_at + FROM leaderboard.leaderboard_invite li + JOIN leaderboard.leaderboard_invite_scope lis ON li.id = lis.invite_id + LEFT JOIN leaderboard.user_info ui ON li.user_id = ui.id + WHERE lis.leaderboard_id = %s + ORDER BY li.created_at + """, + (lb_id,), + ) + return [ + { + "code": row[0], + "user_id": row[1], + "user_name": row[2], + "claimed_at": row[3], + "created_at": row[4], + } + for row in self.cursor.fetchall() + ] + + def revoke_invite_code(self, code: str) -> dict: + """Revoke (delete) an invite code. Returns info about the revoked code. + + Raises KernelBotError if code does not exist. + """ + self.cursor.execute( + """ + DELETE FROM leaderboard.leaderboard_invite + WHERE code = %s + RETURNING code, user_id + """, + (code,), + ) + row = self.cursor.fetchone() + if row is None: + raise KernelBotError("Invalid invite code", code=404) + self.connection.commit() + return {"code": row[0], "was_claimed": row[1] is not None} + + def set_leaderboard_visibility(self, leaderboard_name: str, visibility: str): + """Change the visibility of a leaderboard.""" + lb_id = self.get_leaderboard_id(leaderboard_name) + self.cursor.execute( + """ + UPDATE leaderboard.leaderboard + SET visibility = %s + WHERE id = %s + """, + (visibility, lb_id), + ) + self.connection.commit() + def get_leaderboard_submissions( self, leaderboard_name: str, diff --git a/src/libkernelbot/problem_sync.py b/src/libkernelbot/problem_sync.py index 5c9dd9ff..d63212a8 100644 --- a/src/libkernelbot/problem_sync.py +++ b/src/libkernelbot/problem_sync.py @@ -214,6 +214,7 @@ def sync_problems( # noqa: C901 force: bool = False, creator_id: int = 0, forum_id: int = -1, + visibility: str = "public", ) -> SyncResult: """Sync problems from a GitHub repository. @@ -283,6 +284,7 @@ def sync_problems( # noqa: C901 creator_id=creator_id, forum_id=forum_id, gpu_types=plan.gpus, + visibility=visibility, ) result.created.append(plan.name) else: # update diff --git a/src/libkernelbot/submission.py b/src/libkernelbot/submission.py index 805f7435..6fd8e417 100644 --- a/src/libkernelbot/submission.py +++ b/src/libkernelbot/submission.py @@ -30,16 +30,17 @@ class SubmissionRequest: user_name: str gpus: Union[None, str, list] leaderboard: Optional[str] + identity_type: Optional[str] = None @dataclasses.dataclass class ProcessedSubmissionRequest(SubmissionRequest): - task: LeaderboardTask - secret_seed: int - task_gpus: list + task: LeaderboardTask = None + secret_seed: int = None + task_gpus: list = None -def prepare_submission( +def prepare_submission( # noqa: C901 req: SubmissionRequest, backend: "KernelBackend" ) -> ProcessedSubmissionRequest: if not backend.accepts_jobs: @@ -62,6 +63,13 @@ def prepare_submission( with backend.db as db: leaderboard = db.get_leaderboard(req.leaderboard) + if leaderboard.get("visibility") == "closed": + if req.identity_type != "cli": + raise KernelBotError( + "Closed leaderboards only accept submissions via CLI", code=403 + ) + if not db.check_leaderboard_access(req.leaderboard, str(req.user_id)): + raise KernelBotError("You do not have access to this leaderboard", code=403) check_deadline(leaderboard) task_gpus = get_avail_gpus(req.leaderboard, backend.db) diff --git a/src/libkernelbot/task.py b/src/libkernelbot/task.py index 679a4f56..e0fb0a41 100644 --- a/src/libkernelbot/task.py +++ b/src/libkernelbot/task.py @@ -153,6 +153,8 @@ def make_task_definition(yaml_file: str | Path) -> LeaderboardDefinition: # noq del raw["templates"] description = raw["description"] del raw["description"] + gpus = raw.get("gpus", []) + raw.pop("gpus", None) task = LeaderboardTask.from_dict(raw) # basic validation: @@ -164,9 +166,6 @@ def make_task_definition(yaml_file: str | Path) -> LeaderboardDefinition: # noq if "world_size" not in benchmark: raise KernelBotError(f"multi-gpu benchmark {benchmark} does not specify world_size") - # Read gpus if specified in task.yml - gpus = raw.get("gpus", []) - return LeaderboardDefinition(task=task, templates=templates, description=description, gpus=gpus) diff --git a/src/migrations/20260311_01_closed-leaderboard-visibility.py b/src/migrations/20260311_01_closed-leaderboard-visibility.py new file mode 100644 index 00000000..7aed46c8 --- /dev/null +++ b/src/migrations/20260311_01_closed-leaderboard-visibility.py @@ -0,0 +1,55 @@ +""" +add-leaderboard-visibility +""" + +from yoyo import step + +__depends__ = {'20260226_01_WgYAV-queryindex'} + + +steps = [ + step( + """ + ALTER TABLE leaderboard.leaderboard + ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public'; + """, + """ + ALTER TABLE leaderboard.leaderboard + DROP COLUMN visibility; + """ + ), + step( + """ + CREATE TABLE leaderboard.leaderboard_invite ( + id SERIAL PRIMARY KEY, + leaderboard_id INTEGER NOT NULL REFERENCES leaderboard.leaderboard(id) ON DELETE CASCADE, + code TEXT NOT NULL UNIQUE, + user_id TEXT, + claimed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + """, + """ + DROP TABLE leaderboard.leaderboard_invite; + """ + ), + step( + """ + CREATE INDEX idx_leaderboard_invite_leaderboard + ON leaderboard.leaderboard_invite (leaderboard_id); + """, + """ + DROP INDEX IF EXISTS leaderboard.idx_leaderboard_invite_leaderboard; + """ + ), + step( + """ + CREATE INDEX idx_leaderboard_invite_user + ON leaderboard.leaderboard_invite (user_id) + WHERE user_id IS NOT NULL; + """, + """ + DROP INDEX IF EXISTS leaderboard.idx_leaderboard_invite_user; + """ + ), +] diff --git a/src/migrations/20260313_01_invite-multi-leaderboard.py b/src/migrations/20260313_01_invite-multi-leaderboard.py new file mode 100644 index 00000000..e72ba598 --- /dev/null +++ b/src/migrations/20260313_01_invite-multi-leaderboard.py @@ -0,0 +1,66 @@ +""" +Restructure invite codes to support multiple leaderboards per code. +Moves from leaderboard_invite.leaderboard_id to a junction table leaderboard_invite_scope. +""" + +from yoyo import step + +__depends__ = {'20260311_01_closed-leaderboard-visibility'} + + +steps = [ + # 1. Create the junction table + step( + """ + CREATE TABLE leaderboard.leaderboard_invite_scope ( + invite_id INTEGER NOT NULL REFERENCES leaderboard.leaderboard_invite(id) ON DELETE CASCADE, + leaderboard_id INTEGER NOT NULL REFERENCES leaderboard.leaderboard(id) ON DELETE CASCADE, + PRIMARY KEY (invite_id, leaderboard_id) + ); + """, + """ + DROP TABLE leaderboard.leaderboard_invite_scope; + """ + ), + # 2. Migrate existing data from leaderboard_invite.leaderboard_id into the junction table + step( + """ + INSERT INTO leaderboard.leaderboard_invite_scope (invite_id, leaderboard_id) + SELECT id, leaderboard_id FROM leaderboard.leaderboard_invite + WHERE leaderboard_id IS NOT NULL; + """, + """ + UPDATE leaderboard.leaderboard_invite li + SET leaderboard_id = lis.leaderboard_id + FROM leaderboard.leaderboard_invite_scope lis + WHERE li.id = lis.invite_id; + """ + ), + # 3. Drop the old column and index + step( + """ + DROP INDEX IF EXISTS leaderboard.idx_leaderboard_invite_leaderboard; + ALTER TABLE leaderboard.leaderboard_invite DROP COLUMN leaderboard_id; + """, + """ + ALTER TABLE leaderboard.leaderboard_invite + ADD COLUMN leaderboard_id INTEGER REFERENCES leaderboard.leaderboard(id) ON DELETE CASCADE; + UPDATE leaderboard.leaderboard_invite li + SET leaderboard_id = lis.leaderboard_id + FROM leaderboard.leaderboard_invite_scope lis + WHERE li.id = lis.invite_id; + CREATE INDEX idx_leaderboard_invite_leaderboard + ON leaderboard.leaderboard_invite (leaderboard_id); + """ + ), + # 4. Index on the junction table + step( + """ + CREATE INDEX idx_leaderboard_invite_scope_leaderboard + ON leaderboard.leaderboard_invite_scope (leaderboard_id); + """, + """ + DROP INDEX IF EXISTS leaderboard.idx_leaderboard_invite_scope_leaderboard; + """ + ), +] diff --git a/tests/conftest.py b/tests/conftest.py index 1a049250..2dbe2f03 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,7 +50,8 @@ def _nuke_contents(db): db.cursor.execute( "TRUNCATE leaderboard.code_files, leaderboard.submission, leaderboard.runs, " "leaderboard.leaderboard, leaderboard.user_info, leaderboard.templates, " - "leaderboard.gpu_type RESTART IDENTITY CASCADE" + "leaderboard.gpu_type, leaderboard.leaderboard_invite_scope, " + "leaderboard.leaderboard_invite RESTART IDENTITY CASCADE" ) db.connection.commit() diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index fa4b6751..fa7ab52e 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -425,3 +425,233 @@ def test_update_problems_with_errors(self, test_client, mock_backend): assert data["status"] == "ok" assert len(data["errors"]) == 1 assert data["errors"][0]["name"] == "bad-problem" + + +class TestAdminLeaderboardInvites: + """Test admin leaderboard invite endpoints.""" + + def _setup_db_mock(self, mock_backend): + mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db) + mock_backend.db.__exit__ = MagicMock(return_value=None) + + def test_generate_invites(self, test_client, mock_backend): + """POST /admin/invites generates codes for multiple leaderboards.""" + self._setup_db_mock(mock_backend) + mock_backend.db.generate_invite_codes = MagicMock(return_value=["code1", "code2"]) + + response = test_client.post( + "/admin/invites", + headers={"Authorization": "Bearer test_token"}, + json={"leaderboards": ["lb-1", "lb-2"], "count": 2}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["codes"] == ["code1", "code2"] + assert data["leaderboards"] == ["lb-1", "lb-2"] + mock_backend.db.generate_invite_codes.assert_called_once_with(["lb-1", "lb-2"], 2) + + def test_generate_invites_single_shorthand(self, test_client, mock_backend): + """POST /admin/invites accepts single leaderboard shorthand.""" + self._setup_db_mock(mock_backend) + mock_backend.db.generate_invite_codes = MagicMock(return_value=["code1"]) + + response = test_client.post( + "/admin/invites", + headers={"Authorization": "Bearer test_token"}, + json={"leaderboard": "test-lb", "count": 1}, + ) + assert response.status_code == 200 + mock_backend.db.generate_invite_codes.assert_called_once_with(["test-lb"], 1) + + def test_generate_invites_invalid_count(self, test_client, mock_backend): + """POST /admin/invites rejects invalid count.""" + response = test_client.post( + "/admin/invites", + headers={"Authorization": "Bearer test_token"}, + json={"leaderboards": ["lb-1"], "count": 0}, + ) + assert response.status_code == 400 + + def test_generate_invites_missing_leaderboards(self, test_client, mock_backend): + """POST /admin/invites rejects missing leaderboards.""" + response = test_client.post( + "/admin/invites", + headers={"Authorization": "Bearer test_token"}, + json={"count": 5}, + ) + assert response.status_code == 400 + + def test_generate_invites_requires_auth(self, test_client): + """POST /admin/invites requires admin auth.""" + response = test_client.post( + "/admin/invites", + json={"leaderboards": ["lb-1"], "count": 5}, + ) + assert response.status_code == 401 + + def test_list_invites(self, test_client, mock_backend): + """GET /admin/leaderboards/{lb}/invites lists codes.""" + self._setup_db_mock(mock_backend) + mock_backend.db.get_invite_codes = MagicMock(return_value=[ + {"code": "abc", "user_id": "1", "user_name": "alice", + "claimed_at": "2026-01-01T00:00:00Z", "created_at": "2026-01-01T00:00:00Z"}, + {"code": "def", "user_id": None, "user_name": None, + "claimed_at": None, "created_at": "2026-01-01T00:00:00Z"}, + ]) + + response = test_client.get( + "/admin/leaderboards/test-lb/invites", + headers={"Authorization": "Bearer test_token"}, + ) + assert response.status_code == 200 + data = response.json() + assert len(data["invites"]) == 2 + assert data["invites"][0]["user_id"] == "1" + assert data["invites"][1]["user_id"] is None + + def test_set_visibility(self, test_client, mock_backend): + """POST /admin/leaderboards/{lb}/visibility changes visibility.""" + self._setup_db_mock(mock_backend) + mock_backend.db.set_leaderboard_visibility = MagicMock() + + response = test_client.post( + "/admin/leaderboards/test-lb/visibility", + headers={"Authorization": "Bearer test_token"}, + json={"visibility": "closed"}, + ) + assert response.status_code == 200 + mock_backend.db.set_leaderboard_visibility.assert_called_once_with("test-lb", "closed") + + def test_set_visibility_invalid(self, test_client, mock_backend): + """POST /admin/leaderboards/{lb}/visibility rejects invalid values.""" + response = test_client.post( + "/admin/leaderboards/test-lb/visibility", + headers={"Authorization": "Bearer test_token"}, + json={"visibility": "private"}, + ) + assert response.status_code == 400 + + def test_revoke_invite(self, test_client, mock_backend): + """DELETE /admin/invites/{code} revokes a code.""" + self._setup_db_mock(mock_backend) + mock_backend.db.revoke_invite_code = MagicMock(return_value={"code": "abc123", "was_claimed": False}) + + response = test_client.delete( + "/admin/invites/abc123", + headers={"Authorization": "Bearer test_token"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["was_claimed"] is False + mock_backend.db.revoke_invite_code.assert_called_once_with("abc123") + + def test_revoke_invite_not_found(self, test_client, mock_backend): + """DELETE /admin/invites/{code} returns 404 for invalid code.""" + from libkernelbot.utils import KernelBotError + + self._setup_db_mock(mock_backend) + err = KernelBotError("Invalid invite code", code=404) + mock_backend.db.revoke_invite_code = MagicMock(side_effect=err) + + response = test_client.delete( + "/admin/invites/bad-code", + headers={"Authorization": "Bearer test_token"}, + ) + assert response.status_code == 404 + + def test_revoke_invite_requires_auth(self, test_client): + """DELETE /admin/invites/{code} requires admin auth.""" + response = test_client.delete("/admin/invites/abc123") + assert response.status_code == 401 + + +class TestUserJoin: + """Test user invite claim endpoint.""" + + def _setup_db_mock(self, mock_backend): + mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db) + mock_backend.db.__exit__ = MagicMock(return_value=None) + + def test_join_success(self, test_client, mock_backend): + """POST /user/join claims an invite code.""" + self._setup_db_mock(mock_backend) + mock_backend.db.validate_cli_id = MagicMock( + return_value={"user_id": "42", "user_name": "testuser"} + ) + mock_backend.db.claim_invite_code = MagicMock( + return_value={"leaderboards": ["closed-lb-1", "closed-lb-2"]} + ) + + response = test_client.post( + "/user/join", + headers={"X-Popcorn-Cli-Id": "valid-cli-id"}, + json={"code": "invite-code-123"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["leaderboards"] == ["closed-lb-1", "closed-lb-2"] + mock_backend.db.claim_invite_code.assert_called_once_with("invite-code-123", "42") + + def test_join_missing_code(self, test_client, mock_backend): + """POST /user/join requires code field.""" + self._setup_db_mock(mock_backend) + mock_backend.db.validate_cli_id = MagicMock( + return_value={"user_id": "42", "user_name": "testuser"} + ) + + response = test_client.post( + "/user/join", + headers={"X-Popcorn-Cli-Id": "valid-cli-id"}, + json={}, + ) + assert response.status_code == 400 + + def test_join_requires_cli_auth(self, test_client): + """POST /user/join requires CLI authentication.""" + response = test_client.post( + "/user/join", + json={"code": "invite-code-123"}, + ) + assert response.status_code == 400 # missing header + + +class TestClosedLeaderboardAccess: + """Test that closed leaderboards gate access correctly.""" + + def _setup_db_mock(self, mock_backend): + mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db) + mock_backend.db.__exit__ = MagicMock(return_value=None) + + def test_closed_leaderboard_submissions_no_auth(self, test_client, mock_backend): + """GET /submissions on closed leaderboard without auth returns 401.""" + self._setup_db_mock(mock_backend) + mock_backend.db.get_leaderboard = MagicMock(return_value={"visibility": "closed"}) + + response = test_client.get("/submissions/closed-lb/A100") + assert response.status_code == 401 + + def test_closed_leaderboard_submissions_no_access(self, test_client, mock_backend): + """GET /submissions on closed leaderboard without invite returns 403.""" + self._setup_db_mock(mock_backend) + mock_backend.db.get_leaderboard = MagicMock(return_value={"visibility": "closed"}) + mock_backend.db.check_leaderboard_access = MagicMock(return_value=False) + mock_backend.db.validate_identity = MagicMock( + return_value={"user_id": "1", "user_name": "test", "id_type": "cli"} + ) + + response = test_client.get( + "/submissions/closed-lb/A100", + headers={"X-Popcorn-Cli-Id": "valid-cli-id"}, + ) + assert response.status_code == 403 + + def test_public_leaderboard_submissions_no_auth(self, test_client, mock_backend): + """GET /submissions on public leaderboard without auth works fine.""" + self._setup_db_mock(mock_backend) + mock_backend.db.get_leaderboard = MagicMock(return_value={"visibility": "public"}) + mock_backend.db.get_leaderboard_submissions = MagicMock(return_value=[]) + + response = test_client.get("/submissions/public-lb/A100") + assert response.status_code == 200 diff --git a/tests/test_leaderboard_db.py b/tests/test_leaderboard_db.py index a9680ad8..999cd115 100644 --- a/tests/test_leaderboard_db.py +++ b/tests/test_leaderboard_db.py @@ -798,3 +798,232 @@ def test_get_user_submissions_with_multiple_runs(database, submit_leaderboard): assert 1.5 in scores assert 2.0 in scores + +def test_check_leaderboard_access_public(database, submit_leaderboard): + """Public leaderboards grant access to everyone.""" + with database as db: + assert db.check_leaderboard_access("submit-leaderboard", "999") is True + + +def test_check_leaderboard_access_closed_no_invite(database, task_directory): + """Closed leaderboards deny access without a claimed invite.""" + from libkernelbot.task import make_task_definition + + definition = make_task_definition(task_directory / "task.yml") + deadline = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) + + with database as db: + db.create_leaderboard( + name="closed-lb", + deadline=deadline, + definition=definition, + creator_id=1, + forum_id=5, + gpu_types=["A100"], + visibility="closed", + ) + assert db.check_leaderboard_access("closed-lb", "999") is False + + +def test_check_leaderboard_access_closed_with_invite(database, task_directory): + """Closed leaderboards grant access after claiming an invite.""" + from libkernelbot.task import make_task_definition + + definition = make_task_definition(task_directory / "task.yml") + deadline = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) + + with database as db: + db.create_leaderboard( + name="closed-lb", + deadline=deadline, + definition=definition, + creator_id=1, + forum_id=5, + gpu_types=["A100"], + visibility="closed", + ) + codes = db.generate_invite_codes(["closed-lb"], 1) + assert db.check_leaderboard_access("closed-lb", "42") is False + db.claim_invite_code(codes[0], "42") + assert db.check_leaderboard_access("closed-lb", "42") is True + + +def test_generate_invite_codes(database, task_directory): + """Generate invite codes returns unique codes.""" + from libkernelbot.task import make_task_definition + + definition = make_task_definition(task_directory / "task.yml") + deadline = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) + + with database as db: + db.create_leaderboard( + name="closed-lb", + deadline=deadline, + definition=definition, + creator_id=1, + forum_id=5, + gpu_types=["A100"], + visibility="closed", + ) + codes = db.generate_invite_codes(["closed-lb"], 5) + assert len(codes) == 5 + assert len(set(codes)) == 5 # all unique + + +def test_generate_invite_codes_multi_leaderboard(database, task_directory): + """Generate invite codes covering multiple leaderboards.""" + from libkernelbot.task import make_task_definition + + definition = make_task_definition(task_directory / "task.yml") + deadline = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) + + with database as db: + db.create_leaderboard( + name="closed-lb-1", deadline=deadline, definition=definition, + creator_id=1, forum_id=5, gpu_types=["A100"], visibility="closed", + ) + db.create_leaderboard( + name="closed-lb-2", deadline=deadline, definition=definition, + creator_id=1, forum_id=6, gpu_types=["A100"], visibility="closed", + ) + codes = db.generate_invite_codes(["closed-lb-1", "closed-lb-2"], 2) + assert len(codes) == 2 + + # Claiming one code grants access to both leaderboards + db.claim_invite_code(codes[0], "42") + assert db.check_leaderboard_access("closed-lb-1", "42") is True + assert db.check_leaderboard_access("closed-lb-2", "42") is True + + # Unclaimed code doesn't grant access + assert db.check_leaderboard_access("closed-lb-1", "99") is False + + +def test_claim_invite_code_already_claimed(database, task_directory): + """Claiming an already-claimed code raises an error.""" + from libkernelbot.task import make_task_definition + + definition = make_task_definition(task_directory / "task.yml") + deadline = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) + + with database as db: + db.create_leaderboard( + name="closed-lb", + deadline=deadline, + definition=definition, + creator_id=1, + forum_id=5, + gpu_types=["A100"], + visibility="closed", + ) + codes = db.generate_invite_codes(["closed-lb"], 1) + db.claim_invite_code(codes[0], "42") + + with pytest.raises(KernelBotError, match="already been claimed"): + db.claim_invite_code(codes[0], "99") + + +def test_claim_invite_code_idempotent(database, task_directory): + """Same user claiming the same code again is idempotent.""" + from libkernelbot.task import make_task_definition + + definition = make_task_definition(task_directory / "task.yml") + deadline = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) + + with database as db: + db.create_leaderboard( + name="closed-lb", + deadline=deadline, + definition=definition, + creator_id=1, + forum_id=5, + gpu_types=["A100"], + visibility="closed", + ) + codes = db.generate_invite_codes(["closed-lb"], 1) + db.claim_invite_code(codes[0], "42") + result = db.claim_invite_code(codes[0], "42") # no error + assert result["leaderboards"] == ["closed-lb"] + + +def test_claim_invite_code_invalid(database, task_directory): + """Claiming a nonexistent code raises an error.""" + with database as db: + with pytest.raises(KernelBotError, match="Invalid invite code"): + db.claim_invite_code("bogus-code", "42") + + +def test_get_invite_codes(database, task_directory): + """Admin can list invite codes with claim status.""" + from libkernelbot.task import make_task_definition + + definition = make_task_definition(task_directory / "task.yml") + deadline = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) + + with database as db: + db.create_leaderboard( + name="closed-lb", + deadline=deadline, + definition=definition, + creator_id=1, + forum_id=5, + gpu_types=["A100"], + visibility="closed", + ) + codes = db.generate_invite_codes(["closed-lb"], 3) + db.claim_invite_code(codes[0], "42") + + invites = db.get_invite_codes("closed-lb") + assert len(invites) == 3 + + claimed = [i for i in invites if i["user_id"] is not None] + unclaimed = [i for i in invites if i["user_id"] is None] + assert len(claimed) == 1 + assert len(unclaimed) == 2 + assert claimed[0]["user_id"] == "42" + assert claimed[0]["code"] == codes[0] + + +def test_leaderboard_visibility_field(database, task_directory): + """Leaderboard visibility field is returned correctly.""" + from libkernelbot.task import make_task_definition + + definition = make_task_definition(task_directory / "task.yml") + deadline = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) + + with database as db: + db.create_leaderboard( + name="public-lb", + deadline=deadline, + definition=definition, + creator_id=1, + forum_id=5, + gpu_types=["A100"], + ) + db.create_leaderboard( + name="closed-lb", + deadline=deadline, + definition=definition, + creator_id=1, + forum_id=6, + gpu_types=["A100"], + visibility="closed", + ) + + assert db.get_leaderboard("public-lb")["visibility"] == "public" + assert db.get_leaderboard("closed-lb")["visibility"] == "closed" + + lbs = db.get_leaderboards() + vis_map = {lb["name"]: lb["visibility"] for lb in lbs} + assert vis_map["public-lb"] == "public" + assert vis_map["closed-lb"] == "closed" + + +def test_set_leaderboard_visibility(database, submit_leaderboard): + """Changing visibility works.""" + with database as db: + assert db.get_leaderboard("submit-leaderboard")["visibility"] == "public" + db.set_leaderboard_visibility("submit-leaderboard", "closed") + assert db.get_leaderboard("submit-leaderboard")["visibility"] == "closed" + db.set_leaderboard_visibility("submit-leaderboard", "public") + assert db.get_leaderboard("submit-leaderboard")["visibility"] == "public" + From f797ae63363d944063d2955f58cf48907a86552c Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Fri, 13 Mar 2026 14:51:45 -0700 Subject: [PATCH 2/3] Cleanup --- src/kernelbot/api/main.py | 39 +++++++++++++-------------------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/src/kernelbot/api/main.py b/src/kernelbot/api/main.py index d3f82089..f0146c48 100644 --- a/src/kernelbot/api/main.py +++ b/src/kernelbot/api/main.py @@ -221,6 +221,16 @@ def require_admin( raise HTTPException(status_code=401, detail="Invalid admin token") +def enforce_leaderboard_access(db, leaderboard_name: str, user_info: Optional[dict]) -> None: + """Raise 401/403 if the leaderboard is closed and the user lacks access.""" + lb = db.get_leaderboard(leaderboard_name) + if lb.get("visibility") == "closed": + if user_info is None: + raise HTTPException(status_code=401, detail="Authentication required for closed leaderboard") + if not db.check_leaderboard_access(leaderboard_name, user_info["user_id"]): + raise HTTPException(status_code=403, detail="You do not have access to this leaderboard") + + @app.get("/auth/init") async def auth_init(provider: str, db_context=Depends(get_db)) -> dict: if provider not in ["discord", "github"]: @@ -744,12 +754,7 @@ async def get_gpus( await simple_rate_limit() try: with db_context as db: - lb = db.get_leaderboard(leaderboard_name) - if lb.get("visibility") == "closed": - if user_info is None: - raise HTTPException(status_code=401, detail="Authentication required for closed leaderboard") - if not db.check_leaderboard_access(leaderboard_name, user_info["user_id"]): - raise HTTPException(status_code=403, detail="You do not have access to this leaderboard") + enforce_leaderboard_access(db, leaderboard_name, user_info) return db.get_leaderboard_gpu_types(leaderboard_name) except HTTPException: raise @@ -769,12 +774,7 @@ async def get_submissions( await simple_rate_limit() try: with db_context as db: - lb = db.get_leaderboard(leaderboard_name) - if lb.get("visibility") == "closed": - if user_info is None: - raise HTTPException(status_code=401, detail="Authentication required for closed leaderboard") - if not db.check_leaderboard_access(leaderboard_name, user_info["user_id"]): - raise HTTPException(status_code=403, detail="You do not have access to this leaderboard") + enforce_leaderboard_access(db, leaderboard_name, user_info) return db.get_leaderboard_submissions( leaderboard_name, gpu_name, limit=limit, offset=offset ) @@ -796,12 +796,7 @@ async def get_submission_count( await simple_rate_limit() try: with db_context as db: - lb = db.get_leaderboard(leaderboard_name) - if lb.get("visibility") == "closed": - if user_info is None: - raise HTTPException(status_code=401, detail="Authentication required for closed leaderboard") - if not db.check_leaderboard_access(leaderboard_name, user_info["user_id"]): - raise HTTPException(status_code=403, detail="You do not have access to this leaderboard") + enforce_leaderboard_access(db, leaderboard_name, user_info) count = db.get_leaderboard_submission_count(leaderboard_name, gpu_name, user_id) return {"count": count} except HTTPException: @@ -932,14 +927,6 @@ async def delete_user_submission( raise HTTPException(status_code=500, detail=f"Error deleting submission: {e}") from e -@app.get("/user/me") -async def get_current_user( - user_info: Annotated[dict, Depends(validate_user_header)], -) -> dict: - """Returns the authenticated user's ID and name.""" - return {"user_id": user_info["user_id"], "user_name": user_info["user_name"]} - - @app.post("/admin/invites") async def admin_generate_invites( payload: dict, From 6b809742b9593687735ef77a1e05e4cd08f76069 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Fri, 13 Mar 2026 15:01:27 -0700 Subject: [PATCH 3/3] Defaults --- src/libkernelbot/leaderboard_db.py | 247 ++++++++++++++++------------- 1 file changed, 141 insertions(+), 106 deletions(-) diff --git a/src/libkernelbot/leaderboard_db.py b/src/libkernelbot/leaderboard_db.py index 4058ddc9..5a64bfbb 100644 --- a/src/libkernelbot/leaderboard_db.py +++ b/src/libkernelbot/leaderboard_db.py @@ -647,138 +647,173 @@ def generate_invite_codes(self, leaderboard_names: list[str], count: int) -> lis """Generate N unique invite codes covering multiple leaderboards. Returns the codes.""" import secrets - lb_ids = [] - for name in leaderboard_names: - lb_ids.append(self.get_leaderboard_id(name)) + try: + lb_ids = [] + for name in leaderboard_names: + lb_ids.append(self.get_leaderboard_id(name)) - codes = [] - for _ in range(count): - code = secrets.token_urlsafe(16) - self.cursor.execute( - """ - INSERT INTO leaderboard.leaderboard_invite (code) - VALUES (%s) - RETURNING id - """, - (code,), - ) - invite_id = self.cursor.fetchone()[0] - for lb_id in lb_ids: + codes = [] + for _ in range(count): + code = secrets.token_urlsafe(16) self.cursor.execute( """ - INSERT INTO leaderboard.leaderboard_invite_scope (invite_id, leaderboard_id) - VALUES (%s, %s) + INSERT INTO leaderboard.leaderboard_invite (code) + VALUES (%s) + RETURNING id """, - (invite_id, lb_id), + (code,), ) - codes.append(code) - self.connection.commit() - return codes + invite_id = self.cursor.fetchone()[0] + for lb_id in lb_ids: + self.cursor.execute( + """ + INSERT INTO leaderboard.leaderboard_invite_scope (invite_id, leaderboard_id) + VALUES (%s, %s) + """, + (invite_id, lb_id), + ) + codes.append(code) + self.connection.commit() + return codes + except KernelBotError: + raise + except psycopg2.Error as e: + self.connection.rollback() + logger.exception("Error generating invite codes", exc_info=e) + raise KernelBotError("Error generating invite codes") from e def claim_invite_code(self, code: str, user_id: str) -> dict: """Claim an invite code for a user. Returns list of leaderboard names. Raises KernelBotError if code is invalid or already claimed. """ - self.cursor.execute( - """ - SELECT li.id, li.user_id - FROM leaderboard.leaderboard_invite li - WHERE li.code = %s - """, - (code,), - ) - row = self.cursor.fetchone() - if row is None: - raise KernelBotError("Invalid invite code") - invite_id, existing_user = row + try: + self.cursor.execute( + """ + SELECT li.id, li.user_id + FROM leaderboard.leaderboard_invite li + WHERE li.code = %s + """, + (code,), + ) + row = self.cursor.fetchone() + if row is None: + raise KernelBotError("Invalid invite code") + invite_id, existing_user = row - # Fetch leaderboard names for this invite - self.cursor.execute( - """ - SELECT l.name - FROM leaderboard.leaderboard_invite_scope lis - JOIN leaderboard.leaderboard l ON lis.leaderboard_id = l.id - WHERE lis.invite_id = %s - ORDER BY l.name - """, - (invite_id,), - ) - leaderboards = [r[0] for r in self.cursor.fetchall()] + # Fetch leaderboard names for this invite + self.cursor.execute( + """ + SELECT l.name + FROM leaderboard.leaderboard_invite_scope lis + JOIN leaderboard.leaderboard l ON lis.leaderboard_id = l.id + WHERE lis.invite_id = %s + ORDER BY l.name + """, + (invite_id,), + ) + leaderboards = [r[0] for r in self.cursor.fetchall()] - if existing_user == str(user_id): + if existing_user == str(user_id): + return {"leaderboards": leaderboards} + if existing_user is not None: + raise KernelBotError("This invite code has already been claimed") + self.cursor.execute( + """ + UPDATE leaderboard.leaderboard_invite + SET user_id = %s, claimed_at = CURRENT_TIMESTAMP + WHERE id = %s AND user_id IS NULL + """, + (str(user_id), invite_id), + ) + if self.cursor.rowcount == 0: + raise KernelBotError("This invite code has already been claimed") + self.connection.commit() return {"leaderboards": leaderboards} - if existing_user is not None: - raise KernelBotError("This invite code has already been claimed") - self.cursor.execute( - """ - UPDATE leaderboard.leaderboard_invite - SET user_id = %s, claimed_at = CURRENT_TIMESTAMP - WHERE id = %s AND user_id IS NULL - """, - (str(user_id), invite_id), - ) - if self.cursor.rowcount == 0: - raise KernelBotError("This invite code has already been claimed") - self.connection.commit() - return {"leaderboards": leaderboards} + except KernelBotError: + raise + except psycopg2.Error as e: + self.connection.rollback() + logger.exception("Error claiming invite code", exc_info=e) + raise KernelBotError("Error claiming invite code") from e def get_invite_codes(self, leaderboard_name: str) -> list[dict]: """Returns all invite codes that cover a given leaderboard, with claim status.""" - lb_id = self.get_leaderboard_id(leaderboard_name) - self.cursor.execute( - """ - SELECT li.code, li.user_id, ui.user_name, li.claimed_at, li.created_at - FROM leaderboard.leaderboard_invite li - JOIN leaderboard.leaderboard_invite_scope lis ON li.id = lis.invite_id - LEFT JOIN leaderboard.user_info ui ON li.user_id = ui.id - WHERE lis.leaderboard_id = %s - ORDER BY li.created_at - """, - (lb_id,), - ) - return [ - { - "code": row[0], - "user_id": row[1], - "user_name": row[2], - "claimed_at": row[3], - "created_at": row[4], - } - for row in self.cursor.fetchall() - ] + try: + lb_id = self.get_leaderboard_id(leaderboard_name) + self.cursor.execute( + """ + SELECT li.code, li.user_id, ui.user_name, li.claimed_at, li.created_at + FROM leaderboard.leaderboard_invite li + JOIN leaderboard.leaderboard_invite_scope lis ON li.id = lis.invite_id + LEFT JOIN leaderboard.user_info ui ON li.user_id = ui.id + WHERE lis.leaderboard_id = %s + ORDER BY li.created_at + """, + (lb_id,), + ) + return [ + { + "code": row[0], + "user_id": row[1], + "user_name": row[2], + "claimed_at": row[3], + "created_at": row[4], + } + for row in self.cursor.fetchall() + ] + except KernelBotError: + raise + except psycopg2.Error as e: + self.connection.rollback() + logger.exception("Error fetching invite codes", exc_info=e) + raise KernelBotError("Error fetching invite codes") from e def revoke_invite_code(self, code: str) -> dict: """Revoke (delete) an invite code. Returns info about the revoked code. Raises KernelBotError if code does not exist. """ - self.cursor.execute( - """ - DELETE FROM leaderboard.leaderboard_invite - WHERE code = %s - RETURNING code, user_id - """, - (code,), - ) - row = self.cursor.fetchone() - if row is None: - raise KernelBotError("Invalid invite code", code=404) - self.connection.commit() - return {"code": row[0], "was_claimed": row[1] is not None} + try: + self.cursor.execute( + """ + DELETE FROM leaderboard.leaderboard_invite + WHERE code = %s + RETURNING code, user_id + """, + (code,), + ) + row = self.cursor.fetchone() + if row is None: + raise KernelBotError("Invalid invite code", code=404) + self.connection.commit() + return {"code": row[0], "was_claimed": row[1] is not None} + except KernelBotError: + raise + except psycopg2.Error as e: + self.connection.rollback() + logger.exception("Error revoking invite code", exc_info=e) + raise KernelBotError("Error revoking invite code") from e def set_leaderboard_visibility(self, leaderboard_name: str, visibility: str): """Change the visibility of a leaderboard.""" - lb_id = self.get_leaderboard_id(leaderboard_name) - self.cursor.execute( - """ - UPDATE leaderboard.leaderboard - SET visibility = %s - WHERE id = %s - """, - (visibility, lb_id), - ) - self.connection.commit() + try: + lb_id = self.get_leaderboard_id(leaderboard_name) + self.cursor.execute( + """ + UPDATE leaderboard.leaderboard + SET visibility = %s + WHERE id = %s + """, + (visibility, lb_id), + ) + self.connection.commit() + except KernelBotError: + raise + except psycopg2.Error as e: + self.connection.rollback() + logger.exception("Error setting leaderboard visibility", exc_info=e) + raise KernelBotError("Error setting leaderboard visibility") from e def get_leaderboard_submissions( self,