Skip to content

Commit bb4bea0

Browse files
authored
Add user submission management API endpoints (#412)
1 parent f2460e7 commit bb4bea0

File tree

5 files changed

+530
-1
lines changed

5 files changed

+530
-1
lines changed

CLAUDE.md

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,167 @@ uv run ruff check . --exclude examples/ --line-length 120
1616

1717
## Testing
1818

19+
### Unit Tests
20+
1921
Run tests with pytest:
2022

2123
```bash
2224
uv run pytest tests/ -v
2325
```
2426

27+
### Test Requirements
28+
29+
When adding new functionality:
30+
31+
1. **Database methods** (`src/libkernelbot/leaderboard_db.py`):
32+
- Add tests to `tests/test_leaderboard_db.py`
33+
- Test empty results, basic functionality, edge cases, and pagination
34+
35+
2. **API endpoints** (`src/kernelbot/api/main.py`):
36+
- Add tests to `tests/test_admin_api.py` or create endpoint-specific test files
37+
- Test authentication, authorization (403), not found (404), and validation (400)
38+
39+
3. **Regression tests**: Use the popcorn-cli against a local instance to verify end-to-end functionality
40+
41+
### E2E Regression Testing with popcorn-cli
42+
43+
Full end-to-end testing requires running the API server locally and testing with popcorn-cli. This tests the complete flow from CLI through API to database.
44+
45+
#### Step 1: Start PostgreSQL
46+
47+
```bash
48+
# macOS with Homebrew
49+
brew services start postgresql@14
50+
51+
# Verify it's running
52+
pg_isready
53+
```
54+
55+
#### Step 2: Create Database and Run Migrations
56+
57+
```bash
58+
# Create database (first time only)
59+
createdb kernelbot
60+
61+
# Run migrations
62+
export DATABASE_URL="postgresql://$(whoami)@localhost:5432/kernelbot"
63+
uv run yoyo apply --database "$DATABASE_URL" src/migrations/
64+
```
65+
66+
#### Step 3: Create a Test User
67+
68+
The CLI requires a registered user. Create one in the database:
69+
70+
```bash
71+
export DATABASE_URL="postgresql://$(whoami)@localhost:5432/kernelbot"
72+
psql "$DATABASE_URL" -c "INSERT INTO leaderboard.user_info (id, user_name, cli_id, cli_valid)
73+
VALUES ('999999', 'testuser', 'test-cli-id-123', true)
74+
ON CONFLICT (id) DO UPDATE SET cli_id = 'test-cli-id-123', cli_valid = true;"
75+
```
76+
77+
#### Step 4: Start the API Server
78+
79+
```bash
80+
cd src/kernelbot
81+
82+
# Set required environment variables
83+
export DATABASE_URL="postgresql://$(whoami)@localhost:5432/kernelbot"
84+
export ADMIN_TOKEN="your-admin-token-here" # Check .env for LOCAL_ADMIN_TOKEN
85+
86+
# Start API server (without Discord bot)
87+
uv run python main.py --api-only
88+
89+
# Server runs on http://localhost:8000
90+
```
91+
92+
#### Step 5: Sync Leaderboards
93+
94+
Leaderboards must be synced from reference-kernels before testing submissions:
95+
96+
```bash
97+
curl -X POST "http://localhost:8000/admin/update-problems" \
98+
-H "Authorization: Bearer $ADMIN_TOKEN" \
99+
-H "Content-Type: application/json" \
100+
-d '{"problem_set": "pmpp_v2"}'
101+
```
102+
103+
#### Step 6: Configure popcorn-cli for Local Testing
104+
105+
Temporarily update `~/.popcorn.yaml` to use test credentials:
106+
107+
```bash
108+
# Backup existing config
109+
cp ~/.popcorn.yaml ~/.popcorn.yaml.bak
110+
111+
# Set test CLI ID
112+
echo "cli_id: test-cli-id-123" > ~/.popcorn.yaml
113+
```
114+
115+
#### Step 7: Run CLI Commands
116+
117+
```bash
118+
cd /path/to/popcorn-cli
119+
120+
# Set API URL to local server
121+
export POPCORN_API_URL=http://localhost:8000
122+
123+
# Test various commands
124+
cargo run --release -- submissions list --leaderboard vectoradd_v2
125+
cargo run --release -- submissions show <ID>
126+
cargo run --release -- submissions delete <ID>
127+
128+
# Test submission (runs on Modal H100, requires Modal account)
129+
cargo run --release -- submit solution.py --gpu H100 --leaderboard vectoradd_v2 --mode test
130+
```
131+
132+
#### Step 8: Restore Original Config
133+
134+
```bash
135+
cp ~/.popcorn.yaml.bak ~/.popcorn.yaml
136+
rm ~/.popcorn.yaml.bak
137+
```
138+
139+
#### Quick Reference: Testing API Endpoints Directly
140+
141+
```bash
142+
# List user submissions
143+
curl -s "http://localhost:8000/user/submissions?leaderboard=vectoradd_v2" \
144+
-H "X-Popcorn-Cli-Id: test-cli-id-123"
145+
146+
# Get submission details
147+
curl -s "http://localhost:8000/user/submissions/1" \
148+
-H "X-Popcorn-Cli-Id: test-cli-id-123"
149+
150+
# Delete submission
151+
curl -s -X DELETE "http://localhost:8000/user/submissions/1" \
152+
-H "X-Popcorn-Cli-Id: test-cli-id-123"
153+
154+
# Submit a file (multipart form)
155+
curl -s -X POST "http://localhost:8000/vectoradd_v2/H100/test" \
156+
-H "X-Popcorn-Cli-Id: test-cli-id-123" \
157+
158+
```
159+
160+
#### Troubleshooting
161+
162+
- **401 Unauthorized**: CLI ID not in database or `cli_valid` is false
163+
- **404 Not Found**: Leaderboards not synced - run update-problems first
164+
- **500 Internal Error**: Check server logs in terminal, often a TypedDict vs object access issue
165+
- **"Device not configured" from CLI**: Usually a TTY issue, try running with explicit env vars
166+
167+
## Before Adding New Features
168+
169+
**Important:** Check for existing code before implementing:
170+
171+
1. **Database methods**: `src/libkernelbot/leaderboard_db.py`
172+
2. **Discord commands**: `src/kernelbot/cogs/`
173+
3. **API endpoints**: `src/kernelbot/api/main.py`
174+
175+
Reuse existing patterns:
176+
- `validate_user_header` / `validate_cli_header` for authentication
177+
- `get_submission_by_id()`, `delete_submission()` for submission operations
178+
- `simple_rate_limit()` for rate limiting
179+
25180
## Local Development
26181

27182
See `SKILLS/test_bot.md` for local testing setup instructions.

src/kernelbot/api/main.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,3 +711,121 @@ async def get_submission_count(
711711
return {"count": count}
712712
except Exception as e:
713713
raise HTTPException(status_code=500, detail=f"Error fetching submission count: {e}") from e
714+
715+
716+
@app.get("/user/submissions")
717+
async def get_user_submissions(
718+
user_info: Annotated[dict, Depends(validate_user_header)],
719+
leaderboard: Optional[str] = None,
720+
limit: int = 50,
721+
offset: int = 0,
722+
db_context=Depends(get_db),
723+
) -> list[dict]:
724+
"""Get the authenticated user's submissions.
725+
726+
Args:
727+
leaderboard: Optional filter by leaderboard name
728+
limit: Maximum number of submissions to return (default 50, max 100)
729+
offset: Offset for pagination (must be >= 0)
730+
731+
Returns:
732+
List of user's submissions with summary info
733+
"""
734+
await simple_rate_limit()
735+
736+
# Validate inputs (DB also validates, but fail fast here)
737+
if limit < 1 or limit > 100:
738+
raise HTTPException(status_code=400, detail="limit must be between 1 and 100")
739+
if offset < 0:
740+
raise HTTPException(status_code=400, detail="offset must be >= 0")
741+
742+
try:
743+
with db_context as db:
744+
return db.get_user_submissions(
745+
user_id=user_info["user_id"],
746+
leaderboard_name=leaderboard,
747+
limit=limit,
748+
offset=offset,
749+
)
750+
except Exception as e:
751+
raise HTTPException(status_code=500, detail=f"Error fetching user submissions: {e}") from e
752+
753+
754+
@app.get("/user/submissions/{submission_id}")
755+
async def get_user_submission(
756+
submission_id: int,
757+
user_info: Annotated[dict, Depends(validate_user_header)],
758+
db_context=Depends(get_db),
759+
) -> dict:
760+
"""Get a specific submission by ID. Only the owner can view their submission.
761+
762+
Args:
763+
submission_id: The submission ID to retrieve
764+
765+
Returns:
766+
Full submission details including code
767+
"""
768+
await simple_rate_limit()
769+
try:
770+
with db_context as db:
771+
submission = db.get_submission_by_id(submission_id)
772+
773+
if submission is None:
774+
raise HTTPException(status_code=404, detail="Submission not found")
775+
776+
# Verify ownership
777+
if str(submission["user_id"]) != str(user_info["user_id"]):
778+
raise HTTPException(status_code=403, detail="Not authorized to view this submission")
779+
780+
# RunItem is a TypedDict (already a dict), select fields to expose
781+
run_fields = ("start_time", "end_time", "mode", "secret", "runner", "score", "passed")
782+
return {
783+
"id": submission["submission_id"],
784+
"leaderboard_id": submission["leaderboard_id"],
785+
"leaderboard_name": submission["leaderboard_name"],
786+
"file_name": submission["file_name"],
787+
"user_id": submission["user_id"],
788+
"submission_time": submission["submission_time"],
789+
"done": submission["done"],
790+
"code": submission["code"],
791+
"runs": [{k: r[k] for k in run_fields} for r in submission["runs"]],
792+
}
793+
except HTTPException:
794+
raise
795+
except Exception as e:
796+
raise HTTPException(status_code=500, detail=f"Error fetching submission: {e}") from e
797+
798+
799+
@app.delete("/user/submissions/{submission_id}")
800+
async def delete_user_submission(
801+
submission_id: int,
802+
user_info: Annotated[dict, Depends(validate_user_header)],
803+
db_context=Depends(get_db),
804+
) -> dict:
805+
"""Delete a submission by ID. Only the owner can delete their submission.
806+
807+
Args:
808+
submission_id: The submission ID to delete
809+
810+
Returns:
811+
Success message
812+
"""
813+
await simple_rate_limit()
814+
try:
815+
with db_context as db:
816+
submission = db.get_submission_by_id(submission_id)
817+
818+
if submission is None:
819+
raise HTTPException(status_code=404, detail="Submission not found")
820+
821+
# Verify ownership
822+
if str(submission["user_id"]) != str(user_info["user_id"]):
823+
raise HTTPException(status_code=403, detail="Not authorized to delete this submission")
824+
825+
db.delete_submission(submission_id)
826+
827+
return {"status": "ok", "submission_id": submission_id}
828+
except HTTPException:
829+
raise
830+
except Exception as e:
831+
raise HTTPException(status_code=500, detail=f"Error deleting submission: {e}") from e

src/libkernelbot/leaderboard_db.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,90 @@ def delete_submission(self, submission_id: int):
848848
logger.exception("Could not delete submission %s.", submission_id, exc_info=e)
849849
raise KernelBotError(f"Could not delete submission {submission_id}!") from e
850850

851+
def get_user_submissions(
852+
self,
853+
user_id: str,
854+
leaderboard_name: Optional[str] = None,
855+
limit: int = 50,
856+
offset: int = 0,
857+
) -> list[dict]:
858+
"""
859+
Get submissions for a specific user.
860+
861+
Args:
862+
user_id: The user's ID
863+
leaderboard_name: Optional filter by leaderboard name
864+
limit: Maximum number of submissions to return (max 100)
865+
offset: Offset for pagination
866+
867+
Returns:
868+
List of submission dictionaries with summary info and runs
869+
"""
870+
# Validate and clamp inputs
871+
limit = max(1, min(limit, 100))
872+
offset = max(0, offset)
873+
874+
try:
875+
# Build query with conditional WHERE clause
876+
where_clause = "WHERE s.user_id = %s"
877+
params: list = [user_id]
878+
879+
if leaderboard_name:
880+
where_clause += " AND lb.name = %s"
881+
params.append(leaderboard_name)
882+
883+
# First, get distinct submissions
884+
submission_query = f"""
885+
SELECT s.id, lb.name as leaderboard_name, s.file_name,
886+
s.submission_time, s.done
887+
FROM leaderboard.submission s
888+
JOIN leaderboard.leaderboard lb ON s.leaderboard_id = lb.id
889+
{where_clause}
890+
ORDER BY s.submission_time DESC
891+
LIMIT %s OFFSET %s
892+
"""
893+
self.cursor.execute(submission_query, params + [limit, offset])
894+
submissions = self.cursor.fetchall()
895+
896+
if not submissions:
897+
return []
898+
899+
# Get all runs for these submissions
900+
submission_ids = [row[0] for row in submissions]
901+
runs_query = """
902+
SELECT submission_id, runner as gpu_type, score
903+
FROM leaderboard.runs
904+
WHERE submission_id = ANY(%s) AND NOT secret AND passed
905+
"""
906+
self.cursor.execute(runs_query, (submission_ids,))
907+
runs_by_submission: dict = {}
908+
for run_row in self.cursor.fetchall():
909+
sub_id = run_row[0]
910+
if sub_id not in runs_by_submission:
911+
runs_by_submission[sub_id] = []
912+
runs_by_submission[sub_id].append({
913+
"gpu_type": run_row[1],
914+
"score": run_row[2],
915+
})
916+
917+
# Build result with runs grouped by submission
918+
results = []
919+
for row in submissions:
920+
sub_id = row[0]
921+
results.append({
922+
"id": sub_id,
923+
"leaderboard_name": row[1],
924+
"file_name": row[2],
925+
"submission_time": row[3],
926+
"done": row[4],
927+
"runs": runs_by_submission.get(sub_id, []),
928+
})
929+
return results
930+
except psycopg2.Error as e:
931+
self.connection.rollback()
932+
logger.exception("Error fetching user submissions for user %s", user_id, exc_info=e)
933+
raise KernelBotError("Error fetching user submissions") from e
934+
851935
def get_submission_by_id(self, submission_id: int) -> Optional["SubmissionItem"]:
852936
query = """
853937
SELECT s.leaderboard_id, lb.name, s.file_name, s.user_id,

src/libkernelbot/problem_sync.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def download_problem_repo(repository: str, branch: str, temp_dir: str) -> Path:
7575
# Download
7676
try:
7777
subprocess.check_call(
78-
["wget", "-q", "-O", f"{temp_dir}/problems.zip", url],
78+
["curl", "-sL", "-o", f"{temp_dir}/problems.zip", url],
7979
encoding="utf-8",
8080
timeout=60,
8181
)

0 commit comments

Comments
 (0)