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"} -

-
-
{ - void handleSubmit(onSubmit)(e); - }} - className="mt-4 flex flex-col gap-4" - > -
- - -
-
- - -
-
- ); - } 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 = () => { }

-
{ - void handleSubmit(onSubmit)(e); - }} - className="mt-4 flex flex-col gap-4" - > -
- - -
-
- - -
-
+
+ + +
+
+ ); }; export default Submissions;