diff --git a/src/kernelbot/api/api_utils.py b/src/kernelbot/api/api_utils.py index b5cda480..db25eef1 100644 --- a/src/kernelbot/api/api_utils.py +++ b/src/kernelbot/api/api_utils.py @@ -1,6 +1,13 @@ +import asyncio +from dataclasses import asdict +from datetime import datetime +from re import sub +import time +from typing import Any, Optional, Tuple import requests -from fastapi import HTTPException - +from fastapi import HTTPException, UploadFile +from fastapi import BackgroundTasks +import json from kernelbot.env import env from libkernelbot.backend import KernelBackend from libkernelbot.consts import SubmissionMode @@ -12,9 +19,13 @@ Text, ) from libkernelbot.submission import SubmissionRequest, prepare_submission +from src.kernelbot.api.main import simple_rate_limit +from src.libkernelbot.leaderboard_db import LeaderboardDB -async def _handle_discord_oauth(code: str, redirect_uri: str) -> tuple[str, str]: +async def _handle_discord_oauth( + code: str, redirect_uri: str +) -> tuple[str, str]: """Handles the Discord OAuth code exchange and user info retrieval.""" client_id = env.CLI_DISCORD_CLIENT_ID client_secret = env.CLI_DISCORD_CLIENT_SECRET @@ -22,11 +33,17 @@ async def _handle_discord_oauth(code: str, redirect_uri: str) -> tuple[str, str] user_api_url = "https://discord.com/api/users/@me" if not client_id: - raise HTTPException(status_code=500, detail="Discord client ID not configured.") + raise HTTPException( + status_code=500, detail="Discord client ID not configured." + ) if not client_secret: - raise HTTPException(status_code=500, detail="Discord client secret not configured.") + raise HTTPException( + status_code=500, detail="Discord client secret not configured." + ) if not token_url: - raise HTTPException(status_code=500, detail="Discord token URL not configured.") + raise HTTPException( + status_code=500, detail="Discord token URL not configured." + ) token_data = { "client_id": client_id, @@ -69,13 +86,16 @@ async def _handle_discord_oauth(code: str, redirect_uri: str) -> tuple[str, str] if not user_id or not user_name: raise HTTPException( - status_code=500, detail="Failed to retrieve user ID or username from Discord." + status_code=500, + detail="Failed to retrieve user ID or username from Discord.", ) return user_id, user_name -async def _handle_github_oauth(code: str, redirect_uri: str) -> tuple[str, str]: +async def _handle_github_oauth( + code: str, redirect_uri: str +) -> tuple[str, str]: """Handles the GitHub OAuth code exchange and user info retrieval.""" client_id = env.CLI_GITHUB_CLIENT_ID client_secret = env.CLI_GITHUB_CLIENT_SECRET @@ -84,9 +104,13 @@ async def _handle_github_oauth(code: str, redirect_uri: str) -> tuple[str, str]: user_api_url = "https://api.github.com/user" if not client_id: - raise HTTPException(status_code=500, detail="GitHub client ID not configured.") + raise HTTPException( + status_code=500, detail="GitHub client ID not configured." + ) if not client_secret: - raise HTTPException(status_code=500, detail="GitHub client secret not configured.") + raise HTTPException( + status_code=500, detail="GitHub client secret not configured." + ) token_data = { "client_id": client_id, @@ -98,7 +122,9 @@ async def _handle_github_oauth(code: str, redirect_uri: str) -> tuple[str, str]: headers = {"Accept": "application/json"} # Request JSON response for token try: - token_response = requests.post(token_url, data=token_data, headers=headers) + token_response = requests.post( + token_url, data=token_data, headers=headers + ) token_response.raise_for_status() except requests.exceptions.RequestException as e: raise HTTPException( @@ -125,33 +151,20 @@ async def _handle_github_oauth(code: str, redirect_uri: str) -> tuple[str, str]: ) from e user_json = user_response.json() - user_id = str(user_json.get("id")) # GitHub ID is integer, convert to string for consistency + user_id = str( + user_json.get("id") + ) # GitHub ID is integer, convert to string for consistency user_name = user_json.get("login") # GitHub uses 'login' for username if not user_id or not user_name: raise HTTPException( - status_code=500, detail="Failed to retrieve user ID or username from GitHub." + status_code=500, + detail="Failed to retrieve user ID or username from GitHub.", ) return user_id, user_name -async def _run_submission( - submission: SubmissionRequest, mode: SubmissionMode, backend: KernelBackend -): - try: - req = prepare_submission(submission, backend) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) from e - - if len(req.gpus) != 1: - raise HTTPException(status_code=400, detail="Invalid GPU type") - - reporter = MultiProgressReporterAPI() - sub_id, results = await backend.submit_full(req, mode, reporter) - return results, [rep.get_message() + "\n" + rep.long_report for rep in reporter.runs] - - class MultiProgressReporterAPI(MultiProgressReporter): def __init__(self): self.runs = [] @@ -183,3 +196,230 @@ async def display_report(self, title: str, report: RunResultReport): elif isinstance(part, Log): self.long_report += f"\n\n## {part.header}:\n" self.long_report += f"```\n{part.content}```" + + +async def to_submission_info( + user_info: Any, + submission_mode: str, + file: UploadFile, + leaderboard_name: str, + gpu_type: str, + db_context: LeaderboardDB, +) -> tuple[SubmissionRequest, SubmissionMode]: + user_name = user_info["user_name"] + user_id = user_info["user_id"] + + try: + submission_mode_enum: SubmissionMode = SubmissionMode( + submission_mode.lower() + ) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Invalid submission mode value: '{submission_mode}'", + ) from None + + if submission_mode_enum in [SubmissionMode.PROFILE]: + raise HTTPException( + status_code=400, + detail="Profile submissions are not currently supported via API", + ) + + allowed_modes = [ + SubmissionMode.TEST, + SubmissionMode.BENCHMARK, + SubmissionMode.LEADERBOARD, + ] + if submission_mode_enum not in allowed_modes: + raise HTTPException( + status_code=400, + detail=f"Submission mode '{submission_mode}' is not supported for this endpoint", + ) + + try: + with db_context as db: + leaderboard_item = db.get_leaderboard(leaderboard_name) + gpus = leaderboard_item.get("gpu_types", []) + if gpu_type not in gpus: + supported_gpus = ", ".join(gpus) if gpus else "None" + raise HTTPException( + status_code=400, + detail=f"GPU type '{gpu_type}' is not supported for " + f"leaderboard '{leaderboard_name}'. Supported GPUs: {supported_gpus}", + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Internal server error while validating leaderboard/GPU: {e}", + ) from e + + try: + submission_content = await file.read() + if not submission_content: + raise HTTPException( + status_code=400, + detail="Empty file submitted. Please provide a file with code.", + ) + if len(submission_content) > 1_000_000: + raise HTTPException( + status_code=413, + detail="Submission file is too large (limit: 1MB).", + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=400, detail=f"Error reading submission file: {e}" + ) from e + + try: + submission_code = submission_content.decode("utf-8") + submission_request = SubmissionRequest( + code=submission_code, + file_name=file.filename or "submission.py", + user_id=user_id, + user_name=user_name, + gpus=[gpu_type], + leaderboard=leaderboard_name, + ) + except UnicodeDecodeError: + raise HTTPException( + status_code=400, + detail="Failed to decode submission file content as UTF-8.", + ) from None + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Internal server error creating submission request: {e}", + ) from e + + return submission_request, submission_mode_enum + + +def json_serializer(obj): + """JSON serializer for objects not serializable by default json code""" + if isinstance(obj, (datetime.datetime, datetime.date, datetime.time)): + return obj.isoformat() + raise TypeError(f"Type {type(obj)} not serializable") + + +async def _run_submission( + submission: SubmissionRequest, + mode: SubmissionMode, + backend: KernelBackend, + submission_id: Optional[int] = None, +): + try: + req = prepare_submission(submission, backend) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + if len(req.gpus) != 1: + raise HTTPException(status_code=400, detail="Invalid GPU type") + + reporter = MultiProgressReporterAPI() + sub_id, results = await backend.submit_full( + req, mode, reporter, submission_id + ) + return ( + results, + [rep.get_message() + "\n" + rep.long_report for rep in reporter.runs], + sub_id, + ) + + +def start_detached_run( + submission_request: SubmissionRequest, + submission_mode_enum: SubmissionMode, + backend: KernelBackend, + db: "LeaderboardDB", + background_tasks: BackgroundTasks, +) -> int: + """Starts a submission in the background and returns the submission id immediately.""" + + # create submission id, so that it can be return to client before task is started + with db: + req = submission_request + sub_id = db.create_submission( + leaderboard=req.leaderboard, + file_name=req.file_name, + code=req.code, + user_id=req.user_id, + time=datetime.now(), + user_name=req.user_name, + ) + + # makes the task run in the background + background_tasks.add_task( + _run_submission, + submission_request, + submission_mode_enum, + backend, + db, + sub_id, + ) + return sub_id + + +async def sse_stream_submission( + submission_request: SubmissionRequest, + submission_mode_enum: SubmissionMode, + backend: KernelBackend, +): + start_time = time.time() + task: asyncio.Task | None = None + try: + task = asyncio.create_task( + _run_submission(submission_request, submission_mode_enum, backend) + ) + + while not task.done(): + elapsed_time = time.time() - start_time + yield ( + "event: status\n" + f"data: {json.dumps({'status': 'processing','elapsed_time': round(elapsed_time, 2)}, default=json_serializer)}\n\n" + ) + try: + await asyncio.wait_for(asyncio.shield(task), timeout=15.0) + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + yield ( + "event: error\n" + f"data: {json.dumps({'status': 'error','detail': 'Submission cancelled'}, default=json_serializer)}\n\n" + ) + return + + result, reports = await task + result_data = { + "status": "success", + "results": [asdict(r) for r in result], + "reports": reports, + } + yield "event: result\n" f"data: {json.dumps(result_data, default=json_serializer)}\n\n" + + except HTTPException as http_exc: + error_data = { + "status": "error", + "detail": http_exc.detail, + "status_code": http_exc.status_code, + } + yield "event: error\n" f"data: {json.dumps(error_data, default=json_serializer)}\n\n" + except Exception as e: + error_type = type(e).__name__ + error_data = { + "status": "error", + "detail": f"An unexpected error occurred: {error_type}", + "raw_error": str(e), + } + yield "event: error\n" f"data: {json.dumps(error_data, default=json_serializer)}\n\n" + finally: + if task and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass diff --git a/src/kernelbot/api/main.py b/src/kernelbot/api/main.py index 2848d383..eda249c7 100644 --- a/src/kernelbot/api/main.py +++ b/src/kernelbot/api/main.py @@ -5,18 +5,20 @@ import os import time from dataclasses import asdict -from typing import Annotated, Optional +from typing import Annotated, Any, Optional, Tuple -from fastapi import Depends, FastAPI, Header, HTTPException, Request, UploadFile +from fastapi import Depends, FastAPI, Header, HTTPException, Request, UploadFile, BackgroundTasks from fastapi.responses import JSONResponse, StreamingResponse + from libkernelbot.backend import KernelBackend from libkernelbot.consts import SubmissionMode -from libkernelbot.leaderboard_db import LeaderboardRankedEntry +from libkernelbot.leaderboard_db import LeaderboardDB, LeaderboardRankedEntry from libkernelbot.submission import SubmissionRequest -from libkernelbot.utils import KernelBotError +from libkernelbot.utils import KernelBotError, +from src.libkernelbot.db_types import IdentityType -from .api_utils import _handle_discord_oauth, _handle_github_oauth, _run_submission +from .api_utils import _handle_discord_oauth, _handle_github_oauth, sse_stream_submission, start_detached_run, to_submission_info # yes, we do want ... = Depends() in function signatures # ruff: noqa: B008 @@ -24,19 +26,12 @@ app = FastAPI() -def json_serializer(obj): - """JSON serializer for objects not serializable by default json code""" - if isinstance(obj, (datetime.datetime, datetime.date, datetime.time)): - return obj.isoformat() - raise TypeError(f"Type {type(obj)} not serializable") - backend_instance: KernelBackend = None _last_action = time.time() _submit_limiter = asyncio.Semaphore(3) - async def simple_rate_limit(): """ A very primitive rate limiter. This function returns at most @@ -223,70 +218,48 @@ async def cli_auth(auth_provider: str, code: str, state: str, db_context=Depends "is_reset": is_reset, } - -async def _stream_submission_response( - submission_request: SubmissionRequest, - submission_mode_enum: SubmissionMode, - backend: KernelBackend, -): - start_time = time.time() - task: asyncio.Task | None = None - try: - task = asyncio.create_task( - _run_submission( - submission_request, - submission_mode_enum, - backend, - ) +async def validate_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), +) -> Any: + """ + Validate either X-Web-Auth-Id or X-Popcorn-Cli-Id and return the associated user id. + Prefers X-Web-Auth-Id if both are provided. + """ + token = x_web_auth_id or x_popcorn_cli_id + if not token: + raise HTTPException( + status_code=400, + detail="Missing X-Web-Auth-Id or X-Popcorn-Cli-Id header", ) - while not task.done(): - elapsed_time = time.time() - start_time - yield f"event: status\ndata: {json.dumps({'status': 'processing', - 'elapsed_time': round(elapsed_time, 2)}, - default=json_serializer)}\n\n" - - try: - await asyncio.wait_for(asyncio.shield(task), timeout=15.0) - except asyncio.TimeoutError: - continue - except asyncio.CancelledError: - yield f"event: error\ndata: {json.dumps( - {'status': 'error', 'detail': 'Submission cancelled'}, - default=json_serializer)}\n\n" - return - - result, reports = await task - result_data = { - "status": "success", - "results": [asdict(r) for r in result], - "reports": reports, - } - yield f"event: result\ndata: {json.dumps(result_data, default=json_serializer)}\n\n" - - except HTTPException as http_exc: - error_data = { - "status": "error", - "detail": http_exc.detail, - "status_code": http_exc.status_code, - } - yield f"event: error\ndata: {json.dumps(error_data, default=json_serializer)}\n\n" + if x_web_auth_id: + token = x_web_auth_id + id_type = IdentityType.WEB + elif x_popcorn_cli_id: + token = x_popcorn_cli_id + id_type = IdentityType.CLI + else: + raise HTTPException( + status_code=400, + detail="Missing header must be eother X-Web-Auth-Id or X-Popcorn-Cli-Id header", + ) + try: + with db_context as db: + user_info = db.validate_identity(token, id_type) except Exception as e: - error_type = type(e).__name__ - error_data = { - "status": "error", - "detail": f"An unexpected error occurred: {error_type}", - "raw_error": str(e), - } - yield f"event: error\ndata: {json.dumps(error_data, default=json_serializer)}\n\n" - finally: - if task and not task.done(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass + 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 @app.post("/{leaderboard_name}/{gpu_type}/{submission_mode}") async def run_submission( # noqa: C901 @@ -316,94 +289,48 @@ async def run_submission( # noqa: C901 StreamingResponse: A streaming response containing the status and results of the submission. """ await simple_rate_limit() - user_name = user_info["user_name"] - user_id = user_info["user_id"] - - try: - submission_mode_enum: SubmissionMode = SubmissionMode(submission_mode.lower()) - except ValueError: - raise HTTPException( - status_code=400, detail=f"Invalid submission mode value: '{submission_mode}'" - ) from None - - if submission_mode_enum in [SubmissionMode.PROFILE]: - raise HTTPException( - status_code=400, detail="Profile submissions are not currently supported via API" - ) - - allowed_modes = [ - SubmissionMode.TEST, - SubmissionMode.BENCHMARK, - SubmissionMode.LEADERBOARD, - ] - if submission_mode_enum not in allowed_modes: - raise HTTPException( - status_code=400, - detail=f"Submission mode '{submission_mode}' is not supported for this endpoint", - ) - - try: - with db_context as db: - leaderboard_item = db.get_leaderboard(leaderboard_name) - gpus = leaderboard_item.get("gpu_types", []) - if gpu_type not in gpus: - supported_gpus = ", ".join(gpus) if gpus else "None" - raise HTTPException( - status_code=400, - detail=f"GPU type '{gpu_type}' is not supported for " - f"leaderboard '{leaderboard_name}'. Supported GPUs: {supported_gpus}", - ) - except HTTPException: - raise - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"Internal server error while validating leaderboard/GPU: {e}", - ) from e - - try: - submission_content = await file.read() - if not submission_content: - raise HTTPException( - status_code=400, detail="Empty file submitted. Please provide a file with code." - ) - if len(submission_content) > 1_000_000: - raise HTTPException( - status_code=413, detail="Submission file is too large (limit: 1MB)." - ) - - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=400, detail=f"Error reading submission file: {e}") from e - - try: - submission_code = submission_content.decode("utf-8") - submission_request = SubmissionRequest( - code=submission_code, - file_name=file.filename or "submission.py", - user_id=user_id, - user_name=user_name, - gpus=[gpu_type], - leaderboard=leaderboard_name, - ) - except UnicodeDecodeError: - raise HTTPException( - status_code=400, detail="Failed to decode submission file content as UTF-8." - ) from None - except Exception as e: - raise HTTPException( - status_code=500, detail=f"Internal server error creating submission request: {e}" - ) from e - - generator = _stream_submission_response( + submission_request,submission_mode_enum = await to_submission_info( + user_info, + submission_mode, + file, + leaderboard_name, + gpu_type, + db_context) + + generator = sse_stream_submission( submission_request=submission_request, submission_mode_enum=submission_mode_enum, backend=backend_instance, ) - return StreamingResponse(generator, media_type="text/event-stream") +@app.post("submission/{leaderboard_name}/{gpu_type}/{submission_mode}") +async def run_submission_v2( # noqa: C901 + leaderboard_name: str, + gpu_type: str, + submission_mode: str, + file: UploadFile, + user_info: Annotated[dict, Depends(validate_user_header)], + db_context=Depends(get_db), + background_tasks: BackgroundTasks = Depends() +) -> Any: + + await simple_rate_limit() + + submission_request,submission_mode_enum = await to_submission_info( + user_info, + submission_mode, + file, + leaderboard_name, + gpu_type, + db_context) + + sub_id = start_detached_run(submission_request, submission_mode_enum, backend_instance, db_context, background_tasks) + return JSONResponse( + status_code=202, + content={"id": sub_id, "status": "accepted"}, + ) + @app.get("/leaderboards") async def get_leaderboards(db_context=Depends(get_db)): diff --git a/src/libkernelbot/backend.py b/src/libkernelbot/backend.py index 3874b142..2441a366 100644 --- a/src/libkernelbot/backend.py +++ b/src/libkernelbot/backend.py @@ -1,6 +1,8 @@ import asyncio import copy from datetime import datetime +from os import stat +from re import sub from types import SimpleNamespace from typing import Optional @@ -53,18 +55,25 @@ def register_launcher(self, launcher: Launcher): self.launcher_map[gpu.value] = launcher async def submit_full( - self, req: ProcessedSubmissionRequest, mode: SubmissionMode, reporter: MultiProgressReporter + self, req: ProcessedSubmissionRequest, mode: SubmissionMode, reporter: MultiProgressReporter, + pre_sub_id: Optional[int] = None ): - with self.db as db: - sub_id = db.create_submission( - leaderboard=req.leaderboard, - file_name=req.file_name, - code=req.code, - user_id=req.user_id, - time=datetime.now(), - user_name=req.user_name, - ) + """ + pre_sub_id is used to pass the submission id which is created beforehand. + """ + if pre_sub_id is not None: + sub_id = pre_sub_id + else: + with self.db as db: + sub_id = db.create_submission( + leaderboard=req.leaderboard, + file_name=req.file_name, + code=req.code, + user_id=req.user_id, + time=datetime.now(), + user_name=req.user_name, + ) selected_gpus = [get_gpu_by_name(gpu) for gpu in req.gpus] try: @@ -103,7 +112,6 @@ async def submit_full( finally: with self.db as db: db.mark_submission_done(sub_id) - return sub_id, results async def submit_leaderboard( # noqa: C901 diff --git a/src/libkernelbot/db_types.py b/src/libkernelbot/db_types.py index e8715fa2..0a03ec52 100644 --- a/src/libkernelbot/db_types.py +++ b/src/libkernelbot/db_types.py @@ -1,10 +1,15 @@ # This file provides TypeDict definitions for the return types we get from database queries import datetime +from enum import Enum from typing import TYPE_CHECKING, List, NotRequired, Optional, TypedDict if TYPE_CHECKING: from libkernelbot.task import LeaderboardTask +class IdentityType(str, Enum): + CLI = "cli" + WEB = "web" + UNKNOWN = "unknown" class LeaderboardItem(TypedDict): id: int diff --git a/src/libkernelbot/leaderboard_db.py b/src/libkernelbot/leaderboard_db.py index c322ab68..8b7b17bc 100644 --- a/src/libkernelbot/leaderboard_db.py +++ b/src/libkernelbot/leaderboard_db.py @@ -1,11 +1,11 @@ import dataclasses import datetime import json -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional import psycopg2 -from libkernelbot.db_types import LeaderboardItem, LeaderboardRankedEntry, RunItem, SubmissionItem +from libkernelbot.db_types import IdentityType, LeaderboardItem, LeaderboardRankedEntry, RunItem, SubmissionItem from libkernelbot.run_eval import CompileResult, RunResult, SystemInfo from libkernelbot.task import LeaderboardDefinition, LeaderboardTask from libkernelbot.utils import ( @@ -16,7 +16,6 @@ logger = setup_logging(__name__) - class LeaderboardDB: def __init__( self, host: str, database: str, user: str, password: str, port: str, url: str, ssl_mode: str @@ -235,6 +234,45 @@ def delete_leaderboard(self, leaderboard_name: str, force: bool = False): logger.exception("Could not delete leaderboard %s.", leaderboard_name, exc_info=e) raise KernelBotError(f"Could not delete leaderboard `{leaderboard_name}`.") from e + def validate_identity( + self, + identifier: str, + id_type: IdentityType, + ) -> Optional[dict[str, str]]: + """ + Validate an identity (CLI or Web) and return {user_id, user_name} if found. + + Args: + identifier: The identifier value (CLI ID or Web Auth ID). + id_type: IdentityType enum (IdentityType.CLI or IdentityType.WEB). + + Returns: + Optional[dict[str, str]]: {"user_id": ..., "user_name": ...} if valid; else None. + """ + where_by_type = { + IdentityType.CLI: ("cli_id = %s AND cli_valid = TRUE", "CLI ID"), + IdentityType.WEB: ("web_auth_id = %s", "WEB AUTH ID"), + } + + where_clause, human_label = where_by_type[id_type] + + try: + self.cursor.execute( + f""" + SELECT id, user_name + FROM leaderboard.user_info + WHERE {where_clause} + """, + (identifier,), + ) + row = self.cursor.fetchone() + return {"user_id": row[0], "user_name": row[1], "id_type":id_type.value} if row else None + except psycopg2.Error as e: + self.connection.rollback() + logger.exception("Error validating %s %s", human_label, identifier, exc_info=e) + raise KernelBotError(f"Error validating {human_label}") from e + + def create_submission( self, leaderboard: str, @@ -243,7 +281,7 @@ def create_submission( code: str, time: datetime.datetime, user_name: str = None, - ) -> Optional[int]: + ) -> int: try: # check if we already have the code self.cursor.execute( @@ -305,6 +343,7 @@ def create_submission( ), ) submission_id = self.cursor.fetchone()[0] + submission_id = int(submission_id) assert submission_id is not None self.connection.commit() return submission_id diff --git a/src/migrations/20250822_01_UtXzl-website-submission.py b/src/migrations/20250822_01_UtXzl-website-submission.py new file mode 100644 index 00000000..ab1d3b29 --- /dev/null +++ b/src/migrations/20250822_01_UtXzl-website-submission.py @@ -0,0 +1,13 @@ +""" +website_submission +""" + +from yoyo import step + +__depends__ = {'20250728_01_Q3jso-fix-code-table'} + +steps = [ + step( + "ALTER TABLE leaderboard.user_info ADD COLUMN IF NOT EXISTS web_auth_id VARCHAR(255) DEFAULT NULL;" + ) +] diff --git a/unit-tests/test_leaderboard_db.py b/unit-tests/test_leaderboard_db.py index 939f69b9..3c419557 100644 --- a/unit-tests/test_leaderboard_db.py +++ b/unit-tests/test_leaderboard_db.py @@ -5,6 +5,7 @@ import time import pytest +from libkernelbot.db_types import IdentityType from test_report import sample_compile_result, sample_run_result, sample_system_info from test_task import task_directory @@ -431,6 +432,33 @@ def test_leaderboard_submission_ranked(database, submit_leaderboard): }, ] +def test_validate_identity_web_auth_happy_path(database, submit_leaderboard): + with database as db: + db.cursor.execute( + """ + INSERT INTO leaderboard.user_info (id, user_name, web_auth_id) + VALUES (%s, %s, %s) + """, + (f"1234", f"sara_jojo","2345" ), + ) + user_info = db.validate_identity("2345",IdentityType.WEB) + assert user_info["user_id"] =="1234" + assert user_info["user_name"] =="sara_jojo" + assert user_info["id_type"] ==IdentityType.WEB.value + +def test_validate_identity_web_auth_missing(database, submit_leaderboard): + with database as db: + db.cursor.execute( + """ + INSERT INTO leaderboard.user_info (id, user_name) + VALUES (%s, %s) + """, + (f"1234", f"sara_jojo"), + ) + res = db.validate_identity("2345",IdentityType.WEB) + assert res is None + + def test_leaderboard_submission_deduplication(database, submit_leaderboard): """validate that identical submission codes are added just once"""