Skip to content

Commit b06c760

Browse files
authored
Update runs (#468)
* Update runs * Cleanup * Defaults
1 parent ec9f6fa commit b06c760

14 files changed

Lines changed: 976 additions & 30 deletions

src/kernelbot/api/api_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ async def to_submit_info(
274274
user_name=user_name,
275275
gpus=[gpu_type],
276276
leaderboard=leaderboard_name,
277+
identity_type=user_info.get("id_type"),
277278
)
278279
except UnicodeDecodeError:
279280
raise HTTPException(

src/kernelbot/api/main.py

Lines changed: 158 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ async def validate_cli_header(
131131
if user_info is None:
132132
raise HTTPException(status_code=401, detail="Invalid or unauthorized X-Popcorn-Cli-Id")
133133

134+
user_info["id_type"] = "cli"
134135
return user_info
135136

136137

@@ -178,6 +179,38 @@ async def validate_user_header(
178179
return user_info
179180

180181

182+
async def optional_user_header(
183+
x_web_auth_id: Optional[str] = Header(None, alias="X-Web-Auth-Id"),
184+
x_popcorn_cli_id: Optional[str] = Header(None, alias="X-Popcorn-Cli-Id"),
185+
db_context: LeaderboardDB = Depends(get_db),
186+
) -> Optional[Any]:
187+
"""Like validate_user_header but returns None instead of raising when no auth header is present."""
188+
token = x_web_auth_id or x_popcorn_cli_id
189+
if not token:
190+
return None
191+
192+
if x_web_auth_id:
193+
id_type = IdentityType.WEB
194+
else:
195+
id_type = IdentityType.CLI
196+
197+
try:
198+
with db_context as db:
199+
user_info = db.validate_identity(token, id_type)
200+
except Exception as e:
201+
raise HTTPException(
202+
status_code=500,
203+
detail=f"Database error during validation: {e}",
204+
) from e
205+
206+
if not user_info:
207+
raise HTTPException(
208+
status_code=401,
209+
detail="Invalid or unauthorized auth header",
210+
)
211+
return user_info
212+
213+
181214
def require_admin(
182215
authorization: Optional[str] = Header(None, alias="Authorization"),
183216
) -> None:
@@ -188,6 +221,16 @@ def require_admin(
188221
raise HTTPException(status_code=401, detail="Invalid admin token")
189222

190223

224+
def enforce_leaderboard_access(db, leaderboard_name: str, user_info: Optional[dict]) -> None:
225+
"""Raise 401/403 if the leaderboard is closed and the user lacks access."""
226+
lb = db.get_leaderboard(leaderboard_name)
227+
if lb.get("visibility") == "closed":
228+
if user_info is None:
229+
raise HTTPException(status_code=401, detail="Authentication required for closed leaderboard")
230+
if not db.check_leaderboard_access(leaderboard_name, user_info["user_id"]):
231+
raise HTTPException(status_code=403, detail="You do not have access to this leaderboard")
232+
233+
191234
@app.get("/auth/init")
192235
async def auth_init(provider: str, db_context=Depends(get_db)) -> dict:
193236
if provider not in ["discord", "github"]:
@@ -576,13 +619,18 @@ async def create_dev_leaderboard(
576619
except Exception:
577620
pass # Leaderboard doesn't exist, that's fine
578621

622+
visibility = payload.get("visibility", "public")
623+
if visibility not in ("public", "closed"):
624+
raise HTTPException(status_code=400, detail="visibility must be 'public' or 'closed'")
625+
579626
db.create_leaderboard(
580627
name=leaderboard_name,
581628
deadline=deadline_value,
582629
definition=definition,
583630
creator_id=0,
584631
forum_id=-1,
585632
gpu_types=definition.gpus,
633+
visibility=visibility,
586634
)
587635
return {"status": "ok", "leaderboard": leaderboard_name}
588636

@@ -652,6 +700,9 @@ async def admin_update_problems(
652700
problem_set = payload.get("problem_set")
653701
branch = payload.get("branch", "main")
654702
force = payload.get("force", False)
703+
visibility = payload.get("visibility", "public")
704+
if visibility not in ("public", "closed"):
705+
raise HTTPException(status_code=400, detail="visibility must be 'public' or 'closed'")
655706

656707
try:
657708
result = sync_problems(
@@ -662,6 +713,7 @@ async def admin_update_problems(
662713
force=force,
663714
creator_id=0, # API-created
664715
forum_id=-1, # No Discord forum
716+
visibility=visibility,
665717
)
666718
except ValueError as e:
667719
raise HTTPException(status_code=400, detail=str(e)) from e
@@ -740,21 +792,19 @@ async def get_leaderboards(db_context=Depends(get_db)):
740792

741793

742794
@app.get("/gpus/{leaderboard_name}")
743-
async def get_gpus(leaderboard_name: str, db_context=Depends(get_db)) -> list[str]:
744-
"""An endpoint that returns all GPU types that are available for a given leaderboard and runner.
745-
746-
Args:
747-
leaderboard_name (str): The name of the leaderboard to get the GPU types for.
748-
runner_name (str): The name of the runner to get the GPU types for.
749-
750-
Returns:
751-
list[str]: A list of GPU types that are available for the given leaderboard and runner.
752-
"""
795+
async def get_gpus(
796+
leaderboard_name: str,
797+
user_info: Annotated[Optional[Any], Depends(optional_user_header)] = None,
798+
db_context=Depends(get_db),
799+
) -> list[str]:
800+
"""An endpoint that returns all GPU types that are available for a given leaderboard and runner."""
753801
await simple_rate_limit()
754802
try:
755803
with db_context as db:
804+
enforce_leaderboard_access(db, leaderboard_name, user_info)
756805
return db.get_leaderboard_gpu_types(leaderboard_name)
757-
806+
except HTTPException:
807+
raise
758808
except Exception as e:
759809
raise HTTPException(status_code=500, detail=f"Error fetching GPU data: {e}") from e
760810

@@ -765,29 +815,39 @@ async def get_submissions(
765815
gpu_name: str,
766816
limit: int = None,
767817
offset: int = 0,
818+
user_info: Annotated[Optional[Any], Depends(optional_user_header)] = None,
768819
db_context=Depends(get_db),
769820
) -> list[LeaderboardRankedEntry]:
770821
await simple_rate_limit()
771822
try:
772823
with db_context as db:
773-
# Add validation for leaderboard and GPU? Might be redundant if DB handles it.
824+
enforce_leaderboard_access(db, leaderboard_name, user_info)
774825
return db.get_leaderboard_submissions(
775826
leaderboard_name, gpu_name, limit=limit, offset=offset
776827
)
828+
except HTTPException:
829+
raise
777830
except Exception as e:
778831
raise HTTPException(status_code=500, detail=f"Error fetching submissions: {e}") from e
779832

780833

781834
@app.get("/submission_count/{leaderboard_name}/{gpu_name}")
782835
async def get_submission_count(
783-
leaderboard_name: str, gpu_name: str, user_id: str = None, db_context=Depends(get_db)
836+
leaderboard_name: str,
837+
gpu_name: str,
838+
user_id: str = None,
839+
user_info: Annotated[Optional[Any], Depends(optional_user_header)] = None,
840+
db_context=Depends(get_db),
784841
) -> dict:
785842
"""Get the total count of submissions for pagination"""
786843
await simple_rate_limit()
787844
try:
788845
with db_context as db:
846+
enforce_leaderboard_access(db, leaderboard_name, user_info)
789847
count = db.get_leaderboard_submission_count(leaderboard_name, gpu_name, user_id)
790848
return {"count": count}
849+
except HTTPException:
850+
raise
791851
except Exception as e:
792852
raise HTTPException(status_code=500, detail=f"Error fetching submission count: {e}") from e
793853

@@ -912,3 +972,88 @@ async def delete_user_submission(
912972
raise
913973
except Exception as e:
914974
raise HTTPException(status_code=500, detail=f"Error deleting submission: {e}") from e
975+
976+
977+
@app.post("/admin/invites")
978+
async def admin_generate_invites(
979+
payload: dict,
980+
_: Annotated[None, Depends(require_admin)],
981+
db_context=Depends(get_db),
982+
) -> dict:
983+
"""Generate invite codes covering one or more leaderboards.
984+
985+
Accepts either:
986+
{"leaderboards": ["lb1", "lb2"], "count": 10}
987+
{"leaderboard": "lb1", "count": 10} (single leaderboard shorthand)
988+
"""
989+
count = payload.get("count")
990+
if not isinstance(count, int) or count < 1 or count > 10000:
991+
raise HTTPException(status_code=400, detail="count must be an integer between 1 and 10000")
992+
leaderboards = payload.get("leaderboards") or []
993+
if not leaderboards:
994+
single = payload.get("leaderboard")
995+
if single:
996+
leaderboards = [single]
997+
if not leaderboards or not isinstance(leaderboards, list):
998+
raise HTTPException(status_code=400, detail="Must provide 'leaderboards' list or 'leaderboard' string")
999+
with db_context as db:
1000+
codes = db.generate_invite_codes(leaderboards, count)
1001+
return {"status": "ok", "leaderboards": leaderboards, "codes": codes}
1002+
1003+
1004+
@app.get("/admin/leaderboards/{leaderboard_name}/invites")
1005+
async def admin_list_invites(
1006+
leaderboard_name: str,
1007+
_: Annotated[None, Depends(require_admin)],
1008+
db_context=Depends(get_db),
1009+
) -> dict:
1010+
"""List all invite codes for a leaderboard with claim status."""
1011+
with db_context as db:
1012+
invites = db.get_invite_codes(leaderboard_name)
1013+
return {"status": "ok", "leaderboard": leaderboard_name, "invites": invites}
1014+
1015+
1016+
@app.delete("/admin/invites/{code}")
1017+
async def admin_revoke_invite(
1018+
code: str,
1019+
_: Annotated[None, Depends(require_admin)],
1020+
db_context=Depends(get_db),
1021+
) -> dict:
1022+
"""Revoke an invite code, removing it from the pool."""
1023+
with db_context as db:
1024+
result = db.revoke_invite_code(code)
1025+
return {"status": "ok", **result}
1026+
1027+
1028+
@app.post("/admin/leaderboards/{leaderboard_name}/visibility")
1029+
async def admin_set_visibility(
1030+
leaderboard_name: str,
1031+
payload: dict,
1032+
_: Annotated[None, Depends(require_admin)],
1033+
db_context=Depends(get_db),
1034+
) -> dict:
1035+
"""Change the visibility of an existing leaderboard."""
1036+
visibility = payload.get("visibility")
1037+
if visibility not in ("public", "closed"):
1038+
raise HTTPException(status_code=400, detail="visibility must be 'public' or 'closed'")
1039+
with db_context as db:
1040+
db.set_leaderboard_visibility(leaderboard_name, visibility)
1041+
return {"status": "ok", "leaderboard": leaderboard_name, "visibility": visibility}
1042+
1043+
1044+
@app.post("/user/join")
1045+
async def user_join_leaderboard(
1046+
payload: dict,
1047+
user_info: Annotated[dict, Depends(validate_cli_header)],
1048+
db_context=Depends(get_db),
1049+
) -> dict:
1050+
"""Claim an invite code to join a closed leaderboard. CLI only."""
1051+
code = payload.get("code")
1052+
if not code:
1053+
raise HTTPException(status_code=400, detail="Missing required field: code")
1054+
try:
1055+
with db_context as db:
1056+
result = db.claim_invite_code(code, user_info["user_id"])
1057+
except KernelBotError as e:
1058+
raise HTTPException(status_code=400, detail=str(e)) from e
1059+
return {"status": "ok", "leaderboards": result["leaderboards"]}

src/kernelbot/cogs/admin_cog.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ async def leaderboard_create_local(
169169
interaction: discord.Interaction,
170170
directory: str,
171171
gpu: Optional[app_commands.Choice[str]],
172+
closed: bool = False,
172173
):
173174
is_admin = await self.admin_check(interaction)
174175
if not is_admin:
@@ -218,6 +219,7 @@ async def leaderboard_create_local(
218219
definition=definition,
219220
forum_id=forum_id,
220221
gpu=gpu.value if gpu else None,
222+
visibility="closed" if closed else "public",
221223
):
222224
await send_discord_message(
223225
interaction,
@@ -241,6 +243,7 @@ async def leaderboard_create_impl( # noqa: C901
241243
deadline: str,
242244
definition: LeaderboardDefinition,
243245
gpus: Optional[str | list[str]],
246+
visibility: str = "public",
244247
):
245248
if len(leaderboard_name) > 95:
246249
await send_discord_message(
@@ -282,7 +285,8 @@ async def leaderboard_create_impl( # noqa: C901
282285
)
283286

284287
success = await self.create_leaderboard_in_db(
285-
interaction, leaderboard_name, date_value, definition, forum_thread.thread.id, gpus
288+
interaction, leaderboard_name, date_value, definition, forum_thread.thread.id, gpus,
289+
visibility=visibility,
286290
)
287291
if not success:
288292
await forum_thread.delete()
@@ -331,6 +335,7 @@ async def create_leaderboard_in_db(
331335
definition: LeaderboardDefinition,
332336
forum_id: int,
333337
gpu: Optional[str | list[str]] = None,
338+
visibility: str = "public",
334339
) -> bool:
335340
if gpu is None:
336341
# Ask the user to select GPUs
@@ -361,6 +366,7 @@ async def create_leaderboard_in_db(
361366
gpu_types=selected_gpus,
362367
creator_id=interaction.user.id,
363368
forum_id=forum_id,
369+
visibility=visibility,
364370
)
365371
except KernelBotError as e:
366372
await send_discord_message(
@@ -521,6 +527,7 @@ async def update_problems(
521527
problem_set: Optional[str] = None,
522528
branch: Optional[str] = "main",
523529
force: bool = False,
530+
closed: bool = False,
524531
):
525532
is_admin = await self.admin_check(interaction)
526533
if not is_admin:
@@ -579,7 +586,7 @@ async def update_problems(
579586
)
580587
return
581588
for competition in problem_dir.glob("*.yaml"):
582-
await self.update_competition(interaction, competition)
589+
await self.update_competition(interaction, competition, closed=closed)
583590
else:
584591
problem_set = problem_dir / f"{problem_set}.yaml"
585592
if not problem_set.exists():
@@ -592,7 +599,7 @@ async def update_problems(
592599
ephemeral=True,
593600
)
594601
return
595-
await self.update_competition(interaction, problem_set, force)
602+
await self.update_competition(interaction, problem_set, force, closed=closed)
596603

597604
async def _create_update_plan( # noqa: C901
598605
self,
@@ -699,7 +706,7 @@ async def _create_update_plan( # noqa: C901
699706
return update_list, create_list
700707

701708
async def update_competition(
702-
self, interaction: discord.Interaction, spec_file: Path, force: bool = False
709+
self, interaction: discord.Interaction, spec_file: Path, force: bool = False, closed: bool = False
703710
):
704711
try:
705712
root = spec_file.parent
@@ -738,6 +745,7 @@ async def update_competition(
738745
entry["deadline"],
739746
make_task_definition(root / entry["directory"]),
740747
entry["gpus"],
748+
visibility="closed" if closed else "public",
741749
)
742750
steps += "done\n"
743751

src/kernelbot/cogs/leaderboard_cog.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ async def submit(
124124
user_name=interaction.user.global_name or interaction.user.name,
125125
gpus=gpu,
126126
leaderboard=leaderboard_name,
127+
identity_type="discord",
127128
)
128129
req = prepare_submission(req, self.bot.backend)
129130

src/libkernelbot/db_types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class LeaderboardItem(TypedDict):
2121
gpu_types: List[str]
2222
forum_id: int
2323
secret_seed: NotRequired[int]
24+
visibility: str
2425

2526

2627
class LeaderboardRankedEntry(TypedDict):

0 commit comments

Comments
 (0)