diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..932f60300
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,193 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Galaxy is the infrastructure framework for MIT Battlecode, consisting of three main components:
+
+- **Siarnaq**: Competitor dashboard with Django backend and React frontend
+- **Saturn**: Compute cluster for compiling bots and running matches (Go)
+- **Titan**: Malware scanner for file uploads (Go)
+
+## Development Environment Setup
+
+1. Install [Conda](https://docs.conda.io/en/latest/miniconda.html)
+2. Create and activate environment: `conda env create -n galaxy -f environment-dev.yml && conda activate galaxy`
+3. Install pre-commit hooks: `pre-commit install`
+4. Update environment after upstream changes: `conda env update -n galaxy -f environment-dev.yml`
+
+## Common Commands
+
+### Backend (Django/Siarnaq)
+
+From `backend/` directory:
+
+```bash
+# Database operations
+./manage.py makemigrations # Generate database migrations
+./manage.py migrate # Apply migrations
+./manage.py runserver # Start development server
+
+# Testing
+find * -type f -name "test*.py" | sed "s/\.py$//g" | sed "s/\//./g" | xargs coverage run --branch --source='.' ./manage.py test -v=2
+coverage report # View test coverage
+
+# Deployment checks
+./manage.py check --deploy # Verify production readiness
+```
+
+**Environment configuration**: Set `DJANGO_CONFIGURATION=Staging` to access staging environment (requires GCloud authentication).
+
+### Frontend (React)
+
+From `frontend/` directory:
+
+```bash
+# Development
+npm install # Install dependencies (first time)
+npm run start # Start dev server (http://localhost:3000)
+
+# Code quality
+npm run lint # Run ESLint and Prettier checks
+npm run format # Apply ESLint and Prettier fixes
+
+# Type generation
+./generate_types.sh # Generate TypeScript types from backend OpenAPI schema
+```
+
+**Important**: After backend API changes, run `./generate_types.sh` to regenerate TypeScript types in `src/api/_autogen/`.
+
+### Pre-commit (All modules)
+
+From root directory:
+
+```bash
+pre-commit run -a # Run all pre-commit hooks on all files
+```
+
+Pre-commit runs: black, flake8, isort, mypy, pyupgrade (Python), go-fmt (Go), eslint, tsc (TypeScript).
+
+### Go Modules (Saturn/Titan)
+
+```bash
+go fmt ./... # Format Go code
+go test ./... # Run Go tests
+```
+
+## Architecture and Code Organization
+
+### Backend (Siarnaq - Django)
+
+Located in `backend/siarnaq/`:
+
+- **`api/`**: API endpoints organized by domain (compete, episodes, teams, user)
+- **Django apps**: User, teams, compete apps with models, managers, and signals
+- **Settings**: Uses `django-configurations` with `Local`, `Staging`, and `Production` configurations
+- **Authentication**: JWT-based with djangorestframework-simplejwt
+- **API documentation**: Available at `api/specs/swagger-ui` endpoint
+
+**Key principles**:
+- Avoid loops; use Django Signals instead
+- Be careful with concurrency; use transactions only when absolutely necessary
+- Move complex logic into Managers for cleaner implementation
+- Use type annotations; Mypy enforced by pre-commit
+
+**Learning path for new Django contributors**:
+1. Read `user/models.py`
+2. Read `teams/models.py` (notice object methods and Django filters)
+3. Read `compete/models.py` (notice double-underscore filters like `pk__in`)
+4. Read `compete/managers.py` (understand Manager patterns)
+
+### Frontend (React/TypeScript)
+
+Located in `frontend/src/`:
+
+- **`api/`**: API client code organized by domain, with auto-generated types in `_autogen/`
+- **`components/`**: Reusable React components (elements, tables, sidebar, team, compete)
+- **`views/`**: Page-level components mapped to routes
+- **`contexts/`**: React contexts for shared state (e.g., user authentication)
+- **`utils/`**: Utility functions
+- **`content/`**: Static content and configuration
+
+**Tech stack**:
+- React 18 with TypeScript
+- React Router for routing
+- TanStack Query (React Query) for data fetching and caching
+- Tailwind CSS for styling
+- Headless UI for accessible components
+- Vite for build tooling
+
+**API integration**: Frontend communicates with backend via HTTP requests. Types are auto-generated from backend OpenAPI schema using `generate_types.sh`.
+
+### Saturn (Go)
+
+Compute cluster that compiles competitor bots and runs matches. Uses Pub/Sub subscriptions for job queuing.
+
+### Titan (Go)
+
+Antivirus scanning service triggered by Google EventArc when files are uploaded to storage buckets. Scans files tagged with `Titan-Status: Unverified` metadata.
+
+## Deployment
+
+Infrastructure managed with Terraform in `deploy/`:
+
+```bash
+terraform init # Install modules
+terraform plan -var-file="secret.tfvars" # Preview changes
+terraform apply -var-file="secret.tfvars" # Apply changes
+```
+
+**Note**: `secret.tfvars` must be obtained from a team member.
+
+## Code Quality Guidelines
+
+### General Principles
+
+- **ETU (Easy To Understand)**: Prioritize simplicity and clarity over cleverness
+- **Modularity**: Clear separation between components
+- **Single source of truth**: Derive/manipulate data in one place, pass as-is elsewhere
+- **Avoid over-engineering**: Only implement what's requested; don't add unnecessary features, abstractions, or error handling for impossible scenarios
+- **No premature optimization**: Three similar lines of code is better than a premature abstraction
+
+### Django-Specific
+
+- **Migrations**: Never edit generated migration files directly; excluded from linting
+- **Security**: Be vigilant about SQL injection, XSS, command injection, and OWASP Top 10
+- **Concurrency**: Use transactions judiciously; prefer Signals to loops
+
+### Frontend-Specific
+
+- **NPM packages**: Always use `npm install --save ` and commit both `package.json` and `package-lock.json`
+- **Environment**: Local development uses `.env.development` automatically
+- **Types**: Keep auto-generated types in `src/api/_autogen/` in sync with backend
+
+## Testing
+
+- **Backend**: Django unit tests using `./manage.py test`. Test files follow `test*.py` pattern.
+- **Frontend**: TypeScript type checking with `npx tsc --noEmit`
+- **CI**: GitHub Actions runs all checks on PRs and pushes to `main`
+
+## Git Workflow
+
+- Develop on feature branches (pushes to `main` are blocked)
+- All PRs require at least one approval
+- Pre-commit hooks enforce code quality
+- Create GitHub Issues for tracking work; use priority labels (`critical`, `medium`, `low`) and module labels (`backend`, `frontend`)
+- **No TODOs in code**: Create GitHub issues instead and reference them in comments
+
+## Google Cloud Integration
+
+For staging/production access:
+
+```bash
+gcloud auth application-default login --scopes=openid,https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/userinfo.email
+gcloud config set core/project mitbattlecode
+gcloud auth login
+```
+
+Request "Service Account Token Creator" IAM role from a team member.
+
+## Operations
+
+See `backend/docs/operations.md` for common operational tasks like customer support and email management during the competition season.
diff --git a/backend/siarnaq/api/compete/admin.py b/backend/siarnaq/api/compete/admin.py
index e9a765bb2..cc372e14e 100644
--- a/backend/siarnaq/api/compete/admin.py
+++ b/backend/siarnaq/api/compete/admin.py
@@ -61,6 +61,7 @@ class SubmissionAdmin(admin.ModelAdmin):
"accepted",
"package",
"description",
+ "language",
)
},
),
@@ -75,11 +76,12 @@ class SubmissionAdmin(admin.ModelAdmin):
"pk",
"team",
"episode",
+ "language",
"accepted",
"status",
"created",
)
- list_filter = ("episode", "accepted", "status")
+ list_filter = ("episode", "language", "accepted", "status")
list_select_related = ("team", "episode")
ordering = ("-pk",)
raw_id_fields = ("team", "user")
@@ -88,7 +90,8 @@ class SubmissionAdmin(admin.ModelAdmin):
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj=obj)
if obj is not None:
- fields = ("episode",) + fields
+ # Once created, episode and language cannot be changed
+ fields = ("episode", "language") + fields
return fields
def has_delete_permission(self, request, obj=None):
diff --git a/backend/siarnaq/api/compete/migrations/0010_submission_language.py b/backend/siarnaq/api/compete/migrations/0010_submission_language.py
new file mode 100644
index 000000000..96c802ad5
--- /dev/null
+++ b/backend/siarnaq/api/compete/migrations/0010_submission_language.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.1.2 on 2025-12-31 06:48
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("episodes", "0013_programminglanguage_alter_episode_language_and_more"),
+ ("compete", "0009_adminsettings"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="submission",
+ name="language",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="The programming language of this submission.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="submissions",
+ to="episodes.programminglanguage",
+ ),
+ ),
+ ]
diff --git a/backend/siarnaq/api/compete/migrations/0011_alter_submission_language.py b/backend/siarnaq/api/compete/migrations/0011_alter_submission_language.py
new file mode 100644
index 000000000..a12682489
--- /dev/null
+++ b/backend/siarnaq/api/compete/migrations/0011_alter_submission_language.py
@@ -0,0 +1,25 @@
+# Generated manually on 2025-12-31
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("episodes", "0014_migrate_language_to_m2m"),
+ ("compete", "0010_submission_language"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="submission",
+ name="language",
+ field=models.ForeignKey(
+ help_text="The programming language of this submission.",
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="submissions",
+ to="episodes.programminglanguage",
+ ),
+ ),
+ ]
diff --git a/backend/siarnaq/api/compete/models.py b/backend/siarnaq/api/compete/models.py
index 625aa85ff..102c5ae52 100644
--- a/backend/siarnaq/api/compete/models.py
+++ b/backend/siarnaq/api/compete/models.py
@@ -135,6 +135,14 @@ class Submission(SaturnInvocation):
description = models.CharField(max_length=128, blank=True)
"""A human-readable message describing the submission."""
+ language = models.ForeignKey(
+ "episodes.ProgrammingLanguage",
+ on_delete=models.PROTECT,
+ related_name="submissions",
+ help_text="The programming language of this submission.",
+ )
+ """The programming language of this submission."""
+
objects = SubmissionQuerySet.as_manager()
def __str__(self):
@@ -615,7 +623,7 @@ class ScrimmageRequest(models.Model):
objects = ScrimmageRequestQuerySet.as_manager()
def __str__(self):
- return f"{self.requested_by} \u27F9 {self.requested_to}"
+ return f"{self.requested_by} \u27f9 {self.requested_to}"
def determine_is_alternating(self):
"""Determine whether the player order should be alternating."""
diff --git a/backend/siarnaq/api/compete/serializers.py b/backend/siarnaq/api/compete/serializers.py
index 85a6a4b3e..13e939ef7 100644
--- a/backend/siarnaq/api/compete/serializers.py
+++ b/backend/siarnaq/api/compete/serializers.py
@@ -117,6 +117,7 @@ class Meta:
"package",
"description",
"source_code",
+ "language",
]
read_only_fields = [
"id",
@@ -131,6 +132,40 @@ class Meta:
"accepted",
]
+ def validate_language(self, value):
+ """
+ Validate that the language is supported by the episode
+ and convert to ProgrammingLanguage instance.
+ """
+ from siarnaq.api.episodes.models import Episode, ProgrammingLanguage
+
+ # If value is already a ProgrammingLanguage instance, extract its code
+ if isinstance(value, ProgrammingLanguage):
+ language_code = value.code
+ language_obj = value
+ else:
+ # Value is a string language code
+ language_code = value
+ # Get the ProgrammingLanguage instance
+ try:
+ language_obj = ProgrammingLanguage.objects.get(code=language_code)
+ except ProgrammingLanguage.DoesNotExist:
+ raise serializers.ValidationError(
+ f"Language '{language_code}' does not exist."
+ )
+
+ # Validate that the language is supported by the episode
+ episode_id = self.context.get("episode_id")
+ if episode_id:
+ episode = Episode.objects.get(pk=episode_id)
+ supported = episode.get_supported_languages()
+ if language_code not in supported:
+ raise serializers.ValidationError(
+ f"Language '{language_code}' is not supported by this episode. "
+ f"Supported languages: {', '.join(supported)}"
+ )
+ return language_obj
+
def to_internal_value(self, data):
ret = super().to_internal_value(data)
ret.update(
@@ -164,6 +199,7 @@ class Meta:
"package",
"description",
"source_code",
+ "language",
"tournament",
]
read_only_fields = fields
diff --git a/backend/siarnaq/api/compete/test_models.py b/backend/siarnaq/api/compete/test_models.py
index f31da4ea0..77c56f54f 100644
--- a/backend/siarnaq/api/compete/test_models.py
+++ b/backend/siarnaq/api/compete/test_models.py
@@ -11,7 +11,7 @@
ScrimmageRequest,
Submission,
)
-from siarnaq.api.episodes.models import Episode, Language, Map
+from siarnaq.api.episodes.models import Episode, Map
from siarnaq.api.teams.models import Rating, Team
from siarnaq.api.user.models import User
@@ -21,13 +21,18 @@ class MatchParticipantLinkedListTestCase(TestCase):
def setUp(self):
"""Initialize the episode and teams available in the test suite."""
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
self.e1 = Episode.objects.create(
name_short="e1",
registration=timezone.now(),
game_release=timezone.now(),
game_archive=timezone.now(),
- language=Language.JAVA_8,
)
+ java8, _ = ProgrammingLanguage.objects.get_or_create(
+ code="java8", defaults={"display_name": "Java 8"}
+ )
+ self.e1.languages.add(java8)
for name in ["team1", "team2", "team3"]:
t = Team.objects.create(episode=self.e1, name=name)
u = User.objects.create_user(
@@ -40,6 +45,7 @@ def setUp(self):
team=t,
user=u,
accepted=True,
+ language=java8,
)
def make_match(self):
@@ -162,13 +168,18 @@ class MatchParticipantRatingFinalizationTestCase(TestCase):
def setUp(self):
"""Initialize the episode and teams available in the test suite."""
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
e1 = Episode.objects.create(
name_short="e1",
registration=timezone.now(),
game_release=timezone.now(),
game_archive=timezone.now(),
- language=Language.JAVA_8,
)
+ java8, _ = ProgrammingLanguage.objects.get_or_create(
+ code="java8", defaults={"display_name": "Java 8"}
+ )
+ e1.languages.add(java8)
for name in ["team1", "team2", "team3"]:
t = Team.objects.create(episode=e1, name=name)
u = User.objects.create_user(
@@ -181,6 +192,7 @@ def setUp(self):
team=t,
user=u,
accepted=True,
+ language=java8,
)
# Partitions for: rating. Testing finalizations occur iff prerequisites are met.
@@ -488,13 +500,18 @@ class ScrimmageRequestQuerySetTestCase(TestCase):
"""Test suite for handling sets of scrimmage requests."""
def setUp(self):
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
e1 = Episode.objects.create(
name_short="e1",
registration=timezone.now(),
game_release=timezone.now(),
game_archive=timezone.now(),
- language=Language.JAVA_8,
)
+ java8, _ = ProgrammingLanguage.objects.get_or_create(
+ code="java8", defaults={"display_name": "Java 8"}
+ )
+ e1.languages.add(java8)
self.m = Map.objects.create(episode=e1, name="m", is_public=True)
self.teams = []
for name in ["team1", "team2"]:
@@ -509,6 +526,7 @@ def setUp(self):
team=t,
user=u,
accepted=True,
+ language=java8,
)
self.teams.append(t)
self.n = 100
diff --git a/backend/siarnaq/api/compete/test_views.py b/backend/siarnaq/api/compete/test_views.py
index 1572beb6e..235a3599c 100644
--- a/backend/siarnaq/api/compete/test_views.py
+++ b/backend/siarnaq/api/compete/test_views.py
@@ -25,7 +25,6 @@
from siarnaq.api.compete.serializers import MatchSerializer
from siarnaq.api.episodes.models import (
Episode,
- Language,
Map,
ReleaseStatus,
Tournament,
@@ -78,14 +77,19 @@ class SubmissionViewSetTestCase(APITestCase):
"""Test suite for the Submissions API."""
def setUp(self):
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
self.e1 = Episode.objects.create(
name_short="e1",
registration=timezone.now(),
game_release=timezone.now(),
game_archive=timezone.now(),
submission_frozen=False,
- language=Language.JAVA_8,
)
+ java8, _ = ProgrammingLanguage.objects.get_or_create(
+ code="java8", defaults={"display_name": "Java 8"}
+ )
+ self.e1.languages.add(java8)
self.user = User.objects.create_user(
username="user1", email="user1@example.com"
)
@@ -123,7 +127,12 @@ def test_create_has_team_staff_hidden_small(self, enqueue, client):
with io.BytesIO(b"abcdefg") as f:
response = self.client.post(
reverse("submission-list", kwargs={"episode_id": "e1"}),
- {"package": "bot", "description": "New bot", "source_code": f},
+ {
+ "package": "bot",
+ "description": "New bot",
+ "source_code": f,
+ "language": "java8",
+ },
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -146,7 +155,12 @@ def test_create_has_team_not_staff_frozen(self):
with io.BytesIO(b"abcdefg") as f:
response = self.client.post(
reverse("submission-list", kwargs={"episode_id": "e1"}),
- {"package": "bot", "description": "New bot", "source_code": f},
+ {
+ "package": "bot",
+ "description": "New bot",
+ "source_code": f,
+ "language": "java8",
+ },
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
@@ -167,7 +181,12 @@ def test_create_has_team_not_staff_not_frozen_large(self, enqueue, client):
with io.BytesIO(data) as f:
response = self.client.post(
reverse("submission-list", kwargs={"episode_id": "e1"}),
- {"package": "bot", "description": "New bot", "source_code": f},
+ {
+ "package": "bot",
+ "description": "New bot",
+ "source_code": f,
+ "language": "java8",
+ },
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -192,7 +211,12 @@ def test_create_has_team_not_staff_hidden(self):
with io.BytesIO(b"abcdefg") as f:
response = self.client.post(
reverse("submission-list", kwargs={"episode_id": "e1"}),
- {"package": "bot", "description": "New bot", "source_code": f},
+ {
+ "package": "bot",
+ "description": "New bot",
+ "source_code": f,
+ "language": "java8",
+ },
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@@ -206,7 +230,12 @@ def test_create_no_team(self):
with io.BytesIO(b"abcdefg") as f:
response = self.client.post(
reverse("submission-list", kwargs={"episode_id": "e1"}),
- {"package": "bot", "description": "New bot", "source_code": f},
+ {
+ "package": "bot",
+ "description": "New bot",
+ "source_code": f,
+ "language": "java8",
+ },
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
@@ -216,7 +245,12 @@ def test_create_no_user(self):
with io.BytesIO(b"abcdefg") as f:
response = self.client.post(
reverse("submission-list", kwargs={"episode_id": "e1"}),
- {"package": "bot", "description": "New bot", "source_code": f},
+ {
+ "package": "bot",
+ "description": "New bot",
+ "source_code": f,
+ "language": "java8",
+ },
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@@ -229,6 +263,9 @@ def test_create_no_user(self):
# accepted: provided, blank
def test_report_admin_was_final_now_valid(self):
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
+ java8 = ProgrammingLanguage.objects.get(code="java8")
s = Submission.objects.create(
episode=self.e1,
team=self.team,
@@ -236,6 +273,7 @@ def test_report_admin_was_final_now_valid(self):
status=SaturnStatus.COMPLETED,
logs="abc",
accepted=False,
+ language=java8,
)
self.client.force_authenticate(self.admin)
response = self.client.post(
@@ -253,6 +291,9 @@ def test_report_admin_was_final_now_valid(self):
self.assertFalse(s.accepted)
def test_report_admin_was_unfinal_now_valid_provided(self):
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
+ java8 = ProgrammingLanguage.objects.get(code="java8")
s = Submission.objects.create(
episode=self.e1,
team=self.team,
@@ -260,6 +301,7 @@ def test_report_admin_was_unfinal_now_valid_provided(self):
status=SaturnStatus.RUNNING,
logs="abc",
accepted=False,
+ language=java8,
)
self.client.force_authenticate(self.admin)
response = self.client.post(
@@ -277,6 +319,9 @@ def test_report_admin_was_unfinal_now_valid_provided(self):
self.assertTrue(s.accepted)
def test_report_admin_was_unfinal_now_valid_blank(self):
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
+ java8 = ProgrammingLanguage.objects.get(code="java8")
s = Submission.objects.create(
episode=self.e1,
team=self.team,
@@ -284,6 +329,7 @@ def test_report_admin_was_unfinal_now_valid_blank(self):
status=SaturnStatus.QUEUED,
logs="abc",
accepted=False,
+ language=java8,
)
self.client.force_authenticate(self.admin)
response = self.client.post(
@@ -298,6 +344,9 @@ def test_report_admin_was_unfinal_now_valid_blank(self):
self.assertFalse(s.accepted)
def test_report_admin_was_unfinal_now_invalid(self):
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
+ java8 = ProgrammingLanguage.objects.get(code="java8")
s = Submission.objects.create(
episode=self.e1,
team=self.team,
@@ -305,6 +354,7 @@ def test_report_admin_was_unfinal_now_invalid(self):
status=SaturnStatus.RUNNING,
logs="abc",
accepted=False,
+ language=java8,
)
self.client.force_authenticate(self.admin)
response = self.client.post(
@@ -319,6 +369,9 @@ def test_report_admin_was_unfinal_now_invalid(self):
self.assertFalse(s.accepted)
def test_report_not_admin(self):
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
+ java8 = ProgrammingLanguage.objects.get(code="java8")
s = Submission.objects.create(
episode=self.e1,
team=self.team,
@@ -326,6 +379,7 @@ def test_report_not_admin(self):
status=SaturnStatus.RUNNING,
logs="abc",
accepted=False,
+ language=java8,
)
self.client.force_authenticate(self.user)
response = self.client.post(
@@ -347,13 +401,18 @@ class MatchSerializerTestCase(TestCase):
"""Test suite for the Match serializer."""
def setUp(self):
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
self.e1 = Episode.objects.create(
name_short="e1",
registration=timezone.now(),
game_release=timezone.now(),
game_archive=timezone.now(),
- language=Language.JAVA_8,
)
+ java8, _ = ProgrammingLanguage.objects.get_or_create(
+ code="java8", defaults={"display_name": "Java 8"}
+ )
+ self.e1.languages.add(java8)
self.map = Map.objects.create(episode=self.e1, name="map")
tournament = Tournament.objects.create(
name_short="t",
@@ -390,7 +449,7 @@ def setUp(self):
t.members.add(u)
self.submissions.append(
Submission.objects.create(
- episode=self.e1, team=t, user=u, accepted=True
+ episode=self.e1, team=t, user=u, accepted=True, language=java8
)
)
self.users.append(u)
@@ -967,13 +1026,18 @@ def helper_create_tournament_match(self, tournament_round):
return match
def setUp(self):
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
self.e1 = Episode.objects.create(
name_short="e1",
registration=timezone.now(),
game_release=timezone.now(),
game_archive=timezone.now(),
- language=Language.JAVA_8,
)
+ java8, _ = ProgrammingLanguage.objects.get_or_create(
+ code="java8", defaults={"display_name": "Java 8"}
+ )
+ self.e1.languages.add(java8)
self.map = Map.objects.create(episode=self.e1, name="map")
public_tournament = Tournament.objects.create(
name_short="t",
@@ -1032,7 +1096,7 @@ def setUp(self):
t.members.add(u)
self.submissions.append(
Submission.objects.create(
- episode=self.e1, team=t, user=u, accepted=True
+ episode=self.e1, team=t, user=u, accepted=True, language=java8
)
)
self.users.append(u)
@@ -1227,22 +1291,28 @@ class ScrimmageRequestViewSetTestCase(APITransactionTestCase):
"""Test suite for the Scrimmage Requests API."""
def setUp(self):
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
self.e1 = Episode.objects.create(
name_short="e1",
registration=timezone.now(),
game_release=timezone.now(),
game_archive=timezone.now(),
submission_frozen=False,
- language=Language.JAVA_8,
)
+ java8, _ = ProgrammingLanguage.objects.get_or_create(
+ code="java8", defaults={"display_name": "Java 8"}
+ )
+ self.e1.languages.add(java8)
+
self.e2 = Episode.objects.create(
name_short="e2",
registration=timezone.now(),
game_release=timezone.now(),
game_archive=timezone.now(),
submission_frozen=False,
- language=Language.JAVA_8,
)
+ self.e2.languages.add(java8)
self.maps = []
for i in range(5):
self.maps.append(
@@ -1271,7 +1341,7 @@ def setUp(self):
t.members.add(u)
self.submissions.append(
Submission.objects.create(
- episode=self.e1, team=t, user=u, accepted=True
+ episode=self.e1, team=t, user=u, accepted=True, language=java8
)
)
self.users.append(u)
diff --git a/backend/siarnaq/api/compete/views.py b/backend/siarnaq/api/compete/views.py
index 3c718d19b..12441d3df 100644
--- a/backend/siarnaq/api/compete/views.py
+++ b/backend/siarnaq/api/compete/views.py
@@ -151,7 +151,13 @@ def get_queryset(self):
"package": {"type": "string"},
"description": {"type": "string"},
"source_code": {"type": "string", "format": "binary"},
+ "language": {
+ "type": "string",
+ "enum": ["java8", "java21", "py3"],
+ "description": "The programming language of the submission",
+ },
},
+ "required": ["package", "description", "source_code", "language"],
}
},
)
diff --git a/backend/siarnaq/api/episodes/admin.py b/backend/siarnaq/api/episodes/admin.py
index baab6d9ad..fa6634f4a 100644
--- a/backend/siarnaq/api/episodes/admin.py
+++ b/backend/siarnaq/api/episodes/admin.py
@@ -8,6 +8,7 @@
EligibilityCriterion,
Episode,
Map,
+ ProgrammingLanguage,
Tournament,
TournamentRound,
)
@@ -46,7 +47,7 @@ class EpisodeAdmin(admin.ModelAdmin):
"fields": (
"name_short",
"name_long",
- "language",
+ "languages",
"blurb",
"eligibility_criteria",
),
@@ -83,11 +84,12 @@ class EpisodeAdmin(admin.ModelAdmin):
},
),
)
- filter_horizontal = ("eligibility_criteria",)
+ filter_horizontal = ("eligibility_criteria", "languages")
inlines = [MapInline]
list_display = (
"name_short",
"name_long",
+ "display_languages",
"registration",
"game_release",
"game_archive",
@@ -96,6 +98,12 @@ class EpisodeAdmin(admin.ModelAdmin):
search_fields = ("name_short", "name_long")
search_help_text = "Search for a full or abbreviated name."
+ @admin.display(description="Languages")
+ def display_languages(self, obj):
+ """Display supported languages for the episode."""
+ langs = obj.languages.all()
+ return ", ".join(lang.display_name for lang in langs) if langs else "None"
+
@admin.register(Map)
class MapAdmin(admin.ModelAdmin):
@@ -117,6 +125,14 @@ class EligibilityCriterionAdmin(admin.ModelAdmin):
search_help_text = "Search for a title."
+@admin.register(ProgrammingLanguage)
+class ProgrammingLanguageAdmin(admin.ModelAdmin):
+ fields = ("code", "display_name")
+ list_display = ("code", "display_name")
+ ordering = ("display_name",)
+ search_fields = ("code", "display_name")
+
+
class TournamentRoundInline(admin.TabularInline):
model = TournamentRound
extra = 0
diff --git a/backend/siarnaq/api/episodes/migrations/0013_programminglanguage_alter_episode_language_and_more.py b/backend/siarnaq/api/episodes/migrations/0013_programminglanguage_alter_episode_language_and_more.py
new file mode 100644
index 000000000..ba7c3176e
--- /dev/null
+++ b/backend/siarnaq/api/episodes/migrations/0013_programminglanguage_alter_episode_language_and_more.py
@@ -0,0 +1,59 @@
+# Generated by Django 4.1.2 on 2025-12-31 06:48
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("episodes", "0012_eligibilitycriterion_is_private"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ProgrammingLanguage",
+ fields=[
+ (
+ "code",
+ models.CharField(
+ choices=[
+ ("java8", "Java 8"),
+ ("java21", "Java 21"),
+ ("py3", "Python 3"),
+ ],
+ help_text="The language code (e.g., 'java8', 'py3')",
+ max_length=16,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("display_name", models.CharField(max_length=64)),
+ ],
+ options={
+ "ordering": ["display_name"],
+ },
+ ),
+ migrations.AlterField(
+ model_name="episode",
+ name="language",
+ field=models.CharField(
+ choices=[
+ ("java8", "Java 8"),
+ ("java21", "Java 21"),
+ ("py3", "Python 3"),
+ ],
+ help_text="Deprecated: use languages M2M field instead.",
+ max_length=8,
+ ),
+ ),
+ migrations.AddField(
+ model_name="episode",
+ name="languages",
+ field=models.ManyToManyField(
+ blank=True,
+ help_text="The implementation languages supported for this episode.",
+ related_name="episodes",
+ to="episodes.programminglanguage",
+ ),
+ ),
+ ]
diff --git a/backend/siarnaq/api/episodes/migrations/0014_migrate_language_to_m2m.py b/backend/siarnaq/api/episodes/migrations/0014_migrate_language_to_m2m.py
new file mode 100644
index 000000000..08f2a9915
--- /dev/null
+++ b/backend/siarnaq/api/episodes/migrations/0014_migrate_language_to_m2m.py
@@ -0,0 +1,56 @@
+# Generated by Django 4.1.2 on 2025-12-31 06:49
+
+from django.db import migrations
+
+
+def migrate_language_data(apps, schema_editor):
+ """Populate ProgrammingLanguage and migrate Episode.language → Episode.languages and Submission.language."""
+ ProgrammingLanguage = apps.get_model("episodes", "ProgrammingLanguage")
+ Episode = apps.get_model("episodes", "Episode")
+ Submission = apps.get_model("compete", "Submission")
+
+ # 1. Create ProgrammingLanguage records
+ languages = [
+ {"code": "java8", "display_name": "Java 8"},
+ {"code": "java21", "display_name": "Java 21"},
+ {"code": "py3", "display_name": "Python 3"},
+ ]
+ for lang in languages:
+ ProgrammingLanguage.objects.get_or_create(
+ code=lang["code"],
+ defaults={"display_name": lang["display_name"]}
+ )
+
+ # 2. Migrate Episode.language (CharField) → Episode.languages (M2M)
+ for episode in Episode.objects.all():
+ if episode.language: # Read from old CharField
+ try:
+ lang_obj = ProgrammingLanguage.objects.get(code=episode.language)
+ episode.languages.add(lang_obj) # Add to new M2M
+ except ProgrammingLanguage.DoesNotExist:
+ print(f"Warning: Language '{episode.language}' not found for episode '{episode.name_short}'")
+
+ # 3. Migrate Episode.language → Submission.language (FK)
+ for episode in Episode.objects.all():
+ if episode.language:
+ try:
+ lang_obj = ProgrammingLanguage.objects.get(code=episode.language)
+ # Assign language to all submissions for this episode
+ Submission.objects.filter(
+ episode=episode,
+ language__isnull=True
+ ).update(language=lang_obj)
+ except ProgrammingLanguage.DoesNotExist:
+ print(f"Warning: Language '{episode.language}' not found for episode '{episode.name_short}'")
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("episodes", "0013_programminglanguage_alter_episode_language_and_more"),
+ ("compete", "0010_submission_language"),
+ ]
+
+ operations = [
+ migrations.RunPython(migrate_language_data, migrations.RunPython.noop),
+ ]
diff --git a/backend/siarnaq/api/episodes/migrations/0015_remove_episode_language.py b/backend/siarnaq/api/episodes/migrations/0015_remove_episode_language.py
new file mode 100644
index 000000000..f6fbc9f0a
--- /dev/null
+++ b/backend/siarnaq/api/episodes/migrations/0015_remove_episode_language.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.1.2 on 2025-12-31 06:52
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("episodes", "0014_migrate_language_to_m2m"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="episode",
+ name="language",
+ ),
+ ]
diff --git a/backend/siarnaq/api/episodes/models.py b/backend/siarnaq/api/episodes/models.py
index ff4a6c580..cde934303 100644
--- a/backend/siarnaq/api/episodes/models.py
+++ b/backend/siarnaq/api/episodes/models.py
@@ -26,6 +26,24 @@ class Language(models.TextChoices):
PYTHON_3 = "py3"
+class ProgrammingLanguage(models.Model):
+ """A programming language supported by episodes."""
+
+ code = models.CharField(
+ max_length=16,
+ primary_key=True,
+ choices=Language.choices,
+ help_text="The language code (e.g., 'java8', 'py3')",
+ )
+ display_name = models.CharField(max_length=64)
+
+ class Meta:
+ ordering = ["display_name"]
+
+ def __str__(self):
+ return self.display_name
+
+
class EligibilityCriterion(models.Model):
"""
A database model for an eligibility criterion for entering into a tournament.
@@ -88,8 +106,13 @@ class Episode(models.Model):
autoscrim_schedule = models.CharField(max_length=64, null=True, blank=True)
"""A cron specification for the autoscrim schedule, or null if disabled."""
- language = models.CharField(max_length=8, choices=Language.choices)
- """The implementation language supported for this episode."""
+ languages = models.ManyToManyField(
+ "ProgrammingLanguage",
+ related_name="episodes",
+ blank=True,
+ help_text="The implementation languages supported for this episode.",
+ )
+ """The implementation languages supported for this episode."""
scaffold = models.URLField(blank=True)
"""The URL of the git repository where the scaffold can be obtained."""
@@ -111,6 +134,14 @@ class Episode(models.Model):
)
"""The eligibility criteria active in this episode."""
+ languages = models.ManyToManyField(
+ "ProgrammingLanguage",
+ related_name="episodes",
+ blank=True,
+ help_text="The implementation languages supported for this episode.",
+ )
+ """The implementation languages supported for this episode."""
+
is_allowed_ranked_scrimmage = models.BooleanField(default=True)
"""Whether ranked scrimmages are allowed in this episode."""
@@ -139,6 +170,10 @@ def frozen(self):
is_public=True,
).exists()
+ def get_supported_languages(self):
+ """Return list of supported language codes."""
+ return list(self.languages.values_list("code", flat=True))
+
def autoscrim(self, best_of, override_freeze=False):
"""
Trigger a round of automatically-generated ranked scrimmages for all teams in
@@ -161,9 +196,10 @@ def autoscrim(self, best_of, override_freeze=False):
def for_saturn(self):
"""Return the representation of this object as expected by Saturn."""
+ # TODO(@nour-massri): Update Saturn service to handle multiple languages
return {
"name": self.name_short,
- "language": self.language,
+ "languages": self.get_supported_languages(),
"scaffold": self.scaffold,
}
diff --git a/backend/siarnaq/api/episodes/serializers.py b/backend/siarnaq/api/episodes/serializers.py
index d71646cc8..6cdf8313a 100644
--- a/backend/siarnaq/api/episodes/serializers.py
+++ b/backend/siarnaq/api/episodes/serializers.py
@@ -26,6 +26,7 @@ class Meta:
class EpisodeSerializer(serializers.ModelSerializer):
eligibility_criteria = EligibilityCriterionSerializer(many=True)
frozen = serializers.SerializerMethodField()
+ languages = serializers.SerializerMethodField()
class Meta:
model = Episode
@@ -35,7 +36,7 @@ class Meta:
"blurb",
"game_release",
"game_archive",
- "language",
+ "languages",
"scaffold",
"artifact_name",
"release_version_client",
@@ -49,6 +50,16 @@ class Meta:
def get_frozen(self, obj):
return obj.frozen()
+ @extend_schema_field(
+ {
+ "type": "array",
+ "items": {"type": "string", "enum": ["java8", "java21", "py3"]},
+ }
+ )
+ def get_languages(self, obj):
+ """Return list of supported language codes."""
+ return obj.get_supported_languages()
+
@extend_schema_serializer(
# workaround for https://github.com/OpenAPITools/openapi-generator/issues/9289
diff --git a/backend/siarnaq/api/teams/tests.py b/backend/siarnaq/api/teams/tests.py
index 09106d189..1fd42104e 100644
--- a/backend/siarnaq/api/teams/tests.py
+++ b/backend/siarnaq/api/teams/tests.py
@@ -9,7 +9,7 @@
from rest_framework.test import APITestCase
from siarnaq.api.compete.models import Match, MatchParticipant, Submission
-from siarnaq.api.episodes.models import EligibilityCriterion, Episode, Language, Map
+from siarnaq.api.episodes.models import EligibilityCriterion, Episode, Map
from siarnaq.api.teams.managers import generate_4regular_graph
from siarnaq.api.teams.models import Team, TeamStatus
from siarnaq.api.user.models import User
@@ -67,18 +67,31 @@ def make_episode(self, name, *, n_public_maps):
registration=timezone.now(),
game_release=timezone.now(),
game_archive=timezone.now(),
- language=Language.JAVA_8,
)
+ # Add default language via M2M
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
+ java8, _ = ProgrammingLanguage.objects.get_or_create(
+ code="java8", defaults={"display_name": "Java 8"}
+ )
+ e.languages.add(java8)
+
for i in range(n_public_maps):
Map.objects.create(episode=e, name=f"map{i}", is_public=True)
return e
def make_team(self, *, episode, name, n_submissions, **kwargs):
"""Create a team with the given quantity of accepted submissions."""
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
t = Team.objects.create(episode=episode, name=name, **kwargs)
u = User.objects.create_user(username=name, email=f"{name}@example.com")
+ # Get the language that should already exist from make_episode
+ java8 = ProgrammingLanguage.objects.get(code="java8")
for i in range(n_submissions):
- Submission.objects.create(episode=episode, team=t, user=u, accepted=True)
+ Submission.objects.create(
+ episode=episode, team=t, user=u, accepted=True, language=java8
+ )
return t
# Partitions:
@@ -160,12 +173,15 @@ def test_team_not_regular(self):
self.assertFalse(MatchParticipant.objects.filter(team=t).exists())
def test_team_latest_accepted_older_accepted(self):
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
e1 = self.make_episode("e1", n_public_maps=3)
+ java8 = ProgrammingLanguage.objects.get(code="java8")
for i in range(3):
t = self.make_team(episode=e1, name=f"team{i}", n_submissions=1)
s1 = t.submissions.get()
s2 = Submission.objects.create(
- episode=e1, team=t, user=User.objects.last(), accepted=True
+ episode=e1, team=t, user=User.objects.last(), accepted=True, language=java8
)
with self.patcher:
Team.objects.autoscrim(episode=e1, best_of=3)
@@ -175,12 +191,15 @@ def test_team_latest_accepted_older_accepted(self):
self.assertEqual(s2.participations.count(), 2)
def test_team_latest_not_accepted_older_accepted(self):
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
e1 = self.make_episode("e1", n_public_maps=3)
+ java8 = ProgrammingLanguage.objects.get(code="java8")
for i in range(3):
t = self.make_team(episode=e1, name=f"team{i}", n_submissions=1)
s1 = t.submissions.get()
s2 = Submission.objects.create(
- episode=e1, team=t, user=User.objects.last(), accepted=False
+ episode=e1, team=t, user=User.objects.last(), accepted=False, language=java8
)
with self.patcher:
Team.objects.autoscrim(episode=e1, best_of=3)
@@ -190,15 +209,18 @@ def test_team_latest_not_accepted_older_accepted(self):
self.assertEqual(s2.participations.count(), 0)
def test_team_latest_not_accepted_older_not_accepted(self):
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
e1 = self.make_episode("e1", n_public_maps=3)
+ java8 = ProgrammingLanguage.objects.get(code="java8")
for i in range(3):
self.make_team(episode=e1, name=f"team{i}", n_submissions=1)
t = self.make_team(episode=e1, name="badteam", n_submissions=0)
s1 = Submission.objects.create(
- episode=e1, team=t, user=User.objects.last(), accepted=False
+ episode=e1, team=t, user=User.objects.last(), accepted=False, language=java8
)
s2 = Submission.objects.create(
- episode=e1, team=t, user=User.objects.last(), accepted=False
+ episode=e1, team=t, user=User.objects.last(), accepted=False, language=java8
)
with self.patcher:
Team.objects.autoscrim(episode=e1, best_of=3)
@@ -244,13 +266,18 @@ class EligibilityTestCase(APITestCase):
"""Test suite for team eligibility logic in Team API."""
def setUp(self):
+ from siarnaq.api.episodes.models import ProgrammingLanguage
+
self.episode = Episode.objects.create(
name_short="ep",
registration=timezone.now(),
game_release=timezone.now(),
game_archive=timezone.now(),
- language=Language.JAVA_8,
)
+ java8, _ = ProgrammingLanguage.objects.get_or_create(
+ code="java8", defaults={"display_name": "Java 8"}
+ )
+ self.episode.languages.add(java8)
self.team = Team.objects.create(episode=self.episode, name="t1")
self.user = User.objects.create_user(
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index e3cde472c..f1cff5657 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -106,6 +106,7 @@ export default [
"**/tsconfig.json",
"**/vite-env.d.ts",
"**/*.yml",
+ "**/build/**",
],
},
];
diff --git a/frontend/schema.yml b/frontend/schema.yml
index f1078564d..402fdf5c5 100644
--- a/frontend/schema.yml
+++ b/frontend/schema.yml
@@ -580,6 +580,18 @@ paths:
source_code:
type: string
format: binary
+ language:
+ type: string
+ enum:
+ - java8
+ - java21
+ - py3
+ description: The programming language of the submission
+ required:
+ - package
+ - description
+ - source_code
+ - language
security:
- jwtAuth: []
responses:
@@ -2542,8 +2554,11 @@ components:
game_archive:
type: string
format: date-time
- language:
- $ref: '#/components/schemas/LanguageEnum'
+ languages:
+ type: array
+ items:
+ $ref: '#/components/schemas/LanguagesEnum'
+ readOnly: true
scaffold:
type: string
format: uri
@@ -2572,7 +2587,7 @@ components:
- frozen
- game_archive
- game_release
- - language
+ - languages
- name_long
- name_short
GameMap:
@@ -2619,6 +2634,12 @@ components:
- java21
- py3
type: string
+ LanguagesEnum:
+ type: string
+ enum:
+ - java8
+ - java21
+ - py3
Match:
type: object
properties:
@@ -3162,11 +3183,16 @@ components:
description:
type: string
maxLength: 128
+ language:
+ allOf:
+ - $ref: '#/components/schemas/LanguageEnum'
+ description: The programming language of this submission.
required:
- accepted
- created
- episode
- id
+ - language
- logs
- status
- team
@@ -3612,6 +3638,11 @@ components:
description:
type: string
readOnly: true
+ language:
+ allOf:
+ - $ref: '#/components/schemas/LanguageEnum'
+ description: The programming language of this submission.
+ readOnly: true
tournament:
type: string
readOnly: true
@@ -3621,6 +3652,7 @@ components:
- description
- episode
- id
+ - language
- logs
- package
- status
diff --git a/frontend/src/api/_autogen/.openapi-generator/FILES b/frontend/src/api/_autogen/.openapi-generator/FILES
index a002db3ee..31ac41784 100644
--- a/frontend/src/api/_autogen/.openapi-generator/FILES
+++ b/frontend/src/api/_autogen/.openapi-generator/FILES
@@ -18,6 +18,7 @@ models/GameMap.ts
models/GenderEnum.ts
models/HistoricalRating.ts
models/LanguageEnum.ts
+models/LanguagesEnum.ts
models/Match.ts
models/MatchParticipant.ts
models/MatchRating.ts
diff --git a/frontend/src/api/_autogen/apis/CompeteApi.ts b/frontend/src/api/_autogen/apis/CompeteApi.ts
index bded95392..f2eee6ec2 100644
--- a/frontend/src/api/_autogen/apis/CompeteApi.ts
+++ b/frontend/src/api/_autogen/apis/CompeteApi.ts
@@ -146,9 +146,10 @@ export interface CompeteRequestRejectCreateRequest {
export interface CompeteSubmissionCreateRequest {
episodeId: string;
- _package?: string;
- description?: string;
- sourceCode?: Blob;
+ _package: string;
+ description: string;
+ sourceCode: Blob;
+ language: CompeteSubmissionCreateLanguageEnum;
}
export interface CompeteSubmissionDownloadRetrieveRequest {
@@ -885,6 +886,22 @@ export class CompeteApi extends runtime.BaseAPI {
throw new runtime.RequiredError('episodeId','Required parameter requestParameters.episodeId was null or undefined when calling competeSubmissionCreate.');
}
+ if (requestParameters._package === null || requestParameters._package === undefined) {
+ throw new runtime.RequiredError('_package','Required parameter requestParameters._package was null or undefined when calling competeSubmissionCreate.');
+ }
+
+ if (requestParameters.description === null || requestParameters.description === undefined) {
+ throw new runtime.RequiredError('description','Required parameter requestParameters.description was null or undefined when calling competeSubmissionCreate.');
+ }
+
+ if (requestParameters.sourceCode === null || requestParameters.sourceCode === undefined) {
+ throw new runtime.RequiredError('sourceCode','Required parameter requestParameters.sourceCode was null or undefined when calling competeSubmissionCreate.');
+ }
+
+ if (requestParameters.language === null || requestParameters.language === undefined) {
+ throw new runtime.RequiredError('language','Required parameter requestParameters.language was null or undefined when calling competeSubmissionCreate.');
+ }
+
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
@@ -925,6 +942,10 @@ export class CompeteApi extends runtime.BaseAPI {
formParams.append('source_code', requestParameters.sourceCode as any);
}
+ if (requestParameters.language !== undefined) {
+ formParams.append('language', requestParameters.language as any);
+ }
+
const response = await this.request({
path: `/api/compete/{episode_id}/submission/`.replace(`{${"episode_id"}}`, encodeURIComponent(String(requestParameters.episodeId))),
method: 'POST',
@@ -1157,3 +1178,13 @@ export class CompeteApi extends runtime.BaseAPI {
}
}
+
+/**
+ * @export
+ * @enum {string}
+ */
+export enum CompeteSubmissionCreateLanguageEnum {
+ Java8 = 'java8',
+ Java21 = 'java21',
+ Py3 = 'py3'
+}
diff --git a/frontend/src/api/_autogen/models/Episode.ts b/frontend/src/api/_autogen/models/Episode.ts
index 2a6c38ef1..55c477c4e 100644
--- a/frontend/src/api/_autogen/models/Episode.ts
+++ b/frontend/src/api/_autogen/models/Episode.ts
@@ -19,12 +19,12 @@ import {
EligibilityCriterionFromJSONTyped,
EligibilityCriterionToJSON,
} from './EligibilityCriterion';
-import type { LanguageEnum } from './LanguageEnum';
+import type { LanguagesEnum } from './LanguagesEnum';
import {
- LanguageEnumFromJSON,
- LanguageEnumFromJSONTyped,
- LanguageEnumToJSON,
-} from './LanguageEnum';
+ LanguagesEnumFromJSON,
+ LanguagesEnumFromJSONTyped,
+ LanguagesEnumToJSON,
+} from './LanguagesEnum';
/**
*
@@ -64,10 +64,10 @@ export interface Episode {
game_archive: Date;
/**
*
- * @type {LanguageEnum}
+ * @type {Array}
* @memberof Episode
*/
- language: LanguageEnum;
+ readonly languages: Array;
/**
*
* @type {string}
@@ -121,7 +121,7 @@ export function instanceOfEpisode(value: object): boolean {
isInstance = isInstance && "name_long" in value;
isInstance = isInstance && "game_release" in value;
isInstance = isInstance && "game_archive" in value;
- isInstance = isInstance && "language" in value;
+ isInstance = isInstance && "languages" in value;
isInstance = isInstance && "eligibility_criteria" in value;
isInstance = isInstance && "frozen" in value;
@@ -143,7 +143,7 @@ export function EpisodeFromJSONTyped(json: any, ignoreDiscriminator: boolean): E
'blurb': !exists(json, 'blurb') ? undefined : json['blurb'],
'game_release': (new Date(json['game_release'])),
'game_archive': (new Date(json['game_archive'])),
- 'language': LanguageEnumFromJSON(json['language']),
+ 'languages': ((json['languages'] as Array).map(LanguagesEnumFromJSON)),
'scaffold': !exists(json, 'scaffold') ? undefined : json['scaffold'],
'artifact_name': !exists(json, 'artifact_name') ? undefined : json['artifact_name'],
'release_version_client': !exists(json, 'release_version_client') ? undefined : json['release_version_client'],
@@ -168,7 +168,6 @@ export function EpisodeToJSON(value?: Episode | null): any {
'blurb': value.blurb,
'game_release': (value.game_release.toISOString()),
'game_archive': (value.game_archive.toISOString()),
- 'language': LanguageEnumToJSON(value.language),
'scaffold': value.scaffold,
'artifact_name': value.artifact_name,
'release_version_client': value.release_version_client,
diff --git a/frontend/src/api/_autogen/models/LanguagesEnum.ts b/frontend/src/api/_autogen/models/LanguagesEnum.ts
new file mode 100644
index 000000000..e1b149ff0
--- /dev/null
+++ b/frontend/src/api/_autogen/models/LanguagesEnum.ts
@@ -0,0 +1,38 @@
+/* tslint:disable */
+/* eslint-disable */
+/**
+ *
+ * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
+ *
+ * The version of the OpenAPI document: 0.0.0
+ *
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+/**
+ *
+ * @export
+ * @enum {string}
+ */
+export enum LanguagesEnum {
+ Java8 = 'java8',
+ Java21 = 'java21',
+ Py3 = 'py3'
+}
+
+
+export function LanguagesEnumFromJSON(json: any): LanguagesEnum {
+ return LanguagesEnumFromJSONTyped(json, false);
+}
+
+export function LanguagesEnumFromJSONTyped(json: any, ignoreDiscriminator: boolean): LanguagesEnum {
+ return json as LanguagesEnum;
+}
+
+export function LanguagesEnumToJSON(value?: LanguagesEnum | null): any {
+ return value as any;
+}
+
diff --git a/frontend/src/api/_autogen/models/Submission.ts b/frontend/src/api/_autogen/models/Submission.ts
index d61410819..710d9c0e9 100644
--- a/frontend/src/api/_autogen/models/Submission.ts
+++ b/frontend/src/api/_autogen/models/Submission.ts
@@ -13,6 +13,12 @@
*/
import { exists, mapValues } from '../runtime';
+import type { LanguageEnum } from './LanguageEnum';
+import {
+ LanguageEnumFromJSON,
+ LanguageEnumFromJSONTyped,
+ LanguageEnumToJSON,
+} from './LanguageEnum';
import type { StatusBccEnum } from './StatusBccEnum';
import {
StatusBccEnumFromJSON,
@@ -98,6 +104,12 @@ export interface Submission {
* @memberof Submission
*/
description?: string;
+ /**
+ *
+ * @type {LanguageEnum}
+ * @memberof Submission
+ */
+ language: LanguageEnum;
}
/**
@@ -115,6 +127,7 @@ export function instanceOfSubmission(value: object): boolean {
isInstance = isInstance && "username" in value;
isInstance = isInstance && "created" in value;
isInstance = isInstance && "accepted" in value;
+ isInstance = isInstance && "language" in value;
return isInstance;
}
@@ -141,6 +154,7 @@ export function SubmissionFromJSONTyped(json: any, ignoreDiscriminator: boolean)
'accepted': json['accepted'],
'_package': !exists(json, 'package') ? undefined : json['package'],
'description': !exists(json, 'description') ? undefined : json['description'],
+ 'language': LanguageEnumFromJSON(json['language']),
};
}
@@ -155,6 +169,7 @@ export function SubmissionToJSON(value?: Submission | null): any {
'package': value._package,
'description': value.description,
+ 'language': LanguageEnumToJSON(value.language),
};
}
diff --git a/frontend/src/api/_autogen/models/TournamentSubmission.ts b/frontend/src/api/_autogen/models/TournamentSubmission.ts
index 0a8d04fc3..844a2fc36 100644
--- a/frontend/src/api/_autogen/models/TournamentSubmission.ts
+++ b/frontend/src/api/_autogen/models/TournamentSubmission.ts
@@ -13,6 +13,12 @@
*/
import { exists, mapValues } from '../runtime';
+import type { LanguageEnum } from './LanguageEnum';
+import {
+ LanguageEnumFromJSON,
+ LanguageEnumFromJSONTyped,
+ LanguageEnumToJSON,
+} from './LanguageEnum';
import type { StatusBccEnum } from './StatusBccEnum';
import {
StatusBccEnumFromJSON,
@@ -98,6 +104,12 @@ export interface TournamentSubmission {
* @memberof TournamentSubmission
*/
readonly description: string;
+ /**
+ *
+ * @type {LanguageEnum}
+ * @memberof TournamentSubmission
+ */
+ readonly language: LanguageEnum;
/**
*
* @type {string}
@@ -123,6 +135,7 @@ export function instanceOfTournamentSubmission(value: object): boolean {
isInstance = isInstance && "accepted" in value;
isInstance = isInstance && "_package" in value;
isInstance = isInstance && "description" in value;
+ isInstance = isInstance && "language" in value;
isInstance = isInstance && "tournament" in value;
return isInstance;
@@ -150,6 +163,7 @@ export function TournamentSubmissionFromJSONTyped(json: any, ignoreDiscriminator
'accepted': json['accepted'],
'_package': json['package'],
'description': json['description'],
+ 'language': LanguageEnumFromJSON(json['language']),
'tournament': json['tournament'],
};
}
diff --git a/frontend/src/api/_autogen/models/index.ts b/frontend/src/api/_autogen/models/index.ts
index ba3250f95..e40b70056 100644
--- a/frontend/src/api/_autogen/models/index.ts
+++ b/frontend/src/api/_autogen/models/index.ts
@@ -11,6 +11,7 @@ export * from './GameMap';
export * from './GenderEnum';
export * from './HistoricalRating';
export * from './LanguageEnum';
+export * from './LanguagesEnum';
export * from './Match';
export * from './MatchParticipant';
export * from './MatchRating';
diff --git a/frontend/src/api/compete/competeApi.ts b/frontend/src/api/compete/competeApi.ts
index 12e116f7c..ae351a5d6 100644
--- a/frontend/src/api/compete/competeApi.ts
+++ b/frontend/src/api/compete/competeApi.ts
@@ -38,18 +38,21 @@ const API = new CompeteApi(DEFAULT_API_CONFIGURATION);
* @param _package The name of the submission's package.
* @param description The submission's description.
* @param sourceCode The submission's source code.
+ * @param language The programming language of the submission.
*/
export const uploadSubmission = async ({
episodeId,
_package,
description,
sourceCode,
+ language,
}: CompeteSubmissionCreateRequest): Promise =>
await API.competeSubmissionCreate({
episodeId,
sourceCode,
_package,
description,
+ language,
});
/**
diff --git a/frontend/src/api/compete/useCompete.ts b/frontend/src/api/compete/useCompete.ts
index d9993ed4a..90ff4e28d 100644
--- a/frontend/src/api/compete/useCompete.ts
+++ b/frontend/src/api/compete/useCompete.ts
@@ -289,6 +289,7 @@ export const useUploadSubmission = (
_package,
description,
sourceCode,
+ language,
}: CompeteSubmissionCreateRequest) => {
const toastFn = async (): Promise => {
const result = await uploadSubmission({
@@ -296,6 +297,7 @@ export const useUploadSubmission = (
_package,
description,
sourceCode,
+ language,
});
try {
diff --git a/frontend/src/utils/languageHelpers.ts b/frontend/src/utils/languageHelpers.ts
new file mode 100644
index 000000000..2978b1ea0
--- /dev/null
+++ b/frontend/src/utils/languageHelpers.ts
@@ -0,0 +1,31 @@
+import type { LanguagesEnum } from "../api/_autogen";
+
+/**
+ * Map of LanguagesEnum values to human-readable labels
+ */
+export const LANGUAGE_LABELS: Record = {
+ java8: "Java 8",
+ java21: "Java 21",
+ py3: "Python 3",
+};
+
+/**
+ * Get human-readable label for a language enum value
+ * @param language - The LanguagesEnum value
+ * @returns Human-readable language label
+ */
+export const getLanguageLabel = (language: LanguagesEnum): string =>
+ LANGUAGE_LABELS[language];
+
+/**
+ * Get all supported language options as {value, label} pairs
+ * @param supportedLanguages - Array of supported LanguagesEnum values
+ * @returns Array of {value, label} objects for dropdown/select components
+ */
+export const getLanguageOptions = (
+ supportedLanguages: LanguagesEnum[],
+): Array<{ value: LanguagesEnum; label: string }> =>
+ supportedLanguages.map((lang) => ({
+ value: lang,
+ label: getLanguageLabel(lang),
+ }));
diff --git a/frontend/src/views/Submissions.tsx b/frontend/src/views/Submissions.tsx
index 5a561a741..73318e4c1 100644
--- a/frontend/src/views/Submissions.tsx
+++ b/frontend/src/views/Submissions.tsx
@@ -18,14 +18,17 @@ import {
import { useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { getParamEntries, parsePageParam } from "../utils/searchParamHelpers";
-import { LanguageEnum } from "api/_autogen";
+import type { LanguagesEnum } from "api/_autogen";
import { PageTitle, PageContainer } from "components/elements/BattlecodeStyle";
import { useCurrentUserInfo } from "api/user/useUser";
+import { getLanguageOptions } from "../utils/languageHelpers";
+import type { CompeteSubmissionCreateLanguageEnum } from "api/_autogen/apis/CompeteApi";
interface SubmissionFormInput {
file: FileList;
packageName: string;
description: string;
+ language: LanguagesEnum;
}
interface QueryParams {
@@ -125,9 +128,12 @@ const CodeSubmission: React.FC = () => {
register,
handleSubmit,
reset,
+ watch,
formState: { errors, isDirty },
} = useForm();
+ const selectedLanguage = watch("language");
+
const onSubmit: SubmitHandler = (data) => {
if (uploadSub.isPending) return;
uploadSub.mutate({
@@ -135,6 +141,7 @@ const CodeSubmission: React.FC = () => {
_package: data.packageName,
description: data.description,
sourceCode: data.file[0],
+ language: data.language as unknown as CompeteSubmissionCreateLanguageEnum,
});
reset();
};
@@ -151,10 +158,14 @@ const CodeSubmission: React.FC = () => {
);
- if (episode.data.language === LanguageEnum.Py3) {
- return (
-
-
Submit your python code using the button below.
+ const supportedLanguages = episode.data.languages;
+ const isPythonSelected = selectedLanguage === ("py3" as LanguagesEnum);
+ const isJavaSelected = !isPythonSelected;
+
+ return (
+
+
Submit your code using the form below.
+ {isPythonSelected && (
Create a{" "}
@@ -168,65 +179,8 @@ const CodeSubmission: React.FC = () => {
{" "}
and any other code you have written, for example:
-
-
- {"submission.zip --> examplefuncsplayer --> bot.py, utils.py"}
-
-
-
-
- );
- } else {
- return (
-
-
Submit your java code using the button below.
+ )}
+ {isJavaSelected && (
Create a{" "}
@@ -240,6 +194,15 @@ const CodeSubmission: React.FC = () => {
{" "}
and any other code you have written, for example:
+ )}
+ {isPythonSelected && (
+
+
+ {"submission.zip --> examplefuncsplayer --> bot.py, utils.py"}
+
+
+ )}
+ {isJavaSelected && (
{
@@ -247,57 +210,74 @@ const CodeSubmission: React.FC = () => {
}
-
+
+
+
+
+
+
+
+ );
};
export default Submissions;